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",
);