import * as React from 'react'; import classNames from 'classnames'; import omit from 'lodash/omit'; import debounce from 'lodash/debounce'; import './SubmenuItem.scss'; import Arrow16 from '../../icon/fill/Arrow16'; const SUBMENU_LEFT_ALIGNED_CLASS = 'is-left-aligned'; const SUBMENU_BOTTOM_ALIGNED_CLASS = 'is-bottom-aligned'; export interface SubmenuItemProps { /** 'aria-disabled' - ARIA attribute describing whether the submenu item is disabled */ 'aria-disabled'?: boolean | 'true' | 'false'; /** 'aria-disabled' - ARIA attribute describing whether the submenu item is expanded */ 'aria-expanded'?: boolean | 'true' | 'false'; /** 'aria-disabled' - ARIA attribute describing whether the submenu item has a popup */ 'aria-haspopup'?: boolean | 'true' | 'false'; /** bottomBoundaryElement - an HTMLElement defining the bottom boundary for the submenu item */ bottomBoundaryElement?: HTMLElement; /** children - submenu item content */ children?: Array | React.ReactChild; /** className - CSS class name for the submenu item */ className?: string; /** isDisabled - whether the submenu item is disabled */ isDisabled?: boolean; /** onClick - function called when the submenu item is clicked */ onClick?: (event: React.MouseEvent) => void; /** onClick - function called when a key is pressed within the submenu item */ onKeyDown?: (event: React.KeyboardEvent) => void; /** onClick - function called when the mouse enters the submenu item */ onMouseEnter?: (event: React.MouseEvent) => void; /** onClick - function called when the mouse leaves the submenu item */ onMouseLeave?: (event: React.MouseEvent) => void; /** onOpen - function called when the submenu opens */ onOpen?: () => void; /** ref - reference to the menu element in the DOM */ ref?: string | ((instance: HTMLLIElement | null) => void) | React.RefObject | null | undefined; /** rightBoundaryElement - an HTMLElement defining the right boundary for the submenu item */ rightBoundaryElement?: HTMLElement; /** role - ARIA role for the submenu item */ role?: string; /** tabIndex - indicator of whether the submenu item is focusable */ tabIndex?: number; } interface SubmenuItemState { /** isSubmenuOpen - whether the submenu is open */ isSubmenuOpen: boolean; /** isSubmenuOpen - index of the focused submenu item */ submenuFocusIndex: number | null | undefined; } /** * A menu-item component which triggers open a submenu * * @NOTE: Nested submenus are NOT currently supported, switching * focus with arrow keys in the subsubmenu is not working properly. */ class SubmenuItem extends React.Component { state = { isSubmenuOpen: false, submenuFocusIndex: null, }; getMenuAlignmentClasses = (): Record => { if (!this.submenuTriggerEl || !this.submenuEl) { return {}; } const { rightBoundaryElement, bottomBoundaryElement } = this.props; const submenuElBounding = this.submenuEl.getBoundingClientRect(); const submenuTriggerElBounding = this.submenuTriggerEl.getBoundingClientRect(); const rightBoundaryElementBounding = rightBoundaryElement ? rightBoundaryElement.getBoundingClientRect() : { right: window.innerWidth }; const bottomBoundaryElementBounding = bottomBoundaryElement ? bottomBoundaryElement.getBoundingClientRect() : { bottom: window.innerHeight }; const isLeftAligned = submenuTriggerElBounding.right + submenuElBounding.width > rightBoundaryElementBounding.right; const isBottomAligned = submenuTriggerElBounding.top + submenuElBounding.height > bottomBoundaryElementBounding.bottom; return { [SUBMENU_LEFT_ALIGNED_CLASS]: isLeftAligned, [SUBMENU_BOTTOM_ALIGNED_CLASS]: isBottomAligned, }; }; handleMenuItemClick = (event: React.MouseEvent) => { const { isDisabled, onClick } = this.props; // If aria-disabled is passed as a prop, we should ignore clicks on this menu item if (isDisabled) { event.stopPropagation(); event.preventDefault(); return; } if (onClick) { onClick(event); } }; submenuTriggerEl: HTMLElement | null | undefined; submenuEl: HTMLElement | null | undefined; handleKeyDown = (event: React.KeyboardEvent) => { switch (event.key) { case ' ': case 'Enter': case 'ArrowRight': event.stopPropagation(); event.preventDefault(); this.openSubmenuAndFocus(); break; default: break; } }; closeSubmenu = debounce(() => { this.setState({ isSubmenuOpen: false, }); }, 50); closeSubmenuAndFocusTrigger = (isKeyboardEvent: boolean | null | undefined) => { this.closeSubmenu(); if (this.submenuTriggerEl && isKeyboardEvent) { this.submenuTriggerEl.focus(); } }; openSubmenu = () => { this.closeSubmenu.cancel(); const { onOpen } = this.props; if (onOpen) { onOpen(); } this.setState({ isSubmenuOpen: true, submenuFocusIndex: null, }); }; openSubmenuAndFocus = () => { const { onOpen } = this.props; if (onOpen) { onOpen(); } this.setState({ isSubmenuOpen: true, submenuFocusIndex: 0, }); }; render() { const { children, className, isDisabled, ...rest } = this.props; const { isSubmenuOpen, submenuFocusIndex } = this.state; const elements = React.Children.toArray(children); const submenuTriggerContent = elements[0]; const submenu = elements[1] as React.ReactElement; if (elements.length !== 2 || !submenuTriggerContent || !submenu) { throw new Error('SubmenuItem must have exactly two children, a trigger component and a '); } const chevron = ; const menuItemProps: SubmenuItemProps = { ...omit(rest, ['bottomBoundaryElement', 'onClick', 'onOpen', 'rightBoundaryElement', 'role', 'tabIndex']), 'aria-disabled': isDisabled ? 'true' : undefined, 'aria-expanded': isSubmenuOpen ? 'true' : 'false', 'aria-haspopup': 'true', className: classNames('menu-item', 'submenu-target', className), onClick: this.handleMenuItemClick, onMouseLeave: this.closeSubmenu, onMouseEnter: this.openSubmenu, onKeyDown: this.handleKeyDown, ref: (ref: HTMLLIElement | null) => { this.submenuTriggerEl = ref; }, role: 'menuitem', tabIndex: -1, }; const submenuProps = { className: classNames(submenu.props.className, 'submenu', this.getMenuAlignmentClasses()), initialFocusIndex: submenuFocusIndex, // Hide the menu instead of unmounting it. Otherwise onMouseLeave won't work. isHidden: !isSubmenuOpen, isSubmenu: true, onClose: this.closeSubmenuAndFocusTrigger, setRef: (ref: HTMLLIElement) => { this.submenuEl = ref; }, }; return (
  • {submenuTriggerContent} {chevron} {React.cloneElement(submenu, submenuProps)}
  • ); } } export default SubmenuItem;