Skip to content

Commit

Permalink
feat(controls): Add core media settings control components (#1340)
Browse files Browse the repository at this point in the history
Co-authored-by: Mingze Xiao <mxiao@box.com>
  • Loading branch information
jstoffan and Mingze Xiao authored Mar 12, 2021
1 parent 5de1252 commit 1d26190
Show file tree
Hide file tree
Showing 18 changed files with 626 additions and 67 deletions.
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;
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]);

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

0 comments on commit 1d26190

Please sign in to comment.