UNPKG

box-ui-elements-mlh

Version:
260 lines (215 loc) 7.76 kB
// @flow import * as React from 'react'; import TetherComponent from 'react-tether'; import uniqueId from 'lodash/uniqueId'; import noop from 'lodash/noop'; import { KEYS } from '../../constants'; import './DropdownMenu.scss'; type Props = { bodyElement?: HTMLElement, children: React.Node, /** Forces menu to render within the scroll parent */ className?: string, /** Forces menu to render within the visible window */ constrainToScrollParent: boolean, /** Right aligns menu to button */ constrainToWindow: boolean, /** Function called when menu is opened */ isRightAligned: boolean, /** Handler for dropdown menu close events */ onMenuClose?: (event: SyntheticEvent<> | MouseEvent) => void, /** Handler for dropdown menu open events */ onMenuOpen?: () => void, /** Set true to close dropdown menu on event bubble instead of event capture */ useBubble?: boolean, }; type State = { initialFocusIndex: ?number, isOpen: boolean, }; class DropdownMenu extends React.Component<Props, State> { static defaultProps = { constrainToScrollParent: false, constrainToWindow: false, isRightAligned: false, }; menuID = uniqueId('menu'); menuButtonID = uniqueId('menubutton'); state = { initialFocusIndex: null, isOpen: false, }; componentDidUpdate(prevProps: Props, prevState: State) { const { useBubble } = this.props; if (!prevState.isOpen && this.state.isOpen) { // When menu is being opened document.addEventListener('click', this.handleDocumentClick, !useBubble); document.addEventListener('contextmenu', this.handleDocumentClick, !useBubble); const { onMenuOpen } = this.props; if (onMenuOpen) { onMenuOpen(); } } else if (prevState.isOpen && !this.state.isOpen) { // When menu is being closed document.removeEventListener('contextmenu', this.handleDocumentClick, !useBubble); document.removeEventListener('click', this.handleDocumentClick, !useBubble); } } componentWillUnmount() { const { useBubble } = this.props; if (this.state.isOpen) { // Clean-up global click handlers document.removeEventListener('contextmenu', this.handleDocumentClick, !useBubble); document.removeEventListener('click', this.handleDocumentClick, !useBubble); } } menuID: string; menuButtonID: string; openMenuAndSetFocusIndex = (initialFocusIndex: ?number) => { this.setState({ initialFocusIndex, isOpen: true, }); }; closeMenu = (event: SyntheticEvent<> | MouseEvent) => { const { onMenuClose = noop } = this.props; this.setState( { isOpen: false, }, () => onMenuClose(event), ); }; focusButton = () => { // @NOTE: This breaks encapsulation a bit, but the only other way is passing ref functions to unknown children components const menuButtonEl = document.getElementById(this.menuButtonID); if (menuButtonEl) { menuButtonEl.focus(); } }; handleButtonClick = (event: SyntheticEvent<>) => { const { isOpen } = this.state; event.stopPropagation(); event.preventDefault(); if (isOpen) { this.closeMenu(event); } else { this.openMenuAndSetFocusIndex(null); } }; handleButtonKeyDown = (event: SyntheticKeyboardEvent<>) => { const { isOpen } = this.state; switch (event.key) { case KEYS.space: case KEYS.enter: case KEYS.arrowDown: event.stopPropagation(); event.preventDefault(); this.openMenuAndSetFocusIndex(0); break; case KEYS.arrowUp: event.stopPropagation(); event.preventDefault(); this.openMenuAndSetFocusIndex(-1); break; case KEYS.escape: if (isOpen) { event.stopPropagation(); } event.preventDefault(); this.closeMenu(event); break; default: break; } }; handleMenuClose = (isKeyboardEvent: boolean, event: SyntheticEvent<> | MouseEvent) => { this.closeMenu(event); this.focusButton(); }; handleDocumentClick = (event: MouseEvent) => { const menuEl = document.getElementById(this.menuID); const menuButtonEl = document.getElementById(this.menuButtonID); // Some DOM magic to get global click handlers to close menu when not interacting with menu or associated button if ( menuEl && menuButtonEl && event.target instanceof Node && !menuEl.contains(event.target) && !menuButtonEl.contains(event.target) ) { this.closeMenu(event); } }; render() { const { bodyElement, children, isRightAligned, constrainToScrollParent, constrainToWindow, className, } = this.props; const { isOpen, initialFocusIndex } = this.state; const elements = React.Children.toArray(children); if (elements.length !== 2) { throw new Error('DropdownMenu must have exactly two children: A button component and a <Menu>'); } const menuButton = elements[0]; const menu = elements[1]; const menuButtonProps: Object = { id: this.menuButtonID, key: this.menuButtonID, onClick: this.handleButtonClick, // NOTE: Overrides button's handler onKeyDown: this.handleButtonKeyDown, // NOTE: Overrides button's handler 'aria-haspopup': 'true', 'aria-expanded': isOpen ? 'true' : 'false', }; // Add this only when its open, otherwise the menuID element isn't rendered if (isOpen) { menuButtonProps['aria-controls'] = this.menuID; } const menuProps = { id: this.menuID, key: this.menuID, initialFocusIndex, onClose: this.handleMenuClose, 'aria-labelledby': this.menuButtonID, }; let attachment = 'top left'; let targetAttachment = 'bottom left'; if (isRightAligned) { attachment = 'top right'; targetAttachment = 'bottom right'; } const constraints = []; if (constrainToScrollParent) { constraints.push({ to: 'scrollParent', attachment: 'together', }); } if (constrainToWindow) { constraints.push({ to: 'window', attachment: 'together', }); } const bodyEl = bodyElement instanceof HTMLElement ? bodyElement : document.body; return ( <TetherComponent attachment={attachment} bodyElement={bodyEl} className={className} classPrefix="dropdown-menu" constraints={constraints} enabled={isOpen} targetAttachment={targetAttachment} > {React.cloneElement(menuButton, menuButtonProps)} {isOpen ? React.cloneElement(menu, menuProps) : null} </TetherComponent> ); } } export default DropdownMenu;