/** * Menu is component that shows extra content when it is clicked. * Clicking it toggles visibility of MenuContent. * * Example: * * * Open menu * * * * * * * * */ import React, { Component, PropTypes } from 'react'; import classNames from 'classnames'; import { MenuContent, MenuLabel } from '../../components'; import css from './Menu.css'; const KEY_CODE_ESCAPE = 27; const CONTENT_PLACEMENT_OFFSET = 0; const CONTENT_TO_LEFT = 'left'; const CONTENT_TO_RIGHT = 'right'; const isControlledMenu = (isOpenProp, onToggleActiveProp) => { return isOpenProp !== null && onToggleActiveProp !== null; }; // This should work, but it doesn't
{}} role="button" /> /* eslint-disable jsx-a11y/no-static-element-interactions */ class Menu extends Component { constructor(props) { super(props); this.state = { isOpen: false }; const { isOpen, onToggleActive } = props; const isIndependentMenu = isOpen === null && onToggleActive === null; if (!(isIndependentMenu || isControlledMenu(isOpen, onToggleActive))) { throw new Error( `Menu has invalid props: Both isOpen and onToggleActive need to be defined (controlled menu), or neither of them (menu uses its own state management).` ); } this.onBlur = this.onBlur.bind(this); this.onKeyDown = this.onKeyDown.bind(this); this.toggleOpen = this.toggleOpen.bind(this); this.prepareChildren = this.prepareChildren.bind(this); this.positionStyleForMenuContent = this.positionStyleForMenuContent.bind(this); this.positionStyleForArrow = this.positionStyleForArrow.bind(this); this.menu = null; this.menuContent = null; } onBlur(event) { // FocusEvent is fired faster than the link elements native click handler // gets its own event. Therefore, we need to check the origin of this FocusEvent. if (!this.menu.contains(event.relatedTarget)) { this.setState({ isOpen: false }); } } onKeyDown(e) { // Gather all escape presses to close menu if (e.keyCode === KEY_CODE_ESCAPE) { this.toggleOpen(false); } } toggleOpen(enforcedState) { // If state is handled outside of Menu component, we call a passed in onToggleActive func const { isOpen, onToggleActive } = this.props; if (isControlledMenu(isOpen, onToggleActive)) { const isMenuOpen = enforcedState != null ? enforcedState : !isOpen; onToggleActive(isMenuOpen); } else { // If state is handled inside of Menu component, set state this.setState(prevState => { const isMenuOpen = enforcedState != null ? enforcedState : !prevState.isOpen; return { isOpen: isMenuOpen }; }); } } positionStyleForMenuContent(contentPosition) { if (this.menu && this.menuContent) { // Calculate wether we should show the menu to the left of the component or right const distanceToRight = window.innerWidth - this.menu.getBoundingClientRect().right; const menuWidth = this.menu.offsetWidth; const contentWidthBiggerThanLabel = this.menuContent.offsetWidth - menuWidth; const usePositionRightFromLabel = contentPosition === CONTENT_TO_LEFT; const contentPlacementOffset = this.props.contentPlacementOffset; return usePositionRightFromLabel || distanceToRight < contentWidthBiggerThanLabel ? { right: contentPlacementOffset, minWidth: menuWidth } : { left: 0, minWidth: menuWidth }; } return {}; } positionStyleForArrow(isPositionedRight) { if (this.menu) { const menuWidth = this.menu.offsetWidth; const contentPlacementOffset = this.props.contentPlacementOffset; return isPositionedRight ? Math.floor(menuWidth / 2) - contentPlacementOffset : Math.floor(menuWidth / 2); } return 0; } prepareChildren() { if (React.Children.count(this.props.children) !== 2) { throw new Error('Menu needs to have two children: MenuLabel and MenuContent.'); } return React.Children.map(this.props.children, child => { const { isOpen: isOpenProp, onToggleActive } = this.props; const isOpen = isControlledMenu(isOpenProp, onToggleActive) ? isOpenProp : this.state.isOpen; if (child.type === MenuLabel) { // MenuLabel needs toggleOpen function // We pass that directly so that component user doesn't need to worry about that return React.cloneElement(child, { isOpen, onToggleActive: this.toggleOpen, }); } else if (child.type === MenuContent) { // MenuContent needs some styling data (width, arrowPosition, and isOpen info) // We pass those directly so that component user doesn't need to worry about those. const { contentPosition, useArrow } = this.props; const positionStyles = this.positionStyleForMenuContent(contentPosition); const arrowPosition = useArrow ? this.positionStyleForArrow(positionStyles.right != null) : null; return React.cloneElement(child, { arrowPosition, contentRef: node => { this.menuContent = node; }, isOpen, style: { ...child.props.style, ...positionStyles }, }); } else { throw new Error('Menu has an unknown child. Only MenuLabel and MenuContent are allowed.'); } }); } render() { const { className, rootClassName } = this.props; const rootClass = rootClassName || css.root; const classes = classNames(rootClass, className); const menuChildren = this.prepareChildren(); return (
{ this.menu = c; }} > {menuChildren}
); } } /* eslint-enable jsx-a11y/no-static-element-interactions */ Menu.defaultProps = { className: null, rootClassName: '', contentPlacementOffset: CONTENT_PLACEMENT_OFFSET, contentPosition: CONTENT_TO_RIGHT, isOpen: null, onToggleActive: null, useArrow: true, }; const { bool, func, node, number, string } = PropTypes; Menu.propTypes = { children: node.isRequired, className: string, rootClassName: string, contentPosition: string, contentPlacementOffset: number, useArrow: bool, isOpen: bool, onToggleActive: func, }; export default Menu;