276 lines
7.0 KiB
JavaScript
276 lines
7.0 KiB
JavaScript
import React from 'react';
|
|
import { CSSTransition } from 'react-transition-group';
|
|
import PropTypes from 'prop-types';
|
|
|
|
const MenuTrigger = ({ tag, className, ...attributes }) => React.createElement(tag, {
|
|
className: `menu-trigger ${className}`,
|
|
...attributes,
|
|
});
|
|
MenuTrigger.propTypes = {
|
|
tag: PropTypes.string,
|
|
className: PropTypes.string,
|
|
};
|
|
MenuTrigger.defaultProps = {
|
|
tag: 'div',
|
|
className: null,
|
|
};
|
|
const MenuTriggerType = <MenuTrigger />.type;
|
|
|
|
const MenuContent = ({ tag, className, ...attributes }) => React.createElement(tag, {
|
|
className: ['menu-content', className].join(' '),
|
|
...attributes,
|
|
});
|
|
MenuContent.propTypes = {
|
|
tag: PropTypes.string,
|
|
className: PropTypes.string,
|
|
};
|
|
MenuContent.defaultProps = {
|
|
tag: 'div',
|
|
className: null,
|
|
};
|
|
|
|
const menuPropTypes = {
|
|
tag: PropTypes.string,
|
|
onClose: PropTypes.func,
|
|
onOpen: PropTypes.func,
|
|
closeOnDocumentClick: PropTypes.bool,
|
|
respondToPointerEvents: PropTypes.bool,
|
|
className: PropTypes.string,
|
|
transitionTimeout: PropTypes.number,
|
|
transitionClassName: PropTypes.string,
|
|
children: PropTypes.arrayOf(PropTypes.node).isRequired,
|
|
};
|
|
|
|
class Menu extends React.Component {
|
|
constructor(props) {
|
|
super(props);
|
|
|
|
this.menu = React.createRef();
|
|
this.state = {
|
|
expanded: false,
|
|
};
|
|
|
|
this.onTriggerClick = this.onTriggerClick.bind(this);
|
|
this.onCloseClick = this.onCloseClick.bind(this);
|
|
this.onKeyDown = this.onKeyDown.bind(this);
|
|
this.onDocumentClick = this.onDocumentClick.bind(this);
|
|
this.onMouseEnter = this.onMouseEnter.bind(this);
|
|
this.onMouseLeave = this.onMouseLeave.bind(this);
|
|
}
|
|
|
|
// Lifecycle Events
|
|
componentWillUnmount() {
|
|
document.removeEventListener('touchend', this.onDocumentClick, true);
|
|
document.removeEventListener('click', this.onDocumentClick, true);
|
|
|
|
// Call onClose callback when unmounting and open
|
|
if (this.state.expanded && this.props.onClose) {
|
|
this.props.onClose();
|
|
}
|
|
}
|
|
|
|
// Event handlers
|
|
onDocumentClick(e) {
|
|
if (!this.props.closeOnDocumentClick) {
|
|
return;
|
|
}
|
|
|
|
const clickIsInMenu = this.menu.current === e.target || this.menu.current.contains(e.target);
|
|
if (clickIsInMenu) {
|
|
return;
|
|
}
|
|
|
|
this.close();
|
|
}
|
|
|
|
onTriggerClick(e) {
|
|
// Let the browser follow the link of the trigger if the menu
|
|
// is already expanded and the trigger has an href attribute
|
|
if (this.state.expanded && e.target.getAttribute('href')) {
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
this.toggle();
|
|
}
|
|
|
|
onCloseClick() {
|
|
this.getFocusableElements()[0].focus();
|
|
this.close();
|
|
}
|
|
|
|
onKeyDown(e) {
|
|
if (!this.state.expanded) {
|
|
return;
|
|
}
|
|
switch (e.key) {
|
|
case 'Escape': {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.getFocusableElements()[0].focus();
|
|
this.close();
|
|
break;
|
|
}
|
|
case 'Enter': {
|
|
// Using focusable elements instead of a ref to the trigger
|
|
// because Hyperlink and Button can handle refs as functional components
|
|
if (document.activeElement === this.getFocusableElements()[0]) {
|
|
e.preventDefault();
|
|
this.toggle();
|
|
}
|
|
break;
|
|
}
|
|
case 'Tab': {
|
|
e.preventDefault();
|
|
if (e.shiftKey) {
|
|
this.focusPrevious();
|
|
} else {
|
|
this.focusNext();
|
|
}
|
|
break;
|
|
}
|
|
case 'ArrowDown': {
|
|
e.preventDefault();
|
|
this.focusNext();
|
|
break;
|
|
}
|
|
case 'ArrowUp': {
|
|
e.preventDefault();
|
|
this.focusPrevious();
|
|
break;
|
|
}
|
|
default:
|
|
}
|
|
}
|
|
|
|
onMouseEnter() {
|
|
if (!this.props.respondToPointerEvents) {
|
|
return;
|
|
}
|
|
this.open();
|
|
}
|
|
|
|
onMouseLeave() {
|
|
if (!this.props.respondToPointerEvents) {
|
|
return;
|
|
}
|
|
this.close();
|
|
}
|
|
|
|
// Internal functions
|
|
|
|
getFocusableElements() {
|
|
return this.menu.current.querySelectorAll('button:not([disabled]), [href]:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])');
|
|
}
|
|
|
|
getAttributesFromProps() {
|
|
// Any extra props are attributes for the menu
|
|
const attributes = {};
|
|
Object.keys(this.props)
|
|
.filter(property => menuPropTypes[property] === undefined)
|
|
.forEach((property) => {
|
|
attributes[property] = this.props[property];
|
|
});
|
|
return attributes;
|
|
}
|
|
|
|
focusNext() {
|
|
const focusableElements = Array.from(this.getFocusableElements());
|
|
const activeIndex = focusableElements.indexOf(document.activeElement);
|
|
const nextIndex = (activeIndex + 1) % focusableElements.length;
|
|
focusableElements[nextIndex].focus();
|
|
}
|
|
|
|
focusPrevious() {
|
|
const focusableElements = Array.from(this.getFocusableElements());
|
|
const activeIndex = focusableElements.indexOf(document.activeElement);
|
|
const previousIndex = (activeIndex || focusableElements.length) - 1;
|
|
focusableElements[previousIndex].focus();
|
|
}
|
|
|
|
open() {
|
|
if (this.props.onOpen) {
|
|
this.props.onOpen();
|
|
}
|
|
this.setState({ expanded: true });
|
|
// Listen to touchend and click events to ensure the menu
|
|
// can be closed on mobile, pointer, and mixed input devices
|
|
document.addEventListener('touchend', this.onDocumentClick, true);
|
|
document.addEventListener('click', this.onDocumentClick, true);
|
|
}
|
|
|
|
close() {
|
|
if (this.props.onClose) {
|
|
this.props.onClose();
|
|
}
|
|
this.setState({ expanded: false });
|
|
document.removeEventListener('touchend', this.onDocumentClick, true);
|
|
document.removeEventListener('click', this.onDocumentClick, true);
|
|
}
|
|
|
|
toggle() {
|
|
if (this.state.expanded) {
|
|
this.close();
|
|
} else {
|
|
this.open();
|
|
}
|
|
}
|
|
|
|
renderTrigger(node) {
|
|
return React.cloneElement(node, {
|
|
onClick: this.onTriggerClick,
|
|
'aria-haspopup': 'menu',
|
|
'aria-expanded': this.state.expanded,
|
|
});
|
|
}
|
|
|
|
renderMenuContent(node) {
|
|
return (
|
|
<CSSTransition
|
|
in={this.state.expanded}
|
|
timeout={this.props.transitionTimeout}
|
|
classNames={this.props.transitionClassName}
|
|
unmountOnExit
|
|
>
|
|
{node}
|
|
</CSSTransition>
|
|
);
|
|
}
|
|
|
|
render() {
|
|
const { className } = this.props;
|
|
|
|
const wrappedChildren = React.Children.map(this.props.children, (child) => {
|
|
if (child.type === MenuTriggerType) {
|
|
return this.renderTrigger(child);
|
|
}
|
|
return this.renderMenuContent(child);
|
|
});
|
|
|
|
const rootClassName = this.state.expanded ? 'menu expanded' : 'menu';
|
|
|
|
return React.createElement(this.props.tag, {
|
|
className: `${rootClassName} ${className}`,
|
|
ref: this.menu,
|
|
onKeyDown: this.onKeyDown,
|
|
onMouseEnter: this.onMouseEnter,
|
|
onMouseLeave: this.onMouseLeave,
|
|
...this.getAttributesFromProps(),
|
|
}, wrappedChildren);
|
|
}
|
|
}
|
|
|
|
Menu.propTypes = menuPropTypes;
|
|
Menu.defaultProps = {
|
|
tag: 'div',
|
|
className: null,
|
|
onClose: null,
|
|
onOpen: null,
|
|
respondToPointerEvents: false,
|
|
closeOnDocumentClick: true,
|
|
transitionTimeout: 250,
|
|
transitionClassName: 'menu-content',
|
|
};
|
|
|
|
export { Menu, MenuTrigger, MenuContent };
|