box-ui-elements-mlh
Version:
176 lines (144 loc) • 5.17 kB
Flow
// @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;