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.

431 lines (423 loc) 16.5 kB
'use client'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { useTimeout } from '@base-ui-components/utils/useTimeout'; import { useStableCallback } from '@base-ui-components/utils/useStableCallback'; import { useId } from '@base-ui-components/utils/useId'; import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect'; import { useAnimationFrame } from '@base-ui-components/utils/useAnimationFrame'; import { useScrollLock } from '@base-ui-components/utils/useScrollLock'; import { EMPTY_ARRAY } from '@base-ui-components/utils/empty'; import { FloatingTree, useDismiss, useFloatingNodeId, useFloatingParentNodeId, useInteractions, useListNavigation, useRole, useTypeahead, useSyncedFloatingRootContext } from "../../floating-ui-react/index.js"; import { MenuRootContext, useMenuRootContext } from "./MenuRootContext.js"; import { useMenubarContext } from "../../menubar/MenubarContext.js"; import { TYPEAHEAD_RESET_MS } from "../../utils/constants.js"; import { useDirection } from "../../direction-provider/DirectionContext.js"; import { useOpenInteractionType } from "../../utils/useOpenInteractionType.js"; import { createChangeEventDetails } from "../../utils/createBaseUIEventDetails.js"; import { REASONS } from "../../utils/reasons.js"; import { useContextMenuRootContext } from "../../context-menu/root/ContextMenuRootContext.js"; import { mergeProps } from "../../merge-props/index.js"; import { MenuStore } from "../store/MenuStore.js"; import { useImplicitActiveTrigger, useOpenStateTransitions } from "../../utils/popups/index.js"; import { useMenuSubmenuRootContext } from "../submenu-root/MenuSubmenuRootContext.js"; /** * Groups all parts of the menu. * Doesn’t render its own HTML element. * * Documentation: [Base UI Menu](https://base-ui.com/react/components/menu) */ import { jsx as _jsx } from "react/jsx-runtime"; export function MenuRoot(props) { const { children, open: openProp, onOpenChange, onOpenChangeComplete, defaultOpen = false, disabled: disabledProp = false, modal: modalProp, loopFocus = true, orientation = 'vertical', actionsRef, closeParentOnEsc = true, handle, triggerId: triggerIdProp, defaultTriggerId: defaultTriggerIdProp = null } = props; const contextMenuContext = useContextMenuRootContext(true); const parentMenuRootContext = useMenuRootContext(true); const menubarContext = useMenubarContext(true); const isSubmenu = useMenuSubmenuRootContext(); const parentFromContext = React.useMemo(() => { if (isSubmenu && parentMenuRootContext) { return { type: 'menu', store: parentMenuRootContext.store }; } if (menubarContext) { return { type: 'menubar', context: menubarContext }; } // Ensure this is not a Menu nested inside ContextMenu.Trigger. // ContextMenu parentContext is always undefined as ContextMenu.Root is instantiated with // <MenuRootContext.Provider value={undefined}> if (contextMenuContext && !parentMenuRootContext) { return { type: 'context-menu', context: contextMenuContext }; } return { type: undefined }; }, [contextMenuContext, parentMenuRootContext, menubarContext, isSubmenu]); const store = MenuStore.useStore(handle?.store, { parent: parentFromContext }); const floatingTreeRoot = store.useState('floatingTreeRoot'); const floatingNodeIdFromContext = useFloatingNodeId(floatingTreeRoot); const floatingParentNodeIdFromContext = useFloatingParentNodeId(); useIsoLayoutEffect(() => { if (contextMenuContext && !parentMenuRootContext) { // This is a context menu root. // It doesn't support detached triggers yet, so we have to sync the parent context manually. store.update({ parent: { type: 'context-menu', context: contextMenuContext }, floatingNodeId: floatingNodeIdFromContext, floatingParentNodeId: floatingParentNodeIdFromContext }); } else if (parentMenuRootContext) { store.update({ floatingNodeId: floatingNodeIdFromContext, floatingParentNodeId: floatingParentNodeIdFromContext }); } }, [contextMenuContext, parentMenuRootContext, floatingNodeIdFromContext, floatingParentNodeIdFromContext, store]); store.useControlledProp('open', openProp, defaultOpen); store.useControlledProp('activeTriggerId', triggerIdProp, defaultTriggerIdProp); store.useContextCallback('onOpenChangeComplete', onOpenChangeComplete); const open = store.useState('open'); const activeTriggerElement = store.useState('activeTriggerElement'); const positionerElement = store.useState('positionerElement'); const hoverEnabled = store.useState('hoverEnabled'); const modal = store.useState('modal'); const disabled = store.useState('disabled'); const lastOpenChangeReason = store.useState('lastOpenChangeReason'); const parent = store.useState('parent'); const activeIndex = store.useState('activeIndex'); const payload = store.useState('payload'); const floatingParentNodeId = store.useState('floatingParentNodeId'); const openEventRef = React.useRef(null); const nested = floatingParentNodeId != null; let floatingEvents; 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.'); } } store.useSyncedValues({ disabled: disabledProp, modal: parent.type === undefined ? modalProp : undefined, rootId: useId() }); const { openMethod, triggerProps: interactionTypeProps, reset: resetOpenInteractionType } = useOpenInteractionType(open); useImplicitActiveTrigger(store); const { forceUnmount } = useOpenStateTransitions(open, store, () => { store.update({ allowMouseEnter: false, stickIfOpen: true }); resetOpenInteractionType(); }); 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]); useScrollLock(open && modal && lastOpenChangeReason !== REASONS.triggerHover && openMethod !== 'touch', positionerElement); useIsoLayoutEffect(() => { if (!open && !hoverEnabled) { store.set('hoverEnabled', true); } }, [open, hoverEnabled, store]); const allowTouchToCloseRef = React.useRef(true); const allowTouchToCloseTimeout = useTimeout(); const setOpen = useStableCallback((nextOpen, eventDetails) => { const reason = eventDetails.reason; if (open === nextOpen && eventDetails.trigger === activeTriggerElement) { return; } eventDetails.preventUnmountOnClose = () => { store.set('preventUnmountingOnClose', true); }; // Do not immediately reset the activeTriggerId to allow // exit animations to play and focus to be returned correctly. if (!nextOpen && eventDetails.trigger == null) { eventDetails.trigger = activeTriggerElement ?? undefined; } onOpenChange?.(nextOpen, eventDetails); if (eventDetails.isCanceled) { return; } const details = { open: nextOpen, nativeEvent: eventDetails.event, reason: eventDetails.reason, nested }; floatingEvents?.emit('openchange', details); const nativeEvent = eventDetails.event; if (nextOpen === false && nativeEvent?.type === 'click' && nativeEvent.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 = store.context.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 === REASONS.triggerFocus) { allowTouchToCloseRef.current = false; allowTouchToCloseTimeout.start(300, () => { allowTouchToCloseRef.current = true; }); } else { allowTouchToCloseRef.current = true; allowTouchToCloseTimeout.clear(); } const isKeyboardClick = (reason === REASONS.triggerPress || reason === REASONS.itemPress) && nativeEvent.detail === 0 && nativeEvent?.isTrusted; const isDismissClose = !nextOpen && (reason === REASONS.escapeKey || reason == null); function changeState() { const updatedState = { open: nextOpen, openChangeReason: reason }; openEventRef.current = eventDetails.event ?? null; // If a popup is closing, the `trigger` may be null. // We want to keep the previous value so that exit animations are played and focus is returned correctly. const newTriggerId = eventDetails.trigger?.id ?? null; if (newTriggerId || nextOpen) { updatedState.activeTriggerId = newTriggerId; updatedState.activeTriggerElement = eventDetails.trigger ?? null; } store.update(updatedState); } if (reason === REASONS.triggerHover) { ReactDOM.flushSync(changeState); } else { changeState(); } if (parent.type === 'menubar' && (reason === REASONS.triggerFocus || reason === REASONS.focusOut || reason === REASONS.triggerHover || reason === REASONS.listNavigation || reason === REASONS.siblingOpen)) { store.set('instantType', 'group'); } else if (isKeyboardClick || isDismissClose) { store.set('instantType', isKeyboardClick ? 'click' : 'dismiss'); } else { store.set('instantType', undefined); } }); const createMenuEventDetails = React.useCallback(reason => { const details = createChangeEventDetails(reason); details.preventUnmountOnClose = () => { store.set('preventUnmountingOnClose', true); }; return details; }, [store]); const handleImperativeClose = React.useCallback(() => { store.setOpen(false, createMenuEventDetails(REASONS.imperativeAction)); }, [store, createMenuEventDetails]); React.useImperativeHandle(actionsRef, () => ({ unmount: forceUnmount, close: handleImperativeClose }), [forceUnmount, handleImperativeClose]); let ctx; if (parent.type === 'context-menu') { ctx = parent.context; } React.useImperativeHandle(ctx?.positionerRef, () => positionerElement, [positionerElement]); React.useImperativeHandle(ctx?.actionsRef, () => ({ setOpen }), [setOpen]); const floatingRootContext = useSyncedFloatingRootContext({ popupStore: store, onOpenChange: setOpen }); floatingEvents = floatingRootContext.context.events; React.useEffect(() => { const handleSetOpenEvent = ({ open: nextOpen, eventDetails }) => setOpen(nextOpen, eventDetails); floatingEvents.on('setOpen', handleSetOpenEvent); return () => { floatingEvents?.off('setOpen', handleSetOpenEvent); }; }, [floatingEvents, setOpen]); 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; }, externalTree: nested ? floatingTreeRoot : undefined }); const role = useRole(floatingRootContext, { role: 'menu' }); const direction = useDirection(); const setActiveIndex = React.useCallback(index => { if (store.select('activeIndex') === index) { return; } store.set('activeIndex', index); }, [store]); const listNavigation = useListNavigation(floatingRootContext, { enabled: !disabled, listRef: store.context.itemDomElements, activeIndex, nested: parent.type !== undefined, loopFocus, orientation, parentOrientation: parent.type === 'menubar' ? parent.context.orientation : undefined, rtl: direction === 'rtl', disabledIndices: EMPTY_ARRAY, onNavigate: setActiveIndex, openOnArrowKeyDown: parent.type !== 'context-menu', externalTree: nested ? floatingTreeRoot : undefined }); const onTypingChange = React.useCallback(nextTyping => { store.context.typingRef.current = nextTyping; }, [store]); const typeahead = useTypeahead(floatingRootContext, { listRef: store.context.itemLabels, activeIndex, resetMs: TYPEAHEAD_RESET_MS, onMatch: index => { if (open && index !== activeIndex) { store.set('activeIndex', index); } }, onTypingChange }); const { getReferenceProps, getFloatingProps, getItemProps, getTriggerProps } = useInteractions([dismiss, role, listNavigation, typeahead]); const activeTriggerProps = React.useMemo(() => { const referenceProps = mergeProps(getReferenceProps(), { onMouseEnter() { store.set('hoverEnabled', true); }, onMouseMove() { store.set('allowMouseEnter', true); } }, interactionTypeProps); delete referenceProps.role; return referenceProps; }, [getReferenceProps, store, interactionTypeProps]); const inactiveTriggerProps = React.useMemo(() => { const triggerProps = getTriggerProps(); if (!triggerProps) { return triggerProps; } const { role: roleDiscarded, ['aria-controls']: ariaControlsDiscarded, ...rest } = triggerProps; return rest; }, [getTriggerProps]); const disableHoverTimeout = useAnimationFrame(); const popupProps = React.useMemo(() => getFloatingProps({ onMouseEnter() { if (parent.type === 'menu') { disableHoverTimeout.request(() => store.set('hoverEnabled', false)); } }, onMouseMove() { store.set('allowMouseEnter', true); }, onClick() { if (store.select('hoverEnabled')) { store.set('hoverEnabled', false); } }, onKeyDown(event) { // The Menubar's CompositeRoot captures keyboard events via // event delegation. This works well when Menu.Root is nested inside Menubar, // but with detached triggers we need to manually forward the event to the CompositeRoot. const relay = store.select('keyboardEventRelay'); if (relay && !event.isPropagationStopped()) { relay(event); } } }), [getFloatingProps, parent.type, disableHoverTimeout, store]); const itemProps = React.useMemo(() => getItemProps(), [getItemProps]); store.useSyncedValues({ floatingRootContext, activeTriggerProps, inactiveTriggerProps, popupProps, itemProps }); const context = React.useMemo(() => ({ store, parent: parentFromContext }), [store, parentFromContext]); const content = /*#__PURE__*/_jsx(MenuRootContext.Provider, { value: context, children: typeof children === 'function' ? children({ payload }) : children }); if (parent.type === undefined || parent.type === 'context-menu') { // set up a FloatingTree to provide the context to nested menus return /*#__PURE__*/_jsx(FloatingTree, { externalTree: floatingTreeRoot, children: content }); } return content; }