import type { JSX } from "@solidjs/web"; /** * `` — Solid 2 port of Base UI React's `MenuRoot` * (`packages/react/src/menu/root/MenuRoot.tsx`). * * Groups every menu part. Owns the open/closed state (controlled `open` * prop or internal signal seeded from `defaultOpen`, per Base UI) and the * interaction-hook bundle (click + dismiss + listnav + typeahead). Wraps * itself in a `` when it's the top-level root so submenus * can participate in the tree. * * ### Port deviations from React * * - The React port threads a shared store + selectors across every part. * The Solid port instead exposes plain signal-accessors on the root * context (same shape as ``). `MenuStore` + `MenuHandle` * exist for the imperative + multi-trigger surface; * their internal state mirrors the root's signals and is the only * surface the handle reads. * - `useOpenInteractionType` is threaded; `openMethod` * + trigger-props are exposed on `MenuRootContext`. Downstream * behavior gating (touch-bypass, focus-on-open decisions) needs * browser coverage to observe actual pointerType values. * - Floating-tree cross-menu events are emitted: * `menuopenchange` fires from `setOpen`, `itemhover` from * ``'s `onPointerEnter`. `"close"` (cancel-on-mouseup) * still pending the mouseup-on-item window. * - Trigger focus-guard sentinels wired; hover-to-open * + safe-polygon sibling auto-close still pending. * - `closeParentOnEsc` is wired via `useDismiss`'s `bubbles.escapeKey` * (latched at init). */ import { type Accessor, createEffect, createSignal, onCleanup, untrack, useContext, } from "solid-js"; import { ContextMenuRootContext } from "../../context-menu/root/context-menu-root-context"; import { useBaseUiId } from "../../internals/base-ui-id"; import { createChangeEventDetails, REASONS, } from "../../internals/change-event-details"; import { createControlledState } from "../../internals/create-controlled-state"; import { FloatingNode, FloatingTree, useFloatingNodeId, useFloatingParentNodeId, useFloatingTree, } from "../../internals/floating-ui/components/floating-tree"; import { useDismiss } from "../../internals/floating-ui/hooks/use-dismiss"; import { useFloatingRootContext } from "../../internals/floating-ui/hooks/use-floating-root-context"; import { useInteractions } from "../../internals/floating-ui/hooks/use-interactions"; import { useListNavigation } from "../../internals/floating-ui/hooks/use-list-navigation"; import { useRole } from "../../internals/floating-ui/hooks/use-role"; import { useTypeahead } from "../../internals/floating-ui/hooks/use-typeahead"; import type { MutableRef } from "../../internals/floating-ui/types"; import { type MaybeAccessor, readOption } from "../../internals/maybe-accessor"; import { useOpenChangeComplete } from "../../internals/popups/use-open-change-complete"; import { useOpenInteractionType } from "../../internals/popups/use-open-interaction-type"; import { useTimeout } from "../../internals/use-timeout"; import { useTransitionStatus } from "../../internals/use-transition-status/use-transition-status"; import { MenubarContext } from "../../menubar/menubar-context"; import { createMenu } from "../create-menu"; import type { MenuHandle } from "../store/menu-store"; import { MenuStore } from "../store/menu-store"; import { useMenuSubmenuRootContext } from "../submenu-root/menu-submenu-root-context"; import { type MenuPayloadChildRenderFunction, type MenuRootChangeEventDetails, type MenuRootChangeEventReason, MenuRootContext, type MenuRootContextValue, type MenuRootOrientation, useMenuRootContext, } from "./menu-root-context"; export type { MenuRootChangeEventDetails, MenuRootChangeEventReason }; export interface MenuRootActions { /** Force-unmount the menu popup subtree. */ unmount: () => void; /** Imperatively close the menu. */ close: () => void; } export type MenuRootState = Record; export interface MenuRootProps { /** * Controlled open state. When provided (resolved value not * `undefined`), the consumer owns the state — pair with * {@link onOpenChange}. When omitted, the menu manages its own open * state (seeded from {@link defaultOpen}). */ open?: MaybeAccessor; /** * Initial open state when uncontrolled. Read once at mount. * @default false */ defaultOpen?: MaybeAccessor; /** Whether arrow keys loop at the ends. @default true */ loopFocus?: boolean | undefined; /** Highlight items on hover. @default true */ highlightItemOnHover?: boolean | undefined; /** Modal mode — locks outside interaction while open. @default true */ modal?: boolean | undefined; /** Called when the menu opens or closes. */ onOpenChange?: | ((open: boolean, eventDetails: MenuRootChangeEventDetails) => void) | undefined; /** Called after any animations complete when the menu is closed. */ onOpenChangeComplete?: ((open: boolean) => void) | undefined; /** Visual/keyboard orientation. @default "vertical" */ orientation?: MenuRootOrientation | undefined; /** Whether the menu ignores user interaction. @default false */ disabled?: boolean | undefined; /** Escape-key bubbling in submenus. @default false */ closeParentOnEsc?: boolean | undefined; /** Imperative actions surface. */ actionsRef?: | ((actions: MenuRootActions | null) => void) | { current: MenuRootActions | null } | undefined; /** * ID of the trigger associated with the menu (controlled). When supplied, * the root uses this as the active trigger id for positioning + focus return. */ triggerId?: string | null | undefined; /** * Handle to connect the root with detached `` * components. Allows external triggers to drive the menu's open * state and to associate multiple triggers with one menu. */ handle?: MenuHandle | undefined; /** * Children. May be a regular node or a render function that receives the * active trigger's payload. */ children?: JSX.Element | MenuPayloadChildRenderFunction; } function MenuRootComponent( props: MenuRootProps, ): JSX.Element { const submenuContext = useMenuSubmenuRootContext(); const parentMenu = useMenuRootContext(true); const nested = submenuContext != null && parentMenu != null; // Menubar interop — when this Menu.Root is nested inside a `` as // a TOP-LEVEL menu (not a submenu), we coordinate sibling // auto-close and the `data-has-submenu-open` flag on the menubar. Keep // the branching minimal: only read the context + register an opener/closer // slot. All cross-menu logic lives in the menubar; menu-root only emits // the "I opened/closed" signal. const parentMenubarContext = !nested ? useContext(MenubarContext) : null; // Context-menu interop — when this Menu.Root is // wrapped by ``, we read the virtual-anchor signal // it owns and thread it into the floating-root context (as the // `positionReference` slot, which `useFloating` prefers over the raw // `referenceElement`). We also populate the context's `actionsRef` // with our `setOpen` so `` can open imperatively, // and mirror the positioner element back to the context's // `positionerRef` so the trigger's mouseup handler knows whether a // release landed inside the popup. React's `` // wraps the inner menu in `` to // prevent outer menus from shadowing the context-menu detection; the // Solid port passes `null` for the same purpose, so `parentMenu` // reads null here. Nested context-menu detection (React's // `'nested-context-menu'` parent type) is scoped out of this pass. const parentContextMenuContext = !nested && parentMenu == null ? useContext(ContextMenuRootContext) : null; const modalProp = (): boolean => props.modal ?? true; const disabledProp = (): boolean => props.disabled ?? false; const loopFocusProp = (): boolean => props.loopFocus ?? true; const orientationProp = (): MenuRootOrientation => props.orientation ?? "vertical"; const highlightItemOnHoverProp = (): boolean => props.highlightItemOnHover ?? true; const closeParentOnEscProp = (): boolean => props.closeParentOnEsc ?? false; // Controlled / uncontrolled open state (Base UI `open` / `defaultOpen` // contract): the `open` prop wins while its resolved value is defined; // otherwise an internal signal (seeded once from `defaultOpen`) backs // the state and the root's `setOpen` writes it. const [resolvedOpen, setUncontrolledOpen] = createControlledState({ controlled: () => { const controlled = readOption(props.open); return controlled === undefined ? undefined : Boolean(controlled); }, default: () => readOption(props.defaultOpen), fallback: false, name: "", state: "open", }); // Compose the headless Menu primitive — owns open, disabled, modal, // activeIndex, and payload. The wrapper layers floating-UI, focus trap, // portal, and animation on top. const menuPrimitive = createMenu({ open: resolvedOpen, disabled: () => props.disabled ?? false, modal: () => (props.modal ?? true) && !nested, // onOpenChange is NOT wired here — the wrapper's own setOpen (below) // is the sole writer so it can thread reason/event details through the // full floating-UI pipeline. The primitive's setOpen is unused in the // wrapper path. }); const open = menuPrimitive.open; // `activeTriggerId` is purely-internal coordination state — it tracks // which trigger spawned this menu instance for positioning + focus-return. // It is NOT a consumer-facing controlled prop (consumers use `triggerId` // to seed the initial value, but they don't own the signal). A plain // `createSignal` with `ownedWrite: true` is correct here; this is an // internal implementation detail, not the same kind of consumer-owned // state as `open`. const [activeTriggerId, setActiveTriggerId] = createSignal( props.triggerId ?? null, { ownedWrite: true, name: "menu.activeTriggerId" }, ); // Delegate activeIndex + payload to the primitive for unified ownership. const activeIndex = menuPrimitive.activeIndex; const setActiveIndex = menuPrimitive.setActiveIndex; const payload = menuPrimitive.payload; const setPayload = menuPrimitive.setPayload as ( payload: Payload | undefined, ) => void; const [activeTriggerElement, setActiveTriggerElement] = createSignal(null, { ownedWrite: true }); const [lastOpenChangeReason, setLastOpenChangeReason] = createSignal(null, { ownedWrite: true }); const [instantType, setInstantType] = createSignal< "dismiss" | "click" | "group" | "trigger-change" | undefined >(undefined, { ownedWrite: true }); // Element refs. const popupRef: MutableRef = { current: null }; const positionerRef: MutableRef = { current: null }; const listRef: MutableRef> = { current: [] }; const labelsRef: MutableRef> = { current: [] }; const typingRef: MutableRef = { current: false }; const closeDelayRef: MutableRef = { current: 0 }; // Focus-guard refs — populated by ``'s // `useTriggerFocusGuards` integration. The root owns these slots so // other parts can reach them if needed. const beforeContentFocusGuardRef: MutableRef = { current: null, }; const triggerFocusTargetRef: MutableRef = { current: null, }; // Adopt the handle's store if supplied; otherwise own one. Mirrors the // popover pattern so `MenuHandle.open(triggerId)` writes // through to the same registry triggers read from. const store = (props.handle?.store ?? new MenuStore()) as | MenuStore | MenuStore; // Menu root id — useful for context-menu `data-rootownerid` attribution. const rootId = useBaseUiId(); // Parent-type discriminant (mirrors React's `parent.type`). When the // outer `` itself is nested inside another context- // menu, mark the inner menu's parent as `'nested-context-menu'` so // dismissal / open-behavior routes like a regular submenu. const parentType = ((): | "menu" | "menubar" | "context-menu" | "nested-context-menu" | undefined => { if (nested) return "menu"; if (parentMenubarContext != null) return "menubar"; if (parentContextMenuContext != null) { return parentContextMenuContext.nestedInsideContextMenu ? "nested-context-menu" : "context-menu"; } return undefined; })(); // Mount / transition machinery. const { mounted, transitionStatus, setMounted } = useTransitionStatus( open, () => true, () => false, ); // Deferred unmount on exit animation. useOpenChangeComplete({ open, ref: popupRef, onComplete: () => { if (!open()) { setMounted(false); setActiveIndex(null); props.onOpenChangeComplete?.(false); } else { props.onOpenChangeComplete?.(true); } }, }); // Floating node id + parent id for submenus / cross-tree events. const floatingNodeIdFromContext = useFloatingNodeId(); const floatingParentNodeIdFromContext = useFloatingParentNodeId(); const floatingTreeFromContext = useFloatingTree(); // `useOpenInteractionType` — captures mouse/keyboard/touch at open time // Trigger spreads `openInteractionTypeProps`; `openMethod` // reads reactive and gates focus-on-open downstream. const { openMethod, triggerProps: openInteractionTypeProps } = useOpenInteractionType(open); const floatingRootContext = useFloatingRootContext({ open: untrack(open), onOpenChange: (nextOpen, details) => { setOpen(nextOpen, details as MenuRootChangeEventDetails); }, }); createEffect( () => ({ o: open(), trigger: activeTriggerElement(), s: transitionStatus(), // When wrapped by a context-menu, the anchor becomes the // virtual-element source of truth. Re-read on each tick so // positioning tracks the most recent right-click point. anchor: parentContextMenuContext?.anchor(), }), ({ o, trigger, s, anchor }) => { floatingRootContext.update({ open: o, referenceElement: trigger, domReferenceElement: trigger, transitionStatus: s, }); // Prefer the virtual anchor for positioning when supplied — // `useFloating` uses `positionReference` over `referenceElement`. if (parentContextMenuContext != null && anchor) { floatingRootContext.update({ positionReference: anchor, }); } }, ); // setOpen — mirrors Base UI's change flow: fire `props.onOpenChange`, // honor `details.cancel()`, then write the uncontrolled state (the write // is ignored for reads while the `open` prop is controlled — the // consumer's signal is the source of truth), then trigger mirroring. const setOpen = ( nextOpen: boolean, details: MenuRootChangeEventDetails, ): void => { const reason = details.reason as MenuRootChangeEventReason; const prevOpen = untrack(open); if ( prevOpen === nextOpen && details.trigger === untrack(activeTriggerElement) && untrack(lastOpenChangeReason) === reason ) { return; } setLastOpenChangeReason(reason); // Stash the native event type so the context-menu outside-press // grace window can distinguish a `contextmenu`-initiated open // (dismiss-through allowed) from a `mousedown`/long-press open // (dismiss-through gated by the 500ms timer). const rawEvent = details.event as Event | undefined; if (nextOpen && rawEvent != null) { openEventType = rawEvent.type; } props.onOpenChange?.(nextOpen, details); if (details.isCanceled) return; // Uncontrolled write — inert while the `open` prop is controlled. setUncontrolledOpen(nextOpen); // Track the trigger across close for focus-return / exit animations. if (details.trigger) { setActiveTriggerElement(details.trigger as HTMLElement); } // On close, retain the trigger element briefly (React pattern). if (!nextOpen && details.trigger == null) { // leave element in place } // Skip entry animation for dismiss + keyboard clicks. const nativeEvent = details.event as MouseEvent | KeyboardEvent | undefined; const isKeyboardClick = (reason === REASONS.triggerPress || reason === REASONS.itemPress) && nativeEvent != null && "detail" in nativeEvent && (nativeEvent as MouseEvent).detail === 0 && nativeEvent.isTrusted; const isDismissClose = !nextOpen && (reason === REASONS.escapeKey || reason == null); if (isKeyboardClick) setInstantType("click"); else if (isDismissClose) setInstantType("dismiss"); else setInstantType(undefined); // Emit `menuopenchange` on the top-level floating tree so menubar / // typeahead-routing / sibling-menu coordinators can react. // Matches React's `floatingTreeRoot.events.emit("menuopenchange", // ...)` emission shape. const tree = floatingTreeFromContext; if (tree != null) { tree.events.emit("menuopenchange", { open: nextOpen, nodeId: floatingNodeIdFromContext, parentNodeId: floatingParentNodeIdFromContext, reason, trigger: details.trigger ?? null, }); } }; // Wire the store's imperative setOpen so `MenuHandle.open/close` flows // through the same reason-threading setOpen every other entry point uses. store.attachSetOpen((next, details) => { setOpen(next, details); }); onCleanup(() => { store.attachSetOpen(null); }); // Mirror reactive open into the store's synchronous `isOpen` for // `handle.isOpen` reads. createEffect( () => open(), (o) => { store.setOpenMirror(o); }, ); // Implicit active-trigger selection — when a handle-driven consumer opens // without setting a `triggerId`, pick the sole registered trigger so // positioning + focus return still have a DOM anchor. createEffect( () => open(), (o) => { if (!o) return; if (untrack(activeTriggerId) !== null) return; if (store.context.triggerElements.size !== 1) return; const entry = store.context.triggerElements.entries().next(); if (!entry.done) { const [id, el] = entry.value; setActiveTriggerId(id); setActiveTriggerElement(el as HTMLElement); } }, ); // Imperative actions. const handleUnmount = (): void => { setMounted(false); setActiveIndex(null); }; const handleClose = (): void => { setOpen( false, createChangeEventDetails( REASONS.imperativeAction, ) as MenuRootChangeEventDetails, ); }; const actions: MenuRootActions = { unmount: handleUnmount, close: handleClose, }; const ar = props.actionsRef; if (typeof ar === "function") { ar(actions); onCleanup(() => ar(null)); } else if (ar && typeof ar === "object") { ar.current = actions; onCleanup(() => { ar.current = null; }); } // Context-menu interop: populate the outer context's `actionsRef` so // `` can call `setOpen(true, ...)` imperatively // after writing the anchor. The `setOpen` captured here is the same // reason-threading path used by every other menu entry point. if (parentContextMenuContext != null) { const cm = parentContextMenuContext; cm.actionsRef.current = { setOpen: (nextOpen, details) => { setOpen(nextOpen, details); }, }; onCleanup(() => { if (cm.actionsRef.current != null) { cm.actionsRef.current = null; } }); } // Mirror our positioner element back to the context-menu context so // the trigger's mouseup listener can tell whether a release landed // inside the popup (and therefore shouldn't cancel the open). const ownPositionerRef = positionerRef; if (parentContextMenuContext != null) { const cm = parentContextMenuContext; createEffect( () => mounted(), () => { cm.positionerRef.current = ownPositionerRef.current; }, ); onCleanup(() => { cm.positionerRef.current = null; }); } // Menubar interop — when mounted under a ``, emit // our open state to the parent so sibling menus can auto-close and the // menubar's `data-has-submenu-open` flag stays live. if (parentMenubarContext != null) { const mb = parentMenubarContext; let unregister: (() => void) | null = null; createEffect( () => open(), (isOpen) => { if (isOpen) { unregister?.(); unregister = mb.registerOpenMenu(() => { setOpen( false, createChangeEventDetails( REASONS.siblingOpen, ) as MenuRootChangeEventDetails, ); }); } else { unregister?.(); unregister = null; } return undefined; }, ); onCleanup(() => { unregister?.(); unregister = null; }); } // Context-menu outside-press grace window. Mirrors React's // `allowOutsidePressDismissalRef` + 500ms post-open timer. After // `` opens the menu, a racing `mousedown` on the // page can otherwise fire `outsidePress` → close before the user even // sees the menu. We gate the dismiss callback on this flag. // // Writes happen from an event handler (mount open-flip → timer callback), // both of which run in unowned scopes — no `ownedWrite` escape needed. let allowOutsidePressDismissal = parentType !== "context-menu"; const allowOutsidePressTimeout = useTimeout(); // React tracks the native event that opened the menu so a `contextmenu` // open is allowed to dismiss through immediately (the same native event // is what triggered the open — otherwise the very first right-click // outside the menu wouldn't close it). Mirror by stashing the reason. let openEventType: string | null = null; createEffect( () => open(), (isOpen) => { if (!isOpen) { openEventType = null; } if (parentType !== "context-menu") return; if (!isOpen) { allowOutsidePressTimeout.clear(); allowOutsidePressDismissal = false; return; } allowOutsidePressTimeout.start(500, () => { allowOutsidePressDismissal = true; }); }, ); // Interaction hooks. `enabled` / `bubbles` accept accessors (P3); // reactive flips re-install on the fly. // // `outsidePress` is a function, so `createStableCallback` re-reads our // closure on each dispatch — this is safe to wire reactively. // Floating-UI interaction hooks build their prop bags eagerly at body time // and read store-backed signals while doing so. Those reads are intentional // snapshots (the hooks themselves expose getters that re-fire on tracked // reads), so wrap each call in `untrack(...)` to clear the // `[STRICT_READ_UNTRACKED]` label inherited from the `MenuRootComponent` // devComponent wrapper. The hooks aren't Solid components, so the outer // `untrack` is effective (not "futile" the way it is for child component // bodies that re-set their own `strictRead` label). const dismiss = untrack(() => useDismiss(floatingRootContext, { enabled: () => !disabledProp(), bubbles: { escapeKey: () => closeParentOnEscProp() && nested }, outsidePress: () => { if (parentType !== "context-menu" || openEventType === "contextmenu") { return true; } return allowOutsidePressDismissal; }, }), ); const role = untrack(() => useRole(floatingRootContext, { role: "menu" })); const listNavigation = untrack(() => useListNavigation(floatingRootContext, { enabled: () => !disabledProp(), listRef, activeIndex, nested: () => nested, loopFocus: loopFocusProp, orientation: orientationProp, onNavigate: (nextIndex) => { setActiveIndex(nextIndex); }, focusItemOnHover: highlightItemOnHoverProp, // Suppress the arrow-key open path when the parent is // a context menu (React parity). Context-menu triggers don't // receive keyboard focus by default, but this closes the // corner-case where a trigger is inside a focusable wrapper. openOnArrowKeyDown: parentType !== "context-menu" && parentType !== "nested-context-menu", }), ); const typeahead = untrack(() => useTypeahead(floatingRootContext, { enabled: () => !disabledProp() && open(), listRef: labelsRef, activeIndex, resetMs: 500, onMatch: (index) => { if (untrack(open) && index !== untrack(activeIndex)) { setActiveIndex(index); } }, onTypingChange: (next) => { typingRef.current = next; }, }), ); const interactions = useInteractions([ dismiss, role, listNavigation, typeahead, ]); const parentAccessor: Accessor | null> = () => parentMenu; const contextValue: MenuRootContextValue = { nested: () => nested, parent: parentAccessor, open, mounted, transitionStatus, modal: () => (nested ? false : modalProp()), disabled: disabledProp, orientation: orientationProp, loopFocus: loopFocusProp, highlightItemOnHover: highlightItemOnHoverProp, activeIndex, setActiveIndex, payload, setPayload, activeTriggerElement, setActiveTriggerElement, activeTriggerId, setActiveTriggerId: (id) => { setActiveTriggerId(id as string | null); }, lastOpenChangeReason, instantType, setOpen, floatingRootContext, interactions, listRef, labelsRef, typingRef, closeDelayRef, popupRef, positionerRef, onOpenChangeComplete: props.onOpenChangeComplete, floatingNodeId: () => floatingNodeIdFromContext, closeParentOnEsc: closeParentOnEscProp, rootId: () => rootId, parentType: () => parentType, store: store as MenuStore, openMethod, openInteractionTypeProps, beforeContentFocusGuardRef, triggerFocusTargetRef, floatingTreeRoot: () => floatingTreeFromContext, floatingParentNodeId: () => floatingParentNodeIdFromContext, }; // Render children inside a `get children()` context so the provider is // visible before JSX evaluates (papercut #1). const renderChildren = (): JSX.Element => { const c = props.children; if (typeof c === "function") { return (c as MenuPayloadChildRenderFunction)({ payload: payload(), }); } return c as JSX.Element; }; const treeNodeId = floatingNodeIdFromContext; return ( }> {renderChildren()} ); } /** * Groups all parts of a menu. Renders no DOM element — the root is a pure * context provider. Wraps itself in a `` when not already * inside one. */ export function MenuRoot( props: MenuRootProps, ): JSX.Element { // Reuse the outer `FloatingTree` if one is already mounted (e.g. from // `` / ``). A `FloatingNode` parent id isn't // required — the shared tree is what matters for cross-menu events. // Check the tree context directly rather than the parent // node id so standalone consumers that mount their own `` // can subscribe to `menuopenchange` without nesting under a // ``. if (useFloatingTree() != null) { return {...props} />; } return ( {...props} /> ); }