import type { JSX } from "@solidjs/web"; /** * `createMenu` — headless primitive for the Menu compound. * * Owns the logical state surface only: * - `open` — consumer-owned signal, read through the option accessor. * - `activeIndex` — keyboard-highlighted item index. * - `payload` — active trigger payload (for handle-driven workflows). * - `setOpen` — pure event-emitter via `runWithOwner(null, ...)`. * * All DOM concerns (FloatingTree, floating-ui interaction hooks, focus trap, * portal, transition) stay in ``. */ import { type Accessor, createContext, createMemo, createSignal, runWithOwner, untrack, useContext, } from "solid-js"; import { type MaybeAccessor, readOption } from "../internals/maybe-accessor"; import { SNAPSHOT_KEYS, type Snapshottable, snapshotKeys, } from "../internals/observability/snapshot"; import type { MenuRootChangeEventDetails, MenuRootChangeEventReason, } from "./root/menu-root-context"; // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- export type MenuChangeReason = MenuRootChangeEventReason; export interface CreateMenuOptions<_Payload = unknown> { /** * Open state. Consumer-owned signal — the primitive reads through the * accessor and does NOT keep an internal copy. */ open: MaybeAccessor; /** * Whether the menu is disabled. * @default false */ disabled?: MaybeAccessor; /** * Whether the menu is modal (top-level only; ignored for submenus). * @default true */ modal?: MaybeAccessor; /** * Called via `runWithOwner(null, ...)` so consumer signal writes don't * trigger SIGNAL_WRITE_IN_OWNED_SCOPE. */ onOpenChange?: (open: boolean, details: MenuRootChangeEventDetails) => void; } export interface MenuApi { /** Reactive accessor for the current open state. */ open: Accessor; /** Whether the menu is disabled. */ disabled: Accessor; /** Whether the menu is in modal mode. */ modal: Accessor; /** Keyboard-highlighted item index (null = none). */ activeIndex: Accessor; /** Current payload for handle-driven workflows. */ payload: Accessor; /** * Pure event-emitter — fires `onOpenChange` via `runWithOwner(null, ...)`. * The consumer owns the signal write. */ setOpen: (next: boolean, details?: MenuRootChangeEventDetails) => void; /** Update the highlighted item index. Internal coordination for nav hooks. */ setActiveIndex: (index: number | null) => void; /** Update the active payload. Internal coordination for handle-driven flow. */ setPayload: (payload: Payload | undefined) => void; /** Context provider — expose the api via `useMenu()`. */ Provider: (props: { children: JSX.Element }) => JSX.Element; } // --------------------------------------------------------------------------- // Context // --------------------------------------------------------------------------- const MenuApiContext = createContext | null>(null); /** * Read the nearest {@link MenuApi}. Throws unless `optional` is true. */ export function useMenu(): MenuApi; export function useMenu( optional: true, ): MenuApi | null; export function useMenu( optional?: boolean, ): MenuApi | null { const ctx = useContext(MenuApiContext); if (ctx == null && !optional) { throw new Error("Base UI: useMenu() must be called inside a ."); } return ctx as MenuApi | null; } // --------------------------------------------------------------------------- // Primitive // --------------------------------------------------------------------------- /** * Headless primitive for the Menu compound. Owns logical state — open, * disabled, modal, active item index, and payload. DOM concerns (floating-ui, * focus trap, portal, animation) live in ``. */ export function createMenu( options: CreateMenuOptions, ): MenuApi { const open = createMemo(() => Boolean(readOption(options.open)), { name: "menu.open", }); const disabled = createMemo( () => Boolean(readOption(options.disabled)), { name: "menu.disabled" }, ); const modal = createMemo(() => readOption(options.modal) ?? true, { name: "menu.modal", }); // Internal state — not consumer-owned but privately mutable. const [activeIndex, setActiveIndex] = createSignal(null, { ownedWrite: true, name: "menu.activeIndex", }); const [payload, setPayload] = createSignal(undefined, { ownedWrite: true, name: "menu.payload", }); // Pure event-emitter — escapes the reactive owner so consumer signal // writes inside onOpenChange don't trip SIGNAL_WRITE_IN_OWNED_SCOPE. const setOpen = ( next: boolean, details?: MenuRootChangeEventDetails, ): void => { if (untrack(disabled)) return; const d = details ?? ({ reason: "none", isCanceled: false } as MenuRootChangeEventDetails); runWithOwner(null, () => options.onOpenChange?.(next, d)); }; const api: MenuApi = { open, disabled, modal, activeIndex, payload: payload as Accessor, setOpen, setActiveIndex, setPayload: setPayload as (payload: Payload | undefined) => void, Provider: (props) => ( }> {props.children} ), }; (api as MenuApi & Snapshottable)[SNAPSHOT_KEYS] = MENU_SNAPSHOT_KEYS; return api; } const MENU_SNAPSHOT_KEYS = snapshotKeys( "open", "disabled", "modal", "activeIndex", );