diff --git a/src/lib/viewers/controls/_styles.scss b/src/lib/viewers/controls/_styles.scss index e3b528e8c..07610e22c 100644 --- a/src/lib/viewers/controls/_styles.scss +++ b/src/lib/viewers/controls/_styles.scss @@ -5,6 +5,7 @@ $bp-controls-background: fade-out($black, .2); @mixin bp-Control--hover { opacity: .7; transition: opacity 150ms; + will-change: opacity; // Prevent flickering in Safari &:focus, &:hover { diff --git a/src/lib/viewers/controls/hooks/__tests__/useAttention-test.tsx b/src/lib/viewers/controls/hooks/__tests__/useAttention-test.tsx new file mode 100644 index 000000000..5c81fb499 --- /dev/null +++ b/src/lib/viewers/controls/hooks/__tests__/useAttention-test.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mount, ReactWrapper } from 'enzyme'; +import useAttention from '../useAttention'; + +describe('useAttention', () => { + function TestComponent(): JSX.Element { + const [isActive, handlers] = useAttention(); + return
; + } + + const getElement = (wrapper: ReactWrapper): ReactWrapper => wrapper.childAt(0); + const getWrapper = (): ReactWrapper => mount(); + + test('should return isActive based on focus and/or hover state', () => { + const wrapper = getWrapper(); + const simulate = (event: string): void => { + act(() => { + wrapper.simulate(event); + }); + wrapper.update(); + }; + + expect(getElement(wrapper).hasClass('active')).toBe(false); // Default + + simulate('focus'); + expect(getElement(wrapper).hasClass('active')).toBe(true); // Focus + + simulate('mouseover'); + expect(getElement(wrapper).hasClass('active')).toBe(true); // Focus & Hover + + simulate('blur'); + expect(getElement(wrapper).hasClass('active')).toBe(true); // Hover + + simulate('mouseout'); + expect(getElement(wrapper).hasClass('active')).toBe(false); // Default + }); +}); diff --git a/src/lib/viewers/controls/hooks/index.ts b/src/lib/viewers/controls/hooks/index.ts index 2a7594ea7..8b27d74f0 100644 --- a/src/lib/viewers/controls/hooks/index.ts +++ b/src/lib/viewers/controls/hooks/index.ts @@ -1,2 +1,3 @@ +export { default as useAttention } from './useAttention'; export { default as useFullscreen } from './useFullscreen'; export { default as usePreventKey } from './usePreventKey'; diff --git a/src/lib/viewers/controls/hooks/useAttention.ts b/src/lib/viewers/controls/hooks/useAttention.ts new file mode 100644 index 000000000..96e356d96 --- /dev/null +++ b/src/lib/viewers/controls/hooks/useAttention.ts @@ -0,0 +1,30 @@ +import * as React from 'react'; + +export type handlers = { + onBlur: () => void; + onFocus: () => void; + onMouseOut: () => void; + onMouseOver: () => void; +}; + +export type isActive = boolean; + +export default function useAttention(): [isActive, handlers] { + const [isFocused, setFocused] = React.useState(false); + const [isHovered, setHovered] = React.useState(false); + + const handleBlur = (): void => setFocused(false); + const handleFocus = (): void => setFocused(true); + const handleMouseOut = (): void => setHovered(false); + const handleMouseOver = (): void => setHovered(true); + + return [ + isFocused || isHovered, + { + onBlur: handleBlur, + onFocus: handleFocus, + onMouseOut: handleMouseOut, + onMouseOver: handleMouseOver, + }, + ]; +} diff --git a/src/lib/viewers/controls/media/TimeControls.scss b/src/lib/viewers/controls/media/TimeControls.scss new file mode 100644 index 000000000..52d699dc9 --- /dev/null +++ b/src/lib/viewers/controls/media/TimeControls.scss @@ -0,0 +1,39 @@ +@import '../slider/styles'; + +$bp-TimeControls-size: 18px; // Divisible by 3px (default) and 6px (hovered) for better vertical centering +$bp-TimeControls-space: 10px; + +.bp-TimeControls { + height: $bp-TimeControls-size; + + .bp-SliderControl-input { + padding-right: $bp-TimeControls-space; + padding-left: $bp-TimeControls-space; + + @include bp-SliderThumb { + transform: scale(0); + transition: transform 100ms ease; + will-change: transform; // Prevent flickering in Safari + } + } + + .bp-SliderControl-track { + margin-right: $bp-TimeControls-space; + margin-left: $bp-TimeControls-space; + backface-visibility: hidden; // Prevent jank in Firefox + transition: transform 100ms ease; + will-change: transform; // Prevent flickering in Safari + } + + &:hover { + .bp-SliderControl-input { + @include bp-SliderThumb { + transform: scale(1); + } + } + + .bp-SliderControl-track { + transform: scaleY(2); + } + } +} diff --git a/src/lib/viewers/controls/media/TimeControls.tsx b/src/lib/viewers/controls/media/TimeControls.tsx new file mode 100644 index 000000000..10b888cc4 --- /dev/null +++ b/src/lib/viewers/controls/media/TimeControls.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { bdlBoxBlue, bdlGray62, white } from 'box-ui-elements/es/styles/variables'; +import SliderControl from '../slider'; +import './TimeControls.scss'; + +export type Props = { + bufferedRange?: TimeRanges; + currentTime?: number; + durationTime?: number; + onTimeChange: (volume: number) => void; +}; + +export const round = (value: number): number => { + return +value.toFixed(4); +}; + +export const percent = (value1: number, value2: number): number => { + return round((value1 / value2) * 100); +}; + +export default function TimeControls({ + bufferedRange, + currentTime = 0, + durationTime = 0, + onTimeChange, +}: Props): JSX.Element { + const currentValue = percent(currentTime, durationTime); + const bufferedAmount = bufferedRange && bufferedRange.length ? bufferedRange.end(bufferedRange.length - 1) : 0; + const bufferedValue = percent(bufferedAmount, durationTime); + + const handleChange = (newValue: number): void => { + onTimeChange(round(durationTime * (newValue / 100))); + }; + + return ( + + ); +} diff --git a/src/lib/viewers/controls/media/VolumeControls.scss b/src/lib/viewers/controls/media/VolumeControls.scss new file mode 100644 index 000000000..d469c06af --- /dev/null +++ b/src/lib/viewers/controls/media/VolumeControls.scss @@ -0,0 +1,26 @@ +@import './styles'; + +.bp-VolumeControls { + display: flex; + align-items: center; +} + +.bp-VolumeControls-flyout { + flex: 0 0 auto; + max-width: 0; + height: $bp-MediaControl-height; + overflow: hidden; + transition: max-width 200ms ease-in-out; + + &.bp-is-open { + max-width: 100px; + } +} + +.bp-VolumeControls-slider { + width: 100px; +} + +.bp-VolumeControls-toggle { + @include bp-MediaButton; +} diff --git a/src/lib/viewers/controls/media/VolumeControls.tsx b/src/lib/viewers/controls/media/VolumeControls.tsx new file mode 100644 index 000000000..0baded470 --- /dev/null +++ b/src/lib/viewers/controls/media/VolumeControls.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import classNames from 'classnames'; +import { bdlBoxBlue, white } from 'box-ui-elements/es/styles/variables'; +import IconVolumeHigh24 from '../icons/IconVolumeHigh24'; +import IconVolumeLow24 from '../icons/IconVolumeLow24'; +import IconVolumeMedium24 from '../icons/IconVolumeMedium24'; +import IconVolumeMute24 from '../icons/IconVolumeMute24'; +import MediaToggle from './MediaToggle'; +import SliderControl, { Ref as SliderControlRef } from '../slider'; +import useAttention from '../hooks/useAttention'; +import usePreventKey from '../hooks/usePreventKey'; +import './VolumeControls.scss'; + +export type Props = { + onMuteChange: (isMuted: boolean) => void; + onVolumeChange: (volume: number) => void; + volume?: number; +}; + +export function getIcon(volume: number): (props: React.SVGProps) => JSX.Element { + let Icon = IconVolumeMute24; + + if (volume >= 0.66) { + Icon = IconVolumeHigh24; + } else if (volume >= 0.33) { + Icon = IconVolumeMedium24; + } else if (volume >= 0.01) { + Icon = IconVolumeLow24; + } + + return Icon; +} + +export default function VolumeControls({ onMuteChange, onVolumeChange, volume = 1 }: Props): JSX.Element { + const [isActive, handlers] = useAttention(); + const inputElRef = React.useRef(null); + const isMuted = !volume; + const Icon = isMuted ? IconVolumeMute24 : getIcon(volume); + const title = isMuted ? __('media_unmute') : __('media_mute'); + const value = Math.round(volume * 100); + + const handleMute = (): void => { + onMuteChange(!isMuted); + }; + + const handleVolume = (newValue: number): void => { + onVolumeChange(newValue / 100); + }; + + // Allow the range input to handle its own left/right keydown events + usePreventKey(inputElRef, ['ArrowLeft', 'ArrowRight']); + + return ( +
+ + + + +
+ +
+
+ ); +} diff --git a/src/lib/viewers/controls/media/__tests__/TimeControls-test.tsx b/src/lib/viewers/controls/media/__tests__/TimeControls-test.tsx new file mode 100644 index 000000000..8b2940f54 --- /dev/null +++ b/src/lib/viewers/controls/media/__tests__/TimeControls-test.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import SliderControl from '../../slider'; +import TimeControls from '../TimeControls'; + +describe('TimeControls', () => { + const getBuffer = (end = 1000, start = 0): TimeRanges => ({ + length: end - start, + end: jest.fn().mockReturnValue(end), + start: jest.fn().mockReturnValue(start), + }); + const getWrapper = (props = {}): ShallowWrapper => + shallow(); + + describe('event handlers', () => { + test.each` + percentage | value + ${0} | ${0} + ${20} | ${1111} + ${33} | ${1833.15} + ${33.3333} | ${1851.6648} + ${100} | ${5555} + `( + 'should calculate the absolute value $value based on the relative slider percentage $percentage', + ({ percentage, value }) => { + const onChange = jest.fn(); + const wrapper = getWrapper({ durationTime: 5555, onTimeChange: onChange }); + const slider = wrapper.find(SliderControl); + + slider.simulate('change', percentage); + + expect(onChange).toBeCalledWith(value); + }, + ); + }); + + describe('render', () => { + test('should return a valid wrapper', () => { + const wrapper = getWrapper(); + + expect(wrapper.hasClass('bp-TimeControls')).toBe(true); + expect(wrapper.prop('step')).toEqual(0.1); + }); + + test.each` + currentTime | track | value + ${0} | ${'linear-gradient(to right, #0061d5 0%, #fff 0%, #fff 10%, #767676 10%, #767676 100%)'} | ${0} + ${50} | ${'linear-gradient(to right, #0061d5 0.5%, #fff 0.5%, #fff 10%, #767676 10%, #767676 100%)'} | ${0.5} + ${1000} | ${'linear-gradient(to right, #0061d5 10%, #fff 10%, #fff 10%, #767676 10%, #767676 100%)'} | ${10} + ${2500} | ${'linear-gradient(to right, #0061d5 25%, #fff 25%, #fff 10%, #767676 10%, #767676 100%)'} | ${25} + ${10000} | ${'linear-gradient(to right, #0061d5 100%, #fff 100%, #fff 10%, #767676 10%, #767676 100%)'} | ${100} + `('should render the correct track and value for currentTime $currentTime', ({ currentTime, track, value }) => { + const buffer = getBuffer(1000, 0); // 10% buffered + const wrapper = getWrapper({ bufferedRange: buffer, currentTime }); + + expect(wrapper.find(SliderControl).props()).toMatchObject({ + track, + value, + }); + }); + }); +}); diff --git a/src/lib/viewers/controls/media/__tests__/VolumeControls-test.tsx b/src/lib/viewers/controls/media/__tests__/VolumeControls-test.tsx new file mode 100644 index 000000000..095ee81cf --- /dev/null +++ b/src/lib/viewers/controls/media/__tests__/VolumeControls-test.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import IconVolumeHigh24 from '../../icons/IconVolumeHigh24'; +import IconVolumeLow24 from '../../icons/IconVolumeLow24'; +import IconVolumeMedium24 from '../../icons/IconVolumeMedium24'; +import IconVolumeMute24 from '../../icons/IconVolumeMute24'; +import MediaToggle from '../MediaToggle'; +import SliderControl from '../../slider'; +import VolumeControls from '../VolumeControls'; +import usePreventKey from '../../hooks/usePreventKey'; + +jest.mock('../../hooks/usePreventKey'); +jest.mock('../../slider'); + +describe('VolumeControls', () => { + const getWrapper = (props = {}): ShallowWrapper => + shallow(); + + describe('event handlers', () => { + test.each` + volume | isMuted + ${0} | ${false} + ${0.5} | ${true} + ${1} | ${true} + `('should toggle mute to $isMuted when volume is $volume', ({ isMuted, volume }) => { + const onMuteChange = jest.fn(); + const wrapper = getWrapper({ onMuteChange, volume }); + const toggle = wrapper.find(MediaToggle); + + toggle.simulate('click'); + expect(onMuteChange).toBeCalledWith(isMuted); + }); + + test('should defer to the inner input for left/right arrow keys', () => { + getWrapper(); + + expect(usePreventKey).toBeCalledWith(expect.any(Object), ['ArrowLeft', 'ArrowRight']); + }); + }); + + describe('render', () => { + test('should return a valid wrapper', () => { + const wrapper = getWrapper(); + + expect(wrapper.hasClass('bp-VolumeControls')).toBe(true); + }); + + test.each` + volume | icon | title + ${0} | ${IconVolumeMute24} | ${'Unmute'} + ${0.0} | ${IconVolumeMute24} | ${'Unmute'} + ${0.01} | ${IconVolumeLow24} | ${'Mute'} + ${0.25} | ${IconVolumeLow24} | ${'Mute'} + ${0.33} | ${IconVolumeMedium24} | ${'Mute'} + ${0.51} | ${IconVolumeMedium24} | ${'Mute'} + ${0.66} | ${IconVolumeHigh24} | ${'Mute'} + ${1.0} | ${IconVolumeHigh24} | ${'Mute'} + `('should render the correct icon and title for volume $volume', ({ icon, title, volume }) => { + const wrapper = getWrapper({ volume }); + + expect(wrapper.exists(icon)).toBe(true); + expect(wrapper.find(MediaToggle).prop('title')).toBe(title); + }); + + test.each` + volume | track | value + ${0} | ${`linear-gradient(to right, #0061d5 0%, #fff 0%)`} | ${0} + ${0.0} | ${`linear-gradient(to right, #0061d5 0%, #fff 0%)`} | ${0} + ${0.01} | ${`linear-gradient(to right, #0061d5 1%, #fff 1%)`} | ${1} + ${0.25} | ${`linear-gradient(to right, #0061d5 25%, #fff 25%)`} | ${25} + ${0.254} | ${`linear-gradient(to right, #0061d5 25%, #fff 25%)`} | ${25} + ${0.255} | ${`linear-gradient(to right, #0061d5 26%, #fff 26%)`} | ${26} + ${1.0} | ${`linear-gradient(to right, #0061d5 100%, #fff 100%)`} | ${100} + `('should render the correct track and value for volume $volume', ({ track, value, volume }) => { + const wrapper = getWrapper({ volume }); + + expect(wrapper.find(SliderControl).props()).toMatchObject({ + track, + value, + }); + }); + }); +}); diff --git a/src/lib/viewers/controls/slider/SliderControl.scss b/src/lib/viewers/controls/slider/SliderControl.scss new file mode 100644 index 000000000..5bc53f65e --- /dev/null +++ b/src/lib/viewers/controls/slider/SliderControl.scss @@ -0,0 +1,98 @@ +@import './styles'; + +$bp-SliderControl-thumb-size: 12px; +$bp-SliderControl-track-size: 3px; +$bp-SliderControl-track-space: 3px; + +.bp-SliderControl { + position: relative; + width: 100%; + height: 100%; +} + +.bp-SliderControl-input { + @include bp-Control--focus; + + position: absolute; + width: 100%; + height: 100%; + padding: 0 $bp-SliderControl-track-space; + background: transparent; + cursor: pointer; + appearance: none; + + @include bp-SliderThumb { + width: $bp-SliderControl-thumb-size; + height: $bp-SliderControl-thumb-size; + background: $box-blue; + border: 0; + border-radius: $bp-SliderControl-thumb-size; + cursor: pointer; + } + + @include bp-SliderTrack { + width: 100%; + height: $bp-SliderControl-track-size; + background: transparent; + border: 0; + cursor: pointer; + } + + // Thumb Button + &::-ms-thumb { + margin-top: 0; + } + + &::-webkit-slider-thumb { + margin-top: -5px; + appearance: none; + } + + // Track + &::-ms-fill-lower { + background: transparent; + } + + &::-ms-fill-upper { + background: transparent; + } + + &::-ms-track { + color: transparent; + border-color: transparent; + border-width: 5px 0; + } + + // Tooltip + &::-ms-tooltip { + display: none; + } + + // Overrides + @supports (-ms-ime-align: auto) { + & { + margin: 0; // Edge starts the margin from the thumb, not the track + } + + &::-webkit-slider-thumb { + margin-top: 0; + } + } +} + +.bp-SliderControl-track { + position: absolute; + top: 0; + right: $bp-SliderControl-thumb-size / 2; // Allow the thumb to fully dock to edge of the track + bottom: 0; + left: $bp-SliderControl-thumb-size / 2; // Allow the thumb to fully dock to edge of the track + height: $bp-SliderControl-track-size; + margin: auto $bp-SliderControl-track-space; // Center the track within the slider + background: transparent linear-gradient($white, $white) no-repeat center border-box; + + @media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) { + & { + transition: background-image 100ms ease; // Match the transition IE applies to thumb movement + } + } +} diff --git a/src/lib/viewers/controls/slider/SliderControl.tsx b/src/lib/viewers/controls/slider/SliderControl.tsx new file mode 100644 index 000000000..a9d6c2cdb --- /dev/null +++ b/src/lib/viewers/controls/slider/SliderControl.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import classNames from 'classnames'; +import noop from 'lodash/noop'; +import './SliderControl.scss'; + +export type Props = Omit, 'onChange'> & { + className?: string; + onChange?: (value: number) => void; + track?: string; +}; + +export type Ref = HTMLInputElement; + +function SliderControl({ className, onChange = noop, track, ...rest }: Props, ref: React.Ref): JSX.Element { + const handleChange = ({ target }: React.ChangeEvent): void => { + onChange(parseFloat(target.value)); + }; + + return ( +
+
+ +
+ ); +} + +export default React.forwardRef(SliderControl); diff --git a/src/lib/viewers/controls/slider/__tests__/SliderControl-test.tsx b/src/lib/viewers/controls/slider/__tests__/SliderControl-test.tsx new file mode 100644 index 000000000..a8ce5adcc --- /dev/null +++ b/src/lib/viewers/controls/slider/__tests__/SliderControl-test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import SliderControl from '../SliderControl'; + +describe('SliderControl', () => { + const getWrapper = (props = {}): ShallowWrapper => + shallow(); + + describe('event handlers', () => { + test('should parse and return the current value on change', () => { + const onChange = jest.fn(); + const wrapper = getWrapper({ step: 0.1, onChange }); + const input = wrapper.find('[data-testid="bp-SliderControl-input"]'); + + input.simulate('change', { target: { value: '0.5' } }); + + expect(onChange).toBeCalledWith(0.5); + }); + }); + + describe('render', () => { + test('should return a valid wrapper', () => { + const wrapper = getWrapper(); + + expect(wrapper.hasClass('bp-SliderControl')).toBe(true); + expect(wrapper.find('[data-testid="bp-SliderControl-input"]').props()).toMatchObject({ + max: 100, + min: 0, + step: 1, + }); + }); + + test('should forward the track and value properties properly', () => { + const track = 'linear-gradient(#fff %20, #000 100%'; + const wrapper = getWrapper({ track, value: 20 }); + + expect(wrapper.find('[data-testid="bp-SliderControl-input"]').prop('value')).toEqual(20); + expect(wrapper.find('[data-testid="bp-SliderControl-track"]').prop('style')).toEqual({ + backgroundImage: track, + }); + }); + }); +}); diff --git a/src/lib/viewers/controls/slider/_styles.scss b/src/lib/viewers/controls/slider/_styles.scss new file mode 100644 index 000000000..a2c9630be --- /dev/null +++ b/src/lib/viewers/controls/slider/_styles.scss @@ -0,0 +1,17 @@ +@import '../styles'; + +@mixin bp-SliderThumb { + @each $prefix in ('-moz-range-thumb', '-ms-thumb', '-webkit-slider-thumb') { + &::#{$prefix} { + @content; + } + } +} + +@mixin bp-SliderTrack { + @each $prefix in ('-moz-range-track', '-ms-track', '-webkit-slider-runnable-track') { + &::#{$prefix} { + @content; + } + } +} diff --git a/src/lib/viewers/controls/slider/index.ts b/src/lib/viewers/controls/slider/index.ts new file mode 100644 index 000000000..bec19d23f --- /dev/null +++ b/src/lib/viewers/controls/slider/index.ts @@ -0,0 +1,2 @@ +export { default } from './SliderControl'; +export * from './SliderControl';