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.

418 lines (412 loc) 15.9 kB
"use strict"; 'use client'; var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default; Object.defineProperty(exports, "__esModule", { value: true }); exports.MenuRoot = void 0; var React = _interopRequireWildcard(require("react")); var ReactDOM = _interopRequireWildcard(require("react-dom")); var _useTimeout = require("@base-ui-components/utils/useTimeout"); var _useEventCallback = require("@base-ui-components/utils/useEventCallback"); var _useControlled = require("@base-ui-components/utils/useControlled"); var _useId = require("@base-ui-components/utils/useId"); var _floatingUiReact = require("../../floating-ui-react"); var _MenuRootContext = require("./MenuRootContext"); var _MenubarContext = require("../../menubar/MenubarContext"); var _useTransitionStatus = require("../../utils/useTransitionStatus"); var _constants = require("../../utils/constants"); var _useOpenChangeComplete = require("../../utils/useOpenChangeComplete"); var _DirectionContext = require("../../direction-provider/DirectionContext"); var _useScrollLock = require("../../utils/useScrollLock"); var _useOpenInteractionType = require("../../utils/useOpenInteractionType"); var _translateOpenChangeReason = require("../../utils/translateOpenChangeReason"); var _ContextMenuRootContext = require("../../context-menu/root/ContextMenuRootContext"); var _MenuSubmenuRootContext = require("../submenu-root/MenuSubmenuRootContext"); var _useMixedToggleClickHander = require("../../utils/useMixedToggleClickHander"); var _mergeProps = require("../../merge-props"); var _jsxRuntime = require("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) */ const MenuRoot = exports.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 = (0, _useTimeout.useTimeout)(); const contextMenuContext = (0, _ContextMenuRootContext.useContextMenuRootContext)(true); const isSubmenu = (0, _MenuSubmenuRootContext.useMenuSubmenuRootContext)(); let parent; { const parentContext = (0, _MenuRootContext.useMenuRootContext)(true); const menubarContext = (0, _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 = (0, _useId.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] = (0, _useControlled.useControlled)({ controlled: openProp, default: defaultOpen, name: 'MenuRoot', state: 'open' }); const allowOutsidePressDismissalRef = React.useRef(parent.type !== 'context-menu'); const allowOutsidePressDismissalTimeout = (0, _useTimeout.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 } = (0, _useTransitionStatus.useTransitionStatus)(open); const { openMethod, triggerProps: interactionTypeProps, reset: resetOpenInteractionType } = (0, _useOpenInteractionType.useOpenInteractionType)(open); (0, _useScrollLock.useScrollLock)({ enabled: open && modal && lastOpenChangeReason !== 'trigger-hover' && openMethod !== 'touch', mounted, open, referenceElement: positionerElement }); if (!open && !hoverEnabled) { setHoverEnabled(true); } const handleUnmount = (0, _useEventCallback.useEventCallback)(() => { setMounted(false); setStickIfOpen(true); setAllowMouseEnter(false); onOpenChangeComplete?.(false); resetOpenInteractionType(); }); (0, _useOpenChangeComplete.useOpenChangeComplete)({ enabled: !actionsRef, open, ref: popupRef, onComplete() { if (!open) { handleUnmount(); } } }); const allowTouchToCloseRef = React.useRef(true); const allowTouchToCloseTimeout = (0, _useTimeout.useTimeout)(); const setOpen = (0, _useEventCallback.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(_constants.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 = (0, _floatingUiReact.useFloatingRootContext)({ elements: { reference: triggerElement, floating: positionerElement }, open, onOpenChange(openValue, eventValue, reasonValue) { setOpen(openValue, eventValue, (0, _translateOpenChangeReason.translateOpenChangeReason)(reasonValue)); } }); const hover = (0, _floatingUiReact.useHover)(floatingRootContext, { enabled: hoverEnabled && openOnHover && !disabled && parent.type !== 'context-menu' && (parent.type !== 'menubar' || parent.context.hasSubmenuOpen && !open), handleClose: (0, _floatingUiReact.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 = (0, _floatingUiReact.useFocus)(floatingRootContext, { enabled: !disabled && !open && parent.type === 'menubar' && parent.context.hasSubmenuOpen && !contextMenuContext }); const click = (0, _floatingUiReact.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 = (0, _floatingUiReact.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 = (0, _floatingUiReact.useRole)(floatingRootContext, { role: 'menu' }); const direction = (0, _DirectionContext.useDirection)(); const listNavigation = (0, _floatingUiReact.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 = (0, _floatingUiReact.useTypeahead)(floatingRootContext, { listRef: itemLabels, activeIndex, resetMs: _constants.TYPEAHEAD_RESET_MS, onMatch: index => { if (open && index !== activeIndex) { setActiveIndex(index); } }, onTypingChange }); const { getReferenceProps, getFloatingProps, getItemProps } = (0, _floatingUiReact.useInteractions)([hover, click, dismiss, focus, role, listNavigation, typeahead]); const mixedToggleHandlers = (0, _useMixedToggleClickHander.useMixedToggleClickHandler)({ open, enabled: parent.type === 'menubar', mouseDownAction: 'open' }); const triggerProps = React.useMemo(() => { const referenceProps = (0, _mergeProps.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__*/(0, _jsxRuntime.jsx)(_MenuRootContext.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__*/(0, _jsxRuntime.jsx)(_floatingUiReact.FloatingTree, { children: content }); } return content; };