mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-27 19:42:11 +10:00
202 lines
6.5 KiB
JavaScript
202 lines
6.5 KiB
JavaScript
/**
|
|
* Menu is component that shows extra content when it is clicked.
|
|
* Clicking it toggles visibility of MenuContent.
|
|
*
|
|
* Example:
|
|
* <Menu>
|
|
* <MenuLabel>
|
|
* <span>Open menu</span>
|
|
* </MenuLabel>
|
|
* <MenuContent>
|
|
* <MenuItem key="first item">
|
|
* <Button onClick={onClick}>Click this</Button>
|
|
* </MenuItem>
|
|
* </MenuContent>
|
|
* </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 <div className="foo" onClick={() => {}} 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 (
|
|
<div
|
|
className={classes}
|
|
onBlur={this.onBlur}
|
|
onKeyDown={this.onKeyDown}
|
|
ref={c => {
|
|
this.menu = c;
|
|
}}
|
|
>
|
|
{menuChildren}
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
/* 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;
|