import { useRef, useContext, useEffect, useMemo, useImperativeHandle } from 'react'; import { createPortal } from 'react-dom'; import { node, func, bool, shape, oneOf, oneOfType } from 'prop-types'; import { useBEM, useCombinedRef, useMenuChange, useMenuStateAndFocus, useItemEffect } from '../hooks'; import { MenuList } from './MenuList'; import { attachHandlerProps, batchedUpdates, commonProps, safeCall, stylePropTypes, uncontrolledMenuPropTypes, menuPropTypes, menuClass, subMenuClass, menuItemClass, isMenuOpen, withHovering, SettingsContext, ItemSettingsContext, MenuListContext, MenuListItemContext, Keys, HoverActionTypes, FocusPositions } from '../utils'; export const SubMenu = withHovering( 'SubMenu', function SubMenu({ 'aria-label': ariaLabel, className, disabled, direction, label, openTrigger, onMenuChange, isHovering, instanceRef, itemRef, captureFocus: _1, repositionFlag: _2, itemProps = {}, ...restProps }) { const settings = useContext(SettingsContext); const { rootMenuRef } = settings; const { submenuOpenDelay, submenuCloseDelay } = useContext(ItemSettingsContext); const { parentMenuRef, parentDir, overflow: parentOverflow } = useContext(MenuListContext); const { isParentOpen, isSubmenuOpen, setOpenSubmenuCount, dispatch, updateItems } = useContext(MenuListItemContext); const isPortal = parentOverflow !== 'visible'; const [stateProps, toggleMenu, _openMenu] = useMenuStateAndFocus(settings); const { state } = stateProps; const isDisabled = !!disabled; const isOpen = isMenuOpen(state); const containerRef = useRef(null); const timeoutId = useRef(0); const stopTimer = () => { if (timeoutId.current) { clearTimeout(timeoutId.current); timeoutId.current = 0; } }; const openMenu = (...args) => { stopTimer(); !isDisabled && _openMenu(...args); }; const setHover = () => !isHovering && !isDisabled && dispatch(HoverActionTypes.SET, itemRef.current); const delayOpen = (delay) => { setHover(); if (!openTrigger) timeoutId.current = setTimeout(() => batchedUpdates(openMenu), Math.max(delay, 0)); }; const handleMouseMove = () => { if (timeoutId.current || isOpen || isDisabled) return; if (isSubmenuOpen) { timeoutId.current = setTimeout( () => delayOpen(submenuOpenDelay - submenuCloseDelay), submenuCloseDelay ); } else { delayOpen(submenuOpenDelay); } }; const handleMouseLeave = () => { stopTimer(); if (!isOpen) dispatch(HoverActionTypes.UNSET, itemRef.current); }; const handleKeyDown = (e) => { let handled = false; switch (e.key) { // LEFT key is bubbled up from submenu items case Keys.LEFT: if (isOpen) { itemRef.current.focus(); toggleMenu(false); handled = true; } break; // prevent browser from scrolling page to the right case Keys.RIGHT: if (!isOpen) handled = true; break; } if (handled) { e.preventDefault(); e.stopPropagation(); } }; const handleItemKeyDown = (e) => { if (!isHovering) return; switch (e.key) { case Keys.ENTER: case Keys.SPACE: case Keys.RIGHT: openTrigger !== 'none' && openMenu(FocusPositions.FIRST); break; } }; useItemEffect(isDisabled, itemRef, updateItems); useMenuChange(onMenuChange, isOpen); useEffect(() => () => clearTimeout(timeoutId.current), []); useEffect(() => { // Don't set focus when parent menu is closed, otherwise focus will be lost // and onBlur event will be fired with relatedTarget setting as null. if (isHovering && isParentOpen) { itemRef.current.focus(); } else { toggleMenu(false); } }, [isHovering, isParentOpen, toggleMenu, itemRef]); useEffect(() => { setOpenSubmenuCount((count) => (isOpen ? count + 1 : Math.max(count - 1, 0))); }, [setOpenSubmenuCount, isOpen]); useImperativeHandle(instanceRef, () => ({ openMenu: (...args) => { if (isParentOpen) { setHover(); openMenu(...args); } }, closeMenu: () => { if (isOpen) { itemRef.current.focus(); toggleMenu(false); } } })); const modifiers = useMemo( () => ({ open: isOpen, hover: isHovering, disabled: isDisabled, submenu: true }), [isOpen, isHovering, isDisabled] ); const { ref: externalItemRef, className: itemClassName, ...restItemProps } = itemProps; const itemHandlers = attachHandlerProps( { onMouseMove: handleMouseMove, onMouseLeave: handleMouseLeave, onMouseDown: setHover, onKeyDown: handleItemKeyDown, onClick: () => openTrigger !== 'none' && openMenu() }, restItemProps ); const getMenuList = () => { const menuList = ( <MenuList {...restProps} {...stateProps} ariaLabel={ariaLabel || (typeof label === 'string' ? label : 'Submenu')} anchorRef={itemRef} containerRef={isPortal ? rootMenuRef : containerRef} direction={ direction || (parentDir === 'right' || parentDir === 'left' ? parentDir : 'right') } parentScrollingRef={isPortal && parentMenuRef} isDisabled={isDisabled} /> ); const container = rootMenuRef.current; return isPortal && container ? createPortal(menuList, container) : menuList; }; return ( <li className={useBEM({ block: menuClass, element: subMenuClass, className })} style={{ position: 'relative' }} role="presentation" ref={containerRef} onKeyDown={handleKeyDown} > <div role="menuitem" aria-haspopup aria-expanded={isOpen} {...restItemProps} {...itemHandlers} {...commonProps(isDisabled, isHovering)} ref={useCombinedRef(externalItemRef, itemRef)} className={useBEM({ block: menuClass, element: menuItemClass, modifiers, className: itemClassName })} > {useMemo(() => safeCall(label, modifiers), [label, modifiers])} </div> {state && getMenuList()} </li> ); } ); SubMenu.propTypes = { ...menuPropTypes, ...uncontrolledMenuPropTypes, disabled: bool, openTrigger: oneOf(['none', 'clickOnly']), label: oneOfType([node, func]), itemProps: shape({ ...stylePropTypes() }) };