import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createRenderEffect, omit, Show } from "solid-js"; import { createMountTransition } from "../../internals/use-mount-transition/use-mount-transition"; import { useCollapsible } from "../create-collapsible.tsx"; /** * Props for {@link CollapsiblePanel}. * * Stage-01c rewire: this wrapper is now a structural adapter over * `useCollapsible()` + `createMountTransition` (axes A + G + P). `render={}` * is gone — use `as=` to swap the underlying tag/component. */ export interface CollapsiblePanelProps extends JSX.HTMLAttributes { /** * Tag/component to render. Defaults to `"div"`. Replaces the * pre-spike `render` prop (axis G). */ as?: ValidComponent; /** * When `true`, the browser's built-in page search can find and expand the * panel's contents. The panel stays in the DOM even when closed, and uses * `hidden="until-found"` to hide without removing. * * Overrides {@link keepMounted} (forces it to `true`). * @default false */ hiddenUntilFound?: boolean | undefined; /** * Whether to keep the element in the DOM after the exit animation * completes. Ignored when {@link hiddenUntilFound} is `true` * (which forces it to `true`). * * **Exit animations are honored regardless of this prop** — the panel * always plays its CSS exit transition before either unmounting (when * `keepMounted={false}`) or flipping to `hidden={true}` (when * `keepMounted={true}`). This prop only controls what happens *after* * that transition finishes: * * - `false` (default) → panel unmounts; re-mounts on next open. * - `true` → panel stays in the DOM with `hidden={true}`. * * @default false */ keepMounted?: boolean | undefined; } /** * The collapsible panel. Renders a `
` element (or `as` override). * * Solid 2 notes: * * - Reads the collapsible API via `useCollapsible()` — works both under * `` and inside accordion items (both provide the new * `CollapsibleContext`). * - `createMountTransition` owns the full animation lifecycle (mount state, * enter/exit measurement, CSS vars, `beforematch` listener, `hidden` attr). * - The panel id is registered into `c.setPanelId()` at setup, so the trigger's * `aria-controls` binding reflects the correct id. * * ### `c.panel()` shape decision — option (b) * * `c.panel()` still emits `hidden`, `style`, and `ref` (the full slot bag). * `m.mergePanelProps(userProps)` spreads AFTER `c.panel()`, so * `createMountTransition`'s `hidden`, `style`, and `ref` win on collision. * This keeps inline-headless working (`
`) without requiring * callers to compose `createMountTransition` separately. The cost is that * `c.panel()` emits keys that become unreachable when this wrapper is used — * those keys are dormant rather than harmful. * * Clean-up option (a) (slim `c.panel()` to drop lifecycle keys) is left for a * later stage when inline-headless ergonomics have been confirmed across all * compounds. */ export function CollapsiblePanel(props: CollapsiblePanelProps): JSX.Element { const c = useCollapsible(); // The panel renders with the SHARED `c.panelId()` (seeded with the // primitive's `createUniqueId()` fallback) — the SAME value the trigger // reads for `aria-controls`. This keeps trigger↔panel linked even under SSR, // where the trigger serializes before the panel runs (matches React, which // renders the panel with the context `panelId` and only registers an // explicit `id` prop via effect). An explicit `id` prop is registered into // the shared signal so it takes effect once the write propagates // (client-side / post-hydration). createRenderEffect( () => (typeof props.id === "string" ? (props.id as string) : undefined), (idProp) => { if (!idProp) return undefined; c.setPanelId(idProp); return undefined; }, ); const m = createMountTransition(c.open, { keepMounted: () => Boolean(props.keepMounted), hiddenUntilFound: () => Boolean(props.hiddenUntilFound), onBeforematch: (event) => { c.setOpen(true, event); }, }); // Strip our config keys before passing user props into the slot. // `id` is stripped so it doesn't collide with the explicit id prop below. const userProps = omit(props, "as", "hiddenUntilFound", "id", "keepMounted"); // Animation-state data attrs come from the local `createMountTransition` // — the primitive's `c.panel()` no longer carries them (stage-01c // cleanup chain). const lifecycleAttrs = { get "data-starting-style"(): "" | undefined { return m.transitionStatus() === "starting" ? "" : undefined; }, get "data-ending-style"(): "" | undefined { return m.transitionStatus() === "ending" ? "" : undefined; }, }; // Bind the merged slot bag to a `const` BEFORE the JSX spread — hydration- // critical. Spreading an inline call (`{...c.panel(m.mergePanelProps(...))}`) // makes the Solid SSR compiler wrap it in a lazy thunk, whose server-side // `dynamic()` element resolution diverges from the client's child-id allocation and // throws "Hydration Mismatch". A pre-bound variable spread (`{...panelProps}`) // is passed straight through. See `collapsible-root.tsx` for the full // explanation and `` for the same `const merged` shape. const panelProps = c.panel(m.mergePanelProps(userProps)); const Tag = dynamic(() => props.as ?? "div"); return ( ); }