import {Rect} from '@popperjs/core'; import React from 'react'; import { isElementDisabled, useListItemRegister, useListItemRovingFocus, } from '@workday/canvas-kit-react/collection'; import { ExtractProps, PropsWithModel, composeHooks, createElemPropsHook, createSubcomponent, isElementRTL, subModelHook, useForkRef, } from '@workday/canvas-kit-react/common'; import {Placement, Popper} from '@workday/canvas-kit-react/popup'; import {chevronRightSmallIcon} from '@workday/canvas-system-icons-web'; import {MenuCard} from './MenuCard'; import {MenuDivider} from './MenuDivider'; import {MenuGroup} from './MenuGroup'; import {MenuItem, StyledMenuItem, useMenuItemArrowReturn, useMenuItemFocus} from './MenuItem'; import {MenuList} from './MenuList'; import {MenuOption} from './MenuOption'; import {menuPopperOffset, useMenuPopper} from './MenuPopper'; import {useMenuModel} from './useMenuModel'; type MenuModelConfig = (typeof useMenuModel)['TConfig']; export interface SubmenuProps extends MenuModelConfig, PropsWithModel> { /** * The contents of the Menu. Can be `Menu` children or any valid elements. */ children: React.ReactNode; } type ElementRef = React.RefObject; // The target sits inside the parent menu, so placements are measured against the parent's visible // box instead. Otherwise vertical placements cover the parent, and a target scrolled out of the // parent's list fails the side placements' fallback checks. const getSubmenuAnchorRects = (parentRef?: ElementRef, targetRef?: ElementRef) => { const parentEl = parentRef?.current; const targetEl = targetRef?.current; if (!parentEl || !targetEl) { return undefined; } const parent = parentEl.getBoundingClientRect(); const target = targetEl.getBoundingClientRect(); const top = Math.min(Math.max(target.top, parent.top), parent.bottom); const bottom = Math.max(Math.min(target.bottom, parent.bottom), parent.top); return {parent, anchor: {top, bottom, left: parent.left, right: parent.right}}; }; const getSubmenuOffsetModifier = (parentRef?: ElementRef, targetRef?: ElementRef) => ({ name: 'offset', options: { // Popper includes these offsets when checking overflow, so vertical fallbacks are only chosen // when the submenu fits fully above or below the parent. offset: ({placement, popper}: {placement: Placement; popper: Rect}) => { const [skidding, distance] = menuPopperOffset; const rects = getSubmenuAnchorRects(parentRef, targetRef); const side = placement.split('-')[0]; if (!rects || (side !== 'top' && side !== 'bottom')) { return [skidding, distance]; } const {parent, anchor} = rects; const wanted = side === 'bottom' ? parent.bottom - anchor.bottom : anchor.top - parent.top; // How far the submenu can move before it leaves the viewport it is portalled into. const available = side === 'bottom' ? document.documentElement.clientHeight - popper.height - distance - anchor.bottom : anchor.top - distance - popper.height; // Clear the parent where there is room, and clear as much of it as the viewport allows where // there is not. Going further would only push the submenu off screen, and clamping to the // viewport edge also holds the submenu still while the parent scrolls underneath it. return [skidding, distance + Math.max(0, Math.min(wanted, available))]; }, }, }); export const SubmenuPopper = createSubcomponent('div')({ modelHook: useMenuModel, elemPropsHook: useMenuPopper, })>( ({children, fallbackPlacements, popperOptions, ...elemProps}, Element, model) => { const parentContainerRef = model.UNSTABLE_parentModel?.state.containerRef; const targetRef = model.state.targetRef; const align = targetRef.current && isElementRTL(targetRef.current) ? 'end' : 'start'; const submenuFallbackPlacements = React.useMemo( (): Placement[] => fallbackPlacements ?? [`bottom-${align}`, `top-${align}`], [fallbackPlacements, align] ); // Popper measures this rect for both positioning and overflow checks, and still listens to the // target's scroll containers through `anchorElement`. const getAnchorClientRect = React.useCallback(() => { const rects = getSubmenuAnchorRects(parentContainerRef, targetRef); if (!rects) { return targetRef.current?.getBoundingClientRect() ?? new DOMRect(); } const {top, bottom, left, right} = rects.anchor; return new DOMRect(left, top, right - left, bottom - top); }, [parentContainerRef, targetRef]); const submenuPopperOptions = React.useMemo( () => ({ ...popperOptions, modifiers: [ getSubmenuOffsetModifier(parentContainerRef, targetRef), ...(popperOptions?.modifiers ?? []), ], }), [popperOptions, parentContainerRef, targetRef] ); return ( {children} ); } ); const useIntentTimer = (fn: Function, waitMs: number = 0): {start(): void; clear(): void} => { const timer = React.useRef(undefined); const start = () => { timer.current = window.setTimeout(fn, waitMs); }; const clear = () => { window.clearTimeout(timer.current); timer.current = undefined; }; // be sure to clear our timeout React.useEffect(() => { return () => { window.clearTimeout(timer.current); }; }, [timer]); return { start, clear, }; }; export const useSubmenuTargetItem = composeHooks( subModelHook(model => (model as any).UNSTABLE_parentModel!, useMenuItemFocus), subModelHook(model => (model as any).UNSTABLE_parentModel!, useMenuItemArrowReturn), subModelHook(model => (model as any).UNSTABLE_parentModel!, useListItemRovingFocus), createElemPropsHook(useMenuModel)((model, ref) => { const elementRef = useForkRef(ref, model.state.targetRef); return { ref: elementRef, }; }), subModelHook(model => (model as any).UNSTABLE_parentModel!, useListItemRegister), createElemPropsHook(useMenuModel)(model => { const currentTargetIdRef = React.useRef(undefined); // A submenu owns its own `visibility`, so opening a sibling submenu would otherwise leave this // one open. The parent's cursor always moves to the item being hovered or focused, so treat the // cursor moving away from our target item as a signal to close. const parentCursorId = model.UNSTABLE_parentModel.state.cursorId; const isVisible = model.state.visibility === 'visible'; React.useEffect(() => { if (!isVisible) { return; } const targetId = (model.state.targetRef.current as HTMLElement | null)?.getAttribute( 'data-id' ); if (targetId && parentCursorId && parentCursorId !== targetId) { model.events.hide(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [parentCursorId, isVisible]); const mouseEnterTimer = useIntentTimer(() => { model.UNSTABLE_parentModel.events.goTo({id: currentTargetIdRef.current || ''}); model.events.show(event); }, 300); return { id: model.state.id, role: 'menuitem', 'aria-haspopup': 'true', 'aria-expanded': model.state.visibility === 'visible', onMouseDown(event: React.MouseEvent) { model.UNSTABLE_parentModel.events.goTo({id: event.currentTarget.getAttribute('data-id')!}); }, onMouseEnter(event: React.MouseEvent) { if (isElementDisabled(event.currentTarget)) { return; } currentTargetIdRef.current = event.currentTarget.getAttribute('data-id')!; mouseEnterTimer.start(); }, onMouseLeave() { mouseEnterTimer.clear(); }, onClick(event: React.MouseEvent) { if (isElementDisabled(event.currentTarget)) { return null; } // If we're wrapping a target component that doesn't handle ref forwarding, update the // `state.targetRef` manually. This ensures that custom target components don't need to handle // ref forwarding since ref forwarding is only really needed to programmatically open popups // around a target _before_ a user clicks. In that rare case, ref forwarding is required. if (!(model.state.targetRef.current instanceof Element)) { (model.state.targetRef as React.MutableRefObject).current = event.currentTarget; } if (model.state.visibility !== 'hidden') { model.events.hide(event); } else { model.events.show(event); } return undefined; }, 'data-has-children': true, onKeyDown(event: React.KeyboardEvent) { if (isElementDisabled(event.currentTarget)) { return; } if (model.state.orientation === 'vertical') { // eslint-disable-next-line default-case switch (event.key) { case 'ArrowRight': case 'Enter': case ' ': model.events.show(event); break; } } }, }; }) ); export const SubmenuTargetItem = createSubcomponent('button')({ modelHook: useMenuModel, elemPropsHook: useSubmenuTargetItem, })(({children, ...elemProps}, Element) => { return ( {typeof children === 'string' ? {children} : children} ); }); /** * `Submenu` should be put in place of a `Menu.Item`. It will render a menu item that is the target * for the submenu card. * * ```tsx * First Item * * Second Item * * * * First Sub Item * Second Sub Item * * * * * Third Item * ``` */ export const Submenu = createSubcomponent()({ modelHook: useMenuModel, subComponents: { /** * The menu card is a non-semantic element used to give the dropdown menu its distinct visual * cue that the dropdown menu is floating above other content. A menu card usually contains a * menu list, but can also contain other elements like a header or footer. */ Card: MenuCard, /** * The menu list follows the Collections API. A list can either contain static items * or a render prop and `items`. It is recommended that the `items` comes from a nested * JavaScript object. */ List: MenuList, /** * If the static API is used, a `data-id` prop should be used to identify the item. If you're * using the dynamic API, pass a `getId` and `getTextValue` to the parent `Menu` the model. */ Item: MenuItem, /** * The `Submenu.TargetItem` is similar to the `Menu.Item`, but represents both the target for * the submenu and the item in the menu list. This should only be used once per `` * component. */ TargetItem: SubmenuTargetItem, Group: MenuGroup, /** * A `Menu.Option` is similar to the `Menu.Item`, but has a `role=option` and works with * `aria-activedescendant` and is selectable with a selected checkmark. It adds the * `aria-selected="true/false"` attribute. `Menu.Option` requires much more accessibility * behavior composed into the `Menu.Target` and `Menu.List` component. The `Combobox` and * `Select` components make use of the `Menu.Option`. See those components for a better idea of * how behavior is composed. */ Option: MenuOption, Divider: MenuDivider, /** * The "Popper" of a menu. The popper will appear around the {@link MenuTarget Menu.Target}. It * renders a `div` element that is portalled to the `document.body` which is controlled by the * {@link PopupStack}. The `PopupStack` is not part of React. This means no extra props given to * this component will be forwarded to the `div` element, but the `ref` will be forwarded. */ Popper: SubmenuPopper, }, })(({children, model: _model, ...props}, _Element, parentModel) => { const model = useMenuModel( useMenuModel.mergeConfig(props, { getId: parentModel.getId, getTextValue: parentModel.getTextValue, UNSTABLE_parentModel: parentModel, mode: parentModel.state.mode, orientation: parentModel.state.orientation, onSelect(data) { parentModel.events.select(data); }, onSelectAll() { parentModel.events.selectAll(); }, }) ); const Context = useMenuModel.Context; return {children}; });