@octanejs/base-ui 0.1.15 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/menu.ts ADDED
@@ -0,0 +1,3387 @@
1
+ // Ported from .base-ui/packages/react/src/menu/ (v1.6.0) — Phase 3f STAGES 1 + 2.
2
+ //
3
+ // STAGE 1, the open/close + roving-focus path: `store/MenuStore` + `store/MenuHandle`,
4
+ // `utils/findRootOwnerId` (in `./utils/findRootOwnerId`) + `utils/types`, `root/MenuRoot` +
5
+ // `root/MenuRootContext`, `trigger/`, `portal/`, `positioner/` and `popup/`. It is the first
6
+ // consumer of the Phase 3e list-navigation infrastructure: `MenuRoot` is where `useListNavigation`
7
+ // and `useTypeahead` get wired to a real popup store, and `Menu.Positioner` wraps the popup in the
8
+ // `CompositeList` those hooks read their element/label refs from.
9
+ //
10
+ // STAGE 2, the item family and the remaining popup parts: `utils/stateAttributesMapping`
11
+ // (`itemMapping`), `item/` (`useMenuItemCommonProps` + `useMenuItem` + `MenuItem`), `link-item/`,
12
+ // `checkbox-item/` + `checkbox-item-indicator/`, `radio-group/` + `radio-item/` +
13
+ // `radio-item-indicator/`, `group/` + `group-label/`, `arrow/`, `backdrop/` and `viewport/`. This
14
+ // is where the 3e infrastructure is finally exercised end-to-end: items register themselves with
15
+ // the positioner's `CompositeList`, so `useListNavigation` can rove `data-highlighted` between
16
+ // them and `useTypeahead` can match their labels.
17
+ //
18
+ // `Menu.Separator` re-exports the shared `Separator` exactly as upstream `menu/index.parts.ts`
19
+ // does — it is not a Menu-specific part.
20
+ //
21
+ // STAGE 3, submenus: `submenu-root/` (`MenuSubmenuRoot` + the `MenuSubmenuRootContext` stage 1
22
+ // already declared) and `submenu-trigger/`. `MenuSubmenuTrigger` is the piece that finally uses the
23
+ // `submenu-trigger` arm of `useMenuItem`'s `itemMetadata`, and it activates the sibling-close /
24
+ // parent-close / item-hover relays `MenuPositioner` has carried inertly since stage 1: it is
25
+ // simultaneously an ITEM of the parent menu (parent's `CompositeList`, `itemProps`, `isActive`) and
26
+ // the TRIGGER of its own menu, so it holds both stores at once.
27
+ //
28
+ // `Menubar` and `ContextMenu` land in stage 4.
29
+ //
30
+ // octane adaptations:
31
+ // - components are `createElement`, not JSX; `forwardRef` → ref-as-prop; each component takes its
32
+ // per-call-site slot from `S('…')` and derives every composed hook's slot via `subSlot`;
33
+ // - native, delegated events — `event.nativeEvent` collapses to `event`, and
34
+ // `event.isPropagationStopped()` (React-synthetic-only) becomes a `cancelBubble` read;
35
+ // - `React.createContext`/`useContext` → octane's; `React.useImperativeHandle` → octane's;
36
+ // - `useIsoLayoutEffect` → octane `useLayoutEffect`; `useMergedRefs` → `useComposedRefs`;
37
+ // - `fastComponent`/`fastComponentRef` (React fiber fast paths) are dropped — plain functions,
38
+ // as is the `React.memo` wrapper on `MenuRadioGroup` (octane memoizes renders itself and the
39
+ // wrapper carries no behavioral contract);
40
+ // - this package carries NO `process.env.NODE_ENV` branches, so upstream's dev-only warning for
41
+ // `modal` on a nested menu is dropped (same call as the rest of the binding). `useControlled`
42
+ // likewise takes no `name`/`state` labels here — they exist only for dev warnings.
43
+ //
44
+ // STAGING NOTE: the `menubar` / `context-menu` / `nested-context-menu` arms of `MenuParent` are
45
+ // transcribed verbatim even though no component provides those contexts yet — see
46
+ // `./utils/MenubarContext` and `./utils/ContextMenuRootContext`. Keeping the branches means stage 4
47
+ // only has to add the components, not re-thread `Menu`'s parent logic.
48
+ import {
49
+ createContext,
50
+ createElement,
51
+ Fragment,
52
+ useContext,
53
+ useMemo,
54
+ useRef,
55
+ useEffect,
56
+ useLayoutEffect,
57
+ useCallback,
58
+ useState,
59
+ useImperativeHandle,
60
+ useId,
61
+ isChildrenBlock,
62
+ } from 'octane';
63
+
64
+ import { S, subSlot } from './internal';
65
+ import { Separator } from './separator';
66
+ import { useRenderElement } from './utils/useRenderElement';
67
+ import { mergeProps } from './utils/mergeProps';
68
+ import { useButton } from './utils/useButton';
69
+ import { useBaseUiId } from './utils/useBaseUiId';
70
+ import { useStableCallback } from './utils/useStableCallback';
71
+ import { useControlled } from './utils/useControlled';
72
+ import { useRefWithInit } from './utils/useRefWithInit';
73
+ import { useTimeout } from './utils/useTimeout';
74
+ import { useComposedRefs } from './utils/composeRefs';
75
+ import { useTransitionStatus } from './utils/useTransitionStatus';
76
+ import { usePopupViewport } from './utils/usePopupViewport';
77
+ import { useCompositeListItem } from './utils/composite/useCompositeListItem';
78
+ import { platform } from './utils/platform';
79
+ import {
80
+ createChangeEventDetails,
81
+ REASONS,
82
+ type BaseUIChangeEventDetails,
83
+ } from './utils/createChangeEventDetails';
84
+ import { EMPTY_ARRAY, EMPTY_OBJECT } from './utils/empty';
85
+ import { ReactStore } from './utils/store/ReactStore';
86
+ import { createSelector } from './utils/store/createSelector';
87
+ import { ownerDocument } from './utils/owner';
88
+ import { contains } from './utils/contains';
89
+ import { inertValue } from './utils/inertValue';
90
+ import { getPseudoElementBounds } from './utils/getPseudoElementBounds';
91
+ import { getDisabledMountTransitionStyles } from './utils/getDisabledMountTransitionStyles';
92
+ import { useMixedToggleClickHandler } from './utils/useMixedToggleClickHandler';
93
+ import { useOpenInteractionType } from './utils/useOpenInteractionType';
94
+ import { useOpenChangeComplete } from './utils/useOpenChangeComplete';
95
+ import { useAnimationsFinished } from './utils/useAnimationsFinished';
96
+ import { useAnchoredPopupScrollLock } from './utils/useAnchoredPopupScrollLock';
97
+ import { usePositioner } from './utils/usePositioner';
98
+ import { InternalBackdrop } from './utils/InternalBackdrop';
99
+ import { adaptiveOrigin } from './utils/adaptiveOriginMiddleware';
100
+ import { findRootOwnerId } from './utils/findRootOwnerId';
101
+ import { useDirection } from './utils/DirectionContext';
102
+ import {
103
+ DROPDOWN_COLLISION_AVOIDANCE,
104
+ POPUP_COLLISION_AVOIDANCE,
105
+ PATIENT_CLICK_THRESHOLD,
106
+ TYPEAHEAD_RESET_MS,
107
+ } from './utils/constants';
108
+ import {
109
+ popupStateMapping,
110
+ pressableTriggerOpenStateMapping,
111
+ triggerOpenStateMapping,
112
+ } from './utils/popupStateMapping';
113
+ import { transitionStatusMapping } from './utils/useTransitionStatus';
114
+ import type { TransitionStatus } from './utils/useTransitionStatus';
115
+ import type { StateAttributesMapping } from './utils/getStateAttributesProps';
116
+ import { COMPOSITE_KEYS } from './utils/composite/keys';
117
+ import { CompositeList } from './utils/composite/CompositeList';
118
+ import { CompositeItem } from './utils/composite/CompositeItem';
119
+ import { useCompositeRootContext } from './utils/CompositeRootContext';
120
+ import {
121
+ useAnchorPositioning,
122
+ type Align,
123
+ type Side,
124
+ type UseAnchorPositioningSharedParameters,
125
+ } from './utils/useAnchorPositioning';
126
+ import { useClick } from './utils/floating/useClick';
127
+ import { useFocus } from './utils/floating/useFocus';
128
+ import { useDismiss } from './utils/floating/useDismiss';
129
+ import { useListNavigation } from './utils/floating/useListNavigation';
130
+ import { useTypeahead } from './utils/floating/useTypeahead';
131
+ import {
132
+ safePolygon,
133
+ useHoverReferenceInteraction,
134
+ } from './utils/floating/useHoverReferenceInteraction';
135
+ import { useHoverFloatingInteraction } from './utils/floating/useHoverFloatingInteraction';
136
+ import { useSyncedFloatingRootContext } from './utils/floating/useSyncedFloatingRootContext';
137
+ import { FloatingFocusManager } from './utils/floating/FloatingFocusManager';
138
+ import { FloatingPortal } from './utils/floating/FloatingPortal';
139
+ import { FocusGuard } from './utils/floating/FocusGuard';
140
+ import {
141
+ FloatingTree,
142
+ FloatingNode,
143
+ FloatingTreeStore,
144
+ useFloatingNodeId,
145
+ useFloatingParentNodeId,
146
+ useFloatingTree,
147
+ } from './utils/floating/FloatingTree';
148
+ import { useMenubarContext } from './utils/MenubarContext';
149
+ import type { MenubarContextValue } from './utils/MenubarContext';
150
+ import { useContextMenuRootContext } from './utils/ContextMenuRootContext';
151
+ import type { ContextMenuRootContextValue } from './utils/ContextMenuRootContext';
152
+ import {
153
+ attachPreventUnmountOnClose,
154
+ createInitialPopupStoreState,
155
+ popupStoreSelectors,
156
+ setPopupOpenState,
157
+ useImplicitActiveTrigger,
158
+ useInitialOpenSync,
159
+ useOpenStateTransitions,
160
+ usePopupInteractionProps,
161
+ useTriggerDataForwarding,
162
+ useTriggerRegistration,
163
+ FOCUSABLE_POPUP_PROPS,
164
+ PopupTriggerMap,
165
+ type PopupStoreContext,
166
+ type PopupStoreState,
167
+ } from './utils/popups';
168
+ import { useTriggerFocusGuards } from './utils/popups/useTriggerFocusGuards';
169
+ import type { InteractionType } from './utils/useEnhancedClickHandler';
170
+
171
+ // How far outside the trigger's (pseudo-element-inclusive) bounds a mouseup still counts as "on the
172
+ // trigger" and therefore does not cancel a hover-opened menu.
173
+ const BOUNDARY_OFFSET = 2;
174
+
175
+ type HTMLProps = Record<string, any>;
176
+
177
+ // --- Parent ------------------------------------------------------------------
178
+
179
+ export type MenuRootOrientation = 'horizontal' | 'vertical';
180
+
181
+ /**
182
+ * What a `Menu.Root` is nested inside. `undefined` is a standalone dropdown menu; `menu` is a
183
+ * submenu; `menubar`/`context-menu` are provided by components that land in stage 4.
184
+ */
185
+ export type MenuParent =
186
+ | {
187
+ type: 'menu';
188
+ store: MenuStore<unknown>;
189
+ }
190
+ | {
191
+ type: 'menubar';
192
+ context: MenubarContextValue;
193
+ }
194
+ | {
195
+ type: 'context-menu';
196
+ context: ContextMenuRootContextValue;
197
+ }
198
+ | {
199
+ type: 'nested-context-menu';
200
+ context: ContextMenuRootContextValue;
201
+ menuContext: MenuRootContextValue;
202
+ }
203
+ | {
204
+ type: undefined;
205
+ };
206
+
207
+ export type MenuRootChangeEventReason =
208
+ | typeof REASONS.triggerHover
209
+ | typeof REASONS.triggerFocus
210
+ | typeof REASONS.triggerPress
211
+ | typeof REASONS.outsidePress
212
+ | typeof REASONS.focusOut
213
+ | typeof REASONS.listNavigation
214
+ | typeof REASONS.escapeKey
215
+ | typeof REASONS.itemPress
216
+ | typeof REASONS.closePress
217
+ | typeof REASONS.siblingOpen
218
+ | typeof REASONS.cancelOpen
219
+ | typeof REASONS.imperativeAction
220
+ | typeof REASONS.none;
221
+
222
+ /** What `Menu.Root`'s `onOpenChange` receives. `preventUnmountOnClose` is attached per-dispatch. */
223
+ export type MenuRootChangeEventDetails = BaseUIChangeEventDetails<MenuRootChangeEventReason> & {
224
+ preventUnmountOnClose(): void;
225
+ };
226
+
227
+ export interface MenuOpenEventDetails {
228
+ open: boolean;
229
+ reason: MenuRootChangeEventReason | null;
230
+ nodeId: string | undefined;
231
+ parentNodeId: string | null;
232
+ }
233
+
234
+ // --- Store -------------------------------------------------------------------
235
+
236
+ export type MenuState<Payload> = PopupStoreState<Payload> & {
237
+ disabled: boolean;
238
+ modal: boolean;
239
+ openMethod: InteractionType | null;
240
+ allowMouseEnter: boolean;
241
+ highlightItemOnHover: boolean;
242
+ parent: MenuParent;
243
+ rootId: string | undefined;
244
+ activeIndex: number | null;
245
+ hoverEnabled: boolean;
246
+ stickIfOpen: boolean;
247
+ instantType: 'dismiss' | 'click' | 'group' | 'trigger-change' | undefined;
248
+ openChangeReason: MenuRootChangeEventReason | null;
249
+ floatingTreeRoot: FloatingTreeStore;
250
+ floatingNodeId: string | undefined;
251
+ floatingParentNodeId: string | null;
252
+ itemProps: HTMLProps;
253
+ closeDelay: number;
254
+ keyboardEventRelay: ((event: any) => void) | undefined;
255
+ hasViewport: boolean;
256
+ };
257
+
258
+ type MenuContext = PopupStoreContext<any> & {
259
+ readonly positionerRef: { current: HTMLElement | null };
260
+ readonly popupRef: { current: HTMLElement | null };
261
+ readonly typingRef: { current: boolean };
262
+ readonly itemDomElements: { current: Array<HTMLElement | null> };
263
+ readonly itemLabels: { current: Array<string | null> };
264
+ allowMouseUpTriggerRef: { current: boolean };
265
+ readonly triggerFocusTargetRef: { current: HTMLElement | null };
266
+ readonly beforeContentFocusGuardRef: { current: HTMLElement | null };
267
+ };
268
+
269
+ const menuSelectors = {
270
+ ...popupStoreSelectors,
271
+ disabled: createSelector((state: MenuState<unknown>) =>
272
+ state.parent.type === 'menubar'
273
+ ? state.parent.context.disabled || state.disabled
274
+ : state.disabled,
275
+ ),
276
+ modal: createSelector(
277
+ (state: MenuState<unknown>) =>
278
+ (state.parent.type === undefined || state.parent.type === 'context-menu') &&
279
+ (state.modal ?? true),
280
+ ),
281
+ openMethod: createSelector((state: MenuState<unknown>) => state.openMethod),
282
+ allowMouseEnter: createSelector((state: MenuState<unknown>) => state.allowMouseEnter),
283
+ highlightItemOnHover: createSelector((state: MenuState<unknown>) => state.highlightItemOnHover),
284
+ stickIfOpen: createSelector((state: MenuState<unknown>) => state.stickIfOpen),
285
+ parent: createSelector((state: MenuState<unknown>) => state.parent),
286
+ rootId: createSelector((state: MenuState<unknown>): string | undefined => {
287
+ if (state.parent.type === 'menu') {
288
+ return state.parent.store.select('rootId');
289
+ }
290
+
291
+ return state.parent.type !== undefined ? state.parent.context.rootId : state.rootId;
292
+ }),
293
+ activeIndex: createSelector((state: MenuState<unknown>) => state.activeIndex),
294
+ isActive: createSelector(
295
+ (state: MenuState<unknown>, itemIndex: number) => state.activeIndex === itemIndex,
296
+ ),
297
+ hoverEnabled: createSelector((state: MenuState<unknown>) => state.hoverEnabled),
298
+ instantType: createSelector((state: MenuState<unknown>) => state.instantType),
299
+ lastOpenChangeReason: createSelector((state: MenuState<unknown>) => state.openChangeReason),
300
+ floatingTreeRoot: createSelector((state: MenuState<unknown>): FloatingTreeStore => {
301
+ if (state.parent.type === 'menu') {
302
+ return state.parent.store.select('floatingTreeRoot');
303
+ }
304
+
305
+ return state.floatingTreeRoot;
306
+ }),
307
+ floatingNodeId: createSelector((state: MenuState<unknown>) => state.floatingNodeId),
308
+ floatingParentNodeId: createSelector((state: MenuState<unknown>) => state.floatingParentNodeId),
309
+ itemProps: createSelector((state: MenuState<unknown>) => state.itemProps),
310
+ closeDelay: createSelector((state: MenuState<unknown>) => state.closeDelay),
311
+ hasViewport: createSelector((state: MenuState<unknown>) => state.hasViewport),
312
+ keyboardEventRelay: createSelector(
313
+ (state: MenuState<unknown>): ((event: any) => void) | undefined => {
314
+ if (state.keyboardEventRelay) {
315
+ return state.keyboardEventRelay;
316
+ }
317
+
318
+ if (state.parent.type === 'menu') {
319
+ return state.parent.store.select('keyboardEventRelay');
320
+ }
321
+
322
+ return undefined;
323
+ },
324
+ ),
325
+ };
326
+
327
+ function createInitialMenuState<Payload>(): MenuState<Payload> {
328
+ return {
329
+ ...createInitialPopupStoreState<Payload>(),
330
+ disabled: false,
331
+ modal: true,
332
+ openMethod: null,
333
+ allowMouseEnter: false,
334
+ highlightItemOnHover: true,
335
+ stickIfOpen: true,
336
+ parent: {
337
+ type: undefined,
338
+ },
339
+ rootId: undefined,
340
+ activeIndex: null,
341
+ hoverEnabled: true,
342
+ instantType: undefined,
343
+ openChangeReason: null,
344
+ floatingTreeRoot: new FloatingTreeStore(),
345
+ floatingNodeId: undefined,
346
+ floatingParentNodeId: null,
347
+ itemProps: EMPTY_OBJECT as HTMLProps,
348
+ keyboardEventRelay: undefined,
349
+ closeDelay: 0,
350
+ hasViewport: false,
351
+ };
352
+ }
353
+
354
+ export class MenuStore<Payload> extends ReactStore<
355
+ Readonly<MenuState<Payload>>,
356
+ MenuContext,
357
+ typeof menuSelectors
358
+ > {
359
+ private unsubscribeParentListener: (() => void) | null = null;
360
+
361
+ constructor(initialState?: Partial<MenuState<Payload>>) {
362
+ super(
363
+ { ...createInitialMenuState<Payload>(), ...initialState },
364
+ {
365
+ positionerRef: { current: null },
366
+ popupRef: { current: null },
367
+ typingRef: { current: false },
368
+ itemDomElements: { current: [] },
369
+ itemLabels: { current: [] },
370
+ allowMouseUpTriggerRef: { current: false },
371
+ triggerFocusTargetRef: { current: null },
372
+ beforeContentFocusGuardRef: { current: null },
373
+ onOpenChangeComplete: undefined,
374
+ triggerElements: new PopupTriggerMap(),
375
+ } as MenuContext,
376
+ menuSelectors,
377
+ );
378
+
379
+ // Set up propagation of state from parent menu if applicable.
380
+ this.unsubscribeParentListener = this.observe('parent', (parent: MenuParent) => {
381
+ this.unsubscribeParentListener?.();
382
+
383
+ if (parent.type === 'menu') {
384
+ let rootId = parent.store.select('rootId');
385
+ let floatingTreeRoot = parent.store.select('floatingTreeRoot');
386
+ let keyboardEventRelay = parent.store.select('keyboardEventRelay');
387
+
388
+ this.unsubscribeParentListener = parent.store.subscribe(() => {
389
+ const nextRootId = parent.store.select('rootId');
390
+ const nextFloatingTreeRoot = parent.store.select('floatingTreeRoot');
391
+ const nextKeyboardEventRelay = parent.store.select('keyboardEventRelay');
392
+
393
+ if (
394
+ rootId === nextRootId &&
395
+ floatingTreeRoot === nextFloatingTreeRoot &&
396
+ keyboardEventRelay === nextKeyboardEventRelay
397
+ ) {
398
+ return;
399
+ }
400
+
401
+ rootId = nextRootId;
402
+ floatingTreeRoot = nextFloatingTreeRoot;
403
+ keyboardEventRelay = nextKeyboardEventRelay;
404
+ this.notifyAll();
405
+ });
406
+
407
+ this.context.allowMouseUpTriggerRef = parent.store.context.allowMouseUpTriggerRef;
408
+ return;
409
+ }
410
+
411
+ if (parent.type !== undefined) {
412
+ this.context.allowMouseUpTriggerRef = parent.context.allowMouseUpTriggerRef;
413
+ }
414
+
415
+ this.unsubscribeParentListener = null;
416
+ });
417
+ }
418
+
419
+ // Routed through the floating root context's event bus rather than applied directly, so that
420
+ // `Menu.Root` owns the one `setOpen` implementation even when the caller is a DETACHED trigger
421
+ // (or `MenuHandle`) that only holds the store.
422
+ setOpen(open: boolean, eventDetails: any): void {
423
+ (this.state.floatingRootContext as any).context.events.emit('setOpen', { open, eventDetails });
424
+ }
425
+
426
+ static useStore<Payload>(
427
+ externalStore: MenuStore<Payload> | undefined,
428
+ initialState: Partial<MenuState<Payload>>,
429
+ slot: symbol | undefined,
430
+ ): MenuStore<Payload> {
431
+ const internalStore = useRefWithInit<MenuStore<Payload>>(
432
+ () => new MenuStore<Payload>(initialState),
433
+ subSlot(slot, 'ref'),
434
+ ).current;
435
+
436
+ return externalStore ?? internalStore;
437
+ }
438
+ }
439
+
440
+ // --- Handle ------------------------------------------------------------------
441
+
442
+ export class MenuHandle<Payload> {
443
+ /** Internal store holding the menu's state. */
444
+ readonly store: MenuStore<Payload>;
445
+
446
+ constructor() {
447
+ this.store = new MenuStore<Payload>();
448
+ }
449
+
450
+ /**
451
+ * Opens the menu and associates it with the trigger with the given id. The trigger must be a
452
+ * `Menu.Trigger` with this handle passed as a prop.
453
+ */
454
+ open(triggerId: string) {
455
+ const triggerElement = triggerId
456
+ ? (this.store.context.triggerElements.getById(triggerId) as HTMLElement | undefined)
457
+ : undefined;
458
+
459
+ if (triggerId && !triggerElement) {
460
+ throw new Error(`Base UI: MenuHandle.open: No trigger found with id "${triggerId}".`);
461
+ }
462
+
463
+ this.store.setOpen(
464
+ true,
465
+ createChangeEventDetails(REASONS.imperativeAction, undefined, triggerElement),
466
+ );
467
+ }
468
+
469
+ /** Closes the menu. */
470
+ close() {
471
+ this.store.setOpen(
472
+ false,
473
+ createChangeEventDetails(REASONS.imperativeAction, undefined, undefined),
474
+ );
475
+ }
476
+
477
+ /** Indicates whether the menu is currently open. */
478
+ get isOpen() {
479
+ return this.store.select('open');
480
+ }
481
+ }
482
+
483
+ /** Creates a new handle to connect a `Menu.Root` with detached `Menu.Trigger` components. */
484
+ export function createMenuHandle<Payload>(): MenuHandle<Payload> {
485
+ return new MenuHandle<Payload>();
486
+ }
487
+
488
+ // --- Contexts ----------------------------------------------------------------
489
+
490
+ export interface MenuRootContextValue<Payload = unknown> {
491
+ store: MenuStore<Payload>;
492
+ parent: MenuParent;
493
+ }
494
+
495
+ // Exported so `ContextMenu.Root` can render `<MenuRootContext.Provider value={undefined}>` around
496
+ // its `Menu.Root`, exactly as upstream does — that is what stops a ContextMenu from being mistaken
497
+ // for a nested menu (see the `contextMenuContext && !parentMenuRootContext` guard in `MenuRoot`).
498
+ export const MenuRootContext = createContext<MenuRootContextValue | undefined>(undefined);
499
+
500
+ export function useMenuRootContext(optional?: false): MenuRootContextValue;
501
+ export function useMenuRootContext(optional: true): MenuRootContextValue | undefined;
502
+ export function useMenuRootContext(optional?: boolean): MenuRootContextValue | undefined {
503
+ const context = useContext(MenuRootContext);
504
+ if (context === undefined && !optional) {
505
+ throw new Error(
506
+ 'Base UI: MenuRootContext is missing. Menu parts must be placed within <Menu.Root>.',
507
+ );
508
+ }
509
+
510
+ return context;
511
+ }
512
+
513
+ export interface MenuSubmenuRootContextValue {
514
+ parentMenu: MenuStore<unknown>;
515
+ }
516
+
517
+ // Provided by `Menu.SubmenuRoot` (stage 3). Until then nothing provides it, so a `Menu.Root` nested
518
+ // inside another `Menu.Root` is NOT treated as a submenu — matching upstream, where the submenu
519
+ // relationship is established by `SubmenuRoot`, not by DOM nesting.
520
+ export const MenuSubmenuRootContext = createContext<MenuSubmenuRootContextValue | undefined>(
521
+ undefined,
522
+ );
523
+
524
+ export function useMenuSubmenuRootContext(): MenuSubmenuRootContextValue | undefined {
525
+ return useContext(MenuSubmenuRootContext);
526
+ }
527
+
528
+ const MenuPortalContext = createContext<boolean | undefined>(undefined);
529
+
530
+ export function useMenuPortalContext(): boolean {
531
+ const value = useContext(MenuPortalContext);
532
+ if (value === undefined) {
533
+ throw new Error('Base UI: <Menu.Portal> is missing.');
534
+ }
535
+ return value;
536
+ }
537
+
538
+ export interface MenuPositionerContextValue {
539
+ side: Side;
540
+ align: Align;
541
+ arrowRef: { current: Element | null };
542
+ arrowUncentered: boolean;
543
+ arrowStyles: Record<string, any>;
544
+ context: any;
545
+ }
546
+
547
+ const MenuPositionerContext = createContext<MenuPositionerContextValue | undefined>(undefined);
548
+
549
+ export function useMenuPositionerContext(optional?: false): MenuPositionerContextValue;
550
+ export function useMenuPositionerContext(optional: true): MenuPositionerContextValue | undefined;
551
+ export function useMenuPositionerContext(
552
+ optional?: boolean,
553
+ ): MenuPositionerContextValue | undefined {
554
+ const context = useContext(MenuPositionerContext);
555
+ if (!context && !optional) {
556
+ throw new Error(
557
+ 'Base UI: MenuPositionerContext is missing. Menu parts must be placed within <Menu.Positioner>.',
558
+ );
559
+ }
560
+ return context;
561
+ }
562
+
563
+ // --- Root --------------------------------------------------------------------
564
+
565
+ function MenuRoot<Payload>(props: any): any {
566
+ const slot = S('MenuRoot');
567
+ const {
568
+ children,
569
+ open: openProp,
570
+ onOpenChange,
571
+ onOpenChangeComplete,
572
+ defaultOpen = false,
573
+ disabled: disabledProp = false,
574
+ modal: modalProp,
575
+ loopFocus = true,
576
+ orientation = 'vertical',
577
+ actionsRef,
578
+ closeParentOnEsc = false,
579
+ handle,
580
+ triggerId: triggerIdProp,
581
+ defaultTriggerId: defaultTriggerIdProp = null,
582
+ highlightItemOnHover = true,
583
+ } = props;
584
+
585
+ const contextMenuContext = useContextMenuRootContext(true);
586
+ const parentMenuRootContext = useMenuRootContext(true);
587
+ const menubarContext = useMenubarContext(true);
588
+ const isSubmenu = useMenuSubmenuRootContext();
589
+
590
+ const parentFromContext: MenuParent = useMemo(
591
+ () => {
592
+ if (isSubmenu && parentMenuRootContext) {
593
+ return {
594
+ type: 'menu',
595
+ store: parentMenuRootContext.store,
596
+ };
597
+ }
598
+
599
+ if (menubarContext) {
600
+ return {
601
+ type: 'menubar',
602
+ context: menubarContext,
603
+ };
604
+ }
605
+
606
+ // Ensure this is not a Menu nested inside ContextMenu.Trigger. ContextMenu's
607
+ // parentContext is always undefined as ContextMenu.Root is instantiated with
608
+ // <MenuRootContext.Provider value={undefined}>.
609
+ if (contextMenuContext && !parentMenuRootContext) {
610
+ return {
611
+ type: 'context-menu',
612
+ context: contextMenuContext,
613
+ };
614
+ }
615
+
616
+ return {
617
+ type: undefined,
618
+ };
619
+ },
620
+ [contextMenuContext, parentMenuRootContext, menubarContext, isSubmenu],
621
+ subSlot(slot, 'parent'),
622
+ );
623
+
624
+ const store = MenuStore.useStore<Payload>(
625
+ handle?.store,
626
+ {
627
+ open: defaultOpen,
628
+ openProp,
629
+ activeTriggerId: defaultTriggerIdProp,
630
+ triggerIdProp,
631
+ parent: parentFromContext,
632
+ } as Partial<MenuState<Payload>>,
633
+ subSlot(slot, 'store'),
634
+ );
635
+
636
+ useInitialOpenSync(
637
+ store as any,
638
+ openProp,
639
+ defaultOpen,
640
+ defaultTriggerIdProp,
641
+ subSlot(slot, 'ios'),
642
+ );
643
+
644
+ store.useControlledProp('openProp', openProp, subSlot(slot, 'cp-open'));
645
+ store.useControlledProp('triggerIdProp', triggerIdProp, subSlot(slot, 'cp-tid'));
646
+
647
+ store.useContextCallback('onOpenChangeComplete', onOpenChangeComplete, subSlot(slot, 'cc'));
648
+
649
+ // Raw `useId` (no `base-ui-` prefix), matching Base UI's `@base-ui/utils/useId`.
650
+ const rootId = useId(subSlot(slot, 'rootId'));
651
+ const floatingId = useId(subSlot(slot, 'floatingId'));
652
+ const floatingTreeRoot = store.useState('floatingTreeRoot', subSlot(slot, 'ftr'));
653
+ const floatingNodeIdFromContext = useFloatingNodeId(floatingTreeRoot, subSlot(slot, 'nodeId'));
654
+ const floatingParentNodeIdFromContext = useFloatingParentNodeId();
655
+
656
+ const open = store.useState('open', subSlot(slot, 'open'));
657
+ const activeTriggerElement = store.useState('activeTriggerElement', subSlot(slot, 'ate'));
658
+ const positionerElement = store.useState('positionerElement', subSlot(slot, 'posEl'));
659
+ const hoverEnabled = store.useState('hoverEnabled', subSlot(slot, 'he'));
660
+ const disabled = store.useState('disabled', subSlot(slot, 'disabled'));
661
+ const lastOpenChangeReason = store.useState('lastOpenChangeReason', subSlot(slot, 'locr'));
662
+ const parent = store.useState('parent', subSlot(slot, 'p'));
663
+
664
+ const activeIndex = store.useState('activeIndex', subSlot(slot, 'ai'));
665
+ const payload = store.useState('payload', subSlot(slot, 'payload')) as Payload | undefined;
666
+ const floatingParentNodeId = store.useState('floatingParentNodeId', subSlot(slot, 'fpni'));
667
+
668
+ const openEventRef = useRef<Event | null>(null, subSlot(slot, 'oer'));
669
+ const allowOutsidePressDismissalRef = useRef(
670
+ parent.type !== 'context-menu',
671
+ subSlot(slot, 'aopdr'),
672
+ );
673
+ const allowOutsidePressDismissalTimeout = useTimeout(subSlot(slot, 'aopdt'));
674
+ const allowTouchToCloseRef = useRef(true, subSlot(slot, 'attcr'));
675
+ const allowTouchToCloseTimeout = useTimeout(subSlot(slot, 'attct'));
676
+
677
+ const nested = floatingParentNodeId != null;
678
+
679
+ const { openMethod, triggerProps: interactionTypeProps } = useOpenInteractionType(
680
+ open,
681
+ subSlot(slot, 'oit'),
682
+ );
683
+
684
+ store.useSyncedValues(
685
+ {
686
+ disabled: disabledProp,
687
+ highlightItemOnHover,
688
+ modal: parent.type === undefined ? modalProp : undefined,
689
+ openMethod,
690
+ rootId,
691
+ } as Partial<MenuState<Payload>>,
692
+ subSlot(slot, 'sv'),
693
+ );
694
+
695
+ useImplicitActiveTrigger(store as any, {}, subSlot(slot, 'iat'));
696
+ const { forceUnmount } = useOpenStateTransitions(
697
+ open,
698
+ store as any,
699
+ () => {
700
+ store.update({ allowMouseEnter: false, stickIfOpen: true });
701
+ },
702
+ subSlot(slot, 'ost'),
703
+ );
704
+
705
+ useLayoutEffect(
706
+ () => {
707
+ if (contextMenuContext && !parentMenuRootContext) {
708
+ // This is a context menu root. It doesn't support detached triggers yet, so we have
709
+ // to sync the parent context manually.
710
+ store.update({
711
+ parent: {
712
+ type: 'context-menu',
713
+ context: contextMenuContext,
714
+ },
715
+ floatingNodeId: floatingNodeIdFromContext,
716
+ floatingParentNodeId: floatingParentNodeIdFromContext,
717
+ });
718
+ } else if (parentMenuRootContext) {
719
+ store.update({
720
+ floatingNodeId: floatingNodeIdFromContext,
721
+ floatingParentNodeId: floatingParentNodeIdFromContext,
722
+ });
723
+ }
724
+ },
725
+ [
726
+ contextMenuContext,
727
+ parentMenuRootContext,
728
+ floatingNodeIdFromContext,
729
+ floatingParentNodeIdFromContext,
730
+ store,
731
+ ],
732
+ subSlot(slot, 'e:parentSync'),
733
+ );
734
+
735
+ useEffect(
736
+ () => {
737
+ if (!open) {
738
+ openEventRef.current = null;
739
+ }
740
+
741
+ if (parent.type !== 'context-menu') {
742
+ return;
743
+ }
744
+
745
+ if (!open) {
746
+ allowOutsidePressDismissalTimeout.clear();
747
+ allowOutsidePressDismissalRef.current = false;
748
+ return;
749
+ }
750
+
751
+ // With `mousedown` outside press events and long press touch input, there needs to be a
752
+ // grace period after opening to ensure the dismissal event doesn't fire immediately
753
+ // after open.
754
+ allowOutsidePressDismissalTimeout.start(500, () => {
755
+ allowOutsidePressDismissalRef.current = true;
756
+ });
757
+ },
758
+ [allowOutsidePressDismissalTimeout, open, parent.type],
759
+ subSlot(slot, 'e:outsidePress'),
760
+ );
761
+
762
+ useLayoutEffect(
763
+ () => {
764
+ if (!open && !hoverEnabled) {
765
+ store.set('hoverEnabled', true);
766
+ }
767
+ },
768
+ [open, hoverEnabled, store],
769
+ subSlot(slot, 'e:hover'),
770
+ );
771
+
772
+ const setOpen = useStableCallback(
773
+ (nextOpen: boolean, eventDetails: any) => {
774
+ const reason = eventDetails.reason;
775
+
776
+ if (
777
+ open === nextOpen &&
778
+ eventDetails.trigger === activeTriggerElement &&
779
+ lastOpenChangeReason === reason
780
+ ) {
781
+ return;
782
+ }
783
+
784
+ const shouldPreventUnmountOnClose = attachPreventUnmountOnClose(eventDetails);
785
+
786
+ // Do not immediately reset the activeTriggerId to allow exit animations to play and
787
+ // focus to be returned correctly.
788
+ if (!nextOpen && eventDetails.trigger == null) {
789
+ eventDetails.trigger = activeTriggerElement ?? undefined;
790
+ }
791
+
792
+ onOpenChange?.(nextOpen, eventDetails);
793
+
794
+ if (eventDetails.isCanceled) {
795
+ return;
796
+ }
797
+
798
+ (store.state.floatingRootContext as any).dispatchOpenChange(nextOpen, eventDetails);
799
+
800
+ const nativeEvent = eventDetails.event as Event;
801
+ if (
802
+ nextOpen === false &&
803
+ nativeEvent?.type === 'click' &&
804
+ (nativeEvent as PointerEvent).pointerType === 'touch' &&
805
+ !allowTouchToCloseRef.current
806
+ ) {
807
+ return;
808
+ }
809
+
810
+ // Prevent the menu from closing on mobile devices that have a delayed click event. In
811
+ // some cases the menu, when tapped, will fire the focus event first and then the click
812
+ // event. Without this guard, the menu will close immediately after opening.
813
+ if (nextOpen && reason === REASONS.triggerFocus) {
814
+ allowTouchToCloseRef.current = false;
815
+ allowTouchToCloseTimeout.start(300, () => {
816
+ allowTouchToCloseRef.current = true;
817
+ });
818
+ } else {
819
+ allowTouchToCloseRef.current = true;
820
+ allowTouchToCloseTimeout.clear();
821
+ }
822
+
823
+ const isKeyboardClick =
824
+ (reason === REASONS.triggerPress || reason === REASONS.itemPress) &&
825
+ (nativeEvent as MouseEvent).detail === 0 &&
826
+ nativeEvent?.isTrusted;
827
+ const isDismissClose = !nextOpen && (reason === REASONS.escapeKey || reason == null);
828
+
829
+ const updatedState: Partial<MenuState<Payload>> = {
830
+ open: nextOpen,
831
+ openChangeReason: reason,
832
+ };
833
+ openEventRef.current = eventDetails.event ?? null;
834
+
835
+ setPopupOpenState(
836
+ updatedState as any,
837
+ nextOpen,
838
+ eventDetails.trigger,
839
+ shouldPreventUnmountOnClose(),
840
+ );
841
+
842
+ store.update(updatedState);
843
+
844
+ if (
845
+ parent.type === 'menubar' &&
846
+ (reason === REASONS.triggerFocus ||
847
+ reason === REASONS.focusOut ||
848
+ reason === REASONS.triggerHover ||
849
+ reason === REASONS.listNavigation ||
850
+ reason === REASONS.siblingOpen)
851
+ ) {
852
+ store.set('instantType', 'group');
853
+ } else if (isKeyboardClick || isDismissClose) {
854
+ store.set('instantType', isKeyboardClick ? 'click' : 'dismiss');
855
+ } else {
856
+ store.set('instantType', undefined);
857
+ }
858
+ },
859
+ subSlot(slot, 'setOpen'),
860
+ );
861
+
862
+ const floatingRootContext = useSyncedFloatingRootContext(
863
+ {
864
+ popupStore: store as any,
865
+ floatingId,
866
+ nested: floatingParentNodeIdFromContext != null,
867
+ onOpenChange: setOpen,
868
+ },
869
+ subSlot(slot, 'sfrc'),
870
+ );
871
+
872
+ const floatingEvents = (floatingRootContext as any).context.events;
873
+
874
+ useEffect(
875
+ () => {
876
+ const handleSetOpenEvent = ({
877
+ open: nextOpen,
878
+ eventDetails,
879
+ }: {
880
+ open: boolean;
881
+ eventDetails: any;
882
+ }) => setOpen(nextOpen, eventDetails);
883
+
884
+ floatingEvents.on('setOpen', handleSetOpenEvent);
885
+
886
+ return () => {
887
+ floatingEvents?.off('setOpen', handleSetOpenEvent);
888
+ };
889
+ },
890
+ [floatingEvents, setOpen],
891
+ subSlot(slot, 'e:setOpen'),
892
+ );
893
+
894
+ const handleImperativeClose = useCallback(
895
+ () => {
896
+ store.setOpen(false, createChangeEventDetails(REASONS.imperativeAction));
897
+ },
898
+ [store],
899
+ subSlot(slot, 'close'),
900
+ );
901
+
902
+ useImperativeHandle(
903
+ actionsRef,
904
+ () => ({ unmount: forceUnmount, close: handleImperativeClose }),
905
+ [forceUnmount, handleImperativeClose],
906
+ subSlot(slot, 'imp'),
907
+ );
908
+
909
+ let ctx: ContextMenuRootContextValue | undefined;
910
+ if (parent.type === 'context-menu') {
911
+ ctx = parent.context;
912
+ }
913
+
914
+ useImperativeHandle(
915
+ ctx?.positionerRef,
916
+ () => positionerElement,
917
+ [positionerElement],
918
+ subSlot(slot, 'imp-pos'),
919
+ );
920
+
921
+ useImperativeHandle(ctx?.actionsRef, () => ({ setOpen }), [setOpen], subSlot(slot, 'imp-act'));
922
+
923
+ const dismiss = useDismiss(
924
+ floatingRootContext,
925
+ {
926
+ enabled: !disabled,
927
+ bubbles: { escapeKey: closeParentOnEsc && parent.type === 'menu' },
928
+ outsidePress() {
929
+ if (parent.type !== 'context-menu' || openEventRef.current?.type === 'contextmenu') {
930
+ return true;
931
+ }
932
+
933
+ return allowOutsidePressDismissalRef.current;
934
+ },
935
+ externalTree: nested ? floatingTreeRoot : undefined,
936
+ },
937
+ subSlot(slot, 'dismiss'),
938
+ );
939
+
940
+ const direction = useDirection();
941
+
942
+ const setActiveIndex = useCallback(
943
+ (index: number | null) => {
944
+ if (store.select('activeIndex') === index) {
945
+ return;
946
+ }
947
+ store.set('activeIndex', index);
948
+ },
949
+ [store],
950
+ subSlot(slot, 'sai'),
951
+ );
952
+
953
+ const listNavigation = useListNavigation(
954
+ floatingRootContext,
955
+ {
956
+ enabled: !disabled,
957
+ listRef: store.context.itemDomElements,
958
+ activeIndex,
959
+ nested: parent.type !== undefined,
960
+ loopFocus,
961
+ orientation,
962
+ parentOrientation: parent.type === 'menubar' ? parent.context.orientation : undefined,
963
+ rtl: direction === 'rtl',
964
+ disabledIndices: EMPTY_ARRAY,
965
+ onNavigate: setActiveIndex,
966
+ openOnArrowKeyDown: parent.type !== 'context-menu',
967
+ externalTree: nested ? floatingTreeRoot : undefined,
968
+ focusItemOnHover: highlightItemOnHover,
969
+ },
970
+ subSlot(slot, 'listNav'),
971
+ );
972
+
973
+ const onTyping = useCallback(
974
+ (nextTyping: boolean) => {
975
+ store.context.typingRef.current = nextTyping;
976
+ },
977
+ [store],
978
+ subSlot(slot, 'onTyping'),
979
+ );
980
+
981
+ const typeahead = useTypeahead(
982
+ floatingRootContext,
983
+ {
984
+ enabled: !disabled,
985
+ listRef: store.context.itemLabels,
986
+ elementsRef: store.context.itemDomElements,
987
+ activeIndex,
988
+ resetMs: TYPEAHEAD_RESET_MS,
989
+ onMatch: (index: number) => {
990
+ if (open && index !== activeIndex) {
991
+ store.set('activeIndex', index);
992
+ }
993
+ },
994
+ onTyping,
995
+ },
996
+ subSlot(slot, 'typeahead'),
997
+ );
998
+
999
+ const activeTriggerProps = useMemo(
1000
+ () => {
1001
+ const mergedProps = mergeProps(
1002
+ typeahead.reference,
1003
+ listNavigation.reference,
1004
+ dismiss.reference,
1005
+ {
1006
+ onMouseMove() {
1007
+ store.set('allowMouseEnter', true);
1008
+ },
1009
+ },
1010
+ interactionTypeProps,
1011
+ );
1012
+
1013
+ mergedProps['aria-haspopup'] = 'menu';
1014
+ mergedProps['aria-expanded'] = open;
1015
+
1016
+ return mergedProps;
1017
+ },
1018
+ [
1019
+ store,
1020
+ typeahead.reference,
1021
+ listNavigation.reference,
1022
+ dismiss.reference,
1023
+ interactionTypeProps,
1024
+ open,
1025
+ ],
1026
+ subSlot(slot, 'atp'),
1027
+ );
1028
+
1029
+ const inactiveTriggerProps = useMemo(
1030
+ () => {
1031
+ const mergedProps = mergeProps(listNavigation.trigger, dismiss.trigger, interactionTypeProps);
1032
+
1033
+ mergedProps['aria-haspopup'] = 'menu';
1034
+ mergedProps['aria-expanded'] = false;
1035
+
1036
+ return mergedProps;
1037
+ },
1038
+ [listNavigation.trigger, dismiss.trigger, interactionTypeProps],
1039
+ subSlot(slot, 'itp'),
1040
+ );
1041
+
1042
+ const popupProps = useMemo(
1043
+ () =>
1044
+ mergeProps(
1045
+ FOCUSABLE_POPUP_PROPS,
1046
+ {
1047
+ id: floatingId,
1048
+ role: 'menu' as const,
1049
+ 'aria-labelledby': activeTriggerElement?.id,
1050
+ onMouseMove() {
1051
+ store.set('allowMouseEnter', true);
1052
+ if (parent.type === 'menu') {
1053
+ store.set('hoverEnabled', false);
1054
+ }
1055
+ },
1056
+ onClick() {
1057
+ if (store.select('hoverEnabled')) {
1058
+ store.set('hoverEnabled', false);
1059
+ }
1060
+ },
1061
+ onKeyDown(event: KeyboardEvent) {
1062
+ // The Menubar's CompositeRoot captures keyboard events via event
1063
+ // delegation. This works well when Menu.Root is nested inside Menubar, but
1064
+ // with detached triggers we need to manually forward the event to the
1065
+ // CompositeRoot.
1066
+ //
1067
+ // octane dispatches NATIVE events, so React's
1068
+ // `event.isPropagationStopped()` becomes a `cancelBubble` read — the
1069
+ // standard flag `stopPropagation()` sets on a native event.
1070
+ const relay = store.select('keyboardEventRelay');
1071
+ if (relay && !event.cancelBubble) {
1072
+ relay(event);
1073
+ }
1074
+ },
1075
+ },
1076
+ typeahead.floating,
1077
+ listNavigation.floating,
1078
+ dismiss.floating,
1079
+ ),
1080
+ [
1081
+ activeTriggerElement,
1082
+ floatingId,
1083
+ parent.type,
1084
+ store,
1085
+ typeahead.floating,
1086
+ listNavigation.floating,
1087
+ dismiss.floating,
1088
+ ],
1089
+ subSlot(slot, 'pp'),
1090
+ );
1091
+
1092
+ const itemProps = listNavigation.item ?? EMPTY_OBJECT;
1093
+
1094
+ // `floatingRootContext` is INSTALLED ON THE STORE here (`usePopupInteractionProps` syncs its
1095
+ // whole state part), and this is the only place it happens. Menu deliberately differs from
1096
+ // Dialog/Popover/Tooltip, which build one in the store constructor and hand it to
1097
+ // `usePopupStore`: a Menu's store may be an EXTERNAL `MenuHandle` store shared with triggers
1098
+ // rendered outside this Root, so the context has to be created by the Root that owns `setOpen`
1099
+ // and published from there. Until this effect runs, `store.state.floatingRootContext` is the
1100
+ // inert placeholder from `createInitialPopupStoreState` — the same one-render window upstream
1101
+ // `MenuRoot.tsx` has, since it installs the context the identical way.
1102
+ //
1103
+ // Every store reader depends on this: `MenuTrigger`'s useClick/useFocus/hover, `MenuPositioner`'s
1104
+ // anchoring, `MenuPopup`'s focus manager, and `MenuStore.setOpen` — which emits on this
1105
+ // context's event bus so a detached trigger or `MenuHandle` reaches this Root's `setOpen`.
1106
+ // Pinned by the detached-trigger/handle tests in `tests/menu.test.ts`: dropping this one field
1107
+ // fails six of them.
1108
+ usePopupInteractionProps(
1109
+ store as any,
1110
+ {
1111
+ floatingRootContext,
1112
+ activeTriggerProps,
1113
+ inactiveTriggerProps,
1114
+ popupProps,
1115
+ itemProps,
1116
+ } as any,
1117
+ subSlot(slot, 'pip'),
1118
+ );
1119
+
1120
+ const context: MenuRootContextValue<Payload> = useMemo(
1121
+ () => ({
1122
+ store,
1123
+ parent: parentFromContext,
1124
+ }),
1125
+ [store, parentFromContext],
1126
+ subSlot(slot, 'ctx'),
1127
+ );
1128
+
1129
+ const resolvedChildren =
1130
+ typeof children === 'function' && !isChildrenBlock(children) ? children({ payload }) : children;
1131
+
1132
+ const content = createElement(MenuRootContext.Provider, {
1133
+ value: context as MenuRootContextValue,
1134
+ children: resolvedChildren,
1135
+ });
1136
+
1137
+ if (parent.type === undefined || parent.type === 'context-menu') {
1138
+ // Set up a FloatingTree to provide the context to nested menus.
1139
+ return createElement(FloatingTree, { externalTree: floatingTreeRoot, children: content });
1140
+ }
1141
+
1142
+ return content;
1143
+ }
1144
+
1145
+ // --- Trigger -----------------------------------------------------------------
1146
+
1147
+ function useMenuParent(slot: symbol | undefined): MenuParent {
1148
+ const contextMenuContext = useContextMenuRootContext(true);
1149
+ const parentContext = useMenuRootContext(true);
1150
+ const menubarContext = useMenubarContext(true);
1151
+
1152
+ return useMemo(
1153
+ (): MenuParent => {
1154
+ if (menubarContext) {
1155
+ return {
1156
+ type: 'menubar',
1157
+ context: menubarContext,
1158
+ };
1159
+ }
1160
+
1161
+ // Ensure this is not a Menu nested inside ContextMenu.Trigger.
1162
+ if (contextMenuContext && !parentContext) {
1163
+ return {
1164
+ type: 'context-menu',
1165
+ context: contextMenuContext,
1166
+ };
1167
+ }
1168
+
1169
+ return {
1170
+ type: undefined,
1171
+ };
1172
+ },
1173
+ [contextMenuContext, parentContext, menubarContext],
1174
+ subSlot(slot, 'parent'),
1175
+ );
1176
+ }
1177
+
1178
+ /** Determines whether to ignore clicks after a hover-open. */
1179
+ function useStickIfOpen(open: boolean, openReason: string | null, slot: symbol | undefined) {
1180
+ const stickIfOpenTimeout = useTimeout(subSlot(slot, 'to'));
1181
+ const [stickIfOpen, setStickIfOpen] = useState(false, subSlot(slot, 's'));
1182
+
1183
+ useLayoutEffect(
1184
+ () => {
1185
+ if (open && openReason === REASONS.triggerHover) {
1186
+ // Only allow "patient" clicks to close the menu if it's open. If they clicked
1187
+ // within 500ms of the menu opening, keep it open.
1188
+ setStickIfOpen(true);
1189
+ stickIfOpenTimeout.start(PATIENT_CLICK_THRESHOLD, () => {
1190
+ setStickIfOpen(false);
1191
+ });
1192
+ } else if (!open) {
1193
+ stickIfOpenTimeout.clear();
1194
+ setStickIfOpen(false);
1195
+ }
1196
+ },
1197
+ [open, openReason, stickIfOpenTimeout],
1198
+ subSlot(slot, 'e'),
1199
+ );
1200
+
1201
+ return stickIfOpen;
1202
+ }
1203
+
1204
+ function MenuTrigger(componentProps: any): any {
1205
+ const slot = S('MenuTrigger');
1206
+ const {
1207
+ render,
1208
+ className,
1209
+ style,
1210
+ disabled: disabledProp = false,
1211
+ nativeButton = true,
1212
+ id: idProp,
1213
+ openOnHover: openOnHoverProp,
1214
+ delay = 100,
1215
+ closeDelay = 0,
1216
+ handle,
1217
+ payload,
1218
+ ref: forwardedRef,
1219
+ ...elementProps
1220
+ } = componentProps;
1221
+
1222
+ const rootContext = useMenuRootContext(true);
1223
+ const store = (handle?.store ?? rootContext?.store) as MenuStore<any> | undefined;
1224
+ if (!store) {
1225
+ throw new Error(
1226
+ 'Base UI: <Menu.Trigger> must be either used within a <Menu.Root> component or provided with a handle.',
1227
+ );
1228
+ }
1229
+
1230
+ const thisTriggerId = useBaseUiId(idProp, subSlot(slot, 'id'));
1231
+ const isTriggerActive = store.useState('isTriggerActive', subSlot(slot, 'ita'), thisTriggerId);
1232
+ const floatingRootContext = store.useState('floatingRootContext', subSlot(slot, 'frc'));
1233
+ const isOpenedByThisTrigger = store.useState(
1234
+ 'isOpenedByTrigger',
1235
+ subSlot(slot, 'obt'),
1236
+ thisTriggerId,
1237
+ );
1238
+ const popupId = store.useState('triggerPopupId', subSlot(slot, 'tpid'), thisTriggerId);
1239
+
1240
+ const triggerElementRef = useRef<HTMLElement | null>(null, subSlot(slot, 'ter'));
1241
+
1242
+ const parent = useMenuParent(subSlot(slot, 'mp'));
1243
+ const compositeRootContext = useCompositeRootContext(true);
1244
+ const floatingTreeRootFromContext = useFloatingTree();
1245
+ const floatingTreeRoot = useMemo(
1246
+ () => (floatingTreeRootFromContext as FloatingTreeStore | null) ?? new FloatingTreeStore(),
1247
+ [floatingTreeRootFromContext],
1248
+ subSlot(slot, 'ftr'),
1249
+ );
1250
+
1251
+ const floatingNodeId = useFloatingNodeId(floatingTreeRoot, subSlot(slot, 'nodeId'));
1252
+ const floatingParentNodeId = useFloatingParentNodeId();
1253
+
1254
+ const { registerTrigger, isMountedByThisTrigger } = useTriggerDataForwarding(
1255
+ thisTriggerId,
1256
+ triggerElementRef,
1257
+ store as any,
1258
+ {
1259
+ payload,
1260
+ closeDelay,
1261
+ parent,
1262
+ floatingTreeRoot,
1263
+ floatingNodeId,
1264
+ floatingParentNodeId,
1265
+ keyboardEventRelay: (compositeRootContext as any)?.relayKeyboardEvent,
1266
+ } as any,
1267
+ subSlot(slot, 'tdf'),
1268
+ );
1269
+
1270
+ const isInMenubar = parent.type === 'menubar';
1271
+
1272
+ const rootDisabled = store.useState('disabled', subSlot(slot, 'rd'));
1273
+ const disabled =
1274
+ disabledProp || rootDisabled || (isInMenubar && (parent as any).context.disabled);
1275
+
1276
+ const { getButtonProps, buttonRef } = useButton(
1277
+ { disabled, native: nativeButton },
1278
+ subSlot(slot, 'btn'),
1279
+ );
1280
+
1281
+ useEffect(
1282
+ () => {
1283
+ if (!isOpenedByThisTrigger && parent.type === undefined) {
1284
+ store.context.allowMouseUpTriggerRef.current = false;
1285
+ }
1286
+ },
1287
+ [store, isOpenedByThisTrigger, parent.type],
1288
+ subSlot(slot, 'e:allowMouseUp'),
1289
+ );
1290
+
1291
+ const triggerRef = useRef<HTMLElement | null>(null, subSlot(slot, 'tr'));
1292
+ const allowMouseUpTriggerTimeout = useTimeout(subSlot(slot, 'amutt'));
1293
+
1294
+ const handleDocumentMouseUp = useStableCallback(
1295
+ (mouseEvent: MouseEvent) => {
1296
+ if (!triggerRef.current) {
1297
+ return;
1298
+ }
1299
+
1300
+ allowMouseUpTriggerTimeout.clear();
1301
+ store.context.allowMouseUpTriggerRef.current = false;
1302
+
1303
+ const mouseUpTarget = mouseEvent.target as Element | null;
1304
+
1305
+ if (
1306
+ contains(triggerRef.current, mouseUpTarget) ||
1307
+ contains(store.select('positionerElement'), mouseUpTarget) ||
1308
+ mouseUpTarget === triggerRef.current
1309
+ ) {
1310
+ return;
1311
+ }
1312
+
1313
+ if (mouseUpTarget != null && findRootOwnerId(mouseUpTarget) === store.select('rootId')) {
1314
+ return;
1315
+ }
1316
+
1317
+ const bounds = getPseudoElementBounds(triggerRef.current);
1318
+
1319
+ if (
1320
+ mouseEvent.clientX >= bounds.left - BOUNDARY_OFFSET &&
1321
+ mouseEvent.clientX <= bounds.right + BOUNDARY_OFFSET &&
1322
+ mouseEvent.clientY >= bounds.top - BOUNDARY_OFFSET &&
1323
+ mouseEvent.clientY <= bounds.bottom + BOUNDARY_OFFSET
1324
+ ) {
1325
+ return;
1326
+ }
1327
+
1328
+ floatingTreeRoot.events.emit('close', {
1329
+ domEvent: mouseEvent,
1330
+ reason: REASONS.cancelOpen,
1331
+ });
1332
+ },
1333
+ subSlot(slot, 'hdmu'),
1334
+ );
1335
+
1336
+ useEffect(
1337
+ () => {
1338
+ if (isOpenedByThisTrigger && store.select('lastOpenChangeReason') === REASONS.triggerHover) {
1339
+ const doc = ownerDocument(triggerRef.current);
1340
+ doc.addEventListener('mouseup', handleDocumentMouseUp, { once: true });
1341
+ }
1342
+ },
1343
+ [isOpenedByThisTrigger, handleDocumentMouseUp, store],
1344
+ subSlot(slot, 'e:mouseup'),
1345
+ );
1346
+
1347
+ const parentMenubarHasSubmenuOpen = isInMenubar && (parent as any).context.hasSubmenuOpen;
1348
+ const openOnHover = openOnHoverProp ?? parentMenubarHasSubmenuOpen;
1349
+
1350
+ const hoverProps = useHoverReferenceInteraction(
1351
+ floatingRootContext,
1352
+ {
1353
+ enabled:
1354
+ openOnHover &&
1355
+ !disabled &&
1356
+ parent.type !== 'context-menu' &&
1357
+ (!isInMenubar || (parentMenubarHasSubmenuOpen && !isMountedByThisTrigger)),
1358
+ handleClose: safePolygon({ blockPointerEvents: !isInMenubar }),
1359
+ mouseOnly: true,
1360
+ move: false,
1361
+ restMs: parent.type === undefined ? delay : undefined,
1362
+ delay: { close: closeDelay },
1363
+ triggerElementRef,
1364
+ externalTree: floatingTreeRoot,
1365
+ isActiveTrigger: isTriggerActive,
1366
+ isClosing: () => store.select('transitionStatus') === 'ending',
1367
+ },
1368
+ subSlot(slot, 'hover'),
1369
+ );
1370
+
1371
+ // Whether to ignore clicks to open the menu. `lastOpenChangeReason` doesn't need to be reactive
1372
+ // here, as we need to run this only when `isOpenedByThisTrigger` changes.
1373
+ const stickIfOpen = useStickIfOpen(
1374
+ isOpenedByThisTrigger,
1375
+ store.select('lastOpenChangeReason'),
1376
+ subSlot(slot, 'sio'),
1377
+ );
1378
+
1379
+ const click = useClick(
1380
+ floatingRootContext,
1381
+ {
1382
+ enabled: !disabled && parent.type !== 'context-menu',
1383
+ event: isOpenedByThisTrigger && isInMenubar ? 'click' : 'mousedown',
1384
+ toggle: true,
1385
+ ignoreMouse: false,
1386
+ stickIfOpen: parent.type === undefined ? stickIfOpen : false,
1387
+ },
1388
+ subSlot(slot, 'click'),
1389
+ );
1390
+
1391
+ const focus = useFocus(
1392
+ floatingRootContext,
1393
+ { enabled: !disabled && parentMenubarHasSubmenuOpen },
1394
+ subSlot(slot, 'focus'),
1395
+ );
1396
+
1397
+ const mixedToggleHandlers = useMixedToggleClickHandler(
1398
+ {
1399
+ open: isOpenedByThisTrigger,
1400
+ enabled: isInMenubar,
1401
+ mouseDownAction: 'open',
1402
+ },
1403
+ subSlot(slot, 'mixed'),
1404
+ );
1405
+
1406
+ const localInteractionProps = useMemo(
1407
+ () => mergeProps(focus.reference, click.reference),
1408
+ [focus.reference, click.reference],
1409
+ subSlot(slot, 'lip'),
1410
+ );
1411
+
1412
+ const rootTriggerProps = store.useState(
1413
+ 'triggerProps',
1414
+ subSlot(slot, 'tp'),
1415
+ isMountedByThisTrigger,
1416
+ );
1417
+
1418
+ const { preFocusGuardRef, handlePreFocusGuardFocus, handleFocusTargetFocus } =
1419
+ useTriggerFocusGuards(store as any, triggerElementRef, subSlot(slot, 'tfg'));
1420
+
1421
+ const state: MenuTriggerState = {
1422
+ disabled,
1423
+ open: isOpenedByThisTrigger,
1424
+ };
1425
+
1426
+ const refs = [triggerRef, forwardedRef, buttonRef, registerTrigger, triggerElementRef];
1427
+ const props = [
1428
+ localInteractionProps,
1429
+ hoverProps ?? EMPTY_OBJECT,
1430
+ rootTriggerProps,
1431
+ {
1432
+ 'aria-haspopup': 'menu' as const,
1433
+ 'aria-controls': popupId,
1434
+ id: thisTriggerId,
1435
+ onMouseDown: (event: MouseEvent) => {
1436
+ if (store.select('open')) {
1437
+ return;
1438
+ }
1439
+
1440
+ // mousedown -> mouseup on menu item should not trigger it within 200ms.
1441
+ allowMouseUpTriggerTimeout.start(200, () => {
1442
+ store.context.allowMouseUpTriggerRef.current = true;
1443
+ });
1444
+
1445
+ const doc = ownerDocument(event.currentTarget as Element);
1446
+ doc.addEventListener('mouseup', handleDocumentMouseUp, { once: true });
1447
+ },
1448
+ },
1449
+ isInMenubar ? { role: 'menuitem' } : {},
1450
+ mixedToggleHandlers,
1451
+ elementProps,
1452
+ getButtonProps,
1453
+ ];
1454
+
1455
+ const element = useRenderElement(
1456
+ 'button',
1457
+ componentProps,
1458
+ {
1459
+ enabled: !isInMenubar,
1460
+ stateAttributesMapping: pressableTriggerOpenStateMapping,
1461
+ state,
1462
+ ref: refs,
1463
+ props,
1464
+ },
1465
+ subSlot(slot, 're'),
1466
+ );
1467
+
1468
+ if (isInMenubar) {
1469
+ return createElement(CompositeItem, {
1470
+ tag: 'button',
1471
+ render,
1472
+ className,
1473
+ style,
1474
+ state,
1475
+ refs,
1476
+ props,
1477
+ stateAttributesMapping: pressableTriggerOpenStateMapping,
1478
+ });
1479
+ }
1480
+
1481
+ // A fragment with key is required to ensure that the `element` is mounted to the same DOM node
1482
+ // regardless of whether the focus guards are rendered or not. (octane reconciles a returned
1483
+ // array as a keyed list, so the guards carry stable keys too — otherwise the unkeyed siblings
1484
+ // shift the keyed trigger and it is rebuilt anyway. Same fix as Popover.Trigger.)
1485
+ const keyedElement = createElement(Fragment, { key: thisTriggerId, children: element });
1486
+
1487
+ if (isOpenedByThisTrigger) {
1488
+ return [
1489
+ createElement(FocusGuard, {
1490
+ key: `${thisTriggerId}-pre-focus-guard`,
1491
+ ref: preFocusGuardRef,
1492
+ onFocus: handlePreFocusGuardFocus,
1493
+ }),
1494
+ keyedElement,
1495
+ createElement(FocusGuard, {
1496
+ key: `${thisTriggerId}-post-focus-guard`,
1497
+ ref: store.context.triggerFocusTargetRef,
1498
+ onFocus: handleFocusTargetFocus,
1499
+ }),
1500
+ ];
1501
+ }
1502
+
1503
+ return keyedElement;
1504
+ }
1505
+
1506
+ export interface MenuTriggerState {
1507
+ /** Whether the menu is currently open and was opened by this trigger. */
1508
+ open: boolean;
1509
+ /** Whether the trigger is disabled. */
1510
+ disabled: boolean;
1511
+ }
1512
+
1513
+ // --- Portal ------------------------------------------------------------------
1514
+
1515
+ function MenuPortal(props: any): any {
1516
+ const slot = S('MenuPortal');
1517
+ const { keepMounted = false, ref, ...portalProps } = props;
1518
+
1519
+ const { store } = useMenuRootContext() as MenuRootContextValue;
1520
+ const mounted = store.useState('mounted', subSlot(slot, 'mounted'));
1521
+
1522
+ const shouldRender = mounted || keepMounted;
1523
+ if (!shouldRender) {
1524
+ return null;
1525
+ }
1526
+
1527
+ return createElement(MenuPortalContext.Provider, {
1528
+ value: keepMounted,
1529
+ children: createElement(FloatingPortal, { ref, ...portalProps }),
1530
+ });
1531
+ }
1532
+
1533
+ // --- Positioner --------------------------------------------------------------
1534
+
1535
+ function MenuPositioner(componentProps: any): any {
1536
+ const slot = S('MenuPositioner');
1537
+ const {
1538
+ anchor: anchorProp,
1539
+ positionMethod: positionMethodProp = 'absolute',
1540
+ className,
1541
+ render,
1542
+ side,
1543
+ align: alignProp,
1544
+ sideOffset: sideOffsetProp = 0,
1545
+ alignOffset: alignOffsetProp = 0,
1546
+ collisionBoundary = 'clipping-ancestors',
1547
+ collisionPadding = 5,
1548
+ arrowPadding = 5,
1549
+ sticky = false,
1550
+ disableAnchorTracking = false,
1551
+ collisionAvoidance: collisionAvoidanceProp = DROPDOWN_COLLISION_AVOIDANCE,
1552
+ style,
1553
+ ref: forwardedRef,
1554
+ ...elementProps
1555
+ } = componentProps;
1556
+
1557
+ const { store } = useMenuRootContext() as MenuRootContextValue;
1558
+
1559
+ const keepMounted = useMenuPortalContext();
1560
+ const contextMenuContext = useContextMenuRootContext(true);
1561
+
1562
+ const parent = store.useState('parent', subSlot(slot, 'parent'));
1563
+ const floatingRootContext = store.useState('floatingRootContext', subSlot(slot, 'frc'));
1564
+ const floatingTreeRoot = store.useState('floatingTreeRoot', subSlot(slot, 'ftr'));
1565
+ const mounted = store.useState('mounted', subSlot(slot, 'mounted'));
1566
+ const open = store.useState('open', subSlot(slot, 'open'));
1567
+ const modal = store.useState('modal', subSlot(slot, 'modal'));
1568
+ const openMethod = store.useState('openMethod', subSlot(slot, 'method'));
1569
+ const triggerElement = store.useState('activeTriggerElement', subSlot(slot, 'trigger'));
1570
+ const transitionStatus = store.useState('transitionStatus', subSlot(slot, 'trans'));
1571
+ const positionerElement = store.useState('positionerElement', subSlot(slot, 'posEl'));
1572
+ const instantType = store.useState('instantType', subSlot(slot, 'instant'));
1573
+ const hasViewport = store.useState('hasViewport', subSlot(slot, 'viewport'));
1574
+ const lastOpenChangeReason = store.useState('lastOpenChangeReason', subSlot(slot, 'locr'));
1575
+ const floatingNodeId = store.useState('floatingNodeId', subSlot(slot, 'nodeId'));
1576
+ const floatingParentNodeId = store.useState('floatingParentNodeId', subSlot(slot, 'parentId'));
1577
+ const domReference = floatingRootContext.useState('domReferenceElement', subSlot(slot, 'domRef'));
1578
+
1579
+ const previousTriggerRef = useRef<Element | null>(null, subSlot(slot, 'prevTrigger'));
1580
+ const runOnceAnimationsFinish = useAnimationsFinished(
1581
+ positionerElement,
1582
+ false,
1583
+ false,
1584
+ subSlot(slot, 'anim'),
1585
+ );
1586
+
1587
+ let anchor = anchorProp;
1588
+ let sideOffset = sideOffsetProp;
1589
+ let alignOffset = alignOffsetProp;
1590
+ let align = alignProp;
1591
+ let collisionAvoidance = collisionAvoidanceProp;
1592
+ if (parent.type === 'context-menu') {
1593
+ anchor = anchorProp ?? parent.context?.anchor;
1594
+ align = align ?? 'start';
1595
+ if (!side && align !== 'center') {
1596
+ alignOffset = componentProps.alignOffset ?? 2;
1597
+ sideOffset = componentProps.sideOffset ?? -5;
1598
+ }
1599
+ }
1600
+
1601
+ let computedSide = side;
1602
+ let computedAlign = align;
1603
+ if (parent.type === 'menu') {
1604
+ computedSide = computedSide ?? 'inline-end';
1605
+ computedAlign = computedAlign ?? 'start';
1606
+ collisionAvoidance = componentProps.collisionAvoidance ?? POPUP_COLLISION_AVOIDANCE;
1607
+ } else if (parent.type === 'menubar') {
1608
+ computedSide =
1609
+ computedSide ?? (parent.context.orientation === 'vertical' ? 'inline-end' : 'bottom');
1610
+ computedAlign = computedAlign ?? 'start';
1611
+ }
1612
+
1613
+ const contextMenu = parent.type === 'context-menu';
1614
+
1615
+ const positioner = useAnchorPositioning(
1616
+ {
1617
+ anchor,
1618
+ floatingRootContext,
1619
+ positionMethod: contextMenuContext ? 'fixed' : positionMethodProp,
1620
+ mounted,
1621
+ side: computedSide,
1622
+ sideOffset,
1623
+ align: computedAlign,
1624
+ alignOffset,
1625
+ arrowPadding: contextMenu ? 0 : arrowPadding,
1626
+ collisionBoundary,
1627
+ collisionPadding,
1628
+ sticky,
1629
+ nodeId: floatingNodeId,
1630
+ keepMounted,
1631
+ disableAnchorTracking,
1632
+ collisionAvoidance,
1633
+ shiftCrossAxis:
1634
+ contextMenu && !('side' in collisionAvoidance && collisionAvoidance.side === 'flip'),
1635
+ externalTree: floatingTreeRoot,
1636
+ adaptiveOrigin: hasViewport ? adaptiveOrigin : undefined,
1637
+ },
1638
+ subSlot(slot, 'positioning'),
1639
+ );
1640
+
1641
+ useEffect(
1642
+ () => {
1643
+ function onMenuOpenChange(details: MenuOpenEventDetails) {
1644
+ if (details.open) {
1645
+ if (details.parentNodeId === floatingNodeId) {
1646
+ store.set('hoverEnabled', false);
1647
+ }
1648
+ if (
1649
+ details.nodeId !== floatingNodeId &&
1650
+ details.parentNodeId === store.select('floatingParentNodeId')
1651
+ ) {
1652
+ store.setOpen(false, createChangeEventDetails(REASONS.siblingOpen));
1653
+ }
1654
+ }
1655
+ }
1656
+
1657
+ floatingTreeRoot.events.on('menuopenchange', onMenuOpenChange);
1658
+
1659
+ return () => {
1660
+ floatingTreeRoot.events.off('menuopenchange', onMenuOpenChange);
1661
+ };
1662
+ },
1663
+ [store, floatingTreeRoot.events, floatingNodeId],
1664
+ subSlot(slot, 'e:openChange'),
1665
+ );
1666
+
1667
+ useEffect(
1668
+ () => {
1669
+ if (store.select('floatingParentNodeId') == null) {
1670
+ return undefined;
1671
+ }
1672
+
1673
+ function onParentClose(details: MenuOpenEventDetails) {
1674
+ if (details.open || details.nodeId !== store.select('floatingParentNodeId')) {
1675
+ return;
1676
+ }
1677
+
1678
+ const reason: MenuRootChangeEventReason = details.reason ?? REASONS.siblingOpen;
1679
+ store.setOpen(false, createChangeEventDetails(reason));
1680
+ }
1681
+
1682
+ floatingTreeRoot.events.on('menuopenchange', onParentClose);
1683
+
1684
+ return () => {
1685
+ floatingTreeRoot.events.off('menuopenchange', onParentClose);
1686
+ };
1687
+ },
1688
+ [floatingTreeRoot.events, store],
1689
+ subSlot(slot, 'e:parentClose'),
1690
+ );
1691
+
1692
+ const closeTimeout = useTimeout(subSlot(slot, 'closeTimeout'));
1693
+
1694
+ // Clear pending close timeout when the menu closes.
1695
+ useEffect(
1696
+ () => {
1697
+ if (!open) {
1698
+ closeTimeout.clear();
1699
+ }
1700
+ },
1701
+ [open, closeTimeout],
1702
+ subSlot(slot, 'e:clearClose'),
1703
+ );
1704
+
1705
+ // Close unrelated child submenus when hovering a different item in the parent menu.
1706
+ useEffect(
1707
+ () => {
1708
+ function onItemHover(event: { nodeId: string | undefined; target: Element | null }) {
1709
+ // If an item within our parent menu is hovered, and this menu's trigger is not that
1710
+ // item, close this submenu. This ensures hovering a different item in the parent
1711
+ // closes other branches.
1712
+ if (!open || event.nodeId !== store.select('floatingParentNodeId')) {
1713
+ return;
1714
+ }
1715
+
1716
+ if (event.target && triggerElement && triggerElement !== event.target) {
1717
+ const delay = store.select('closeDelay');
1718
+ if (delay > 0) {
1719
+ if (!closeTimeout.isStarted()) {
1720
+ closeTimeout.start(delay, () => {
1721
+ store.setOpen(false, createChangeEventDetails(REASONS.siblingOpen));
1722
+ });
1723
+ }
1724
+ } else {
1725
+ store.setOpen(false, createChangeEventDetails(REASONS.siblingOpen));
1726
+ }
1727
+ } else {
1728
+ // User re-hovered the submenu trigger, cancel pending close.
1729
+ closeTimeout.clear();
1730
+ }
1731
+ }
1732
+
1733
+ floatingTreeRoot.events.on('itemhover', onItemHover);
1734
+ return () => {
1735
+ floatingTreeRoot.events.off('itemhover', onItemHover);
1736
+ };
1737
+ },
1738
+ [floatingTreeRoot.events, open, triggerElement, store, closeTimeout],
1739
+ subSlot(slot, 'e:itemHover'),
1740
+ );
1741
+
1742
+ useEffect(
1743
+ () => {
1744
+ const eventDetails: MenuOpenEventDetails = {
1745
+ open,
1746
+ nodeId: floatingNodeId,
1747
+ parentNodeId: floatingParentNodeId,
1748
+ reason: store.select('lastOpenChangeReason'),
1749
+ };
1750
+
1751
+ floatingTreeRoot.events.emit('menuopenchange', eventDetails);
1752
+ },
1753
+ [floatingTreeRoot.events, open, store, floatingNodeId, floatingParentNodeId],
1754
+ subSlot(slot, 'e:emit'),
1755
+ );
1756
+
1757
+ // Keep positioner transition behavior aligned with Popover when switching detached triggers.
1758
+ useLayoutEffect(
1759
+ () => {
1760
+ const currentTrigger = domReference;
1761
+ const previousTrigger = previousTriggerRef.current;
1762
+
1763
+ if (currentTrigger) {
1764
+ previousTriggerRef.current = currentTrigger;
1765
+ }
1766
+
1767
+ if (previousTrigger && currentTrigger && currentTrigger !== previousTrigger) {
1768
+ store.set('instantType', undefined);
1769
+
1770
+ const abortController = new AbortController();
1771
+ runOnceAnimationsFinish(() => {
1772
+ store.set('instantType', 'trigger-change');
1773
+ }, abortController.signal);
1774
+
1775
+ return () => {
1776
+ abortController.abort();
1777
+ };
1778
+ }
1779
+
1780
+ return undefined;
1781
+ },
1782
+ [domReference, runOnceAnimationsFinish, store],
1783
+ subSlot(slot, 'e:triggerChange'),
1784
+ );
1785
+
1786
+ const state: MenuPositionerState = {
1787
+ open,
1788
+ side: positioner.side,
1789
+ align: positioner.align,
1790
+ anchorHidden: positioner.anchorHidden,
1791
+ nested: parent.type === 'menu',
1792
+ instant: instantType,
1793
+ };
1794
+
1795
+ const menubarModal = parent.type === 'menubar' && parent.context.modal;
1796
+ const popupModal = modal && lastOpenChangeReason !== REASONS.triggerHover;
1797
+
1798
+ useAnchoredPopupScrollLock(
1799
+ open && (menubarModal || popupModal),
1800
+ openMethod === 'touch',
1801
+ positionerElement,
1802
+ triggerElement,
1803
+ );
1804
+
1805
+ const setPositionerElement = store.useStateSetter('positionerElement', subSlot(slot, 'setPosEl'));
1806
+
1807
+ const element = usePositioner(
1808
+ componentProps,
1809
+ state,
1810
+ {
1811
+ styles: positioner.positionerStyles,
1812
+ transitionStatus,
1813
+ props: elementProps,
1814
+ refs: [forwardedRef, setPositionerElement],
1815
+ hidden: !mounted,
1816
+ inert: !open,
1817
+ },
1818
+ subSlot(slot, 'positioner'),
1819
+ );
1820
+
1821
+ const shouldRenderBackdrop =
1822
+ mounted &&
1823
+ parent.type !== 'menu' &&
1824
+ ((parent.type !== 'menubar' && modal && lastOpenChangeReason !== REASONS.triggerHover) ||
1825
+ (parent.type === 'menubar' && parent.context.modal));
1826
+
1827
+ // Cuts a hole in the backdrop to allow pointer interaction with the menubar or dropdown menu
1828
+ // trigger element.
1829
+ let backdropCutout: HTMLElement | null = null;
1830
+ if (parent.type === 'menubar') {
1831
+ backdropCutout = parent.context.contentElement;
1832
+ } else if (parent.type === undefined) {
1833
+ backdropCutout = triggerElement as HTMLElement | null;
1834
+ }
1835
+
1836
+ return createElement(MenuPositionerContext.Provider, {
1837
+ value: positioner as unknown as MenuPositionerContextValue,
1838
+ children: [
1839
+ shouldRenderBackdrop
1840
+ ? createElement(InternalBackdrop, {
1841
+ key: 'backdrop',
1842
+ ref:
1843
+ parent.type === 'context-menu' || parent.type === 'nested-context-menu'
1844
+ ? parent.context.internalBackdropRef
1845
+ : null,
1846
+ inert: inertValue(!open),
1847
+ cutout: backdropCutout,
1848
+ })
1849
+ : null,
1850
+ createElement(FloatingNode, {
1851
+ key: 'node',
1852
+ id: floatingNodeId,
1853
+ children: createElement(CompositeList, {
1854
+ elementsRef: store.context.itemDomElements,
1855
+ labelsRef: store.context.itemLabels,
1856
+ children: element,
1857
+ }),
1858
+ }),
1859
+ ],
1860
+ });
1861
+ }
1862
+
1863
+ export interface MenuPositionerState {
1864
+ /** Whether the menu is currently open. */
1865
+ open: boolean;
1866
+ /** The side of the anchor the component is placed on. */
1867
+ side: Side;
1868
+ /** The alignment of the component relative to the anchor. */
1869
+ align: Align;
1870
+ /** Whether the anchor element is hidden. */
1871
+ anchorHidden: boolean;
1872
+ /** Whether the component is nested. */
1873
+ nested: boolean;
1874
+ /** Whether CSS transitions should be disabled. */
1875
+ instant: string | undefined;
1876
+ }
1877
+
1878
+ export interface MenuPositionerProps extends UseAnchorPositioningSharedParameters {
1879
+ [key: string]: any;
1880
+ }
1881
+
1882
+ // --- Popup -------------------------------------------------------------------
1883
+
1884
+ const menuPopupStateAttributesMapping: StateAttributesMapping<any> = {
1885
+ ...popupStateMapping,
1886
+ ...transitionStatusMapping,
1887
+ };
1888
+
1889
+ function MenuPopup(componentProps: any): any {
1890
+ const slot = S('MenuPopup');
1891
+ const {
1892
+ render,
1893
+ className,
1894
+ style,
1895
+ finalFocus,
1896
+ ref: forwardedRef,
1897
+ ...elementProps
1898
+ } = componentProps;
1899
+
1900
+ const { store } = useMenuRootContext() as MenuRootContextValue;
1901
+ const { side, align } = useMenuPositionerContext();
1902
+ // No Toolbar context in this phase; the composite key-stop is toolbar-only.
1903
+ const insideToolbar = false;
1904
+
1905
+ const open = store.useState('open', subSlot(slot, 'open'));
1906
+ const transitionStatus = store.useState('transitionStatus', subSlot(slot, 'trans'));
1907
+ const popupProps = store.useState('popupProps', subSlot(slot, 'popupProps'));
1908
+ const mounted = store.useState('mounted', subSlot(slot, 'mounted'));
1909
+ const instantType = store.useState('instantType', subSlot(slot, 'instant'));
1910
+ const triggerElement = store.useState('activeTriggerElement', subSlot(slot, 'trigger'));
1911
+ const parent = store.useState('parent', subSlot(slot, 'parent'));
1912
+ const lastOpenChangeReason = store.useState('lastOpenChangeReason', subSlot(slot, 'locr'));
1913
+ const rootId = store.useState('rootId', subSlot(slot, 'rootId'));
1914
+ const floatingContext = store.useState('floatingRootContext', subSlot(slot, 'frc'));
1915
+ const floatingTreeRoot = store.useState('floatingTreeRoot', subSlot(slot, 'ftr'));
1916
+ const closeDelay = store.useState('closeDelay', subSlot(slot, 'closeDelay'));
1917
+ const hoverEnabled = store.useState('hoverEnabled', subSlot(slot, 'hover'));
1918
+ const disabled = store.useState('disabled', subSlot(slot, 'disabled'));
1919
+ const openMethod = store.useState('openMethod', subSlot(slot, 'method'));
1920
+
1921
+ const isContextMenu = parent.type === 'context-menu';
1922
+
1923
+ useOpenChangeComplete(
1924
+ {
1925
+ open,
1926
+ ref: store.context.popupRef,
1927
+ onComplete() {
1928
+ if (open) {
1929
+ store.context.onOpenChangeComplete?.(true);
1930
+ }
1931
+ },
1932
+ },
1933
+ subSlot(slot, 'occ'),
1934
+ );
1935
+
1936
+ useEffect(
1937
+ () => {
1938
+ function handleClose(event: {
1939
+ domEvent: Event | undefined;
1940
+ reason: MenuRootChangeEventReason;
1941
+ }) {
1942
+ store.setOpen(false, createChangeEventDetails(event.reason, event.domEvent));
1943
+ }
1944
+
1945
+ floatingTreeRoot.events.on('close', handleClose);
1946
+
1947
+ return () => {
1948
+ floatingTreeRoot.events.off('close', handleClose);
1949
+ };
1950
+ },
1951
+ [floatingTreeRoot.events, store],
1952
+ subSlot(slot, 'e:close'),
1953
+ );
1954
+
1955
+ useHoverFloatingInteraction(
1956
+ floatingContext,
1957
+ {
1958
+ enabled: hoverEnabled && !disabled && !isContextMenu && parent.type !== 'menubar',
1959
+ closeDelay,
1960
+ },
1961
+ subSlot(slot, 'hoverInteraction'),
1962
+ );
1963
+
1964
+ const setPopupElement = store.useStateSetter('popupElement', subSlot(slot, 'setPopupEl'));
1965
+
1966
+ const state: MenuPopupState = {
1967
+ transitionStatus,
1968
+ side,
1969
+ align,
1970
+ open,
1971
+ nested: parent.type === 'menu',
1972
+ instant: instantType,
1973
+ };
1974
+
1975
+ const element = useRenderElement(
1976
+ 'div',
1977
+ componentProps,
1978
+ {
1979
+ state,
1980
+ ref: [forwardedRef, store.context.popupRef, setPopupElement],
1981
+ stateAttributesMapping: menuPopupStateAttributesMapping,
1982
+ props: [
1983
+ popupProps,
1984
+ {
1985
+ onKeyDown(event: KeyboardEvent) {
1986
+ if (insideToolbar && COMPOSITE_KEYS.has(event.key)) {
1987
+ event.stopPropagation();
1988
+ }
1989
+ },
1990
+ },
1991
+ getDisabledMountTransitionStyles(transitionStatus),
1992
+ elementProps,
1993
+ { 'data-rootownerid': rootId } as Record<string, string>,
1994
+ ],
1995
+ },
1996
+ subSlot(slot, 're'),
1997
+ );
1998
+
1999
+ let returnFocus = parent.type === undefined || isContextMenu;
2000
+ if (
2001
+ triggerElement ||
2002
+ (parent.type === 'menubar' && lastOpenChangeReason !== REASONS.outsidePress)
2003
+ ) {
2004
+ returnFocus = true;
2005
+ }
2006
+
2007
+ return createElement(FloatingFocusManager, {
2008
+ context: floatingContext,
2009
+ openInteractionType: openMethod,
2010
+ modal: isContextMenu,
2011
+ disabled: !mounted,
2012
+ returnFocus: finalFocus === undefined ? returnFocus : finalFocus,
2013
+ initialFocus: parent.type !== 'menu',
2014
+ restoreFocus: true,
2015
+ externalTree: parent.type !== 'menubar' ? floatingTreeRoot : undefined,
2016
+ previousFocusableElement: triggerElement as HTMLElement | null,
2017
+ nextFocusableElement:
2018
+ parent.type === undefined ? store.context.triggerFocusTargetRef : undefined,
2019
+ beforeContentFocusGuardRef:
2020
+ parent.type === undefined ? store.context.beforeContentFocusGuardRef : undefined,
2021
+ children: element,
2022
+ });
2023
+ }
2024
+
2025
+ export interface MenuPopupState {
2026
+ /** The transition status of the component. */
2027
+ transitionStatus: TransitionStatus;
2028
+ /** The side of the anchor the component is placed on. */
2029
+ side: Side;
2030
+ /** The alignment of the component relative to the anchor. */
2031
+ align: Align;
2032
+ /** Whether the menu is currently open. */
2033
+ open: boolean;
2034
+ /** Whether the component is nested. */
2035
+ nested: boolean;
2036
+ /** Whether transitions should be skipped. */
2037
+ instant: 'dismiss' | 'click' | 'group' | 'trigger-change' | undefined;
2038
+ }
2039
+
2040
+ // --- Item state attributes ----------------------------------------------------
2041
+
2042
+ /**
2043
+ * Ported from `.base-ui/packages/react/src/menu/utils/stateAttributesMapping.ts` (v1.6.0), with the
2044
+ * `MenuCheckboxItemDataAttributes` values it references inlined. Shared by `CheckboxItem`,
2045
+ * `RadioItem` and both indicators: a `checked` state maps to a PRESENT/ABSENT attribute pair rather
2046
+ * than `data-checked="false"`.
2047
+ */
2048
+ export const itemMapping: StateAttributesMapping<{ checked: boolean }> = {
2049
+ checked(value: boolean): Record<string, string> {
2050
+ if (value) {
2051
+ return { 'data-checked': '' };
2052
+ }
2053
+ return { 'data-unchecked': '' };
2054
+ },
2055
+ ...transitionStatusMapping,
2056
+ };
2057
+
2058
+ // --- Item internals -----------------------------------------------------------
2059
+
2060
+ export const REGULAR_ITEM = {
2061
+ type: 'regular-item' as const,
2062
+ };
2063
+
2064
+ export type UseMenuItemMetadata =
2065
+ | typeof REGULAR_ITEM
2066
+ | {
2067
+ type: 'submenu-trigger';
2068
+ setActive: () => void;
2069
+ };
2070
+
2071
+ export interface UseMenuItemCommonPropsParameters {
2072
+ closeOnClick: boolean;
2073
+ highlighted: boolean;
2074
+ id: string | undefined;
2075
+ nodeId: string | undefined;
2076
+ store: MenuStore<any>;
2077
+ typingRef?: { current: boolean } | undefined;
2078
+ itemRef: { current: HTMLElement | null };
2079
+ itemMetadata?: UseMenuItemMetadata | undefined;
2080
+ }
2081
+
2082
+ /**
2083
+ * The props every menu item type shares: id/role/tabIndex plus the keydown, mousemove, click and
2084
+ * mouseup handlers. Ported from `menu/item/useMenuItemCommonProps.ts`.
2085
+ */
2086
+ export function useMenuItemCommonProps(
2087
+ params: UseMenuItemCommonPropsParameters,
2088
+ slot: symbol | undefined,
2089
+ ): HTMLProps {
2090
+ const { closeOnClick, highlighted, id, nodeId, store, typingRef, itemRef, itemMetadata } = params;
2091
+
2092
+ const { events: menuEvents } = store.useState('floatingTreeRoot', subSlot(slot, 'ftr'));
2093
+ const open = store.useState('open', subSlot(slot, 'open'));
2094
+ const contextMenuContext = useContextMenuRootContext(true);
2095
+ const isContextMenu = contextMenuContext !== undefined;
2096
+
2097
+ return useMemo(
2098
+ () => ({
2099
+ id,
2100
+ role: 'menuitem' as const,
2101
+ tabIndex: open && highlighted ? 0 : -1,
2102
+ onKeyDown(event: KeyboardEvent) {
2103
+ if (event.key === ' ' && typingRef?.current) {
2104
+ event.preventDefault();
2105
+ }
2106
+ },
2107
+ onMouseMove(event: MouseEvent) {
2108
+ if (!nodeId) {
2109
+ return;
2110
+ }
2111
+
2112
+ // Inform the floating tree that a menu item within this menu was hovered/moved over
2113
+ // so unrelated descendant submenus can be closed.
2114
+ menuEvents.emit('itemhover', {
2115
+ nodeId,
2116
+ target: event.currentTarget,
2117
+ });
2118
+ },
2119
+ onClick(event: MouseEvent) {
2120
+ if (closeOnClick) {
2121
+ menuEvents.emit('close', { domEvent: event, reason: REASONS.itemPress });
2122
+ }
2123
+ },
2124
+ onMouseUp(event: MouseEvent) {
2125
+ if (contextMenuContext) {
2126
+ const initialCursorPoint = contextMenuContext.initialCursorPointRef.current;
2127
+ contextMenuContext.initialCursorPointRef.current = null;
2128
+ if (
2129
+ isContextMenu &&
2130
+ initialCursorPoint &&
2131
+ Math.abs(event.clientX - initialCursorPoint.x) <= 1 &&
2132
+ Math.abs(event.clientY - initialCursorPoint.y) <= 1
2133
+ ) {
2134
+ return;
2135
+ }
2136
+
2137
+ // On non-macOS platforms, this mouseup belongs to the right-click gesture that
2138
+ // opened the context menu, so it must not activate an item.
2139
+ if (isContextMenu && !platform.os.mac && event.button === 2) {
2140
+ return;
2141
+ }
2142
+ }
2143
+
2144
+ if (
2145
+ itemRef.current &&
2146
+ store.context.allowMouseUpTriggerRef.current &&
2147
+ (!isContextMenu || event.button === 2)
2148
+ ) {
2149
+ // Fires when the user presses the trigger, moves the cursor, and releases over
2150
+ // the item. Trigger the click and override `closeOnClick` to always close.
2151
+ if (!itemMetadata || itemMetadata.type === 'regular-item') {
2152
+ itemRef.current.click();
2153
+ }
2154
+ }
2155
+ },
2156
+ }),
2157
+ [
2158
+ closeOnClick,
2159
+ highlighted,
2160
+ id,
2161
+ menuEvents,
2162
+ nodeId,
2163
+ open,
2164
+ store,
2165
+ typingRef,
2166
+ itemRef,
2167
+ contextMenuContext,
2168
+ isContextMenu,
2169
+ itemMetadata,
2170
+ ],
2171
+ subSlot(slot, 'm'),
2172
+ );
2173
+ }
2174
+
2175
+ export interface UseMenuItemParameters {
2176
+ closeOnClick: boolean;
2177
+ disabled: boolean;
2178
+ highlighted: boolean;
2179
+ id: string | undefined;
2180
+ nativeButton: boolean;
2181
+ itemMetadata: UseMenuItemMetadata;
2182
+ nodeId: string | undefined;
2183
+ store: MenuStore<any>;
2184
+ typingRef?: { current: boolean } | undefined;
2185
+ }
2186
+
2187
+ export interface UseMenuItemReturnValue {
2188
+ getItemProps: (externalProps?: HTMLProps) => HTMLProps;
2189
+ itemRef: (node: HTMLElement | null) => void;
2190
+ }
2191
+
2192
+ /** Ported from `menu/item/useMenuItem.ts`. */
2193
+ export function useMenuItem(
2194
+ params: UseMenuItemParameters,
2195
+ slot: symbol | undefined,
2196
+ ): UseMenuItemReturnValue {
2197
+ const {
2198
+ closeOnClick,
2199
+ disabled: disabledProp = false,
2200
+ highlighted,
2201
+ id,
2202
+ store,
2203
+ typingRef = store.context.typingRef,
2204
+ nativeButton,
2205
+ itemMetadata,
2206
+ nodeId,
2207
+ } = params;
2208
+
2209
+ const rootDisabled = store.useState('disabled', subSlot(slot, 'rd'));
2210
+ const disabled = disabledProp || rootDisabled;
2211
+
2212
+ const itemRef = useRef<HTMLElement | null>(null, subSlot(slot, 'ref'));
2213
+
2214
+ const { getButtonProps, buttonRef } = useButton(
2215
+ {
2216
+ disabled,
2217
+ focusableWhenDisabled: true,
2218
+ native: nativeButton,
2219
+ composite: true,
2220
+ },
2221
+ subSlot(slot, 'btn'),
2222
+ );
2223
+
2224
+ const commonProps = useMenuItemCommonProps(
2225
+ {
2226
+ closeOnClick,
2227
+ highlighted,
2228
+ id,
2229
+ nodeId,
2230
+ store,
2231
+ typingRef,
2232
+ itemRef,
2233
+ itemMetadata,
2234
+ },
2235
+ subSlot(slot, 'common'),
2236
+ );
2237
+
2238
+ const getItemProps = useCallback(
2239
+ (externalProps?: HTMLProps): HTMLProps => {
2240
+ return mergeProps(
2241
+ commonProps,
2242
+ {
2243
+ onMouseEnter() {
2244
+ if (itemMetadata.type !== 'submenu-trigger') {
2245
+ return;
2246
+ }
2247
+
2248
+ itemMetadata.setActive();
2249
+ },
2250
+ },
2251
+ externalProps,
2252
+ getButtonProps,
2253
+ );
2254
+ },
2255
+ [commonProps, getButtonProps, itemMetadata],
2256
+ subSlot(slot, 'gip'),
2257
+ );
2258
+
2259
+ const mergedRef = useComposedRefs(itemRef, buttonRef, subSlot(slot, 'refs'));
2260
+
2261
+ return useMemo(
2262
+ () => ({
2263
+ getItemProps,
2264
+ itemRef: mergedRef,
2265
+ }),
2266
+ [getItemProps, mergedRef],
2267
+ subSlot(slot, 'ret'),
2268
+ );
2269
+ }
2270
+
2271
+ // --- Item ---------------------------------------------------------------------
2272
+
2273
+ function MenuItem(componentProps: any): any {
2274
+ const slot = S('MenuItem');
2275
+ const {
2276
+ render,
2277
+ className,
2278
+ id: idProp,
2279
+ label,
2280
+ nativeButton = false,
2281
+ disabled = false,
2282
+ closeOnClick = true,
2283
+ style,
2284
+ ref: forwardedRef,
2285
+ ...elementProps
2286
+ } = componentProps;
2287
+
2288
+ const listItem = useCompositeListItem({ label }, subSlot(slot, 'li'));
2289
+ const menuPositionerContext = useMenuPositionerContext(true);
2290
+ const id = useBaseUiId(idProp, subSlot(slot, 'id'));
2291
+
2292
+ const { store } = useMenuRootContext() as MenuRootContextValue;
2293
+ const highlighted = store.useState('isActive', subSlot(slot, 'hl'), listItem.index);
2294
+ const itemProps = store.useState('itemProps', subSlot(slot, 'ip'));
2295
+
2296
+ const { getItemProps, itemRef } = useMenuItem(
2297
+ {
2298
+ closeOnClick,
2299
+ disabled,
2300
+ highlighted,
2301
+ id,
2302
+ store,
2303
+ nativeButton,
2304
+ nodeId: menuPositionerContext?.context.nodeId,
2305
+ itemMetadata: REGULAR_ITEM,
2306
+ },
2307
+ subSlot(slot, 'item'),
2308
+ );
2309
+
2310
+ const state: MenuItemState = {
2311
+ disabled,
2312
+ highlighted,
2313
+ };
2314
+
2315
+ return useRenderElement(
2316
+ 'div',
2317
+ componentProps,
2318
+ {
2319
+ state,
2320
+ props: [itemProps, elementProps, getItemProps],
2321
+ ref: [itemRef, forwardedRef, listItem.ref],
2322
+ },
2323
+ subSlot(slot, 're'),
2324
+ );
2325
+ }
2326
+
2327
+ export interface MenuItemState {
2328
+ /** Whether the item should ignore user interaction. */
2329
+ disabled: boolean;
2330
+ /** Whether the item is highlighted. */
2331
+ highlighted: boolean;
2332
+ }
2333
+
2334
+ // --- LinkItem -----------------------------------------------------------------
2335
+
2336
+ function MenuLinkItem(componentProps: any): any {
2337
+ const slot = S('MenuLinkItem');
2338
+ const {
2339
+ render,
2340
+ className,
2341
+ id: idProp,
2342
+ label,
2343
+ closeOnClick = false,
2344
+ style,
2345
+ ref: forwardedRef,
2346
+ ...elementProps
2347
+ } = componentProps;
2348
+
2349
+ const linkRef = useRef<HTMLAnchorElement | null>(null, subSlot(slot, 'link'));
2350
+
2351
+ const listItem = useCompositeListItem({ label }, subSlot(slot, 'li'));
2352
+ const menuPositionerContext = useMenuPositionerContext(true);
2353
+ const nodeId = menuPositionerContext?.context.nodeId;
2354
+
2355
+ const id = useBaseUiId(idProp, subSlot(slot, 'id'));
2356
+
2357
+ const { store } = useMenuRootContext() as MenuRootContextValue;
2358
+ const highlighted = store.useState('isActive', subSlot(slot, 'hl'), listItem.index);
2359
+ const itemProps = store.useState('itemProps', subSlot(slot, 'ip'));
2360
+ const typingRef = store.context.typingRef;
2361
+
2362
+ const { getButtonProps, buttonRef } = useButton(
2363
+ { native: false, composite: true },
2364
+ subSlot(slot, 'btn'),
2365
+ );
2366
+
2367
+ const commonProps = useMenuItemCommonProps(
2368
+ {
2369
+ closeOnClick,
2370
+ highlighted,
2371
+ id,
2372
+ nodeId,
2373
+ store,
2374
+ typingRef,
2375
+ itemRef: linkRef,
2376
+ },
2377
+ subSlot(slot, 'common'),
2378
+ );
2379
+
2380
+ function getItemProps(externalProps?: HTMLProps): HTMLProps {
2381
+ return mergeProps(commonProps, externalProps, getButtonProps);
2382
+ }
2383
+
2384
+ const state: MenuLinkItemState = { highlighted };
2385
+
2386
+ return useRenderElement(
2387
+ 'a',
2388
+ componentProps,
2389
+ {
2390
+ state,
2391
+ props: [itemProps, elementProps, getItemProps],
2392
+ ref: [linkRef, buttonRef, forwardedRef, listItem.ref],
2393
+ },
2394
+ subSlot(slot, 're'),
2395
+ );
2396
+ }
2397
+
2398
+ export interface MenuLinkItemState {
2399
+ /** Whether the item is highlighted. */
2400
+ highlighted: boolean;
2401
+ }
2402
+
2403
+ // --- CheckboxItem -------------------------------------------------------------
2404
+
2405
+ export interface MenuCheckboxItemContextValue {
2406
+ checked: boolean;
2407
+ highlighted: boolean;
2408
+ disabled: boolean;
2409
+ }
2410
+
2411
+ const MenuCheckboxItemContext = createContext<MenuCheckboxItemContextValue | undefined>(undefined);
2412
+
2413
+ export function useMenuCheckboxItemContext(): MenuCheckboxItemContextValue {
2414
+ const context = useContext(MenuCheckboxItemContext);
2415
+ if (context === undefined) {
2416
+ throw new Error(
2417
+ 'Base UI: MenuCheckboxItemContext is missing. MenuCheckboxItem parts must be placed within <Menu.CheckboxItem>.',
2418
+ );
2419
+ }
2420
+ return context;
2421
+ }
2422
+
2423
+ function MenuCheckboxItem(componentProps: any): any {
2424
+ const slot = S('MenuCheckboxItem');
2425
+ const {
2426
+ render,
2427
+ className,
2428
+ id: idProp,
2429
+ label,
2430
+ nativeButton = false,
2431
+ disabled = false,
2432
+ closeOnClick = false,
2433
+ checked: checkedProp,
2434
+ defaultChecked,
2435
+ onCheckedChange,
2436
+ style,
2437
+ ref: forwardedRef,
2438
+ ...elementProps
2439
+ } = componentProps;
2440
+
2441
+ const listItem = useCompositeListItem({ label }, subSlot(slot, 'li'));
2442
+ const menuPositionerContext = useMenuPositionerContext(true);
2443
+ const id = useBaseUiId(idProp, subSlot(slot, 'id'));
2444
+
2445
+ const { store } = useMenuRootContext() as MenuRootContextValue;
2446
+ const highlighted = store.useState('isActive', subSlot(slot, 'hl'), listItem.index);
2447
+ const itemProps = store.useState('itemProps', subSlot(slot, 'ip'));
2448
+
2449
+ const [checked, setChecked] = useControlled<boolean>(
2450
+ {
2451
+ controlled: checkedProp,
2452
+ default: defaultChecked ?? false,
2453
+ },
2454
+ subSlot(slot, 'ctrl'),
2455
+ );
2456
+
2457
+ const { getItemProps, itemRef } = useMenuItem(
2458
+ {
2459
+ closeOnClick,
2460
+ disabled,
2461
+ highlighted,
2462
+ id,
2463
+ store,
2464
+ nativeButton,
2465
+ nodeId: menuPositionerContext?.context.nodeId,
2466
+ itemMetadata: REGULAR_ITEM,
2467
+ },
2468
+ subSlot(slot, 'item'),
2469
+ );
2470
+
2471
+ const state: MenuCheckboxItemState = useMemo(
2472
+ () => ({
2473
+ disabled,
2474
+ highlighted,
2475
+ checked,
2476
+ }),
2477
+ [disabled, highlighted, checked],
2478
+ subSlot(slot, 'state'),
2479
+ );
2480
+
2481
+ function handleClick(event: MouseEvent) {
2482
+ // octane dispatches NATIVE events, so upstream's `event.nativeEvent` collapses to `event`.
2483
+ const details = createChangeEventDetails(REASONS.itemPress, event, undefined, {
2484
+ preventUnmountOnClose() {},
2485
+ });
2486
+
2487
+ onCheckedChange?.(!checked, details);
2488
+
2489
+ if (details.isCanceled) {
2490
+ return;
2491
+ }
2492
+
2493
+ setChecked((currentlyChecked: boolean) => !currentlyChecked);
2494
+ }
2495
+
2496
+ const element = useRenderElement(
2497
+ 'div',
2498
+ componentProps,
2499
+ {
2500
+ state,
2501
+ stateAttributesMapping: itemMapping,
2502
+ props: [
2503
+ itemProps,
2504
+ {
2505
+ role: 'menuitemcheckbox',
2506
+ 'aria-checked': checked,
2507
+ onClick: handleClick,
2508
+ },
2509
+ elementProps,
2510
+ getItemProps,
2511
+ ],
2512
+ ref: [itemRef, forwardedRef, listItem.ref],
2513
+ },
2514
+ subSlot(slot, 're'),
2515
+ );
2516
+
2517
+ return createElement(MenuCheckboxItemContext.Provider, { value: state, children: element });
2518
+ }
2519
+
2520
+ export interface MenuCheckboxItemState {
2521
+ /** Whether the checkbox item should ignore user interaction. */
2522
+ disabled: boolean;
2523
+ /** Whether the checkbox item is currently highlighted. */
2524
+ highlighted: boolean;
2525
+ /** Whether the checkbox item is currently ticked. */
2526
+ checked: boolean;
2527
+ }
2528
+
2529
+ function MenuCheckboxItemIndicator(componentProps: any): any {
2530
+ const slot = S('MenuCheckboxItemIndicator');
2531
+ const {
2532
+ render,
2533
+ className,
2534
+ style,
2535
+ keepMounted = false,
2536
+ ref: forwardedRef,
2537
+ ...elementProps
2538
+ } = componentProps;
2539
+
2540
+ const item = useMenuCheckboxItemContext();
2541
+
2542
+ const indicatorRef = useRef<HTMLSpanElement | null>(null, subSlot(slot, 'ref'));
2543
+
2544
+ const { transitionStatus, setMounted } = useTransitionStatus(
2545
+ item.checked,
2546
+ undefined,
2547
+ undefined,
2548
+ subSlot(slot, 'ts'),
2549
+ );
2550
+
2551
+ useOpenChangeComplete(
2552
+ {
2553
+ open: item.checked,
2554
+ ref: indicatorRef,
2555
+ onComplete() {
2556
+ if (!item.checked) {
2557
+ setMounted(false);
2558
+ }
2559
+ },
2560
+ },
2561
+ subSlot(slot, 'occ'),
2562
+ );
2563
+
2564
+ const state: MenuCheckboxItemIndicatorState = {
2565
+ checked: item.checked,
2566
+ disabled: item.disabled,
2567
+ highlighted: item.highlighted,
2568
+ transitionStatus,
2569
+ };
2570
+
2571
+ // UPSTREAM QUIRK, transcribed deliberately: this gates on `item.checked`, NOT on the `mounted`
2572
+ // flag `useTransitionStatus` returns — which is what `Checkbox.Indicator` and `Radio.Indicator`
2573
+ // use. So unchecking drops the node on the same commit and the exit transition
2574
+ // (`data-ending-style`) never runs, making the `useOpenChangeComplete` → `setMounted(false)`
2575
+ // pair below effectively dead for the uncheck direction. Base UI 1.6.0's menu indicators do not
2576
+ // even destructure `mounted` (`MenuCheckboxItemIndicator.tsx` L26). Reported as a bug in review;
2577
+ // "fixing" it would diverge from the real `@base-ui/react`, so it stays, pinned by the toggle
2578
+ // steps in `tests/differential/parity.test.ts`.
2579
+ return useRenderElement(
2580
+ 'span',
2581
+ componentProps,
2582
+ {
2583
+ state,
2584
+ ref: [forwardedRef, indicatorRef],
2585
+ stateAttributesMapping: itemMapping,
2586
+ props: {
2587
+ 'aria-hidden': true,
2588
+ ...elementProps,
2589
+ },
2590
+ enabled: keepMounted || item.checked,
2591
+ },
2592
+ subSlot(slot, 're'),
2593
+ );
2594
+ }
2595
+
2596
+ export interface MenuCheckboxItemIndicatorState {
2597
+ /** Whether the checkbox item is currently ticked. */
2598
+ checked: boolean;
2599
+ /** Whether the component should ignore user interaction. */
2600
+ disabled: boolean;
2601
+ /** Whether the item is highlighted. */
2602
+ highlighted: boolean;
2603
+ /** The transition status of the component. */
2604
+ transitionStatus: TransitionStatus;
2605
+ }
2606
+
2607
+ // --- Group / GroupLabel -------------------------------------------------------
2608
+
2609
+ export type MenuGroupContextValue = (id: string | undefined) => void;
2610
+
2611
+ const MenuGroupContext = createContext<MenuGroupContextValue | undefined>(undefined);
2612
+
2613
+ export function useMenuGroupRootContext(): MenuGroupContextValue {
2614
+ const context = useContext(MenuGroupContext);
2615
+ if (context === undefined) {
2616
+ throw new Error(
2617
+ 'Base UI: MenuGroupContext is missing. Menu group parts must be used within <Menu.Group> or <Menu.RadioGroup>.',
2618
+ );
2619
+ }
2620
+ return context;
2621
+ }
2622
+
2623
+ function MenuGroup(componentProps: any): any {
2624
+ const slot = S('MenuGroup');
2625
+ const { render, className, style, ref: forwardedRef, ...elementProps } = componentProps;
2626
+
2627
+ const [labelId, setLabelId] = useState<string | undefined>(undefined, subSlot(slot, 'label'));
2628
+
2629
+ const element = useRenderElement(
2630
+ 'div',
2631
+ componentProps,
2632
+ {
2633
+ ref: forwardedRef,
2634
+ props: {
2635
+ role: 'group',
2636
+ 'aria-labelledby': labelId,
2637
+ ...elementProps,
2638
+ },
2639
+ },
2640
+ subSlot(slot, 're'),
2641
+ );
2642
+
2643
+ return createElement(MenuGroupContext.Provider, { value: setLabelId, children: element });
2644
+ }
2645
+
2646
+ function MenuGroupLabel(componentProps: any): any {
2647
+ const slot = S('MenuGroupLabel');
2648
+ const {
2649
+ render,
2650
+ className,
2651
+ style,
2652
+ id: idProp,
2653
+ ref: forwardedRef,
2654
+ ...elementProps
2655
+ } = componentProps;
2656
+
2657
+ const id = useBaseUiId(idProp, subSlot(slot, 'id'));
2658
+
2659
+ const setLabelId = useMenuGroupRootContext();
2660
+
2661
+ useLayoutEffect(
2662
+ () => {
2663
+ setLabelId(id);
2664
+ return () => {
2665
+ setLabelId(undefined);
2666
+ };
2667
+ },
2668
+ [setLabelId, id],
2669
+ subSlot(slot, 'e'),
2670
+ );
2671
+
2672
+ return useRenderElement(
2673
+ 'div',
2674
+ componentProps,
2675
+ {
2676
+ ref: forwardedRef,
2677
+ props: {
2678
+ id,
2679
+ role: 'presentation',
2680
+ ...elementProps,
2681
+ },
2682
+ },
2683
+ subSlot(slot, 're'),
2684
+ );
2685
+ }
2686
+
2687
+ // --- RadioGroup / RadioItem ---------------------------------------------------
2688
+
2689
+ export interface MenuRadioGroupContextValue {
2690
+ value: any;
2691
+ setValue: (newValue: any, eventDetails: any) => void;
2692
+ disabled: boolean;
2693
+ }
2694
+
2695
+ const MenuRadioGroupContext = createContext<MenuRadioGroupContextValue | undefined>(undefined);
2696
+
2697
+ export function useMenuRadioGroupContext(): MenuRadioGroupContextValue {
2698
+ const context = useContext(MenuRadioGroupContext);
2699
+ if (context === undefined) {
2700
+ throw new Error(
2701
+ 'Base UI: MenuRadioGroupContext is missing. MenuRadioGroup parts must be placed within <Menu.RadioGroup>.',
2702
+ );
2703
+ }
2704
+ return context;
2705
+ }
2706
+
2707
+ // Upstream wraps this in `React.memo`; octane memoizes component renders itself (autoMemo), and the
2708
+ // wrapper carries no behavioral contract, so it is dropped.
2709
+ function MenuRadioGroup(componentProps: any): any {
2710
+ const slot = S('MenuRadioGroup');
2711
+ const {
2712
+ render,
2713
+ className,
2714
+ value: valueProp,
2715
+ defaultValue,
2716
+ onValueChange: onValueChangeProp,
2717
+ disabled = false,
2718
+ style,
2719
+ 'aria-labelledby': ariaLabelledByProp,
2720
+ ref: forwardedRef,
2721
+ ...elementProps
2722
+ } = componentProps;
2723
+
2724
+ const [labelId, setLabelId] = useState<string | undefined>(undefined, subSlot(slot, 'label'));
2725
+
2726
+ const [value, setValueUnwrapped] = useControlled<any>(
2727
+ {
2728
+ controlled: valueProp,
2729
+ default: defaultValue,
2730
+ },
2731
+ subSlot(slot, 'ctrl'),
2732
+ );
2733
+
2734
+ const setValue = useStableCallback(
2735
+ (newValue: any, eventDetails: any) => {
2736
+ onValueChangeProp?.(newValue, eventDetails);
2737
+
2738
+ if (eventDetails.isCanceled) {
2739
+ return;
2740
+ }
2741
+
2742
+ setValueUnwrapped(newValue);
2743
+ },
2744
+ subSlot(slot, 'setValue'),
2745
+ );
2746
+
2747
+ const state: MenuRadioGroupState = { disabled };
2748
+
2749
+ const element = useRenderElement(
2750
+ 'div',
2751
+ componentProps,
2752
+ {
2753
+ state,
2754
+ ref: forwardedRef,
2755
+ props: {
2756
+ role: 'group',
2757
+ 'aria-labelledby': ariaLabelledByProp ?? labelId,
2758
+ 'aria-disabled': disabled || undefined,
2759
+ ...elementProps,
2760
+ },
2761
+ },
2762
+ subSlot(slot, 're'),
2763
+ );
2764
+
2765
+ const context: MenuRadioGroupContextValue = useMemo(
2766
+ () => ({
2767
+ value,
2768
+ setValue,
2769
+ disabled,
2770
+ }),
2771
+ [value, setValue, disabled],
2772
+ subSlot(slot, 'ctx'),
2773
+ );
2774
+
2775
+ return createElement(MenuGroupContext.Provider, {
2776
+ value: setLabelId,
2777
+ children: createElement(MenuRadioGroupContext.Provider, { value: context, children: element }),
2778
+ });
2779
+ }
2780
+
2781
+ export interface MenuRadioGroupState {
2782
+ /** Whether the component is disabled. */
2783
+ disabled: boolean;
2784
+ }
2785
+
2786
+ export interface MenuRadioItemContextValue {
2787
+ checked: boolean;
2788
+ highlighted: boolean;
2789
+ disabled: boolean;
2790
+ }
2791
+
2792
+ const MenuRadioItemContext = createContext<MenuRadioItemContextValue | undefined>(undefined);
2793
+
2794
+ export function useMenuRadioItemContext(): MenuRadioItemContextValue {
2795
+ const context = useContext(MenuRadioItemContext);
2796
+ if (context === undefined) {
2797
+ throw new Error(
2798
+ 'Base UI: MenuRadioItemContext is missing. MenuRadioItem parts must be placed within <Menu.RadioItem>.',
2799
+ );
2800
+ }
2801
+ return context;
2802
+ }
2803
+
2804
+ function MenuRadioItem(componentProps: any): any {
2805
+ const slot = S('MenuRadioItem');
2806
+ const {
2807
+ render,
2808
+ className,
2809
+ id: idProp,
2810
+ label,
2811
+ nativeButton = false,
2812
+ disabled: disabledProp = false,
2813
+ closeOnClick = false,
2814
+ value,
2815
+ style,
2816
+ ref: forwardedRef,
2817
+ ...elementProps
2818
+ } = componentProps;
2819
+
2820
+ const listItem = useCompositeListItem({ label }, subSlot(slot, 'li'));
2821
+ const menuPositionerContext = useMenuPositionerContext(true);
2822
+ const id = useBaseUiId(idProp, subSlot(slot, 'id'));
2823
+
2824
+ const { store } = useMenuRootContext() as MenuRootContextValue;
2825
+ const highlighted = store.useState('isActive', subSlot(slot, 'hl'), listItem.index);
2826
+ const itemProps = store.useState('itemProps', subSlot(slot, 'ip'));
2827
+
2828
+ const {
2829
+ value: selectedValue,
2830
+ setValue: setSelectedValue,
2831
+ disabled: groupDisabled,
2832
+ } = useMenuRadioGroupContext();
2833
+
2834
+ const disabled = groupDisabled || disabledProp;
2835
+ const checked = selectedValue === value;
2836
+
2837
+ const { getItemProps, itemRef } = useMenuItem(
2838
+ {
2839
+ closeOnClick,
2840
+ disabled,
2841
+ highlighted,
2842
+ id,
2843
+ store,
2844
+ nativeButton,
2845
+ nodeId: menuPositionerContext?.context.nodeId,
2846
+ itemMetadata: REGULAR_ITEM,
2847
+ },
2848
+ subSlot(slot, 'item'),
2849
+ );
2850
+
2851
+ const state: MenuRadioItemState = useMemo(
2852
+ () => ({
2853
+ disabled,
2854
+ highlighted,
2855
+ checked,
2856
+ }),
2857
+ [disabled, highlighted, checked],
2858
+ subSlot(slot, 'state'),
2859
+ );
2860
+
2861
+ function handleClick(event: MouseEvent) {
2862
+ // octane dispatches NATIVE events, so upstream's `event.nativeEvent` collapses to `event`.
2863
+ const details = createChangeEventDetails(REASONS.itemPress, event, undefined, {
2864
+ preventUnmountOnClose() {},
2865
+ });
2866
+
2867
+ setSelectedValue(value, details);
2868
+ }
2869
+
2870
+ const element = useRenderElement(
2871
+ 'div',
2872
+ componentProps,
2873
+ {
2874
+ state,
2875
+ stateAttributesMapping: itemMapping,
2876
+ props: [
2877
+ itemProps,
2878
+ {
2879
+ role: 'menuitemradio',
2880
+ 'aria-checked': checked,
2881
+ onClick: handleClick,
2882
+ },
2883
+ elementProps,
2884
+ getItemProps,
2885
+ ],
2886
+ ref: [itemRef, forwardedRef, listItem.ref],
2887
+ },
2888
+ subSlot(slot, 're'),
2889
+ );
2890
+
2891
+ return createElement(MenuRadioItemContext.Provider, { value: state, children: element });
2892
+ }
2893
+
2894
+ export interface MenuRadioItemState {
2895
+ /** Whether the radio item should ignore user interaction. */
2896
+ disabled: boolean;
2897
+ /** Whether the radio item is currently highlighted. */
2898
+ highlighted: boolean;
2899
+ /** Whether the radio item is currently selected. */
2900
+ checked: boolean;
2901
+ }
2902
+
2903
+ function MenuRadioItemIndicator(componentProps: any): any {
2904
+ const slot = S('MenuRadioItemIndicator');
2905
+ const {
2906
+ render,
2907
+ className,
2908
+ style,
2909
+ keepMounted = false,
2910
+ ref: forwardedRef,
2911
+ ...elementProps
2912
+ } = componentProps;
2913
+
2914
+ const item = useMenuRadioItemContext();
2915
+
2916
+ const indicatorRef = useRef<HTMLSpanElement | null>(null, subSlot(slot, 'ref'));
2917
+
2918
+ const { transitionStatus, setMounted } = useTransitionStatus(
2919
+ item.checked,
2920
+ undefined,
2921
+ undefined,
2922
+ subSlot(slot, 'ts'),
2923
+ );
2924
+
2925
+ useOpenChangeComplete(
2926
+ {
2927
+ open: item.checked,
2928
+ ref: indicatorRef,
2929
+ onComplete() {
2930
+ if (!item.checked) {
2931
+ setMounted(false);
2932
+ }
2933
+ },
2934
+ },
2935
+ subSlot(slot, 'occ'),
2936
+ );
2937
+
2938
+ const state: MenuRadioItemIndicatorState = {
2939
+ checked: item.checked,
2940
+ disabled: item.disabled,
2941
+ highlighted: item.highlighted,
2942
+ transitionStatus,
2943
+ };
2944
+
2945
+ // UPSTREAM QUIRK, transcribed deliberately: this gates on `item.checked`, NOT on the `mounted`
2946
+ // flag `useTransitionStatus` returns — which is what `Checkbox.Indicator` and `Radio.Indicator`
2947
+ // use. So unchecking drops the node on the same commit and the exit transition
2948
+ // (`data-ending-style`) never runs, making the `useOpenChangeComplete` → `setMounted(false)`
2949
+ // pair below effectively dead for the uncheck direction. Base UI 1.6.0's menu indicators do not
2950
+ // even destructure `mounted` (`MenuCheckboxItemIndicator.tsx` L26). Reported as a bug in review;
2951
+ // "fixing" it would diverge from the real `@base-ui/react`, so it stays, pinned by the toggle
2952
+ // steps in `tests/differential/parity.test.ts`.
2953
+ return useRenderElement(
2954
+ 'span',
2955
+ componentProps,
2956
+ {
2957
+ state,
2958
+ stateAttributesMapping: itemMapping,
2959
+ ref: [forwardedRef, indicatorRef],
2960
+ props: {
2961
+ 'aria-hidden': true,
2962
+ ...elementProps,
2963
+ },
2964
+ enabled: keepMounted || item.checked,
2965
+ },
2966
+ subSlot(slot, 're'),
2967
+ );
2968
+ }
2969
+
2970
+ export interface MenuRadioItemIndicatorState {
2971
+ /** Whether the radio item is currently selected. */
2972
+ checked: boolean;
2973
+ /** Whether the component should ignore user interaction. */
2974
+ disabled: boolean;
2975
+ /** Whether the item is highlighted. */
2976
+ highlighted: boolean;
2977
+ /** The transition status of the component. */
2978
+ transitionStatus: TransitionStatus;
2979
+ }
2980
+
2981
+ // --- Arrow --------------------------------------------------------------------
2982
+
2983
+ function MenuArrow(componentProps: any): any {
2984
+ const slot = S('MenuArrow');
2985
+ const { render, className, style, ref: forwardedRef, ...elementProps } = componentProps;
2986
+
2987
+ const { store } = useMenuRootContext() as MenuRootContextValue;
2988
+ const { arrowRef, side, align, arrowUncentered, arrowStyles } = useMenuPositionerContext();
2989
+ const open = store.useState('open', subSlot(slot, 'open'));
2990
+
2991
+ const state: MenuArrowState = {
2992
+ open,
2993
+ side,
2994
+ align,
2995
+ uncentered: arrowUncentered,
2996
+ };
2997
+
2998
+ return useRenderElement(
2999
+ 'div',
3000
+ componentProps,
3001
+ {
3002
+ ref: [arrowRef, forwardedRef],
3003
+ stateAttributesMapping: popupStateMapping,
3004
+ state,
3005
+ props: {
3006
+ style: arrowStyles,
3007
+ 'aria-hidden': true,
3008
+ ...elementProps,
3009
+ },
3010
+ },
3011
+ subSlot(slot, 're'),
3012
+ );
3013
+ }
3014
+
3015
+ export interface MenuArrowState {
3016
+ /** Whether the menu is currently open. */
3017
+ open: boolean;
3018
+ /** The side of the anchor the component is placed on. */
3019
+ side: Side;
3020
+ /** The alignment of the component relative to the anchor. */
3021
+ align: Align;
3022
+ /** Whether the arrow cannot be centered on the anchor. */
3023
+ uncentered: boolean;
3024
+ }
3025
+
3026
+ // --- Backdrop -----------------------------------------------------------------
3027
+
3028
+ const menuBackdropStateAttributesMapping: StateAttributesMapping<any> = {
3029
+ ...popupStateMapping,
3030
+ ...transitionStatusMapping,
3031
+ };
3032
+
3033
+ function MenuBackdrop(componentProps: any): any {
3034
+ const slot = S('MenuBackdrop');
3035
+ const { render, className, style, ref: forwardedRef, ...elementProps } = componentProps;
3036
+
3037
+ const { store } = useMenuRootContext() as MenuRootContextValue;
3038
+ const open = store.useState('open', subSlot(slot, 'open'));
3039
+ const mounted = store.useState('mounted', subSlot(slot, 'mounted'));
3040
+ const transitionStatus = store.useState('transitionStatus', subSlot(slot, 'trans'));
3041
+ const lastOpenChangeReason = store.useState('lastOpenChangeReason', subSlot(slot, 'locr'));
3042
+
3043
+ const contextMenuContext = useContextMenuRootContext();
3044
+
3045
+ const state: MenuBackdropState = {
3046
+ open,
3047
+ transitionStatus,
3048
+ };
3049
+
3050
+ return useRenderElement(
3051
+ 'div',
3052
+ componentProps,
3053
+ {
3054
+ ref: contextMenuContext?.backdropRef
3055
+ ? [forwardedRef, contextMenuContext.backdropRef]
3056
+ : forwardedRef,
3057
+ state,
3058
+ stateAttributesMapping: menuBackdropStateAttributesMapping,
3059
+ props: [
3060
+ {
3061
+ role: 'presentation',
3062
+ hidden: !mounted,
3063
+ style: {
3064
+ pointerEvents: lastOpenChangeReason === REASONS.triggerHover ? 'none' : undefined,
3065
+ userSelect: 'none',
3066
+ WebkitUserSelect: 'none',
3067
+ },
3068
+ },
3069
+ elementProps,
3070
+ ],
3071
+ },
3072
+ subSlot(slot, 're'),
3073
+ );
3074
+ }
3075
+
3076
+ export interface MenuBackdropState {
3077
+ /** Whether the menu is currently open. */
3078
+ open: boolean;
3079
+ /** The transition status of the component. */
3080
+ transitionStatus: TransitionStatus;
3081
+ }
3082
+
3083
+ // --- Viewport -----------------------------------------------------------------
3084
+
3085
+ export const MenuViewportCssVars = {
3086
+ /**
3087
+ * The width of the parent popup when the previous content was rendered. Placed on the
3088
+ * 'previous' container so the popup's dimensions can be frozen while content animates.
3089
+ */
3090
+ popupWidth: '--popup-width',
3091
+ /** The height of the parent popup when the previous content was rendered. */
3092
+ popupHeight: '--popup-height',
3093
+ } as const;
3094
+
3095
+ const menuViewportStateAttributesMapping: StateAttributesMapping<any> = {
3096
+ activationDirection: (value: string | undefined) =>
3097
+ value ? { 'data-activation-direction': value } : null,
3098
+ };
3099
+
3100
+ // A viewport for displaying content transitions. Only needed when one popup can be opened by
3101
+ // multiple triggers, its content changes per trigger, and switching between them is animated.
3102
+ function MenuViewport(componentProps: any): any {
3103
+ const slot = S('MenuViewport');
3104
+ const { render, className, style, children, ref: forwardedRef, ...elementProps } = componentProps;
3105
+
3106
+ const { store } = useMenuRootContext() as MenuRootContextValue;
3107
+ const { side } = useMenuPositionerContext();
3108
+
3109
+ const instantType = store.useState('instantType', subSlot(slot, 'instant'));
3110
+
3111
+ const { children: childrenToRender, state: viewportState } = usePopupViewport(
3112
+ { store, side, cssVars: MenuViewportCssVars, children },
3113
+ subSlot(slot, 'vp'),
3114
+ );
3115
+
3116
+ const state: MenuViewportState = {
3117
+ activationDirection: viewportState.activationDirection,
3118
+ transitioning: viewportState.transitioning,
3119
+ instant: instantType,
3120
+ };
3121
+
3122
+ return useRenderElement(
3123
+ 'div',
3124
+ componentProps,
3125
+ {
3126
+ state,
3127
+ ref: forwardedRef,
3128
+ props: [elementProps, { children: childrenToRender }],
3129
+ stateAttributesMapping: menuViewportStateAttributesMapping,
3130
+ },
3131
+ subSlot(slot, 're'),
3132
+ );
3133
+ }
3134
+
3135
+ export interface MenuViewportState {
3136
+ /** The activation direction of the transitioned content. */
3137
+ activationDirection: string | undefined;
3138
+ /** Whether the viewport is currently transitioning between contents. */
3139
+ transitioning: boolean;
3140
+ /** Present if animations should be instant. */
3141
+ instant: 'dismiss' | 'click' | 'group' | 'trigger-change' | undefined;
3142
+ }
3143
+
3144
+ // --- SubmenuRoot --------------------------------------------------------------
3145
+
3146
+ /**
3147
+ * Groups all parts of a submenu. Renders no HTML element of its own — it provides
3148
+ * `MenuSubmenuRootContext` (which is what makes the nested `MenuRoot` resolve its `parent` to
3149
+ * `{ type: 'menu' }`) and then renders a plain `MenuRoot`.
3150
+ */
3151
+ function MenuSubmenuRoot(props: any): any {
3152
+ const slot = S('MenuSubmenuRoot');
3153
+ const parentMenu = (useMenuRootContext() as MenuRootContextValue).store;
3154
+
3155
+ const contextValue = useMemo(() => ({ parentMenu }), [parentMenu], subSlot(slot, 'ctx'));
3156
+
3157
+ return createElement(MenuSubmenuRootContext.Provider, {
3158
+ value: contextValue,
3159
+ children: createElement(MenuRoot, props),
3160
+ });
3161
+ }
3162
+
3163
+ // --- SubmenuTrigger -----------------------------------------------------------
3164
+
3165
+ /**
3166
+ * A menu item that opens a submenu. It is BOTH an item of the parent menu (it registers with the
3167
+ * parent's `CompositeList` and reads the parent's `itemProps`/`isActive`) and the trigger of its own
3168
+ * menu (it registers with this menu's store and drives its open state), which is why it reaches for
3169
+ * two stores at once.
3170
+ */
3171
+ function MenuSubmenuTrigger(componentProps: any): any {
3172
+ const slot = S('MenuSubmenuTrigger');
3173
+ const {
3174
+ render,
3175
+ className,
3176
+ style,
3177
+ label,
3178
+ id: idProp,
3179
+ nativeButton = false,
3180
+ openOnHover = true,
3181
+ delay = 100,
3182
+ closeDelay = 0,
3183
+ disabled: disabledProp = false,
3184
+ ref: forwardedRef,
3185
+ ...elementProps
3186
+ } = componentProps;
3187
+
3188
+ const listItem = useCompositeListItem({ label }, subSlot(slot, 'li'));
3189
+ const menuPositionerContext = useMenuPositionerContext();
3190
+
3191
+ const { store } = useMenuRootContext() as MenuRootContextValue;
3192
+
3193
+ const thisTriggerId = useBaseUiId(idProp, subSlot(slot, 'id'));
3194
+ const open = store.useState('open', subSlot(slot, 'open'));
3195
+ const floatingRootContext = store.useState('floatingRootContext', subSlot(slot, 'frc'));
3196
+ const floatingTreeRoot = store.useState('floatingTreeRoot', subSlot(slot, 'ftr'));
3197
+ const popupId = store.useState('triggerPopupId', subSlot(slot, 'tpid'), thisTriggerId);
3198
+
3199
+ const baseRegisterTrigger = useTriggerRegistration(
3200
+ thisTriggerId,
3201
+ store as any,
3202
+ subSlot(slot, 'reg'),
3203
+ );
3204
+ const registerTrigger = useCallback(
3205
+ (element: Element | null) => {
3206
+ const cleanup = baseRegisterTrigger(element);
3207
+
3208
+ if (element !== null && store.select('open') && store.select('activeTriggerId') == null) {
3209
+ store.update({
3210
+ activeTriggerId: thisTriggerId,
3211
+ activeTriggerElement: element,
3212
+ closeDelay,
3213
+ });
3214
+ }
3215
+
3216
+ return cleanup;
3217
+ },
3218
+ [baseRegisterTrigger, closeDelay, store, thisTriggerId],
3219
+ subSlot(slot, 'regcb'),
3220
+ );
3221
+
3222
+ const triggerElementRef = useRef<HTMLElement | null>(null, subSlot(slot, 'ter'));
3223
+ const handleTriggerElementRef = useCallback(
3224
+ (el: HTMLElement | null) => {
3225
+ triggerElementRef.current = el;
3226
+ store.set('activeTriggerElement', el);
3227
+ },
3228
+ [store],
3229
+ subSlot(slot, 'tercb'),
3230
+ );
3231
+
3232
+ const submenuRootContext = useMenuSubmenuRootContext();
3233
+ if (!submenuRootContext?.parentMenu) {
3234
+ throw new Error('Base UI: <Menu.SubmenuTrigger> must be placed in <Menu.SubmenuRoot>.');
3235
+ }
3236
+
3237
+ store.useSyncedValue('closeDelay', closeDelay, subSlot(slot, 'cd'));
3238
+
3239
+ const parentMenuStore = submenuRootContext.parentMenu;
3240
+ const rootDisabled = store.useState('disabled', subSlot(slot, 'rd'));
3241
+ const parentDisabled = parentMenuStore.useState('disabled', subSlot(slot, 'pd'));
3242
+ const disabled = disabledProp || rootDisabled || parentDisabled;
3243
+
3244
+ // Upstream's dev-only `isElementDisabled` warning is dropped — this package carries no
3245
+ // `process.env.NODE_ENV` branches.
3246
+
3247
+ const itemProps = parentMenuStore.useState('itemProps', subSlot(slot, 'ip'));
3248
+ const highlighted = parentMenuStore.useState('isActive', subSlot(slot, 'hl'), listItem.index);
3249
+
3250
+ const itemMetadata = useMemo(
3251
+ () => ({
3252
+ type: 'submenu-trigger' as const,
3253
+ setActive() {
3254
+ if (parentMenuStore.select('highlightItemOnHover')) {
3255
+ parentMenuStore.set('activeIndex', listItem.index);
3256
+ }
3257
+ },
3258
+ }),
3259
+ [parentMenuStore, listItem.index],
3260
+ subSlot(slot, 'meta'),
3261
+ );
3262
+
3263
+ const { getItemProps, itemRef } = useMenuItem(
3264
+ {
3265
+ closeOnClick: false,
3266
+ disabled,
3267
+ highlighted,
3268
+ id: thisTriggerId,
3269
+ store,
3270
+ typingRef: parentMenuStore.context.typingRef,
3271
+ nativeButton,
3272
+ itemMetadata,
3273
+ nodeId: menuPositionerContext?.context.nodeId,
3274
+ },
3275
+ subSlot(slot, 'item'),
3276
+ );
3277
+
3278
+ const hoverEnabled = store.useState('hoverEnabled', subSlot(slot, 'he'));
3279
+
3280
+ const hoverProps = useHoverReferenceInteraction(
3281
+ floatingRootContext,
3282
+ {
3283
+ enabled: hoverEnabled && openOnHover && !disabled,
3284
+ handleClose: safePolygon({ blockPointerEvents: true }),
3285
+ mouseOnly: true,
3286
+ move: true,
3287
+ restMs: delay,
3288
+ delay: { open: delay, close: closeDelay },
3289
+ shouldOpen: delay > 0 ? () => parentMenuStore.select('allowMouseEnter') : undefined,
3290
+ triggerElementRef,
3291
+ externalTree: floatingTreeRoot,
3292
+ isClosing: () => store.select('transitionStatus') === 'ending',
3293
+ },
3294
+ subSlot(slot, 'hover'),
3295
+ );
3296
+
3297
+ const click = useClick(
3298
+ floatingRootContext,
3299
+ {
3300
+ enabled: !disabled,
3301
+ event: 'mousedown',
3302
+ toggle: !openOnHover,
3303
+ ignoreMouse: openOnHover,
3304
+ stickIfOpen: false,
3305
+ },
3306
+ subSlot(slot, 'click'),
3307
+ );
3308
+
3309
+ const localInteractionProps = click.reference ?? EMPTY_OBJECT;
3310
+
3311
+ // Transcribed verbatim, mutation included: the submenu trigger owns its OWN `id` (it is also a
3312
+ // parent-menu item), so the root's trigger props must not stamp one over it.
3313
+ const rootTriggerProps = store.useState('triggerProps', subSlot(slot, 'tp'), true);
3314
+ delete rootTriggerProps.id;
3315
+
3316
+ const state: MenuSubmenuTriggerState = { disabled, highlighted, open };
3317
+
3318
+ return useRenderElement(
3319
+ 'div',
3320
+ componentProps,
3321
+ {
3322
+ state,
3323
+ stateAttributesMapping: triggerOpenStateMapping,
3324
+ props: [
3325
+ localInteractionProps,
3326
+ hoverProps,
3327
+ rootTriggerProps,
3328
+ itemProps,
3329
+ {
3330
+ 'aria-controls': popupId,
3331
+ tabIndex: open || highlighted ? 0 : -1,
3332
+ onBlur() {
3333
+ if (highlighted) {
3334
+ parentMenuStore.set('activeIndex', null);
3335
+ }
3336
+ },
3337
+ },
3338
+ elementProps,
3339
+ getItemProps,
3340
+ ],
3341
+ ref: [forwardedRef, listItem.ref, itemRef, registerTrigger, handleTriggerElementRef],
3342
+ },
3343
+ subSlot(slot, 're'),
3344
+ );
3345
+ }
3346
+
3347
+ export interface MenuSubmenuTriggerState {
3348
+ /** Whether the component should ignore user interaction. */
3349
+ disabled: boolean;
3350
+ /** Whether the item is highlighted. */
3351
+ highlighted: boolean;
3352
+ /** Whether the menu is currently open. */
3353
+ open: boolean;
3354
+ }
3355
+
3356
+ // --- Namespace ---------------------------------------------------------------
3357
+
3358
+ /**
3359
+ * The full upstream `menu/index.parts.ts` surface (Phase 3f stages 1–3).
3360
+ *
3361
+ * `Separator` is upstream's shared `Separator`, re-exported through the Menu namespace exactly as
3362
+ * `menu/index.parts.ts` does — not a Menu-specific part.
3363
+ */
3364
+ export const Menu = {
3365
+ Root: MenuRoot,
3366
+ Trigger: MenuTrigger,
3367
+ Portal: MenuPortal,
3368
+ Positioner: MenuPositioner,
3369
+ Popup: MenuPopup,
3370
+ Arrow: MenuArrow,
3371
+ Backdrop: MenuBackdrop,
3372
+ Viewport: MenuViewport,
3373
+ Item: MenuItem,
3374
+ LinkItem: MenuLinkItem,
3375
+ CheckboxItem: MenuCheckboxItem,
3376
+ CheckboxItemIndicator: MenuCheckboxItemIndicator,
3377
+ RadioGroup: MenuRadioGroup,
3378
+ RadioItem: MenuRadioItem,
3379
+ RadioItemIndicator: MenuRadioItemIndicator,
3380
+ Group: MenuGroup,
3381
+ GroupLabel: MenuGroupLabel,
3382
+ Separator,
3383
+ SubmenuRoot: MenuSubmenuRoot,
3384
+ SubmenuTrigger: MenuSubmenuTrigger,
3385
+ createHandle: createMenuHandle,
3386
+ Handle: MenuHandle,
3387
+ };