import type { JSX } from "@solidjs/web"; import { type Accessor, createContext, createMemo, runWithOwner, untrack, useContext, } from "solid-js"; import { type MaybeAccessor, readOption } from "./maybe-accessor"; import { SNAPSHOT_KEYS, type Snapshottable, snapshotKeys, } from "./observability/snapshot"; /** * Why a popup's open state changed. * * Compounds may extend this with their own reason literals — e.g. * `"trigger-hover"` for Tooltip. The common reasons are surfaced here so * `createPopupCore` can emit a meaningful default without knowing the * per-compound vocabulary. * * - `"trigger-press"` — user activated the trigger (click / Enter / Space). * - `"imperative"` — `popup.setOpen()` called with no originating DOM event. * - `"escape-key"` — Escape key dismissed the popup. * - `"outside-press"` — pointer press outside the popup dismissed it. */ export type PopupCoreChangeReason = | "trigger-press" | "imperative" | "escape-key" | "outside-press"; /** * Options accepted by {@link createPopupCore}. Every reactive field is * `MaybeAccessor` — pass an accessor to keep the option live. */ export interface CreatePopupCoreOptions< Reason extends string = PopupCoreChangeReason, > { /** * Open state. Consumer-owned signal — the primitive reads through the * accessor and does NOT keep an internal copy. Writes happen via * `onOpenChange` (the consumer's responsibility to update their signal). */ open: MaybeAccessor; /** * Whether interaction is suppressed. * @default false */ disabled?: MaybeAccessor; /** * Pre-change callback. Called via `runWithOwner(null, ...)` so consumer * signal writes inside the handler don't trigger SIGNAL_WRITE_IN_OWNED_SCOPE. */ onOpenChange?: (open: boolean, event: Event, reason: Reason) => void; } /** * Public API returned by {@link createPopupCore}. */ export interface PopupCoreApi { /** Reactive accessor for the current open state. */ open: Accessor; /** Whether interaction is suppressed. */ disabled: Accessor; /** * Imperative open setter. Fires `onOpenChange(next, event, reason)` via * `runWithOwner(null, ...)` to escape the reactive owner. */ setOpen: (next: boolean, event?: Event, reason?: Reason) => void; /** * Flip open (no-op when disabled). Same event/reason contract as setOpen. */ toggle: (event?: Event, reason?: Reason) => void; /** * Context provider. Wrap a subtree to expose the core api via * `usePopupCore()`. */ Provider: (props: { children: JSX.Element }) => JSX.Element; } const PopupCoreContext = createContext | null>(null); /** * Read the nearest {@link PopupCoreApi} from a ``. * Throws unless `optional` is true. */ export function usePopupCore(): PopupCoreApi; export function usePopupCore(optional: true): PopupCoreApi | null; export function usePopupCore(optional?: boolean): PopupCoreApi | null { const ctx = useContext(PopupCoreContext); if (ctx == null && !optional) { throw new Error( "Base UI: usePopupCore() must be called inside a .", ); } return ctx; } /** * Shared headless primitive for popup compounds (Tooltip, Popover, Dialog, * PreviewCard, AlertDialog, Drawer). Owns: * * - `open` memo — reads through the consumer's accessor. * - `disabled` memo — guards setOpen / toggle. * - `setOpen` / `toggle` — pure event-emitters that fire `onOpenChange` * via `runWithOwner(null, ...)`. * - `Provider` — optional context provider. * * DOM machinery (floating-ui, portal, focus trap, scroll lock, animation) * lives in the per-compound `` wrappers that compose this. */ export function createPopupCore( options: CreatePopupCoreOptions, ): PopupCoreApi { const open = createMemo(() => Boolean(readOption(options.open)), { name: "popup.open", }); const disabled = createMemo( () => Boolean(readOption(options.disabled)), { name: "popup.disabled" }, ); const setOpen = ( next: boolean, event?: Event, reason: Reason = "imperative" as Reason, ): void => { const ev = event ?? new Event("openchange", { cancelable: true }); // Escape the reactive owner so consumer signal writes inside // onOpenChange don't trip SIGNAL_WRITE_IN_OWNED_SCOPE. runWithOwner(null, () => options.onOpenChange?.(next, ev, reason)); }; const toggle = (event?: Event, reason?: Reason): void => { if (untrack(disabled)) return; setOpen(!untrack(open), event, reason); }; const api: PopupCoreApi = { open, disabled, setOpen, toggle, Provider: (props) => ( }> {props.children} ), }; (api as PopupCoreApi & Snapshottable)[SNAPSHOT_KEYS] = POPUP_CORE_SNAPSHOT_KEYS; return api; } const POPUP_CORE_SNAPSHOT_KEYS = snapshotKeys("open", "disabled");