UNPKG

box-ui-elements-mlh

Version:
176 lines (144 loc) 5.17 kB
// @flow import * as React from 'react'; import TetherComponent from 'react-tether'; import uniqueId from 'lodash/uniqueId'; import './ContextMenu.scss'; type Props = { /** A target component to attach to and a menu */ children: React.Node, /** * An array of tether constraints * * @see See [Tether](http://tether.io) for possible constraints */ constraints: Array<mixed>, /** When disabled, native context menu behavior is applied, and the menu will close if it was open */ isDisabled?: boolean, /** Called when menu is closed */ onMenuClose?: Function, /** Called when menu is opened */ onMenuOpen?: Function, }; type State = { isOpen: boolean, targetOffset: string, }; class ContextMenu extends React.Component<Props, State> { static defaultProps = { constraints: [], }; constructor(props: Props) { super(props); this.menuID = uniqueId('contextmenu'); this.menuTargetID = uniqueId('contextmenutarget'); } state = { isOpen: false, targetOffset: '', }; componentDidUpdate(prevProps: Props, prevState: State): void { const { isOpen } = this.state; const { isOpen: prevIsOpen } = prevState; const { isDisabled: prevIsDisabled } = prevProps; const { isDisabled } = this.props; if (!prevIsOpen && isOpen) { // When menu is being opened document.addEventListener('click', this.handleDocumentClick, true); document.addEventListener('contextmenu', this.handleDocumentClick, true); } else if (prevIsOpen && !isOpen) { // When menu is being closed document.removeEventListener('contextmenu', this.handleDocumentClick, true); document.removeEventListener('click', this.handleDocumentClick, true); } // if the menu becomes disabled while it is open, we should close it if (!isDisabled && prevIsDisabled && isOpen) { this.handleMenuClose(); } } componentWillUnmount() { if (this.state.isOpen) { // Clean-up global click handlers document.removeEventListener('contextmenu', this.handleDocumentClick, true); document.removeEventListener('click', this.handleDocumentClick, true); } } menuID: string; menuTargetID: string; closeMenu = () => { const { onMenuClose } = this.props; this.setState({ isOpen: false }); if (onMenuClose) { onMenuClose(); } }; focusTarget = () => { // breaks encapsulation but the only alternative is passing a ref to an unknown child component const menuTargetEl = document.getElementById(this.menuTargetID); if (menuTargetEl) { menuTargetEl.focus(); } }; handleMenuClose = () => { this.closeMenu(); this.focusTarget(); }; handleDocumentClick = (event: MouseEvent) => { const menuEl = document.getElementById(this.menuID); if (menuEl && event.target instanceof Node && menuEl.contains(event.target)) { return; } this.closeMenu(); }; handleContextMenu = (event: MouseEvent) => { if (this.props.isDisabled) { return; } const menuTargetEl = document.getElementById(this.menuTargetID); const targetRect = menuTargetEl ? menuTargetEl.getBoundingClientRect() : { left: 0, top: 0 }; const verticalOffset = event.clientY - targetRect.top; const horizontalOffset = event.clientX - targetRect.left; this.setState({ isOpen: true, targetOffset: `${verticalOffset}px ${horizontalOffset}px`, }); const { onMenuOpen } = this.props; if (onMenuOpen) { onMenuOpen(); } event.preventDefault(); }; render() { const { children, constraints } = this.props; const { isOpen, targetOffset } = this.state; const elements = React.Children.toArray(children); if (elements.length !== 2) { throw new Error('ContextMenu must have exactly two children: A target component and a <Menu>'); } const menuTarget = elements[0]; const menu = elements[1]; const menuTargetProps = { id: this.menuTargetID, key: this.menuTargetID, onContextMenu: this.handleContextMenu, }; const menuProps = { id: this.menuID, key: this.menuID, initialFocusIndex: null, onClose: this.handleMenuClose, }; return ( <TetherComponent attachment="top left" classPrefix="context-menu" constraints={constraints} targetAttachment="top left" targetOffset={targetOffset} > {React.cloneElement(menuTarget, menuTargetProps)} {isOpen && React.cloneElement(menu, menuProps)} </TetherComponent> ); } } export default ContextMenu;