UNPKG

@base-ui-components/react

Version:

Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.

412 lines (406 loc) 15.1 kB
'use client'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { useTimeout } from '@base-ui-components/utils/useTimeout'; import { useEventCallback } from '@base-ui-components/utils/useEventCallback'; import { useControlled } from '@base-ui-components/utils/useControlled'; import { useId } from '@base-ui-components/utils/useId'; import { FloatingTree, useClick, useDismiss, useFloatingRootContext, useFocus, useHover, useInteractions, useListNavigation, useRole, useTypeahead, safePolygon } from "../../floating-ui-react/index.js"; import { MenuRootContext, useMenuRootContext } from "./MenuRootContext.js"; import { useMenubarContext } from "../../menubar/MenubarContext.js"; import { useTransitionStatus } from "../../utils/useTransitionStatus.js"; import { PATIENT_CLICK_THRESHOLD, TYPEAHEAD_RESET_MS } from "../../utils/constants.js"; import { useOpenChangeComplete } from "../../utils/useOpenChangeComplete.js"; import { useDirection } from "../../direction-provider/DirectionContext.js"; import { useScrollLock } from "../../utils/useScrollLock.js"; import { useOpenInteractionType } from "../../utils/useOpenInteractionType.js"; import { translateOpenChangeReason } from "../../utils/translateOpenChangeReason.js"; import { useContextMenuRootContext } from "../../context-menu/root/ContextMenuRootContext.js"; import { useMenuSubmenuRootContext } from "../submenu-root/MenuSubmenuRootContext.js"; import { useMixedToggleClickHandler } from "../../utils/useMixedToggleClickHander.js"; import { mergeProps } from "../../merge-props/index.js"; import { jsx as _jsx } from "react/jsx-runtime"; const EMPTY_ARRAY = []; const EMPTY_REF = { current: false }; /** * Groups all parts of the menu. * Doesn’t render its own HTML element. * * Documentation: [Base UI Menu](https://base-ui.com/react/components/menu) */ export const MenuRoot = function MenuRoot(props) { const { children, open: openProp, onOpenChange, onOpenChangeComplete, defaultOpen = false, disabled = false, modal: modalProp, loop = true, orientation = 'vertical', actionsRef, openOnHover: openOnHoverProp, delay = 100, closeDelay = 0, closeParentOnEsc = true } = props; const [triggerElement, setTriggerElement] = React.useState(null); const [positionerElement, setPositionerElementUnwrapped] = React.useState(null); const [instantType, setInstantType] = React.useState(); const [hoverEnabled, setHoverEnabled] = React.useState(true); const [activeIndex, setActiveIndex] = React.useState(null); const [lastOpenChangeReason, setLastOpenChangeReason] = React.useState(null); const [stickIfOpen, setStickIfOpen] = React.useState(true); const [allowMouseEnterState, setAllowMouseEnterState] = React.useState(false); const openEventRef = React.useRef(null); const popupRef = React.useRef(null); const positionerRef = React.useRef(null); const itemDomElements = React.useRef([]); const itemLabels = React.useRef([]); const stickIfOpenTimeout = useTimeout(); const contextMenuContext = useContextMenuRootContext(true); const isSubmenu = useMenuSubmenuRootContext(); let parent; { const parentContext = useMenuRootContext(true); const menubarContext = useMenubarContext(true); if (isSubmenu && parentContext) { parent = { type: 'menu', context: parentContext }; } else if (menubarContext) { parent = { type: 'menubar', context: menubarContext }; } else if (contextMenuContext) { parent = { type: 'context-menu', context: contextMenuContext }; } else { parent = { type: undefined }; } } let rootId = useId(); if (parent.type !== undefined) { rootId = parent.context.rootId; } const modal = (parent.type === undefined || parent.type === 'context-menu') && (modalProp ?? true); // If this menu is a submenu, it should inherit `allowMouseEnter` from its // parent. Otherwise it manages the state on its own. const allowMouseEnter = parent.type === 'menu' ? parent.context.allowMouseEnter : allowMouseEnterState; const setAllowMouseEnter = parent.type === 'menu' ? parent.context.setAllowMouseEnter : setAllowMouseEnterState; if (process.env.NODE_ENV !== 'production') { if (parent.type !== undefined && modalProp !== undefined) { console.warn('Base UI: The `modal` prop is not supported on nested menus. It will be ignored.'); } } const openOnHover = openOnHoverProp ?? (parent.type === 'menu' || parent.type === 'menubar' && parent.context.hasSubmenuOpen); const [open, setOpenUnwrapped] = useControlled({ controlled: openProp, default: defaultOpen, name: 'MenuRoot', state: 'open' }); const allowOutsidePressDismissalRef = React.useRef(parent.type !== 'context-menu'); const allowOutsidePressDismissalTimeout = useTimeout(); React.useEffect(() => { if (!open) { openEventRef.current = null; } if (parent.type !== 'context-menu') { return; } if (!open) { allowOutsidePressDismissalTimeout.clear(); allowOutsidePressDismissalRef.current = false; return; } // With `mousedown` outside press events and long press touch input, there // needs to be a grace period after opening to ensure the dismissal event // doesn't fire immediately after open. allowOutsidePressDismissalTimeout.start(500, () => { allowOutsidePressDismissalRef.current = true; }); }, [allowOutsidePressDismissalTimeout, open, parent.type]); const setPositionerElement = React.useCallback(value => { positionerRef.current = value; setPositionerElementUnwrapped(value); }, []); const { mounted, setMounted, transitionStatus } = useTransitionStatus(open); const { openMethod, triggerProps: interactionTypeProps, reset: resetOpenInteractionType } = useOpenInteractionType(open); useScrollLock({ enabled: open && modal && lastOpenChangeReason !== 'trigger-hover' && openMethod !== 'touch', mounted, open, referenceElement: positionerElement }); if (!open && !hoverEnabled) { setHoverEnabled(true); } const handleUnmount = useEventCallback(() => { setMounted(false); setStickIfOpen(true); setAllowMouseEnter(false); onOpenChangeComplete?.(false); resetOpenInteractionType(); }); useOpenChangeComplete({ enabled: !actionsRef, open, ref: popupRef, onComplete() { if (!open) { handleUnmount(); } } }); const allowTouchToCloseRef = React.useRef(true); const allowTouchToCloseTimeout = useTimeout(); const setOpen = useEventCallback((nextOpen, event, reason) => { if (open === nextOpen) { return; } if (nextOpen === false && event?.type === 'click' && event.pointerType === 'touch' && !allowTouchToCloseRef.current) { return; } // Workaround `enableFocusInside` in Floating UI setting `tabindex=0` of a non-highlighted // option upon close when tabbing out due to `keepMounted=true`: // https://github.com/floating-ui/floating-ui/pull/3004/files#diff-962a7439cdeb09ea98d4b622a45d517bce07ad8c3f866e089bda05f4b0bbd875R194-R199 // This otherwise causes options to retain `tabindex=0` incorrectly when the popup is closed // when tabbing outside. if (!nextOpen && activeIndex !== null) { const activeOption = itemDomElements.current[activeIndex]; // Wait for Floating UI's focus effect to have fired queueMicrotask(() => { activeOption?.setAttribute('tabindex', '-1'); }); } // Prevent the menu from closing on mobile devices that have a delayed click event. // In some cases the menu, when tapped, will fire the focus event first and then the click event. // Without this guard, the menu will close immediately after opening. if (nextOpen && reason === 'trigger-focus') { allowTouchToCloseRef.current = false; allowTouchToCloseTimeout.start(300, () => { allowTouchToCloseRef.current = true; }); } else { allowTouchToCloseRef.current = true; allowTouchToCloseTimeout.clear(); } const isKeyboardClick = (reason === 'trigger-press' || reason === 'item-press') && event.detail === 0 && event?.isTrusted; const isDismissClose = !nextOpen && (reason === 'escape-key' || reason == null); function changeState() { onOpenChange?.(nextOpen, event, reason); setOpenUnwrapped(nextOpen); setLastOpenChangeReason(reason ?? null); openEventRef.current = event ?? null; } if (reason === 'trigger-hover') { // Only allow "patient" clicks to close the menu if it's open. // If they clicked within 500ms of the menu opening, keep it open. setStickIfOpen(true); stickIfOpenTimeout.start(PATIENT_CLICK_THRESHOLD, () => { setStickIfOpen(false); }); ReactDOM.flushSync(changeState); } else { changeState(); } if (parent.type === 'menubar' && (reason === 'trigger-focus' || reason === 'focus-out' || reason === 'trigger-hover' || reason === 'list-navigation' || reason === 'sibling-open')) { setInstantType('group'); } else if (isKeyboardClick || isDismissClose) { setInstantType(isKeyboardClick ? 'click' : 'dismiss'); } else { setInstantType(undefined); } }); React.useImperativeHandle(actionsRef, () => ({ unmount: handleUnmount }), [handleUnmount]); let ctx; if (parent.type === 'context-menu') { ctx = parent.context; } React.useImperativeHandle(ctx?.positionerRef, () => positionerElement, [positionerElement]); React.useImperativeHandle(ctx?.actionsRef, () => ({ setOpen }), [setOpen]); React.useEffect(() => { if (!open) { stickIfOpenTimeout.clear(); } }, [stickIfOpenTimeout, open]); const floatingRootContext = useFloatingRootContext({ elements: { reference: triggerElement, floating: positionerElement }, open, onOpenChange(openValue, eventValue, reasonValue) { setOpen(openValue, eventValue, translateOpenChangeReason(reasonValue)); } }); const hover = useHover(floatingRootContext, { enabled: hoverEnabled && openOnHover && !disabled && parent.type !== 'context-menu' && (parent.type !== 'menubar' || parent.context.hasSubmenuOpen && !open), handleClose: safePolygon({ blockPointerEvents: true }), mouseOnly: true, move: parent.type === 'menu', restMs: parent.type === undefined || parent.type === 'menu' && allowMouseEnter ? delay : undefined, delay: parent.type === 'menu' ? { open: allowMouseEnter ? delay : 10 ** 10, close: closeDelay } : { close: closeDelay } }); const focus = useFocus(floatingRootContext, { enabled: !disabled && !open && parent.type === 'menubar' && parent.context.hasSubmenuOpen && !contextMenuContext }); const click = useClick(floatingRootContext, { enabled: !disabled && parent.type !== 'context-menu', event: open && parent.type === 'menubar' ? 'click' : 'mousedown', toggle: !openOnHover || parent.type !== 'menu', ignoreMouse: openOnHover && parent.type === 'menu', stickIfOpen: parent.type === undefined ? stickIfOpen : false }); const dismiss = useDismiss(floatingRootContext, { enabled: !disabled, bubbles: closeParentOnEsc && parent.type === 'menu', outsidePress() { if (parent.type !== 'context-menu' || openEventRef.current?.type === 'contextmenu') { return true; } return allowOutsidePressDismissalRef.current; } }); const role = useRole(floatingRootContext, { role: 'menu' }); const direction = useDirection(); const listNavigation = useListNavigation(floatingRootContext, { enabled: !disabled, listRef: itemDomElements, activeIndex, nested: parent.type !== undefined, loop, orientation, parentOrientation: parent.type === 'menubar' ? parent.context.orientation : undefined, rtl: direction === 'rtl', disabledIndices: EMPTY_ARRAY, onNavigate: setActiveIndex, openOnArrowKeyDown: parent.type !== 'context-menu' }); const typingRef = React.useRef(false); const onTypingChange = React.useCallback(nextTyping => { typingRef.current = nextTyping; }, []); const typeahead = useTypeahead(floatingRootContext, { listRef: itemLabels, activeIndex, resetMs: TYPEAHEAD_RESET_MS, onMatch: index => { if (open && index !== activeIndex) { setActiveIndex(index); } }, onTypingChange }); const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([hover, click, dismiss, focus, role, listNavigation, typeahead]); const mixedToggleHandlers = useMixedToggleClickHandler({ open, enabled: parent.type === 'menubar', mouseDownAction: 'open' }); const triggerProps = React.useMemo(() => { const referenceProps = mergeProps(getReferenceProps(), { onMouseEnter() { setHoverEnabled(true); }, onMouseMove() { setAllowMouseEnter(true); } }, interactionTypeProps, mixedToggleHandlers); delete referenceProps.role; return referenceProps; }, [getReferenceProps, mixedToggleHandlers, setAllowMouseEnter, interactionTypeProps]); const popupProps = React.useMemo(() => getFloatingProps({ onMouseEnter() { if (!openOnHover || parent.type === 'menu') { setHoverEnabled(false); } }, onMouseMove() { setAllowMouseEnter(true); }, onClick() { if (openOnHover) { setHoverEnabled(false); } } }), [getFloatingProps, openOnHover, parent.type, setAllowMouseEnter]); const itemProps = React.useMemo(() => getItemProps(), [getItemProps]); const context = React.useMemo(() => ({ activeIndex, setActiveIndex, allowMouseUpTriggerRef: parent.type ? parent.context.allowMouseUpTriggerRef : EMPTY_REF, floatingRootContext, itemProps, popupProps, triggerProps, itemDomElements, itemLabels, mounted, open, popupRef, positionerRef, setOpen, setPositionerElement, triggerElement, setTriggerElement, transitionStatus, lastOpenChangeReason, instantType, onOpenChangeComplete, setHoverEnabled, typingRef, modal, disabled, parent, rootId, allowMouseEnter, setAllowMouseEnter }), [activeIndex, floatingRootContext, itemProps, popupProps, triggerProps, itemDomElements, itemLabels, mounted, open, positionerRef, setOpen, transitionStatus, triggerElement, setPositionerElement, lastOpenChangeReason, instantType, onOpenChangeComplete, modal, disabled, parent, rootId, allowMouseEnter, setAllowMouseEnter]); const content = /*#__PURE__*/_jsx(MenuRootContext.Provider, { value: context, children: children }); if (parent.type === undefined || parent.type === 'context-menu') { // set up a FloatingTree to provide the context to nested menus return /*#__PURE__*/_jsx(FloatingTree, { children: content }); } return content; };