import type { JSX } from "@solidjs/web"; /** * `createContextMenu` — headless primitive for the ContextMenu compound. * * ContextMenu is a thin façade over `` that: * - Owns an `anchor` signal (virtual element at the right-click point). * - Owns the open state per Base UI's controlled/uncontrolled contract: * the gesture (right-click / long-press) writes the internal signal, * seeded once from `defaultOpen`; a defined `open` option wins. * - Exposes `setAnchor` + `setOpen` so `` can update * both atomically on contextmenu / pointerdown events. * * Gesture capture (contextmenu event, long-press timer, move threshold) * and DOM concerns stay in `` / ``. * * Why a separate primitive instead of reusing `createMenu` directly? * ContextMenu needs the `anchor` state and the gesture-driven internal open * signal. `createMenu` owns `open` as a pure pass-through, but ContextMenu * must manage its own open signal that the trigger writes imperatively — * that asymmetry warrants a dedicated primitive rather than composing * createMenu with extra options. */ import { type Accessor, createContext, createMemo, createSignal, runWithOwner, untrack, useContext, } from "solid-js"; import { createControlledState } from "../internals/create-controlled-state"; import { type MaybeAccessor, readOption } from "../internals/maybe-accessor"; import { SNAPSHOT_KEYS, type Snapshottable, snapshotKeys, } from "../internals/observability/snapshot"; import type { MenuRootChangeEventDetails, MenuRootChangeEventReason, } from "../menu/root/menu-root-context"; import type { ContextMenuAnchor } from "./root/context-menu-root-context"; // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- export type ContextMenuChangeReason = MenuRootChangeEventReason; /** Default zero-sized anchor used before the first contextmenu event. */ function createDefaultAnchor(): ContextMenuAnchor { return { getBoundingClientRect(): DOMRect { return typeof DOMRect !== "undefined" ? DOMRect.fromRect({ width: 0, height: 0, x: 0, y: 0 }) : ({ width: 0, height: 0, x: 0, y: 0, top: 0, right: 0, bottom: 0, left: 0, toJSON: () => ({}), } as DOMRect); }, }; } export interface CreateContextMenuOptions { /** * Controlled open state. While its resolved value is not `undefined` * the consumer owns the state — pair with `onOpenChange`. When * omitted, the gesture-driven internal signal (seeded from * {@link defaultOpen}) backs the state. */ open?: MaybeAccessor; /** * Initial open state when uncontrolled. Read once at creation. * @default false */ defaultOpen?: MaybeAccessor; /** * Whether the menu is disabled. * @default false */ disabled?: 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 ContextMenuApi { /** Reactive accessor for the effective open state. */ open: Accessor; /** Whether the menu is disabled. */ disabled: Accessor; /** Virtual anchor element — updated by the trigger on each gesture. */ anchor: Accessor; /** * Update the anchor position. Called by `` when the * contextmenu / long-press gesture fires. */ setAnchor: (anchor: ContextMenuAnchor) => void; /** * Fires `onOpenChange` via `runWithOwner(null, ...)`, honors * `details.cancel()`, then writes the internal gesture-driven open * signal so uncontrolled consumers see the change (the write is inert * while a controlled `open` option is provided). */ setOpen: (next: boolean, details?: MenuRootChangeEventDetails) => void; /** Context provider — expose the api via `useContextMenu()`. */ Provider: (props: { children: JSX.Element }) => JSX.Element; } // --------------------------------------------------------------------------- // Context // --------------------------------------------------------------------------- const ContextMenuApiContext = createContext(null); /** * Read the nearest {@link ContextMenuApi}. Throws unless `optional` is true. */ export function useContextMenu(): ContextMenuApi; export function useContextMenu(optional: true): ContextMenuApi | null; export function useContextMenu(optional?: boolean): ContextMenuApi | null { const ctx = useContext(ContextMenuApiContext); if (ctx == null && !optional) { throw new Error( "Base UI: useContextMenu() must be called inside a .", ); } return ctx; } // --------------------------------------------------------------------------- // Primitive // --------------------------------------------------------------------------- /** * Headless primitive for the ContextMenu compound. Owns logical state — open, * disabled, anchor. Gesture capture and DOM concerns live in * `` and ``. */ export function createContextMenu( options: CreateContextMenuOptions = {}, ): ContextMenuApi { const disabled = createMemo( () => Boolean(readOption(options.disabled)), { name: "contextMenu.disabled" }, ); // Controlled / uncontrolled open state (Base UI `open` / `defaultOpen` // contract): the `open` option wins while its resolved value is defined; // otherwise the gesture-driven internal signal (seeded once from // `defaultOpen`, written by `setOpen`) backs the state. const [open, setUncontrolledOpen] = createControlledState({ controlled: () => { const controlled = readOption(options.open); return controlled === undefined ? undefined : Boolean(controlled); }, default: () => readOption(options.defaultOpen), fallback: false, name: "", state: "open", }); const [anchor, setAnchor] = createSignal( createDefaultAnchor(), { ownedWrite: true, name: "contextMenu.anchor" }, ); // Base UI change flow: fire `onOpenChange` (via `runWithOwner(null, ...)` // so consumer signal writes don't trip SIGNAL_WRITE_IN_OWNED_SCOPE), // honor `details.cancel()`, then write the uncontrolled state (inert // while the `open` option is controlled). 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)); if (d.isCanceled) return; setUncontrolledOpen(next); }; const api: ContextMenuApi = { open, disabled, anchor, setAnchor, setOpen, Provider: (props) => ( {props.children} ), }; (api as ContextMenuApi & Snapshottable)[SNAPSHOT_KEYS] = CONTEXT_MENU_SNAPSHOT_KEYS; return api; } const CONTEXT_MENU_SNAPSHOT_KEYS = snapshotKeys( "open", "disabled", );