@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.
436 lines (429 loc) • 17.3 kB
JavaScript
"use strict";
'use client';
var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.MenuRoot = MenuRoot;
var React = _interopRequireWildcard(require("react"));
var ReactDOM = _interopRequireWildcard(require("react-dom"));
var _useTimeout = require("@base-ui-components/utils/useTimeout");
var _useStableCallback = require("@base-ui-components/utils/useStableCallback");
var _useId = require("@base-ui-components/utils/useId");
var _useIsoLayoutEffect = require("@base-ui-components/utils/useIsoLayoutEffect");
var _useAnimationFrame = require("@base-ui-components/utils/useAnimationFrame");
var _useScrollLock = require("@base-ui-components/utils/useScrollLock");
var _empty = require("@base-ui-components/utils/empty");
var _floatingUiReact = require("../../floating-ui-react");
var _MenuRootContext = require("./MenuRootContext");
var _MenubarContext = require("../../menubar/MenubarContext");
var _constants = require("../../utils/constants");
var _DirectionContext = require("../../direction-provider/DirectionContext");
var _useOpenInteractionType = require("../../utils/useOpenInteractionType");
var _createBaseUIEventDetails = require("../../utils/createBaseUIEventDetails");
var _reasons = require("../../utils/reasons");
var _ContextMenuRootContext = require("../../context-menu/root/ContextMenuRootContext");
var _mergeProps = require("../../merge-props");
var _MenuStore = require("../store/MenuStore");
var _popups = require("../../utils/popups");
var _MenuSubmenuRootContext = require("../submenu-root/MenuSubmenuRootContext");
var _jsxRuntime = require("react/jsx-runtime");
/**
* Groups all parts of the menu.
* Doesn’t render its own HTML element.
*
* Documentation: [Base UI Menu](https://base-ui.com/react/components/menu)
*/
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 = (0, _ContextMenuRootContext.useContextMenuRootContext)(true);
const parentMenuRootContext = (0, _MenuRootContext.useMenuRootContext)(true);
const menubarContext = (0, _MenubarContext.useMenubarContext)(true);
const isSubmenu = (0, _MenuSubmenuRootContext.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.MenuStore.useStore(handle?.store, {
parent: parentFromContext
});
const floatingTreeRoot = store.useState('floatingTreeRoot');
const floatingNodeIdFromContext = (0, _floatingUiReact.useFloatingNodeId)(floatingTreeRoot);
const floatingParentNodeIdFromContext = (0, _floatingUiReact.useFloatingParentNodeId)();
(0, _useIsoLayoutEffect.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: (0, _useId.useId)()
});
const {
openMethod,
triggerProps: interactionTypeProps,
reset: resetOpenInteractionType
} = (0, _useOpenInteractionType.useOpenInteractionType)(open);
(0, _popups.useImplicitActiveTrigger)(store);
const {
forceUnmount
} = (0, _popups.useOpenStateTransitions)(open, store, () => {
store.update({
allowMouseEnter: false,
stickIfOpen: true
});
resetOpenInteractionType();
});
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]);
(0, _useScrollLock.useScrollLock)(open && modal && lastOpenChangeReason !== _reasons.REASONS.triggerHover && openMethod !== 'touch', positionerElement);
(0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => {
if (!open && !hoverEnabled) {
store.set('hoverEnabled', true);
}
}, [open, hoverEnabled, store]);
const allowTouchToCloseRef = React.useRef(true);
const allowTouchToCloseTimeout = (0, _useTimeout.useTimeout)();
const setOpen = (0, _useStableCallback.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.REASONS.triggerFocus) {
allowTouchToCloseRef.current = false;
allowTouchToCloseTimeout.start(300, () => {
allowTouchToCloseRef.current = true;
});
} else {
allowTouchToCloseRef.current = true;
allowTouchToCloseTimeout.clear();
}
const isKeyboardClick = (reason === _reasons.REASONS.triggerPress || reason === _reasons.REASONS.itemPress) && nativeEvent.detail === 0 && nativeEvent?.isTrusted;
const isDismissClose = !nextOpen && (reason === _reasons.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.REASONS.triggerHover) {
ReactDOM.flushSync(changeState);
} else {
changeState();
}
if (parent.type === 'menubar' && (reason === _reasons.REASONS.triggerFocus || reason === _reasons.REASONS.focusOut || reason === _reasons.REASONS.triggerHover || reason === _reasons.REASONS.listNavigation || reason === _reasons.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 = (0, _createBaseUIEventDetails.createChangeEventDetails)(reason);
details.preventUnmountOnClose = () => {
store.set('preventUnmountingOnClose', true);
};
return details;
}, [store]);
const handleImperativeClose = React.useCallback(() => {
store.setOpen(false, createMenuEventDetails(_reasons.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 = (0, _floatingUiReact.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 = (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;
},
externalTree: nested ? floatingTreeRoot : undefined
});
const role = (0, _floatingUiReact.useRole)(floatingRootContext, {
role: 'menu'
});
const direction = (0, _DirectionContext.useDirection)();
const setActiveIndex = React.useCallback(index => {
if (store.select('activeIndex') === index) {
return;
}
store.set('activeIndex', index);
}, [store]);
const listNavigation = (0, _floatingUiReact.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.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 = (0, _floatingUiReact.useTypeahead)(floatingRootContext, {
listRef: store.context.itemLabels,
activeIndex,
resetMs: _constants.TYPEAHEAD_RESET_MS,
onMatch: index => {
if (open && index !== activeIndex) {
store.set('activeIndex', index);
}
},
onTypingChange
});
const {
getReferenceProps,
getFloatingProps,
getItemProps,
getTriggerProps
} = (0, _floatingUiReact.useInteractions)([dismiss, role, listNavigation, typeahead]);
const activeTriggerProps = React.useMemo(() => {
const referenceProps = (0, _mergeProps.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 = (0, _useAnimationFrame.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__*/(0, _jsxRuntime.jsx)(_MenuRootContext.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__*/(0, _jsxRuntime.jsx)(_floatingUiReact.FloatingTree, {
externalTree: floatingTreeRoot,
children: content
});
}
return content;
}