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