import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createSignal, omit } from "solid-js"; import type { BaseUIChangeEventDetails, REASONS, } from "../../internals/change-event-details"; import { createControlledState } from "../../internals/create-controlled-state"; import { type MaybeAccessor, readOption } from "../../internals/maybe-accessor"; import { mergeRootProps } from "../../internals/merge-root-props"; import { createMountTransition } from "../../internals/use-mount-transition/use-mount-transition"; import type { TransitionStatus } from "../../internals/use-transition-status/use-transition-status"; import { type CollapsibleApi, type CollapsibleChangeReason, createCollapsible, } from "../create-collapsible.tsx"; /** * Change-reason namespace for `Collapsible.Root` (shipped public type). * Mirrors Base UI React: explicit trigger presses use {@link REASONS.triggerPress}; * indirect flows (e.g. `beforematch` from `hiddenUntilFound`) use {@link REASONS.none}. * * NOTE: the new primitive's `onOpenChange(next, event, reason)` signature uses * the `CollapsibleChangeReason` taxonomy directly. This `BaseUIChangeEventDetails`- * shaped type is kept as a public re-export for downstream consumers that * imported it from `quoin` before the primitive rewrite. */ export type CollapsibleRootChangeEventReason = | typeof REASONS.triggerPress | typeof REASONS.none; export type CollapsibleRootChangeEventDetails = BaseUIChangeEventDetails; /** * Public state surface — kept as a shipped type for any consumer projecting * accordion/collapsible state through their own `data-*` mappings. Stage-01c * cleanup: the wrapper itself no longer reads `transitionStatus` (it's now * owned by the panel's local `createMountTransition`); this type still * surfaces it for `accordionItemStateAttributesMapping` and any consumer-side * mappings that rely on the same shape. */ export interface CollapsibleRootState { open: boolean; disabled: boolean; transitionStatus: TransitionStatus; } /** * Props for {@link CollapsibleRoot}. * * Stage-01 spike rewire: the wrapper is a structural adapter over the * `createCollapsible` primitive (axes A + P). * * - `onOpenChange` is `(open, event, reason)` — cancel via * `event.preventDefault()` (axis C). Reason is the third argument: * `"trigger-press"` / `"beforematch"` / `"imperative"`. * - `render={(p) => ...}` is replaced by `as="..."` (axis G). * - Optional `api` prop lets a consumer pre-build the primitive (so they can * `createEffect(c.open, ...)` outside the wrapper) and pass it back in. */ export interface CollapsibleRootProps { /** * Pre-built primitive. When supplied, the root uses it instead of * calling `createCollapsible(...)` itself. The pre-built primitive * already owns its own `open` accessor; consumers can ignore the * `open` / `onOpenChange` props on this component when supplying one. */ api?: CollapsibleApi; /** * Controlled open state. When provided (resolved value not * `undefined`), the consumer owns the state — pair with * {@link onOpenChange}. When omitted, the collapsible manages its own * open state (seeded from {@link defaultOpen}). Ignored when `api` is * supplied (the pre-built primitive owns the accessor). */ open?: MaybeAccessor; /** * Initial open state when uncontrolled. Read once at mount. * @default false */ defaultOpen?: MaybeAccessor; /** Whether interaction is suppressed. */ disabled?: MaybeAccessor; /** * Pre-change callback. Fired before the uncontrolled state write; * calling `event.preventDefault()` vetoes the write so an * uncontrolled collapsible keeps its current state (the quoin * equivalent of React Base UI's `eventDetails.cancel()`). Controlled * consumers simply don't update their signal. Third argument is the * {@link CollapsibleChangeReason}. */ onOpenChange?: ( open: boolean, event: Event, reason: CollapsibleChangeReason, ) => void; /** Keep the panel mounted while closed (passes through to the primitive). */ keepMounted?: MaybeAccessor; /** Browser-page-search expansion (passes through to the primitive). */ hiddenUntilFound?: MaybeAccessor; /** Tag/component to render. Defaults to `"div"`. */ as?: ValidComponent; children?: JSX.Element; [key: string]: unknown; } /** * Groups all parts of the collapsible. Renders a `
` (or `as` override). * * Implementation notes: * * - Calls `createCollapsible` (or reuses `props.api`) once at setup. * - Provides the `CollapsibleContext` (consumed by ``, * ``, the directives, and any inline `useCollapsible()`). * - Owns its own `createMountTransition(c.open)` instance bound to the root * element. Emits `data-open` / `data-closed` plus `data-starting-style` / * `data-ending-style` so consumers can drive root-level CSS transitions * independently from the panel's own lifecycle. The Panel wrapper has its * own instance for panel-anchored animation — the two are independent * (each tracks the CSS animations on its own element). */ export function CollapsibleRoot(props: CollapsibleRootProps): JSX.Element { // Controlled / uncontrolled open state (Base UI `open` / `defaultOpen` // contract): the `open` prop wins while its resolved value is defined; // otherwise an internal signal (seeded once from `defaultOpen`) backs // the state. Unused when a pre-built `api` is supplied. const [resolvedOpen, setUncontrolledOpen] = createControlledState({ controlled: () => { const controlled = readOption(props.open); return controlled === undefined ? undefined : Boolean(controlled); }, default: () => readOption(props.defaultOpen), fallback: false, name: "", state: "open", }); const c = props.api ?? createCollapsible({ open: resolvedOpen, disabled: (): boolean => Boolean(readOption(props.disabled)), onOpenChange: (next, event, reason) => { // Mirror React's `useCollapsibleRoot.handleTrigger`: fire the // consumer callback first, honor the veto (quoin's cancel // channel is `event.preventDefault()`), then write the // uncontrolled state (ignored for reads while the `open` // prop is controlled — the consumer's signal write is the // source of truth). props.onOpenChange?.(next, event, reason); if (event.defaultPrevented) return; setUncontrolledOpen(next); }, keepMounted: (): boolean => Boolean(readOption(props.keepMounted)), hiddenUntilFound: (): boolean => Boolean(readOption(props.hiddenUntilFound)), }); // Root-level animation lifecycle. Bound to the root element so // `data-starting-style` / `data-ending-style` track the root's own CSS // transitions (independent from the panel's instance). const [rootEl, setRootEl] = createSignal(null); const m = createMountTransition(c.open, { panelElement: rootEl, keepMounted: true, }); // Root-level reactive data attrs. const rootInternal = { ref: setRootEl, get "data-open"(): "" | undefined { return c.open() ? "" : undefined; }, get "data-closed"(): "" | undefined { return c.open() ? undefined : ""; }, get "data-starting-style"(): "" | undefined { return m.transitionStatus() === "starting" ? "" : undefined; }, get "data-ending-style"(): "" | undefined { return m.transitionStatus() === "ending" ? "" : undefined; }, }; // Strip our own keys before spreading user HTML attributes through. const userHtmlProps = omit( props, "api", "as", "children", "defaultOpen", "disabled", "hiddenUntilFound", "keepMounted", "onOpenChange", "open", ) as Record; // Bind the merged slot bag to a `const` BEFORE the JSX spread. This is // hydration-critical, not cosmetic: when the Solid SSR compiler sees a spread // of an inline *call expression* (``) it // wraps it in a lazy thunk `() => mergeRootProps(...)`. The server's // `dynamic()` runtime resolves that thunk on a DIFFERENT child-id allocation // path than the client does, which (a) drops every merged attribute from the // SSR markup and (b) shifts the hydration keys by one slot — throwing // "Hydration Mismatch". Spreading a pre-bound *variable* (`{...merged}`) makes // the compiler pass the object straight through, exactly as `` // does (which is why Tabs already hydrated cleanly). See // `merge-root-props.ts` for the getter-bag shape. const merged = mergeRootProps(rootInternal, userHtmlProps); const Tag = dynamic(() => props.as ?? "div"); return ( {props.children} ); }