From 7dda0c61cb5833132e6c7bdb93a2d12b8a08b630 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Wed, 30 Aug 2017 12:45:39 +0300 Subject: [PATCH] Extend Menu (state can be handled by parent component; and arrow, position, offset are optional) --- src/components/Menu/Menu.js | 74 ++++++++++++++++++++++----- src/components/MenuLabel/MenuLabel.js | 2 + 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/components/Menu/Menu.js b/src/components/Menu/Menu.js index 17b85707..35a55660 100644 --- a/src/components/Menu/Menu.js +++ b/src/components/Menu/Menu.js @@ -24,6 +24,12 @@ 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 */ @@ -33,6 +39,15 @@ class Menu extends Component { this.state = { isOpen: false }; + const { isOpen, onToggleActive } = props; + if ((isOpen !== null || onToggleActive !== null) && !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); @@ -60,20 +75,30 @@ class Menu extends Component { } toggleOpen(enforcedState) { - this.setState(prevState => { - const isOpen = enforcedState != null ? enforcedState : !prevState.isOpen; - return { isOpen }; - }); + // 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() { + 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; - return distanceToRight < contentWidthBiggerThanLabel - ? { right: -1 * CONTENT_PLACEMENT_OFFSET, minWidth: 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 {}; @@ -82,8 +107,9 @@ class Menu extends Component { positionStyleForArrow(isPositionedRight) { if (this.menu) { const menuWidth = this.menu.offsetWidth; + const contentPlacementOffset = this.props.contentPlacementOffset; return isPositionedRight - ? Math.floor(menuWidth / 2) + CONTENT_PLACEMENT_OFFSET + ? Math.floor(menuWidth / 2) - contentPlacementOffset : Math.floor(menuWidth / 2); } return 0; @@ -95,23 +121,30 @@ class Menu extends Component { } 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: this.state.isOpen, + 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 positionStyles = this.positionStyleForMenuContent(); + const { contentPosition, useArrow } = this.props; + const positionStyles = this.positionStyleForMenuContent(contentPosition); + const arrowPosition = useArrow + ? this.positionStyleForArrow(positionStyles.right != null) + : null; return React.cloneElement(child, { - arrowPosition: this.positionStyleForArrow(positionStyles.right != null), + arrowPosition, contentRef: node => { this.menuContent = node; }, - isOpen: this.state.isOpen, + isOpen, style: { ...child.props.style, ...positionStyles }, }); } else { @@ -142,14 +175,27 @@ class Menu extends Component { } /* eslint-enable jsx-a11y/no-static-element-interactions */ -Menu.defaultProps = { className: null, rootClassName: '' }; +Menu.defaultProps = { + className: null, + rootClassName: '', + contentPlacementOffset: CONTENT_PLACEMENT_OFFSET, + contentPosition: CONTENT_TO_RIGHT, + isOpen: null, + onToggleActive: null, + useArrow: true, +}; -const { node, string } = PropTypes; +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; diff --git a/src/components/MenuLabel/MenuLabel.js b/src/components/MenuLabel/MenuLabel.js index 38fb045e..db85ca89 100644 --- a/src/components/MenuLabel/MenuLabel.js +++ b/src/components/MenuLabel/MenuLabel.js @@ -17,6 +17,8 @@ class MenuLabel extends Component { } onClick(e) { + e.stopPropagation(); + e.preventDefault(); this.props.onToggleActive(); // Don't show focus outline if user just clicked the element with mouse