/**
* Menu is component that shows extra content when it is clicked.
* Clicking it toggles visibility of MenuContent.
*
* Example:
*
{}} 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;