@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
JavaScript
'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;
}