-
Notifications
You must be signed in to change notification settings - Fork 116
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(controls): Add core media settings control components (#1340)
Co-authored-by: Mingze Xiao <[email protected]>
- Loading branch information
Showing
18 changed files
with
626 additions
and
67 deletions.
There are no files selected for viewing
23 changes: 23 additions & 0 deletions
23
src/lib/viewers/controls/media/MediaSettingsControls/MediaSettingsContext.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}); |
83 changes: 83 additions & 0 deletions
83
src/lib/viewers/controls/media/MediaSettingsControls/MediaSettingsControls.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
29 changes: 29 additions & 0 deletions
29
src/lib/viewers/controls/media/MediaSettingsControls/MediaSettingsFlyout.scss
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
47 changes: 47 additions & 0 deletions
47
src/lib/viewers/controls/media/MediaSettingsControls/MediaSettingsFlyout.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
8 changes: 8 additions & 0 deletions
8
src/lib/viewers/controls/media/MediaSettingsControls/MediaSettingsMenu.scss
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
63 changes: 63 additions & 0 deletions
63
src/lib/viewers/controls/media/MediaSettingsControls/MediaSettingsMenu.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
8 changes: 4 additions & 4 deletions
8
...wers/controls/media/SettingsControls.scss → ...SettingsControls/MediaSettingsToggle.scss
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
27 changes: 27 additions & 0 deletions
27
src/lib/viewers/controls/media/MediaSettingsControls/MediaSettingsToggle.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
24 changes: 24 additions & 0 deletions
24
src/lib/viewers/controls/media/MediaSettingsControls/__tests__/MediaSettingsContext-test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
Oops, something went wrong.