UNPKG

metadata-based-explorer1

Version:
171 lines (141 loc) 5.06 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: [], }; state = { isOpen: false, targetOffset: '', }; componentWillMount() { this.menuID = uniqueId('contextmenu'); this.menuTargetID = uniqueId('contextmenutarget'); } componentWillReceiveProps(nextProps: Props) { // if the menu becomes disabled while it is open, we should close it if (!this.props.isDisabled && nextProps.isDisabled && this.state.isOpen) { this.handleMenuClose(); } } componentDidUpdate(prevProps: Props, prevState: State) { if (!prevState.isOpen && this.state.isOpen) { // When menu is being opened document.addEventListener('click', this.handleDocumentClick, true); document.addEventListener('contextmenu', this.handleDocumentClick, true); } else if (prevState.isOpen && !this.state.isOpen) { // When menu is being closed document.removeEventListener('contextmenu', this.handleDocumentClick, true); document.removeEventListener('click', this.handleDocumentClick, true); } } 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;