Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(controls): Add core media settings control components #1340

Merged
merged 2 commits into from
Mar 12, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';
import noop from 'lodash/noop';

export type Context = {
activeMenu: Menu;
jstoffan marked this conversation as resolved.
Show resolved Hide resolved
activeRect?: Rect;
setActiveMenu: (menu: Menu) => void;
setActiveRect: (activeRect: Rect) => void;
};

export enum Menu {
MAIN = 'main',
AUTOPLAY = 'autoplay',
RATE = 'rate',
}

export type Rect = ClientRect;

export default React.createContext<Context>({
activeMenu: Menu.MAIN,
setActiveMenu: noop,
setActiveRect: noop,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import React from 'react';
import classNames from 'classnames';
import MediaSettingsContext, { Menu, Rect } from './MediaSettingsContext';
import MediaSettingsFlyout from './MediaSettingsFlyout';
import MediaSettingsToggle, { Ref as MediaSettingsToggleRef } from './MediaSettingsToggle';
import { decodeKeydown } from '../../../../util';

export type Props = React.PropsWithChildren<{
className?: string;
}>;

export default function MediaSettingsControls({ children, className, ...rest }: Props): JSX.Element | null {
const [activeMenu, setActiveMenu] = React.useState(Menu.MAIN);
const [activeRect, setActiveRect] = React.useState<Rect>();
const [isFocused, setIsFocused] = React.useState(false);
const [isOpen, setIsOpen] = React.useState(false);
const buttonElRef = React.useRef<MediaSettingsToggleRef>(null);
const controlsElRef = React.useRef<HTMLDivElement>(null);
const resetControls = React.useCallback(() => {
setActiveMenu(Menu.MAIN);
setActiveRect(undefined);
setIsFocused(false);
setIsOpen(false);
}, []);

const handleClick = (): void => {
setActiveMenu(Menu.MAIN);
setActiveRect(undefined);
setIsFocused(false);
setIsOpen(!isOpen);
};

const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
const key = decodeKeydown(event);

if (key === 'Enter' || key === 'Space' || key === 'Tab' || key.indexOf('Arrow') >= 0) {
setIsFocused(true); // User has interacted with the menu via keyboard directly
}

if (key === 'Escape') {
resetControls();

if (buttonElRef.current) {
buttonElRef.current.focus(); // Prevent focus from falling back to the body on flyout close
}
}

event.stopPropagation();
};

React.useEffect(() => {
const handleDocumentClick = ({ target }: MouseEvent): void => {
const { current: controlsEl } = controlsElRef;

if (controlsEl && controlsEl.contains(target as Node)) {
return;
}

resetControls();
};

document.addEventListener('click', handleDocumentClick);

return (): void => {
document.removeEventListener('click', handleDocumentClick);
};
}, [resetControls]);

return (
<div
ref={controlsElRef}
className={classNames('bp-MediaSettingsControls', className, { 'bp-is-focused': isFocused })}
onKeyDown={handleKeyDown}
role="presentation"
{...rest}
>
<MediaSettingsContext.Provider value={{ activeMenu, activeRect, setActiveMenu, setActiveRect }}>
<MediaSettingsToggle ref={buttonElRef} isOpen={isOpen} onClick={handleClick} />
<MediaSettingsFlyout isOpen={isOpen}>{children}</MediaSettingsFlyout>
</MediaSettingsContext.Provider>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
@import '../styles';

.bp-MediaSettingsFlyout {
position: absolute;
top: -5px;
right: 0;
display: none;
max-width: 400px;
max-height: 210px;
overflow-x: hidden;
overflow-y: auto; // Prevent scrollbar from showing on hover in IE/Edge
color: $fours;
font-size: 10px;
line-height: normal;
background-color: $white;
border-radius: 2px;
box-shadow: 0 0 1px 1px $sf-fog; // Prevent overflow due to global box-sizing: border-box
transform: translateY(-100%);
transition: width 200ms, height 200ms;
-ms-overflow-style: -ms-autohiding-scrollbar;

&.bp-is-open {
display: inline-block;
}

&.bp-is-transitioning {
overflow-y: hidden; // Hide scrollbar during menu -> submenu transition
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React from 'react';
import classNames from 'classnames';
import MediaSettingsContext from './MediaSettingsContext';
import './MediaSettingsFlyout.scss';

export type Props = React.PropsWithChildren<{
className?: string;
isOpen: boolean;
}>;

export default function MediaSettingsFlyout({ children, className, isOpen }: Props): JSX.Element {
const [isTransitioning, setIsTransitioning] = React.useState(false);
const flyoutElRef = React.useRef<HTMLDivElement>(null);
const { activeRect } = React.useContext(MediaSettingsContext);
const { height, width } = activeRect || { height: 'auto', width: 'auto' };

React.useEffect(() => {
const { current: flyoutEl } = flyoutElRef;
const handleTransitionEnd = (): void => setIsTransitioning(false);
const handleTransitionStart = (): void => setIsTransitioning(true);

if (flyoutEl) {
flyoutEl.addEventListener('transitionend', handleTransitionEnd);
flyoutEl.addEventListener('transitionstart', handleTransitionStart);
}

return (): void => {
if (flyoutEl) {
flyoutEl.removeEventListener('transitionend', handleTransitionEnd);
flyoutEl.removeEventListener('transitionstart', handleTransitionStart);
}
};
}, []);

return (
<div
ref={flyoutElRef}
className={classNames('bp-MediaSettingsFlyout', className, {
'bp-is-open': isOpen,
'bp-is-transitioning': isTransitioning,
})}
style={{ height, width }}
>
{isOpen && children}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.bp-MediaSettingsMenu {
display: none;
padding: 8px;

&.bp-is-active {
display: table;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React from 'react';
import classNames from 'classnames';
import MediaSettingsContext, { Menu } from './MediaSettingsContext';
import { decodeKeydown } from '../../../../util';
import './MediaSettingsMenu.scss';

export type Props = React.PropsWithChildren<{
className?: string;
name: Menu;
}>;

export default function MediaSettingsMenu({ children, className, name }: Props): JSX.Element | null {
const [activeIndex, setActiveIndex] = React.useState(0);
const [activeItem, setActiveItem] = React.useState<HTMLDivElement | null>(null);
const { activeMenu, setActiveRect } = React.useContext(MediaSettingsContext);
const isActive = activeMenu === name;
const menuElRef = React.useRef<HTMLDivElement>(null);

const handleKeyDown = (event: React.KeyboardEvent): void => {
const key = decodeKeydown(event);
const max = React.Children.toArray(children).length - 1;

if (key === 'ArrowUp' && activeIndex > 0) {
setActiveIndex(activeIndex - 1);
}

if (key === 'ArrowDown' && activeIndex < max) {
setActiveIndex(activeIndex + 1);
}
};

React.useEffect(() => {
const { current: menuEl } = menuElRef;

if (menuEl && isActive) {
setActiveRect(menuEl.getBoundingClientRect());
}
}, [isActive, setActiveRect]);
ConradJChan marked this conversation as resolved.
Show resolved Hide resolved

React.useEffect(() => {
if (activeItem && isActive) {
activeItem.focus();
}
}, [activeItem, isActive]);

return (
<div
ref={menuElRef}
className={classNames('bp-MediaSettingsMenu', className, { 'bp-is-active': isActive })}
onKeyDown={handleKeyDown}
role="menu"
tabIndex={0}
>
{React.Children.map(children, (menuItem, menuIndex) => {
if (React.isValidElement(menuItem) && menuIndex === activeIndex) {
return React.cloneElement(menuItem, { ref: setActiveItem, ...menuItem.props });
}

return menuItem;
})}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
@import './styles';
@import '../styles';

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

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

.bp-SettingsControls-toggle-icon {
.bp-MediaSettingsToggle-icon {
transform: rotate(0);
transition: transform 300ms ease;
}
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 './MediaSettingsToggle.scss';

export type Props = {
isOpen: boolean;
onClick: () => void;
};

export type Ref = HTMLButtonElement;

function MediaSettingsToggle({ isOpen, onClick }: Props, ref: React.Ref<Ref>): JSX.Element {
return (
<button
ref={ref}
className={classNames('bp-MediaSettingsToggle', { 'bp-is-open': isOpen })}
onClick={onClick}
title={__('media_settings')}
type="button"
>
<IconGear24 className="bp-MediaSettingsToggle-icon" />
</button>
);
}

export default React.forwardRef(MediaSettingsToggle);
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { mount } from 'enzyme';
import MediaSettingsContext, { Context, Menu } from '../MediaSettingsContext';

describe('MediaSettingsContext', () => {
const getContext = (): Context => ({
activeMenu: Menu.MAIN,
activeRect: undefined,
setActiveMenu: jest.fn(),
setActiveRect: jest.fn(),
});
const TestComponent = (): JSX.Element => (
<div className="test">{React.useContext(MediaSettingsContext).activeMenu}</div>
);

test('should populate its context values', () => {
const wrapper = mount(<TestComponent />, {
wrappingComponent: MediaSettingsContext.Provider,
wrappingComponentProps: { value: getContext() },
});

expect(wrapper.text()).toBe(Menu.MAIN);
});
});
Loading