Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion docs/src/app/(docs)/react/components/menu/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -1078,7 +1078,6 @@ type MenuParent =
| { type: 'menu'; store: MenuStore<unknown> }
| { type: 'menubar'; context: MenubarContext }
| { type: 'context-menu'; context: ContextMenuRootContext }
| { type: 'nested-context-menu'; context: ContextMenuRootContext; menuContext: MenuRootContext }
| { type: undefined };
```

Expand Down
6 changes: 1 addition & 5 deletions packages/react/src/menu/positioner/MenuPositioner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,11 +298,7 @@ export const MenuPositioner = React.forwardRef(function MenuPositioner(
<MenuPositionerContext.Provider value={positioner}>
{shouldRenderBackdrop && (
<InternalBackdrop
ref={
parent.type === 'context-menu' || parent.type === 'nested-context-menu'
? parent.context.internalBackdropRef
: null
}
ref={parent.type === 'context-menu' ? parent.context.internalBackdropRef : null}
inert={inertValue(!open)}
cutout={backdropCutout}
/>
Expand Down
48 changes: 27 additions & 21 deletions packages/react/src/menu/root/MenuRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useId } from '@base-ui/utils/useId';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { EMPTY_ARRAY, EMPTY_OBJECT } from '@base-ui/utils/empty';
import { fastComponent } from '@base-ui/utils/fastHooks';
import { warn } from '@base-ui/utils/warn';
import {
FloatingTree,
useDismiss,
Expand Down Expand Up @@ -147,8 +148,10 @@ export const MenuRoot = fastComponent(function MenuRoot<Payload>(props: MenuRoot

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.',
// `warn` dedupes, so this won't spam on every render. `parent.type !== undefined` also
// covers menubar and context menus, not just submenus.
warn(
'The `modal` prop is not supported on submenus, menubar menus, or context menus. It will be ignored.',
);
}
}
Expand Down Expand Up @@ -238,6 +241,22 @@ export const MenuRoot = fastComponent(function MenuRoot<Payload>(props: MenuRoot
return;
}

const nativeEvent = eventDetails.event as Event;

// 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.
// This must bail before notifying `onOpenChange` and dispatching the open change, otherwise
// controlled consumers (and floating-ui's own state) would close the menu regardless.
if (
nextOpen === false &&
nativeEvent?.type === 'click' &&
(nativeEvent as PointerEvent).pointerType === 'touch' &&
!allowTouchToCloseRef.current
) {
return;
}

const shouldPreventUnmountOnClose = attachPreventUnmountOnClose(
eventDetails as MenuRoot.ChangeEventDetails,
);
Expand All @@ -256,19 +275,8 @@ export const MenuRoot = fastComponent(function MenuRoot<Payload>(props: MenuRoot

store.state.floatingRootContext.dispatchOpenChange(nextOpen, eventDetails);

const nativeEvent = eventDetails.event as Event;
if (
nextOpen === false &&
nativeEvent?.type === 'click' &&
(nativeEvent as PointerEvent).pointerType === 'touch' &&
!allowTouchToCloseRef.current
) {
return;
}

// 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.
// Reset the touch-to-close guard so a delayed click after a focus-driven open is ignored
// (see the early return above).
if (nextOpen && reason === REASONS.triggerFocus) {
allowTouchToCloseRef.current = false;
allowTouchToCloseTimeout.start(300, () => {
Expand Down Expand Up @@ -394,7 +402,10 @@ export const MenuRoot = fastComponent(function MenuRoot<Payload>(props: MenuRoot
enabled: !disabled,
listRef: store.context.itemDomElements,
activeIndex,
nested: parent.type !== undefined,
// A root context menu has no parent menu to return to, so it must not be treated as nested:
// otherwise the cross-orientation close key (e.g. ArrowLeft) would close it, which native
// context menus never do. Its submenus are regular menus with `parent.type === 'menu'`.
nested: parent.type !== undefined && parent.type !== 'context-menu',
loopFocus,
orientation,
parentOrientation: parent.type === 'menubar' ? parent.context.orientation : undefined,
Expand Down Expand Up @@ -666,11 +677,6 @@ export type MenuParent =
type: 'context-menu';
context: ContextMenuRootContext;
}
| {
type: 'nested-context-menu';
context: ContextMenuRootContext;
menuContext: MenuRootContext;
}
| {
type: undefined;
};
Expand Down
20 changes: 15 additions & 5 deletions packages/react/src/menu/store/MenuStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,16 +129,24 @@ export class MenuStore<Payload> extends ReactStore<
selectors,
);

// This menu's own mouse-up gate, used when it has no parent to borrow from.
const ownAllowMouseUpTriggerRef = this.context.allowMouseUpTriggerRef;

// Set up propagation of state from parent menu if applicable.
this.unsubscribeParentListener = this.observe('parent', (parent) => {
this.unsubscribeParentListener?.();
// `observe` fires the listener synchronously here and again on every `parent` change, so the
// observer's own unsubscriber and the per-parent store subscription must live in separate
// fields — otherwise the synchronous first call clobbers one with the other and the first
// parent change tears down the observer itself.
this.observe('parent', (parent) => {
this.unsubscribeParentStore?.();
this.unsubscribeParentStore = null;

if (parent.type === 'menu') {
let rootId = parent.store.select('rootId');
let floatingTreeRoot = parent.store.select('floatingTreeRoot');
let keyboardEventRelay = parent.store.select('keyboardEventRelay');

this.unsubscribeParentListener = parent.store.subscribe(() => {
this.unsubscribeParentStore = parent.store.subscribe(() => {
const nextRootId = parent.store.select('rootId');
const nextFloatingTreeRoot = parent.store.select('floatingTreeRoot');
const nextKeyboardEventRelay = parent.store.select('keyboardEventRelay');
Expand All @@ -163,9 +171,11 @@ export class MenuStore<Payload> extends ReactStore<

if (parent.type !== undefined) {
this.context.allowMouseUpTriggerRef = parent.context.allowMouseUpTriggerRef;
return;
}

this.unsubscribeParentListener = null;
// Back at the root: stop borrowing a parent's ref so mouse-up state stays isolated.
this.context.allowMouseUpTriggerRef = ownAllowMouseUpTriggerRef;
});
}

Expand All @@ -185,7 +195,7 @@ export class MenuStore<Payload> extends ReactStore<
return externalStore ?? internalStore;
}

private unsubscribeParentListener: (() => void) | null = null;
private unsubscribeParentStore: (() => void) | null = null;
}

function createInitialState<Payload>(): State<Payload> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,6 @@ export const MenuSubmenuTrigger = React.forwardRef(function MenuSubmenuTrigger(
const localInteractionProps = click.reference ?? EMPTY_OBJECT;

const rootTriggerProps = store.useState('triggerProps', true);
delete rootTriggerProps.id;

const state: MenuSubmenuTriggerState = { disabled, highlighted, open };

Expand Down
15 changes: 10 additions & 5 deletions packages/react/src/menu/trigger/MenuTrigger.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { useTimeout } from '@base-ui/utils/useTimeout';
import { addEventListener } from '@base-ui/utils/addEventListener';
import { ownerDocument } from '@base-ui/utils/owner';
import { fastComponentRef } from '@base-ui/utils/fastHooks';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
Expand Down Expand Up @@ -138,8 +139,7 @@ export const MenuTrigger = fastComponentRef(function MenuTrigger(

if (
contains(triggerRef.current, mouseUpTarget) ||
contains(store.select('positionerElement'), mouseUpTarget) ||
mouseUpTarget === triggerRef.current
contains(store.select('positionerElement'), mouseUpTarget)
) {
return;
}
Expand All @@ -165,8 +165,12 @@ export const MenuTrigger = fastComponentRef(function MenuTrigger(
React.useEffect(() => {
if (isOpenedByThisTrigger && store.select('lastOpenChangeReason') === REASONS.triggerHover) {
const doc = ownerDocument(triggerRef.current);
doc.addEventListener('mouseup', handleDocumentMouseUp, { once: true });
// A hover-open can close again (hover out) without ever seeing a mouseup, leaving the
// `{ once: true }` listener armed to fire on an unrelated later interaction. Returning the
// cleanup removes it.
return addEventListener(doc, 'mouseup', handleDocumentMouseUp, { once: true });
}
return undefined;
}, [isOpenedByThisTrigger, handleDocumentMouseUp, store]);

const parentMenubarHasSubmenuOpen = isInMenubar && parent.context.hasSubmenuOpen;
Expand All @@ -189,7 +193,8 @@ export const MenuTrigger = fastComponentRef(function MenuTrigger(
isClosing: () => store.select('transitionStatus') === 'ending',
});

// Whether to ignore clicks to open the menu.
// Whether to keep the menu open when the trigger is clicked shortly after a hover-open
// (i.e. ignore the click that would otherwise close it).
// `lastOpenChangeReason` doesn't need to be reactive here, as we need to run this
// only when `isOpenedByThisTrigger` changes.
const stickIfOpen = useStickIfOpen(isOpenedByThisTrigger, store.select('lastOpenChangeReason'));
Expand Down Expand Up @@ -369,7 +374,7 @@ function useStickIfOpen(open: boolean, openReason: string | null) {
const stickIfOpenTimeout = useTimeout();
const [stickIfOpen, setStickIfOpen] = React.useState(false);
useIsoLayoutEffect(() => {
if (open && openReason === 'trigger-hover') {
if (open && openReason === REASONS.triggerHover) {
// 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);
Expand Down
Loading