Skip to content

Commit

Permalink
feat(controls): Add react versions of core media controls (#1308)
Browse files Browse the repository at this point in the history
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
  • Loading branch information
jstoffan and mergify[bot] authored Dec 12, 2020
1 parent 0f8d7d9 commit 4adb425
Show file tree
Hide file tree
Showing 17 changed files with 349 additions and 15 deletions.
33 changes: 21 additions & 12 deletions src/lib/viewers/controls/_styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,28 @@

$bp-controls-background: fade-out($black, .2);

@mixin bp-Control--hover {
opacity: .7;
transition: opacity 150ms;

&:focus,
&:hover {
opacity: 1;
}
}

@mixin bp-Control--focus {
outline: 0;

&:focus {
box-shadow: inset 0 0 0 1px fade-out($white, .5), 0 1px 2px fade-out($black, .9);
}
}

@mixin bp-ControlButton($height: 48px, $width: 48px) {
@include bp-Control--hover;
@include bp-Control--focus;

display: flex;
align-items: center;
justify-content: center;
Expand All @@ -13,23 +34,11 @@ $bp-controls-background: fade-out($black, .2);
color: $white;
background: transparent;
border: 1px solid transparent;
outline: 0;
cursor: pointer;
opacity: .7;
transition: opacity 150ms;
user-select: none;
touch-action: manipulation;
zoom: 1;

&:focus,
&:hover {
opacity: 1;
}

&:focus {
box-shadow: inset 0 0 0 1px fade-out($white, .5), 0 1px 2px fade-out($black, .9);
}

&:disabled {
cursor: default;
opacity: .2;
Expand Down
4 changes: 2 additions & 2 deletions src/lib/viewers/controls/annotations/AnnotationsButton.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import React, { ButtonHTMLAttributes } from 'react';
import React from 'react';
import classNames from 'classnames';
import noop from 'lodash/noop';
import { AnnotationMode } from './types';
import './AnnotationsButton.scss';

export type Props = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> & {
export type Props = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> & {
children?: React.ReactNode;
className?: string;
isActive?: boolean;
Expand Down
32 changes: 32 additions & 0 deletions src/lib/viewers/controls/hooks/__tests__/usePreventKey-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import usePreventKey from '../usePreventKey';

describe('usePreventKey', () => {
function TestComponent({ keys }: { keys?: string[] }): JSX.Element {
const ref = React.useRef<HTMLDivElement>(null);
usePreventKey(ref, keys);
return <div ref={ref} />;
}

const getElement = (wrapper: ReactWrapper): HTMLDivElement => wrapper.childAt(0).getDOMNode();
const getEvent = (options = {}): KeyboardEvent => {
const event = new KeyboardEvent('keydown', options);
event.stopPropagation = jest.fn();
return event;
};
const getWrapper = (props = {}): ReactWrapper => mount(<TestComponent {...props} />);

test('should stop propagation of a matching event triggered on the provided element', () => {
const wrapper = getWrapper({ keys: ['Enter'] });
const element = getElement(wrapper);
const enterEvent = getEvent({ key: 'Enter' });
const escapeEvent = getEvent({ key: 'Escape' });

element.dispatchEvent(enterEvent);
expect(enterEvent.stopPropagation).toBeCalled();

element.dispatchEvent(escapeEvent);
expect(escapeEvent.stopPropagation).not.toBeCalled();
});
});
2 changes: 1 addition & 1 deletion src/lib/viewers/controls/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as useFullscreen } from './useFullscreen';
export { default as usePreventKey } from './usePreventKey';
26 changes: 26 additions & 0 deletions src/lib/viewers/controls/hooks/usePreventKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as React from 'react';
import { decodeKeydown } from '../../../util';

export default function usePreventKey(ref: React.RefObject<HTMLElement | null>, keys: string[] = []): void {
React.useEffect(() => {
const { current: element } = ref;

const handleKeydown = (event: KeyboardEvent): void => {
const key = decodeKeydown(event);

if (keys.includes(key)) {
event.stopPropagation(); // Prevents global key handling. Can be simplified with React v17.
}
};

if (element) {
element.addEventListener('keydown', handleKeydown);
}

return (): void => {
if (element) {
element.removeEventListener('keydown', handleKeydown);
}
};
}, [keys, ref]);
}
17 changes: 17 additions & 0 deletions src/lib/viewers/controls/media/DurationLabels.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@import './styles';

.bp-DurationLabels {
display: flex;
align-items: center;
margin-right: 10px;
margin-left: 10px;
}

.bp-DurationLabels-label {
@include bp-Control--hover;

padding-right: 4px;
padding-left: 4px;
color: $white;
cursor: default;
}
28 changes: 28 additions & 0 deletions src/lib/viewers/controls/media/DurationLabels.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from 'react';
import './DurationLabels.scss';

export type Props = {
currentTime?: number;
durationTime?: number;
};

export function formatTime(time: number): string {
const hours = Math.floor(time / 3600);
const minutes = Math.floor((time % 3600) / 60);
const seconds = Math.floor((time % 3600) % 60);
const hour = hours > 0 ? `${hours.toString()}:` : '';
const min = hours > 0 && minutes < 10 ? `0${minutes.toString()}` : minutes.toString();
const sec = seconds < 10 ? `0${seconds.toString()}` : seconds.toString();

return `${hour}${min}:${sec}`;
}

export default function DurationLabels({ currentTime = 0, durationTime = 0 }: Props): JSX.Element {
return (
<div className="bp-DurationLabels">
<span className="bp-DurationLabels-label">{formatTime(currentTime)}</span>
<span className="bp-DurationLabels-label">/</span>
<span className="bp-DurationLabels-label">{formatTime(durationTime)}</span>
</div>
);
}
12 changes: 12 additions & 0 deletions src/lib/viewers/controls/media/MediaToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import React from 'react';
import usePreventKey from '../hooks/usePreventKey';

export type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;

export default function MediaToggle(props: Props): JSX.Element {
const buttonElRef = React.useRef<HTMLButtonElement>(null);

usePreventKey(buttonElRef, ['Enter', 'Space']);

return <button ref={buttonElRef} type="button" {...props} />;
}
5 changes: 5 additions & 0 deletions src/lib/viewers/controls/media/PlayPauseToggle.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@import './styles';

.bp-PlayPauseToggle {
@include bp-MediaButton;
}
22 changes: 22 additions & 0 deletions src/lib/viewers/controls/media/PlayPauseToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react';
import noop from 'lodash/noop';
import IconPause24 from '../icons/IconPause24';
import IconPlay24 from '../icons/IconPlay24';
import MediaToggle from './MediaToggle';
import './PlayPauseToggle.scss';

export type Props = {
isPlaying?: boolean;
onPlayPause: (isPlaying: boolean) => void;
};

export default function PlayPauseToggle({ isPlaying, onPlayPause = noop }: Props): JSX.Element {
const Icon = isPlaying ? IconPause24 : IconPlay24;
const title = isPlaying ? __('media_pause') : __('media_play');

return (
<MediaToggle className="bp-PlayPauseToggle" onClick={(): void => onPlayPause(!isPlaying)} title={title}>
<Icon />
</MediaToggle>
);
}
16 changes: 16 additions & 0 deletions src/lib/viewers/controls/media/SettingsControls.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
@import './styles';

.bp-SettingsControls-toggle {
@include bp-MediaButton;

&.bp-is-open {
.bp-SettingsControls-toggle-icon {
transform: rotate(60deg);
}
}
}

.bp-SettingsControls-toggle-icon {
transform: rotate(0);
transition: transform 300ms ease;
}
27 changes: 27 additions & 0 deletions src/lib/viewers/controls/media/SettingsControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from 'react';
import classNames from 'classnames';
import IconGear24 from '../icons/IconGear24';
import MediaToggle from './MediaToggle';
import './SettingsControls.scss';

export default function SettingsControls(): JSX.Element {
const [isOpen, setOpen] = React.useState(false);

const handleClick = (): void => {
setOpen(!isOpen);
};

return (
<div className="bp-SettingsControls">
<MediaToggle
className={classNames('bp-SettingsControls-toggle', { 'bp-is-open': isOpen })}
onClick={handleClick}
title={__('media_settings')}
>
<IconGear24 className="bp-SettingsControls-toggle-icon" />
</MediaToggle>

{/* TODO: Add settings popup(s) */}
</div>
);
}
33 changes: 33 additions & 0 deletions src/lib/viewers/controls/media/__tests__/DurationLabels-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import DurationLabels from '../DurationLabels';

describe('DurationLabels', () => {
const getWrapper = (props = {}): ShallowWrapper =>
shallow(<DurationLabels currentTime={0} durationTime={60} {...props} />);

describe('render', () => {
test('should return a valid wrapper', () => {
const wrapper = getWrapper();

expect(wrapper.hasClass('bp-DurationLabels')).toBe(true);
});

test.each`
input | result
${0} | ${'0:00'}
${9} | ${'0:09'}
${105} | ${'1:45'}
${705} | ${'11:45'}
${10800} | ${'3:00:00'}
${11211} | ${'3:06:51'}
`('should render both current and duration time $input to $result', ({ input, result }) => {
const wrapper = getWrapper({ currentTime: input, durationTime: input });
const current = wrapper.childAt(0);
const duration = wrapper.childAt(2);

expect(current.text()).toBe(result);
expect(duration.text()).toBe(result);
});
});
});
27 changes: 27 additions & 0 deletions src/lib/viewers/controls/media/__tests__/MediaToggle-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import MediaToggle from '../MediaToggle';
import usePreventKey from '../../hooks/usePreventKey';

jest.mock('../../hooks/usePreventKey');

describe('MediaToggle', () => {
const getWrapper = (props = {}): ShallowWrapper => shallow(<MediaToggle {...props} />);

describe('render', () => {
test('should return a valid wrapper', () => {
const wrapper = getWrapper({ className: 'test' });

expect(wrapper.props()).toMatchObject({
className: 'test',
type: 'button',
});
});

test('should call the usePreventKey hook with specific keys', () => {
getWrapper();

expect(usePreventKey).toBeCalledWith({ current: expect.any(Object) }, ['Enter', 'Space']);
});
});
});
40 changes: 40 additions & 0 deletions src/lib/viewers/controls/media/__tests__/PlayPauseToggle-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import IconPause24 from '../../icons/IconPause24';
import IconPlay24 from '../../icons/IconPlay24';
import MediaToggle from '../MediaToggle';
import PlayPauseToggle from '../PlayPauseToggle';

describe('PlayPauseToggle', () => {
const getWrapper = (props = {}): ShallowWrapper => shallow(<PlayPauseToggle onPlayPause={jest.fn()} {...props} />);

describe('event handlers', () => {
test('should toggle isPlaying when clicked', () => {
const onPlayPause = jest.fn();
const wrapper = getWrapper({ isPlaying: false, onPlayPause });
const toggle = wrapper.find(MediaToggle);

toggle.simulate('click');
expect(onPlayPause).toBeCalledWith(true);
});
});

describe('render', () => {
test('should return a valid wrapper', () => {
const wrapper = getWrapper();

expect(wrapper.hasClass('bp-PlayPauseToggle')).toBe(true);
});

test.each`
isPlaying | icon | title
${true} | ${IconPause24} | ${'Pause'}
${false} | ${IconPlay24} | ${'Play'}
`('should render the correct icon and title if isPlaying is $isPlaying', ({ isPlaying, icon, title }) => {
const wrapper = getWrapper({ isPlaying });

expect(wrapper.exists(icon)).toBe(true);
expect(wrapper.prop('title')).toBe(title);
});
});
});
32 changes: 32 additions & 0 deletions src/lib/viewers/controls/media/__tests__/SettingsControls-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import IconGear24 from '../../icons/IconGear24';
import MediaToggle from '../MediaToggle';
import SettingsControls from '../SettingsControls';

describe('SettingsControls', () => {
const getWrapper = (props = {}): ShallowWrapper => shallow(<SettingsControls {...props} />);

describe('event handlers', () => {
test('should update the toggle button when clicked', () => {
const wrapper = getWrapper();
const isOpen = (): boolean => wrapper.find(MediaToggle).hasClass('bp-is-open');

expect(isOpen()).toBe(false);

wrapper.find(MediaToggle).simulate('click');

expect(isOpen()).toBe(true);
});
});

describe('render', () => {
test('should return a valid wrapper', () => {
const wrapper = getWrapper();

expect(wrapper.hasClass('bp-SettingsControls')).toBe(true);
expect(wrapper.exists(MediaToggle)).toBe(true);
expect(wrapper.exists(IconGear24)).toBe(true);
});
});
});
Loading

0 comments on commit 4adb425

Please sign in to comment.