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} />
);
}