import type { JSX } from "@solidjs/web"; import { type Accessor, createContext, createMemo, createSignal, createUniqueId, runWithOwner, untrack, useContext, } from "solid-js"; import { makeSlotRefDirective } from "../internals/make-slot-ref-directive"; import { type MaybeAccessor, readOption } from "../internals/maybe-accessor"; import { mergeRootProps } from "../internals/merge-root-props"; import { SNAPSHOT_KEYS, type Snapshottable, snapshotKeys, } from "../internals/observability/snapshot"; /** * Options for {@link createCollapsible}. * * All reactive inputs accept `MaybeAccessor` so a consumer can flip * `open` / `disabled` / `keepMounted` post-construction without * re-creating the primitive. * * **Solid-native shape (2026-04-30).** No internal state. `open` is * required and the consumer always owns the signal. The primitive is * a pure function from `(open, onOpenChange) → api`. Migration from * pre-2026-04-30: `createCollapsible({ defaultOpen: false })` becomes * `const [open, setOpen] = createSignal(false); createCollapsible({ open, onOpenChange: setOpen })`. * See `solidify.md` §6 for rationale. */ export interface CreateCollapsibleOptions { /** * 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 in response). */ open: MaybeAccessor; /** * Whether interaction is suppressed. * @default false */ disabled?: MaybeAccessor; /** * Pre-change callback. Called synchronously before any internal state * write with `(next, event, reason)`. The consumer can call * `event.preventDefault()` to veto the transition. * * **Pre/post asymmetry — important.** This callback is pre-change ONLY. * Reading `c.open()` inside this handler returns the OLD value (the * setter's microtask write hasn't flushed yet). Don't drive post-change * side effects from here. For post-change observation, use: * ```ts * createEffect(c.open, (next) => { ... }); * ``` * * **Event contract.** The `event` argument is one of two shapes: * * | Trigger | `event` shape | * |---|---| * | DOM (click via `c.trigger()`/directive, keydown via `useButton`) | The live `MouseEvent` / `KeyboardEvent`. | * | Programmatic `c.setOpen(next)` / `c.toggle()` with no event arg | A synthesized `new Event("openchange", { cancelable: true })`. | * | Programmatic `c.setOpen(next, event)` with explicit event | The supplied event, used as-is. | * * Cancellation flows by reference: any layer in the chain that calls * `event.preventDefault()` is observed by every later layer. */ onOpenChange?: ( open: boolean, event: Event, reason: CollapsibleChangeReason, ) => void; /** * Keep the panel in the DOM even while closed. Read by inline-headless * consumers via `c.panel()` (currently dormant under the structural * `` wrapper, which composes its own * `createMountTransition`). * @default false */ keepMounted?: MaybeAccessor; /** * Use `hidden="until-found"` so the browser's built-in page search can * find and expand the panel. Forces {@link keepMounted}. Same dormancy * note as `keepMounted` for the structural wrapper path. * @default false */ hiddenUntilFound?: MaybeAccessor; } /** * Why the open state changed. Emitted on the third arg of * `onOpenChange(next, event, reason)` and accepted by * `c.setOpen(next, event?, reason?)` / `c.toggle(event?, reason?)`. * * - `"trigger-press"` — click or keyboard-activated (Enter/Space) trigger. * - `"beforematch"` — browser find-in-page expanded the panel (when * `hiddenUntilFound` is enabled, via the wrapper's `createMountTransition`). * - `"imperative"` — programmatic change with no originating DOM event, * or caller-supplied reason not covered by the above. * * Outer compounds that embed Collapsible (Accordion, etc.) receive this * as-is. */ export type CollapsibleChangeReason = | "trigger-press" | "beforematch" | "imperative"; /** * Public API returned by {@link createCollapsible}. Flat accessors and * setters at the root; no `state` snapshot blob (axis D). * * Stage-01c cleanup-chain (post AccordionPanel migration): `mounted`, * `transitionStatus`, `shouldRenderPanel`, and `_setMounted` are no longer * on this api — animation lifecycle lives in `createMountTransition`, * composed by each structural panel wrapper. */ export interface CollapsibleApi { /** Reactive accessor for the current open state. */ open: Accessor; /** Whether interaction is suppressed. */ disabled: Accessor; /** * Stable id for the panel element. Generated at primitive setup * (`createUniqueId()` is stable for the owner's lifetime). Inline- * headless consumers reading the panel slot get this on * `c.panel().id`; outer compounds that embed this primitive * (Accordion, etc.) read it directly to wire `aria-labelledby` / * `aria-controls` cross-component without going through the slot bag. */ panelId: Accessor; /** * Override the panel id. Called by `` / * `` when the consumer provides an explicit `id` * prop, so the trigger's `aria-controls` binding reflects the * consumer-supplied value. */ setPanelId: (id: string) => void; /** * Flip `open` (no-op if `disabled`). Same `event?` / `reason?` contract * as {@link setOpen}. */ toggle: (event?: Event, reason?: CollapsibleChangeReason) => void; /** * Imperative open setter. Fires `onOpenChange(next, event, reason)` first; * if the consumer calls `event.preventDefault()` the internal write is * skipped. */ setOpen: ( next: boolean, event?: Event, reason?: CollapsibleChangeReason, ) => void; /** * Trigger slot — returns a getter-bag of HTML attributes ready for * spreading onto a ` * ``` */ triggerRef: () => (el: HTMLButtonElement | null) => void; /** Directive form of {@link panel}. */ panelRef: () => (el: HTMLElement | null) => void; /** * Context provider. Wrap a subtree to make the API discoverable via * `useCollapsible()` without prop-drilling. */ Provider: (props: { children: JSX.Element }) => JSX.Element; } /** * Shape of attributes emitted by `c.trigger()`. */ export interface CollapsibleTriggerSlotProps { type: "button"; "aria-expanded": "true" | "false"; "aria-controls": string | undefined; "data-panel-open": "" | undefined; "data-disabled": "" | undefined; onClick: (event: MouseEvent) => void; } export interface CollapsiblePanelSlotProps { id: string | undefined; "data-state": "open" | "closed"; "data-open": "" | undefined; "data-closed": "" | undefined; } const CollapsibleContext = createContext(null); /** * Read the nearest {@link CollapsibleApi} provided by `c.Provider`. * Throws if used outside a Provider. */ export function useCollapsible(): CollapsibleApi { const ctx = useContext(CollapsibleContext); if (ctx == null) { throw new Error( "Base UI: useCollapsible() must be called inside a .", ); } return ctx; } /** * Solid 2.0-native compound primitive for collapsible disclosure. One call * owns logical state (open/disabled, panel id, change callbacks). Animation * lifecycle (mount, exit, measurement, beforematch) is NOT in this primitive * — wrappers compose `createMountTransition` over `c.open`. */ export function createCollapsible( options: CreateCollapsibleOptions, ): CollapsibleApi { // `open` reads through the consumer's accessor — no internal signal, // no controlled-or-internal branching. Memoized so dependents see the // same identity within a tracking scope and so dev-time observability // (`name`) lines up with the rest of the api's signals. const open = createMemo(() => Boolean(readOption(options.open)), { name: "collapsible.open", }); const disabled = createMemo( () => Boolean(readOption(options.disabled)), { name: "collapsible.disabled" }, ); // `setOpen` / `toggle` are pure event-emitters. They fire // `onOpenChange(next, event, reason)`; the consumer is responsible // for updating their open-signal in response. There is no internal // state to write — `event.preventDefault()` is now a no-op for the // primitive (the consumer's handler is the only writer). const setOpen = ( next: boolean, event?: Event, reason: CollapsibleChangeReason = "imperative", ): void => { const ev = event ?? new Event("openchange", { cancelable: true }); // Use runWithOwner(null, ...) to escape the current reactive owner so // that consumer signal writes inside onOpenChange don't trigger // SIGNAL_WRITE_IN_OWNED_SCOPE. The callback is an event-emitter — it // exits the reactive graph intentionally (same pattern as DOM event // handlers). untrack() alone doesn't clear the owner context. runWithOwner(null, () => options.onOpenChange?.(next, ev, reason)); }; const toggle = ( event?: Event, reason: CollapsibleChangeReason = "imperative", ): void => { if (untrack(disabled)) return; setOpen(!untrack(open), event, reason); }; // Panel-id signal. Seeded with a pre-generated fallback so the trigger's // `aria-controls` getter has a non-undefined initial value (Solid 2's DOM // spread filters undefined keys out of `ownKeys` at first spread and never // re-enters them — see `test/internals/merge-props-undefined-attr.test.tsx`). const fallbackPanelId = createUniqueId(); const [panelId, setPanelId] = createSignal(fallbackPanelId, { ownedWrite: true, name: "collapsible.panelId", }); // Internal getter-bag for the trigger slot. const triggerInternal = { type: "button" as const, get "aria-expanded"() { return open() ? ("true" as const) : ("false" as const); }, get "aria-controls"() { // Match React (`open ? panelId : undefined`): omit `aria-controls` // entirely while closed so a closed collapsible never points at a // panel that isn't rendered — notably under SSR, where a closed // panel has no element in the DOM. When open, `shouldRender` // guarantees the panel is mounted, so the id resolves to a real // element. `mergeRootProps` keeps this key reactive across spreads // even though it can start `undefined`, so the old `""`-seed // undefined-attr workaround is no longer needed. return open() ? panelId() : undefined; }, get "data-panel-open"() { return open() ? "" : undefined; }, get "data-disabled"() { return disabled() ? "" : undefined; }, onClick(event: MouseEvent) { if (disabled()) return; setOpen(!untrack(open), event, "trigger-press"); }, }; // Internal getter-bag for the panel slot. Only logical state attrs — // animation lifecycle (`hidden`, `style`, `data-{starting,ending}-style`, // ref) is the wrapper's `createMountTransition` instance's job. const panelInternal = { get id() { return panelId(); }, get "data-state"() { return open() ? ("open" as const) : ("closed" as const); }, get "data-open"() { return open() ? "" : undefined; }, get "data-closed"() { return open() ? undefined : ""; }, }; const trigger = (userProps?: U) => mergeRootProps(triggerInternal, userProps) as CollapsibleTriggerSlotProps & (U extends object ? U : object); const panel = (userProps?: U) => mergeRootProps(panelInternal, userProps) as CollapsiblePanelSlotProps & (U extends object ? U : object); const triggerRef = (): ((el: HTMLButtonElement | null) => void) => makeSlotRefDirective(() => trigger()); const panelRef = (): ((el: HTMLElement | null) => void) => makeSlotRefDirective(() => panel()); const api: CollapsibleApi = { open, disabled, panelId, setPanelId, toggle, setOpen, trigger, panel, triggerRef, panelRef, Provider: (props) => ( {props.children} ), }; (api as CollapsibleApi & Snapshottable)[SNAPSHOT_KEYS] = COLLAPSIBLE_SNAPSHOT_KEYS; return api; } const COLLAPSIBLE_SNAPSHOT_KEYS = snapshotKeys( "open", "disabled", "panelId", ); /** * Shared directive-factory pattern used by both `c.triggerRef()` / * `c.panelRef()` (above) and the top-level `collapsibleTrigger(c)` / * `collapsiblePanel(c)` exports. */