import React, { forwardRef, useEffect, useRef, useState } from "react"; import ReactDOM from "react-dom"; import { Portal } from "../../../portal"; import { useId } from "../../../utils-external"; import { composeEventHandlers, createStrictContext } from "../../helpers"; import { createDescendantContext, useEventCallback, useMergeRefs, } from "../../hooks"; import { DismissableLayer } from "../dismissablelayer/DismissableLayer"; import { Floating } from "../floating/Floating"; import { FocusBoundary } from "../focus-boundary/FocusBoundary"; import { RovingFocus, RovingFocusProps } from "./parts/RovingFocus"; import { SlottedDivElement, SlottedDivElementRef, SlottedDivProps, } from "./parts/SlottedDivElement"; /* -------------------------------------------------------------------------- */ /* Constants */ /* -------------------------------------------------------------------------- */ const FIRST_KEYS = ["ArrowDown", "PageUp", "Home"]; const LAST_KEYS = ["ArrowUp", "PageDown", "End"]; const FIRST_LAST_KEYS = [...FIRST_KEYS, ...LAST_KEYS]; type CheckedState = boolean | "indeterminate"; /* -------------------------------------------------------------------------- */ /* Menu */ /* -------------------------------------------------------------------------- */ interface MenuProps { children?: React.ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; modal?: boolean; } interface MenuComponent extends React.FC { Anchor: typeof MenuAnchor; Portal: typeof MenuPortal; Content: typeof MenuContent; Group: typeof MenuGroup; Item: typeof MenuItem; CheckboxItem: typeof MenuCheckboxItem; RadioGroup: typeof MenuRadioGroup; RadioItem: typeof MenuRadioItem; Divider: typeof MenuDivider; Sub: typeof MenuSub; SubTrigger: typeof MenuSubTrigger; SubContent: typeof MenuSubContent; ItemIndicator: typeof MenuItemIndicator; } const [ MenuDescendantsProvider, useMenuDescendantsContext, useMenuDescendants, useMenuDescendant, ] = createDescendantContext< SlottedDivElementRef, { closeMenu: () => void; } >(); type MenuContentElementRef = React.ElementRef; type MenuContextValue = { open: boolean; onOpenChange: (open: boolean) => void; content: MenuContentElementRef | null; onContentChange: (content: MenuContentElementRef | null) => void; }; const { Provider: MenuProvider, useContext: useMenuContext } = createStrictContext({ name: "MenuContext", }); type MenuRootContextValue = { onClose: () => void; isUsingKeyboardRef: React.RefObject; modal: boolean; }; const { Provider: MenuRootProvider, useContext: useMenuRootContext } = createStrictContext({ name: "MenuRootContext", }); const MenuRoot = ({ open = false, children, onOpenChange, modal = true, }: MenuProps) => { const [content, setContent] = useState(null); const isUsingKeyboardRef = useRef(false); const handleOpenChange = useEventCallback(onOpenChange); useEffect(() => { const globalDocument = globalThis.document; // Capturephase ensures we set the boolean before any side effects execute // in response to the key or pointer event as they might depend on this value. const handlePointer = () => { isUsingKeyboardRef.current = false; }; const handleKeyDown = () => { isUsingKeyboardRef.current = true; globalDocument.addEventListener("pointerdown", handlePointer, { capture: true, once: true, }); globalDocument.addEventListener("pointermove", handlePointer, { capture: true, once: true, }); }; globalDocument.addEventListener("keydown", handleKeyDown, { capture: true, }); return () => { globalDocument.removeEventListener("keydown", handleKeyDown, { capture: true, }); globalDocument.removeEventListener("pointerdown", handlePointer, { capture: true, }); globalDocument.removeEventListener("pointermove", handlePointer, { capture: true, }); }; }, []); return ( handleOpenChange(false), [handleOpenChange], )} isUsingKeyboardRef={isUsingKeyboardRef} modal={modal} > {children} ); }; const Menu = MenuRoot as MenuComponent; /* -------------------------------------------------------------------------- */ /* Menu Anchor */ /* -------------------------------------------------------------------------- */ type MenuAnchorElement = React.ElementRef; type MenuAnchorProps = React.ComponentPropsWithoutRef; const MenuAnchor = forwardRef( (props: MenuAnchorProps, forwardedRef) => { return ; }, ); /* -------------------------------------------------------------------------- */ /* Menu Content */ /* -------------------------------------------------------------------------- */ type MenuContentElement = MenuContentInternalElement; type MenuContentProps = MenuContentInternalTypeProps; const MenuContent = React.forwardRef< MenuContentInternalElement, MenuContentProps >((props: MenuContentProps, ref) => { const descendants = useMenuDescendants(); const rootContext = useMenuRootContext(); return ( {rootContext.modal ? ( ) : ( )} ); }); /* ---------------------------- Non-modal content --------------------------- */ const MenuRootContentNonModal = React.forwardRef< MenuContentInternalElement, MenuContentInternalTypeProps >((props: MenuContentInternalTypeProps, ref) => { const context = useMenuContext(); return ( context.onOpenChange(false)} /> ); }); /* ------------------------------ Modal content ----------------------------- */ const MenuRootContentModal = forwardRef< MenuContentInternalElement, MenuContentInternalTypeProps >((props: MenuContentInternalTypeProps, ref) => { const context = useMenuContext(); return ( event.preventDefault(), { checkForDefaultPrevented: false }, )} onDismiss={() => context.onOpenChange(false)} /> ); }); /* -------------------------- Menu content internals ------------------------- */ type MenuContentInternalElement = React.ElementRef; type FocusScopeProps = React.ComponentPropsWithoutRef; type DismissableLayerProps = React.ComponentPropsWithoutRef< typeof DismissableLayer >; type MenuContentInternalPrivateProps = { initialFocus?: FocusScopeProps["initialFocus"]; onDismiss?: DismissableLayerProps["onDismiss"]; disableOutsidePointerEvents?: DismissableLayerProps["disableOutsidePointerEvents"]; }; interface MenuContentInternalProps extends MenuContentInternalPrivateProps, Omit< React.ComponentPropsWithoutRef, "dir" | "onPlaced" > { returnFocus?: FocusScopeProps["returnFocus"]; onEntryFocus?: RovingFocusProps["onEntryFocus"]; onEscapeKeyDown?: DismissableLayerProps["onEscapeKeyDown"]; onPointerDownOutside?: DismissableLayerProps["onPointerDownOutside"]; onFocusOutside?: DismissableLayerProps["onFocusOutside"]; onInteractOutside?: DismissableLayerProps["onInteractOutside"]; safeZone?: DismissableLayerProps["safeZone"]; } const MenuContentInternal = forwardRef< MenuContentInternalElement, MenuContentInternalProps >( ( { initialFocus, returnFocus, disableOutsidePointerEvents, onEntryFocus, onEscapeKeyDown, onPointerDownOutside, onFocusOutside, onInteractOutside, onDismiss, safeZone, ...rest }: MenuContentInternalProps, forwardedRef, ) => { const descendants = useMenuDescendantsContext(); const context = useMenuContext(); const contentRef = useRef(null); const composedRefs = useMergeRefs( forwardedRef, contentRef, context.onContentChange, ); return ( { event.preventDefault(); })} > { // submenu key events bubble through portals. We only care about keys in this menu. const target = event.target as HTMLElement; const isKeyDownInside = target.closest("[data-aksel-menu-content]") === event.currentTarget; if (isKeyDownInside) { // menus should not be navigated using tab key so we prevent it if (event.key === "Tab") event.preventDefault(); } // focus first/last item based on key pressed const content = contentRef.current; if (event.target !== content) return; if (!FIRST_LAST_KEYS.includes(event.key)) return; event.preventDefault(); if (LAST_KEYS.includes(event.key)) { descendants.lastEnabled()?.node?.focus(); return; } descendants.firstEnabled()?.node?.focus(); })} /> ); }, ); type MenuContentInternalTypeProps = Omit< MenuContentInternalProps, keyof MenuContentInternalPrivateProps >; /* -------------------------------------------------------------------------- */ /* Menu item */ /* -------------------------------------------------------------------------- */ const ITEM_SELECT_EVENT = "menu.itemSelect"; type MenuItemElement = MenuItemInternalElement; interface MenuItemProps extends Omit { onSelect?: (event: Event) => void; } const MenuItem = forwardRef( ( { disabled = false, onSelect, onClick, onPointerUp, onPointerDown, onKeyDown, onKeyUp, ...rest }: MenuItemProps, forwardedRef, ) => { const ref = useRef(null); const rootContext = useMenuRootContext(); const composedRefs = useMergeRefs(forwardedRef, ref); const isPointerDownRef = useRef(false); const handleSelect = () => { const menuItem = ref.current; if (!disabled && menuItem && onSelect) { const itemSelectEvent = new CustomEvent(ITEM_SELECT_EVENT, { bubbles: true, cancelable: true, }); menuItem.addEventListener(ITEM_SELECT_EVENT, onSelect, { once: true }); /** * We flush the event synchronously to ensure that the event is dispatched before other events react to side-effect from event. * This is necessary to prevent the menu from potentially closing before we are able to prevent it. */ ReactDOM.flushSync(() => menuItem.dispatchEvent(itemSelectEvent)); if (itemSelectEvent.defaultPrevented) { isPointerDownRef.current = false; } else { rootContext.onClose(); } } else if (!disabled && menuItem) { rootContext.onClose(); } }; const handleKey = ( event: React.KeyboardEvent, key: "Enter" | " ", ) => { if (disabled || event.repeat) { return; } if (key === event.key) { event.currentTarget.click(); /** * We prevent default browser behaviour for selection keys as they should only trigger * selection. * - Prevents space from scrolling the page. * - If keydown causes focus to move, prevents keydown from firing on the new target. */ event.preventDefault(); } }; return ( { isPointerDownRef.current = true; }, { checkForDefaultPrevented: false }, )} onPointerUp={composeEventHandlers(onPointerUp, (event) => { // Pointer down can move to a different menu item which should activate it on pointer up. // We dispatch a click for selection to allow composition with click based triggers and to // prevent Firefox from getting stuck in text selection mode when the menu closes. if (!isPointerDownRef.current) event.currentTarget?.click(); })} onKeyDown={composeEventHandlers(onKeyDown, (event) => handleKey(event, "Enter"), )} onKeyUp={composeEventHandlers(onKeyUp, (event) => handleKey(event, " "), )} /> ); }, ); /* --------------------------- Menu Item internals --------------------------- */ type MenuItemInternalElement = SlottedDivElementRef; interface MenuItemInternalProps extends SlottedDivProps { disabled?: boolean; } const MenuItemInternal = forwardRef< MenuItemInternalElement, MenuItemInternalProps >( ( { disabled = false, onPointerMove, onPointerLeave, ...rest }: MenuItemInternalProps, forwardedRef, ) => { const context = useMenuContext(); const { register } = useMenuDescendant({ disabled, closeMenu: () => { rest["data-submenu-trigger"] && context.open && context.onOpenChange(false); }, }); const ref = useRef(null); const composedRefs = useMergeRefs(forwardedRef, ref, register); return ( { if (disabled) { /** * In the edgecase the focus is still stuck on a previous item, we make sure to reset it * even when the disabled item can't be focused itself to reset it. */ context.content?.focus(); } else { event.currentTarget.focus(); } }), )} onPointerLeave={composeEventHandlers( onPointerLeave, whenMouse(() => context.content?.focus()), )} /> ); }, ); /* -------------------------------------------------------------------------- */ /* Menu Group */ /* -------------------------------------------------------------------------- */ type MenuGroupProps = SlottedDivProps; const MenuGroup = forwardRef( (props: MenuGroupProps, ref) => { return ; }, ); /* -------------------------------------------------------------------------- */ /* Menu Portal */ /* -------------------------------------------------------------------------- */ type PortalProps = React.ComponentPropsWithoutRef; type MenuPortalElement = React.ElementRef; type MenuPortalProps = PortalProps & { children: React.ReactElement; }; const MenuPortal = forwardRef( ({ children, rootElement }: MenuPortalProps, ref) => { const context = useMenuContext(); if (!context.open) { return null; } return ( {children} ); }, ); /* -------------------------------------------------------------------------- */ /* Menu Radio */ /* -------------------------------------------------------------------------- */ const { Provider: RadioGroupProvider, useContext: useMenuRadioGroupContext } = createStrictContext({ name: "MenuRadioGroupContext", defaultValue: { value: undefined, onValueChange: () => {}, }, }); interface MenuRadioGroupProps extends MenuGroupProps { value?: string; onValueChange?: (value: string) => void; } const MenuRadioGroup = React.forwardRef< React.ElementRef, MenuRadioGroupProps >(({ value, onValueChange, ...rest }: MenuRadioGroupProps, ref) => { const handleValueChange = useEventCallback(onValueChange); return ( ); }); /* -------------------------------------------------------------------------- */ /* Menu Item Indicator */ /* -------------------------------------------------------------------------- */ const { Provider: MenuItemIndicatorProvider, useContext: useMenuItemIndicatorContext, } = createStrictContext<{ state: CheckedState; }>({ name: "MenuItemIndicatorContext", }); type MenuItemIndicatorProps = SlottedDivProps; const MenuItemIndicator = forwardRef< SlottedDivElementRef, MenuItemIndicatorProps >(({ asChild, ...rest }, ref) => { const ctx = useMenuItemIndicatorContext(); return ( ); }); /* -------------------------------------------------------------------------- */ /* Menu Radio */ /* -------------------------------------------------------------------------- */ interface MenuRadioItemProps extends MenuItemProps { value: string; } const MenuRadioItem = forwardRef< React.ElementRef, MenuRadioItemProps >(({ value, onSelect, ...rest }: MenuRadioItemProps, forwardedRef) => { const context = useMenuRadioGroupContext(); const checked = value === context.value; return ( context.onValueChange?.(value), { checkForDefaultPrevented: false }, )} /> ); }); /* -------------------------------------------------------------------------- */ /* Menu Checkbox */ /* -------------------------------------------------------------------------- */ interface MenuCheckboxItemProps extends MenuItemProps { checked?: CheckedState; // `onCheckedChange` can never be called with `"indeterminate"` from the inside onCheckedChange?: (checked: boolean) => void; } const MenuCheckboxItem = forwardRef( ( { checked = false, onCheckedChange, onSelect, ...rest }: MenuCheckboxItemProps, forwardedRef, ) => { return ( onCheckedChange?.(isIndeterminate(checked) ? true : !checked), { checkForDefaultPrevented: false }, )} /> ); }, ); /* -------------------------------------------------------------------------- */ /* Menu Divider */ /* -------------------------------------------------------------------------- */ type MenuDividerProps = SlottedDivProps; const MenuDivider = forwardRef( (props: MenuDividerProps, ref) => { return ( ); }, ); /* -------------------------------------------------------------------------- */ /* Menu SubMenu */ /* -------------------------------------------------------------------------- */ type MenuSubContextValue = { contentId: string; triggerId: string; trigger: MenuItemElement | null; onTriggerChange: (trigger: MenuItemElement | null) => void; }; const { Provider: MenuSubProvider, useContext: useMenuSubContext } = createStrictContext({ name: "MenuSubContext", }); interface MenuSubProps { children?: React.ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; } const MenuSub: React.FC = ({ children, onOpenChange, open = false, }: MenuSubProps) => { const parentMenuContext = useMenuContext(); const { values } = useMenuDescendantsContext(); const [trigger, setTrigger] = useState(null); const [content, setContent] = useState( null, ); const handleOpenChange = useEventCallback(onOpenChange); // Prevent the parent menu from reopening with open submenus. useEffect(() => { if (parentMenuContext.open === false) { handleOpenChange(false); } return () => handleOpenChange(false); }, [parentMenuContext.open, handleOpenChange]); return ( { handleOpenChange(_open); if (_open) { /* Makes sure to close all adjacent submenus if they are open */ values().forEach((descendant) => { if (descendant.node !== trigger) { descendant.closeMenu(); } }); } }} content={content} onContentChange={setContent} > {children} ); }; /* -------------------------------------------------------------------------- */ /* Menu SubMenu Trigger */ /* -------------------------------------------------------------------------- */ type MenuSubTriggerProps = MenuItemInternalProps; const MenuSubTrigger = forwardRef( (props: MenuSubTriggerProps, forwardedRef) => { const context = useMenuContext(); const subContext = useMenuSubContext(); const composedRefs = useMergeRefs(forwardedRef, subContext.onTriggerChange); const handleKey = ( event: React.KeyboardEvent, keys: string[], ) => { if (props.disabled) { return; } if (keys.includes(event.key)) { context.onOpenChange(true); // The trigger may hold focus if opened via pointer interaction // so we ensure content is given focus again when switching to keyboard. context.content?.focus(); // prevent window from scrolling event.preventDefault(); } }; return ( { if (props.disabled || event.defaultPrevented) { return; } props.onClick?.(event); /* * Solves edgecase where the user clicks the trigger, * but the focus is outside browser-window or viewport at first. */ event.currentTarget.focus(); context.onOpenChange(!context.open); }} onKeyDown={composeEventHandlers(props.onKeyDown, (event) => handleKey(event, ["Enter", "ArrowRight"]), )} onKeyUp={composeEventHandlers(props.onKeyUp, (event) => handleKey(event, [" "]), )} /> ); }, ); /* -------------------------------------------------------------------------- */ /* Menu SubMenu Content */ /* -------------------------------------------------------------------------- */ type MenuSubContentProps = Omit< MenuContentInternalProps, | keyof MenuContentInternalPrivateProps | "onCloseAutoFocus" | "onEntryFocus" | "side" | "align" >; const MenuSubContent = forwardRef< MenuContentInternalElement, MenuSubContentProps >((props: MenuSubContentProps, forwardedRef) => { const descendants = useMenuDescendants(); const context = useMenuContext(); const rootContext = useMenuRootContext(); const subContext = useMenuSubContext(); const ref = useRef(null); const composedRefs = useMergeRefs(forwardedRef, ref); return ( { if (rootContext.isUsingKeyboardRef.current) { return ref.current; } return false; }} /* Since we manually focus Subtrigger, we prevent use of auto-focus */ returnFocus={false} onEscapeKeyDown={composeEventHandlers( props.onEscapeKeyDown, (event) => { rootContext.onClose(); // Ensure pressing escape in submenu doesn't escape full screen mode event.preventDefault(); }, )} onKeyDown={composeEventHandlers(props.onKeyDown, (event) => { // Submenu key events bubble through portals. We only care about keys in this menu. const isKeyDownInside = event.currentTarget.contains( event.target as HTMLElement, ); let isCloseKey = event.key === "ArrowLeft"; /* When submenu opens to the left, we allow closing it with ArrowRight */ if (context.content?.dataset.side === "left") { isCloseKey = isCloseKey || event.key === "ArrowRight"; } if (isKeyDownInside && isCloseKey) { context.onOpenChange(false); // We focus manually because we prevented it in `onCloseAutoFocus` subContext.trigger?.focus(); // Prevent window from scrolling event.preventDefault(); } })} /> ); }); /* -------------------------------------------------------------------------- */ /* Utilities */ /* -------------------------------------------------------------------------- */ function getOpenState(open: boolean) { return open ? "open" : "closed"; } function isIndeterminate(checked?: CheckedState): checked is "indeterminate" { return checked === "indeterminate"; } function getCheckedState(checked: CheckedState) { return isIndeterminate(checked) ? "indeterminate" : checked ? "checked" : "unchecked"; } function whenMouse( handler: React.PointerEventHandler, ): React.PointerEventHandler { return (event) => event.pointerType === "mouse" ? handler(event) : undefined; } /* -------------------------------------------------------------------------- */ Menu.Anchor = MenuAnchor; Menu.Portal = MenuPortal; Menu.Content = MenuContent; Menu.Group = MenuGroup; Menu.Item = MenuItem; Menu.CheckboxItem = MenuCheckboxItem; Menu.RadioGroup = MenuRadioGroup; Menu.RadioItem = MenuRadioItem; Menu.Divider = MenuDivider; Menu.Sub = MenuSub; Menu.SubTrigger = MenuSubTrigger; Menu.SubContent = MenuSubContent; Menu.ItemIndicator = MenuItemIndicator; export { Menu, MenuAnchor, MenuCheckboxItem, MenuContent, MenuDivider, MenuGroup, MenuItem, MenuItemIndicator, MenuPortal, MenuRadioGroup, MenuRadioItem, MenuSub, MenuSubContent, MenuSubTrigger, type MenuAnchorProps, type MenuCheckboxItemProps, type MenuContentProps, type MenuDividerProps, type MenuGroupProps, type MenuItemElement, type MenuItemIndicatorProps, type MenuPortalProps, type MenuProps, type MenuRadioGroupProps, type MenuRadioItemProps, type MenuSubContentProps, type MenuSubProps, type MenuSubTriggerProps, };