import type { JSX } from "@solidjs/web"; import { type Accessor, createRenderEffect, createSignal, untrack, } from "solid-js"; import { type MaybeAccessor, readOption } from "../maybe-accessor"; import { SNAPSHOT_KEYS, type Snapshottable, snapshotKeys, } from "../observability/snapshot"; import { resetLayoutStyles } from "../reset-layout-styles"; import { useAnimationsFinished } from "../use-animations-finished"; import { type TransitionStatus, useTransitionStatus, } from "../use-transition-status/use-transition-status"; /** Props that `mergePanelProps` injects onto the panel element. */ export interface MountPanelProps { ref: (el: HTMLElement | null) => void; hidden: true | "until-found" | undefined; style: JSX.CSSProperties; } /** * Options for {@link createMountTransition}. */ export interface MountTransitionOptions { /** * When provided the helper reads but does not own the panel element signal; * `panelRef` becomes a no-op. When absent the helper creates its own internal * element signal and `panelRef` must be attached to the panel DOM element. */ panelElement?: Accessor; /** * Keep the panel element in the DOM even when closed. * * **What this does NOT control: exit animations.** Exit animations are * honored automatically regardless of `keepMounted`. The state machine * always waits for CSS animations/transitions on the panel element to * settle before flipping `mounted` → `false`. The two values differ * only in **what happens after the exit animation finishes**: * * - `keepMounted: false` (default): panel unmounts via ``. * `mounted` flips to `false`, `shouldRender` flips to `false`, * the element leaves the DOM. Re-mounts on next open. * - `keepMounted: true`: panel stays in the DOM with `hidden={true}`. * `mounted` flips to `false` but `shouldRender` stays `true`. * The element is hidden via the `hidden` attribute instead of * removed. * * Either way, the consumer's CSS exit transition (keyed off `data-state` / * `data-ending-style` / measured CSS vars) plays to completion first. * @default false */ keepMounted?: MaybeAccessor; /** * Use `hidden="until-found"` so the browser's built-in find-in-page can * reveal the panel. Overrides `keepMounted` (forces it true) — the * element must remain in the DOM for the browser's find-in-page to * locate matched content. * @default false */ hiddenUntilFound?: MaybeAccessor; /** * Called when the `beforematch` event fires on the panel element * (only relevant when `hiddenUntilFound` is true). The caller is * responsible for setting open=true in this callback. */ onBeforematch?: (event: Event) => void; /** * Prefix for the dimension CSS custom properties emitted in * `mergePanelProps().style`. Resolves to `--{prefix}-height` and * `--{prefix}-width`. Defaults to `"collapsible-panel"` for * backwards-compatibility with the existing Collapsible CSS contract; * new compounds (Tooltip, Dialog, Popover, Menu) should pass their * own (e.g. `"tooltip-popup"`) so consumer CSS stays * namespace-clean. The leading `--` is added by the helper. * * @default "collapsible-panel" */ cssVarPrefix?: string; } /** * Return value of {@link createMountTransition}. */ export interface MountTransition { /** True while the panel is mounted (during enter and while open, + exit animation). */ mounted: Accessor; /** * True when the panel element should be in the DOM. * `mounted || keepMounted || hiddenUntilFound` */ shouldRender: Accessor; /** Enter/exit animation phase. */ transitionStatus: Accessor; /** Measured height (undefined = auto / settled). */ height: Accessor; /** Measured width (undefined = auto / settled). */ width: Accessor; /** * The `hidden` attribute value to apply to the panel element: * - `true` → hidden (panel is closed and fully unmounted or keepMounted) * - `"until-found"` → hiddenUntilFound mode * - `undefined` → no hidden attribute (panel is open) */ hidden: Accessor; /** * Ref callback to bind to the panel element. No-op when `panelElement` was * passed in via options (caller owns the ref). */ panelRef: (el: HTMLElement | null) => void; /** * Merges mount-lifecycle props (ref, hidden, CSS vars) into a user prop bag. * Designed for easy composition inside a wrapper component's JSX spread. * * Returns a shallow-merged object whose getters stay reactive, so Solid's * compiled spread sees live values on each flush. * * @example * ```tsx *
* ``` */ mergePanelProps:

(userProps?: P) => P & MountPanelProps; } /** * Reusable animation-lifecycle primitive for any animated compound surface * (Collapsible, Dialog, Popover, Tooltip, etc.). * * Extracts: * - `useTransitionStatus` state machine (mounted / transitionStatus) * - Panel measurement (scrollHeight / scrollWidth → CSS vars) * - Exit-animation wait via `useAnimationsFinished` * - `beforematch` listener for `hiddenUntilFound` * * The caller owns the `open` signal; this helper wraps the rest. * * ### Design deviation from recommended signature * * The recommended interface included `setMounted` as an escape hatch. This * implementation omits it from the public surface — `setMounted` is an * internal implementation detail of the exit-animation path and should not be * called directly by callers. If callers need it, they should re-implement the * exit-animation logic. This keeps the surface smaller. * * `mergePanelProps` replaces the proposed `mergePanelProps

` function — same * idea, slightly renamed for clarity. The returned object uses getters so * Solid's compiled spread stays reactive after the merge. * * ### Solid 2 notes * * - `panelElement` signal uses `ownedWrite: true` so the ref callback (unowned * scope) can write — same pattern as `renderElement`'s host-capture signal. * - Dimension signals use `ownedWrite: true` for the same reason (writes from * animation-finished callbacks). * - Effects are `createRenderEffect` so dimension writes are synchronous with * DOM updates (no paint flash). */ export function createMountTransition( open: Accessor, options: MountTransitionOptions = {}, ): MountTransition { // Enter settle must not drop `data-starting-style` until measured height is // committed — otherwise `--*-height` stays `0px`/`auto` and open is instant. const [enterMeasureReady, setEnterMeasureReady] = createSignal(false, { ownedWrite: true, name: "mount-transition.enterMeasureReady", }); createRenderEffect( () => open(), (isOpen, wasOpen) => { // Only reset on false → true. Resetting on every `open === true` // re-apply would clear `enterMeasureReady` right after measure commits. if (isOpen && wasOpen !== true) { setEnterMeasureReady(false); } }, ); // ── Transition state machine ────────────────────────────────────────────── // idle-state enabled (stays at "idle" while open) + deferred ending (fires // on next frame after close). Matches the Collapsible root's call signature. const { mounted, transitionStatus, setMounted } = useTransitionStatus( open, () => true, () => true, enterMeasureReady, ); // ── Panel element reference ─────────────────────────────────────────────── // If the caller passes in a pre-owned accessor we read from it; otherwise // we create our own signal. const [ownedPanelElement, setOwnedPanelElement] = createSignal(null, { ownedWrite: true, name: "mount-transition.panelElement", }); const panelElement: Accessor = options.panelElement ?? ownedPanelElement; const panelRef = options.panelElement ? (_el: HTMLElement | null): void => {} : (el: HTMLElement | null): void => { setOwnedPanelElement(el); }; // ── Dimensions ─────────────────────────────────────────────────────────── const [height, setHeight] = createSignal(undefined, { ownedWrite: true, name: "mount-transition.height", }); const [width, setWidth] = createSignal(undefined, { ownedWrite: true, name: "mount-transition.width", }); // Open settle mirrors React `useOpenChangeComplete({ open: true })`: // wait for `[data-starting-style]` removal before probing animations. // Close uses `false` for `waitForStartingStyleRemoved` and // `treatAbortedAsFinished: false` — matches `useCollapsiblePanel`. const runOnceOpenAnimationsFinish = useAnimationsFinished(panelElement, true); const runOnceCloseAnimationsFinish = useAnimationsFinished( panelElement, false, false, ); function measure(el: HTMLElement): void { setHeight(el.scrollHeight); setWidth(el.scrollWidth); } /** * Measure expanded size for the enter animation. Returns `true` when a * non-zero size was committed (or dimensions were already settled). * Mirrors React `useCollapsiblePanel` open pass: `resetLayoutStyles` then * `getDimensions`. */ function measureForOpen(el: HTMLElement): boolean { // Drop stale `0` from the previous close cycle so the first open frame // does not lock `--*-height` to `0px` before we read scroll sizes. if (untrack(height) === 0) { setHeight(undefined); setWidth(undefined); } const restoreLayout = resetLayoutStyles(el); const nextHeight = el.scrollHeight; const nextWidth = el.scrollWidth; restoreLayout(); if (nextHeight === 0 && nextWidth === 0) { return false; } setHeight(nextHeight); setWidth(nextWidth); // Commit the starting frame (height: 0 via `data-starting-style`) // before `useTransitionStatus` advances to idle on the next rAF(s). void el.offsetHeight; setEnterMeasureReady(true); return true; } // Enter: measure natural size during `"starting"` so the CSS var transitions // from 0 → natural height. Mirrors React `useCollapsiblePanel` which only // measures while `transitionStatus === 'starting'`. Measuring on every // `open+mounted` tick would fight the post-animation clear to `auto`. // // Solid mounts conditional panel DOM in the same commit as `starting`; the // first synchronous read often sees `scrollHeight === 0` because the ref // fires before the subtree is connected (especially under ``). By the // end of the flush the element IS connected, so a microtask retry lands in // the SAME frame as the click — matching React's layout-effect timing. This // keeps simultaneous enter/exit transitions in lockstep (accordion item // switch: total height stays constant). rAF retries remain as fallback for // layouts that genuinely need a frame. createRenderEffect( () => ({ isOpen: open(), isMounted: mounted(), status: transitionStatus(), el: panelElement(), }), ({ isOpen, isMounted, status, el }) => { if (!el || !isOpen || !isMounted || status !== "starting") { return undefined; } if (measureForOpen(el)) { return undefined; } let cancelled = false; let outerId = 0; let innerId = 0; queueMicrotask(() => { if (cancelled || measureForOpen(el)) { return; } outerId = requestAnimationFrame(() => { if (measureForOpen(el)) { return; } innerId = requestAnimationFrame(() => { if (!measureForOpen(el)) { // Empty panel or layout never reported a size — still // advance so we do not stick in `starting` forever. setEnterMeasureReady(true); } }); }); }); return () => { cancelled = true; if (outerId !== 0) cancelAnimationFrame(outerId); if (innerId !== 0) cancelAnimationFrame(innerId); }; }, ); // Exit: capture current size as starting frame, wait for anim, then collapse. // When no panel element is registered, runOnceAnimationsFinish returns early // (null-element check in useAnimationsFinished) without calling the callback. // In that case setMounted(false) never fires and the panel stays "mounted" // indefinitely — which is acceptable because there's nothing to animate or // hide. Callers that need unmount without a panel element should pass an open // signal that never reaches false while the panel is absent. createRenderEffect( () => ({ isOpen: open(), el: panelElement() }), ({ isOpen, el }) => { if (!el) return; if (!isOpen && untrack(mounted)) { measure(el); runOnceCloseAnimationsFinish(() => { setHeight(0); setWidth(0); setMounted(false); }); } }, ); // Once open and enter animations settle, clear dimensions so the panel // reverts to auto sizing (resize-aware). Mirrors React // `useOpenChangeComplete({ enabled: open && mounted && status === 'idle' })`. // Clearing while still in `"starting"` drops the measured CSS var before the // browser paints the 0 → natural-height transition. createRenderEffect( () => ({ isOpen: open(), isMounted: mounted(), status: transitionStatus(), }), ({ isOpen, isMounted, status }) => { if (!isOpen || !isMounted || status !== "idle") { return undefined; } const el = untrack(panelElement); if (!el) return undefined; const abortController = new AbortController(); runOnceOpenAnimationsFinish(() => { if (!open()) return; setHeight(undefined); setWidth(undefined); }, abortController.signal); return () => { abortController.abort(); }; }, ); // ── beforematch listener ────────────────────────────────────────────────── createRenderEffect( () => ({ el: panelElement(), enabled: readOption(options.hiddenUntilFound ?? false), }), ({ el, enabled }) => { if (!el || !enabled) return undefined; const handler = (event: Event): void => { options.onBeforematch?.(event); }; el.addEventListener("beforematch", handler); return () => { el.removeEventListener("beforematch", handler); }; }, ); // ── Derived accessors ───────────────────────────────────────────────────── const keepMounted = (): boolean => readOption(options.keepMounted ?? false); const hiddenUntilFound = (): boolean => readOption(options.hiddenUntilFound ?? false); const shouldRender = (): boolean => mounted() || keepMounted() || hiddenUntilFound(); /** * `hidden` semantics: * - Open panel → `undefined` (no attribute) * - hiddenUntilFound + closed → `"until-found"` (special browser value) * - keepMounted + closed (after anim) → `true` * - Unmounted (`!shouldRender`) → irrelevant; element isn't in the DOM */ const hidden = (): true | "until-found" | undefined => { if (open()) return undefined; if (!mounted()) { // Fully settled closed state if (hiddenUntilFound()) return "until-found"; if (keepMounted()) return true; // Element should not render at all; this value won't be used. return true; } // Still mounted (exit animation in progress) return undefined; }; // ── CSS var style bag ──────────────────────────────────────────────────── const prefix = options.cssVarPrefix ?? "collapsible-panel"; const heightVar = `--${prefix}-height`; const widthVar = `--${prefix}-width`; function dimensionStyle(): JSX.CSSProperties { const h = height(); const w = width(); return { [heightVar]: h === undefined ? "auto" : `${h}px`, [widthVar]: w === undefined ? "auto" : `${w}px`, }; } function mergePanelProps

( userProps?: P, ): P & MountPanelProps { const base = (userProps ?? {}) as P; return Object.defineProperties(Object.assign({}, base), { ref: { get: () => panelRef, enumerable: true, configurable: true }, hidden: { get: hidden, enumerable: true, configurable: true }, style: { get: dimensionStyle, enumerable: true, configurable: true }, }) as P & MountPanelProps; } const api: MountTransition = { mounted, shouldRender, transitionStatus, height, width, hidden, panelRef, mergePanelProps, }; (api as MountTransition & Snapshottable)[SNAPSHOT_KEYS] = MOUNT_TRANSITION_SNAPSHOT_KEYS; return api; } const MOUNT_TRANSITION_SNAPSHOT_KEYS = snapshotKeys( "mounted", "shouldRender", "transitionStatus", "height", "width", "hidden", );