box-ui-elements-mlh
Version:
278 lines (220 loc) • 8.69 kB
Flow
// @flow
import * as React from 'react';
import omit from 'lodash/omit';
import classNames from 'classnames';
import './Menu.scss';
/**
* The selectors are used to identify the menu item that is selected. We need to eventually
* rewrite this logic as there seem to be strong coupling between the selector and MenuItem
* that we want to decouple. The span is here to allow Menu to recognize MenuItem even if it is
* wrapped by a span coming from a tooltip.
*/
const MENU_ITEM_SELECTOR = '.menu-item:not([aria-disabled])';
const TOP_LEVEL_MENU_ITEM_SELECTOR = `ul:not(.submenu) > ${MENU_ITEM_SELECTOR}, ul:not(.submenu) > li > ${MENU_ITEM_SELECTOR}, ul:not(.submenu) > span > ${MENU_ITEM_SELECTOR}`;
const SUBMENU_ITEM_SELECTOR = `ul.submenu > ${MENU_ITEM_SELECTOR}, ul.submenu > li > ${MENU_ITEM_SELECTOR}, ul.submenu > span > ${MENU_ITEM_SELECTOR}`;
function stopPropagationAndPreventDefault(event) {
event.stopPropagation();
event.preventDefault();
}
type Props = {
children: React.Node,
className: string,
/** Focuses a specific menu item index when menu is mounted */
initialFocusIndex?: number,
isHidden?: boolean,
isSubmenu?: boolean,
/** menuItemSelector - overrides the default menu selector */
menuItemSelector?: string,
onClose?: Function,
/** Will fire this callback when menu should "close' */
setRef?: Function,
shouldOutlineFocus?: boolean,
};
class Menu extends React.Component<Props> {
static defaultProps = {
className: '',
isSubmenu: false,
isHidden: false,
};
constructor(props: Props) {
super(props);
this.focusIndex = 0;
this.menuEl = null;
this.menuItemEls = [];
}
componentDidMount() {
this.setMenuItemEls();
this.setInitialFocusIndex();
}
componentDidUpdate({ isHidden: prevIsHidden, children: prevChildren }: Props) {
const { children, isHidden, isSubmenu } = this.props;
if (isSubmenu && prevIsHidden && !isHidden) {
// If updating submenu, use the current props instead of previous props.
this.setMenuItemEls();
this.setInitialFocusIndex(this.props);
}
// update focus index and menu item elements when the number of children changes
if (React.Children.toArray(prevChildren).length !== React.Children.toArray(children).length) {
const focusedMenuItemEl = this.menuItemEls[this.focusIndex];
this.setMenuItemEls();
const { menuIndex } = this.getMenuItemElFromEventTarget(focusedMenuItemEl);
this.setFocus(menuIndex);
}
}
setInitialFocusIndex = (props: Props = this.props) => {
const { initialFocusIndex, isHidden } = props;
if (isHidden || initialFocusIndex === undefined) {
return;
}
// If an initialFocusIndex was specified, attempt to use it to focus
if (typeof initialFocusIndex === 'number') {
// We do this after a timeout so that the menu is properly mounted before we attempt to focus it
setTimeout(() => {
this.setFocus(initialFocusIndex);
}, 0);
} else if (initialFocusIndex === null) {
// If no initial focus index is set, focus on the menu itself so that keyboard shortcut still works after a mouse click.
setTimeout(() => {
if (this.menuEl) {
this.menuEl.focus();
}
}, 0);
}
};
setMenuItemEls = () => {
const { isSubmenu, menuItemSelector } = this.props;
const selector = menuItemSelector || (isSubmenu ? SUBMENU_ITEM_SELECTOR : TOP_LEVEL_MENU_ITEM_SELECTOR);
// Keep track of all the valid menu items that were rendered (querySelector since we don't want to pass ref functions to every single child)
this.menuItemEls = this.menuEl ? [].slice.call(this.menuEl.querySelectorAll(selector)) : [];
};
getMenuItemElFromEventTarget = (target: Node) => {
let menuItemEl = null;
let menuIndex = -1;
for (let i = 0; i < this.menuItemEls.length; i += 1) {
if (this.menuItemEls[i].contains(target)) {
menuItemEl = this.menuItemEls[i];
menuIndex = i;
break;
}
}
return { menuItemEl, menuIndex };
};
setFocus = (index: number) => {
if (!this.menuItemEls.length) {
return;
}
const numMenuItems = this.menuItemEls.length;
if (index >= numMenuItems) {
this.focusIndex = 0;
} else if (index < 0) {
this.focusIndex = numMenuItems - 1;
} else {
this.focusIndex = index;
}
this.menuItemEls[this.focusIndex].focus();
};
focusIndex: number;
keyboardPressed: ?boolean;
menuEl: ?HTMLUListElement;
menuItemEls: Array<HTMLElement>;
focusFirstItem = () => {
this.setFocus(0);
};
focusLastItem = () => {
this.setFocus(-1);
};
focusNextItem = () => {
this.setFocus(this.focusIndex + 1);
};
focusPreviousItem = () => {
this.setFocus(this.focusIndex - 1);
};
fireOnCloseHandler = (isKeyboardEvent: ?boolean, event: SyntheticEvent<>) => {
const { onClose } = this.props;
if (onClose) {
// We need to pass the event type so we know which item to focus.
onClose(isKeyboardEvent, event);
}
};
handleClick = (event: SyntheticEvent<HTMLUListElement>) => {
const { menuItemEl } = event.target instanceof Node ? this.getMenuItemElFromEventTarget(event.target) : {};
if (!menuItemEl) {
return;
}
this.fireOnCloseHandler(false, event);
};
handleKeyDown = (event: SyntheticKeyboardEvent<>) => {
const { isSubmenu, initialFocusIndex } = this.props;
switch (event.key) {
case 'ArrowDown':
stopPropagationAndPreventDefault(event);
// If it's first keyboard event, focus on first item.
if (initialFocusIndex === null && !this.keyboardPressed) {
this.focusFirstItem();
} else {
this.focusNextItem();
}
break;
case 'ArrowUp':
stopPropagationAndPreventDefault(event);
this.focusPreviousItem();
break;
case 'ArrowLeft':
// Close submenu when arrow-left is clicked
if (!isSubmenu) {
return;
}
stopPropagationAndPreventDefault(event);
this.fireOnCloseHandler(true, event);
break;
case 'Home':
case 'PageUp':
stopPropagationAndPreventDefault(event);
this.focusFirstItem();
break;
case 'End':
case 'PageDown':
stopPropagationAndPreventDefault(event);
this.focusLastItem();
break;
case 'Escape':
stopPropagationAndPreventDefault(event);
this.fireOnCloseHandler(true, event);
break;
case 'Tab':
// DO NOT PREVENT DEFAULT OR STOP PROPAGATION - This should move focus natively
this.fireOnCloseHandler(true, event);
break;
case ' ': // Spacebar
case 'Enter':
stopPropagationAndPreventDefault(event);
if (event.target instanceof HTMLElement) {
event.target.click();
}
break;
default:
break;
}
this.keyboardPressed = true;
};
render() {
const { children, className, isHidden, setRef, shouldOutlineFocus, ...rest } = this.props;
const menuProps = omit(rest, ['onClose', 'initialFocusIndex', 'isSubmenu', 'menuItemSelector']);
menuProps.className = classNames('aria-menu', className, {
'is-hidden': isHidden,
'should-outline-focus': shouldOutlineFocus,
});
menuProps.ref = ref => {
this.menuEl = ref;
if (setRef) {
setRef(ref);
}
};
menuProps.role = 'menu';
menuProps.tabIndex = -1;
menuProps.onClick = this.handleClick;
menuProps.onKeyDown = this.handleKeyDown;
return <ul {...menuProps}>{children}</ul>;
}
}
export default Menu;