import { type Active, type ActiveProps } from '$libs/reactive'; import { watch } from 'runed'; import type { ActiveDom } from '$adom'; import type { EngineMotion } from '$motion'; import type { PresenceGroup, PresenceMember, PresencePhase, PresenceRole } from './presence-group'; // ── Types ──────────────────────────────────────────────────────────────────── export interface PresenceOptions extends ActiveProps<{ open: boolean; ref: HTMLElement | null }> { dom: ActiveDom; /** Called after open/close animation completes. Receives the current open state. */ onComplete?: (open: boolean) => void; /** When false, skips animation waiting — unmounts immediately on close. @default true */ enabled?: boolean; /** * The motion runtime (`uix.motion` / `soma.motion`). When present, the cycle * runs `motion.run(node, phase)` and awaits its `finished` ALONGSIDE * `getAnimations()` — so a JS preset (a `spring`, invisible to * `getAnimations()`) still gates the unmount. CSS presets settle immediately * (their generated CSS is awaited via `getAnimations()`). The component passes * `this.soma.motion`; the engine reads the node's `data-animation-style`. */ motion?: EngineMotion; /** * Presence-coordination group (RFC: eidos/MOTION_SERVICE_RFC.md §7). When * present, this surface CEDES its enter timing to the group: it mounts and * waits to be released instead of self-driving (the group sequences releases * per the morfo's `animation.children.{enter,exit}` and aggregates `finished`). * Absent (the common case) ⇒ this `Presence` is an island, exactly as before. */ group?: PresenceGroup; /** * Role within the group. The parent/coordinating surface is `'owner'`; nested * surfaces are `'child'` (the default). Only the owner triggers the group's * coordinated enter; children mount and wait to be released. */ groupRole?: PresenceRole; } export type TransitionStatus = 'starting' | 'ending' | undefined; // ── Presence ───────────────────────────────────────────────────────────────── /** * Animation-aware presence manager. * * Lifecycle: * * OPENING: * open=true → shouldRender=true + transitionStatus='starting' * → next rAF: transitionStatus=undefined (removes data-starting-style, triggers CSS transition) * → getAnimations().finished → onComplete(true) * * CLOSING: * open=false → transitionStatus='ending' (element stays in DOM!) * → getAnimations().finished * → shouldRender=false + transitionStatus=undefined → onComplete(false) */ export class Presence implements PresenceMember { readonly opts: PresenceOptions; shouldRender = $state(false); transitionStatus = $state(undefined); private runId = 0; private frameIds = new Set(); /** Run id captured when a grouped surface mounts and waits to be released (RFC §7). */ private pendingRunId = 0; /** Deregister callback from the coordination group, if any. */ private deregister: (() => void) | undefined; constructor(opts: PresenceOptions) { this.opts = opts; this.shouldRender = opts.open.current; // RFC §7.1: cede to a coordination group if one is in scope. Register now // (synchronously, in the provider's init) and deregister on teardown so the // group's member set tracks mount/unmount. No group ⇒ island (below). if (opts.group) { this.deregister = opts.group.register(this); $effect(() => () => this.deregister?.()); } watch( () => opts.open.current, (isOpen) => { if (isOpen) { this.handleOpen(); } else { this.handleClose(); } } ); } /** Whether the element should be in the DOM. Use in template: `{#if presence.isPresent}` */ readonly isPresent = $derived.by(() => this.shouldRender); /** Data attrs to spread onto the element for CSS transition support. */ readonly transitionAttrs = $derived.by(() => { if (this.transitionStatus === 'starting') return { 'data-starting-style': '' } as const; if (this.transitionStatus === 'ending') return { 'data-ending-style': '' } as const; return {} as const; }); // ── Open ───────────────────────────────────────────────────────────────── private handleOpen() { // Grouped surface: cede the release timing to the group (RFC §7). The // island path below is unchanged. if (this.opts.group) { this.handleOpenGrouped(); return; } this.cleanup(); this.shouldRender = true; this.transitionStatus = 'starting'; const runId = ++this.runId; // Remove data-starting-style on next frame to trigger CSS transition this.requestFrame(() => { if (runId !== this.runId) return; this.transitionStatus = undefined; // Start any JS-driven motion, then wait for CSS + JS to complete. const extra = this.startMotion('enter'); this.waitForAnimations(runId, extra, () => { this.opts.onComplete?.(true); }); }); } // ── Close ──────────────────────────────────────────────────────────────── private handleClose() { // Grouped surface: cede the exit to the group, which coordinates the tree's // exit and unmounts after (DOM retention §8.1). The island path is unchanged. if (this.opts.group) { this.handleCloseGrouped(); return; } this.cleanup(); const enabled = this.opts.enabled ?? true; if (!enabled) { this.shouldRender = false; this.transitionStatus = undefined; this.opts.onComplete?.(false); return; } this.transitionStatus = 'ending'; const runId = ++this.runId; // Start any JS-driven exit motion, then wait for CSS + JS, then unmount. const extra = this.startMotion('exit'); this.waitForAnimations(runId, extra, () => { if (runId !== this.runId) return; this.shouldRender = false; this.transitionStatus = undefined; this.opts.onComplete?.(false); }); } // ── Grouped lifecycle (RFC §7) — only active when `opts.group` is set ────── /** * Grouped ENTER: mount immediately, then WAIT to be released by the group. * Only the owner triggers the group's coordinated enter; children mount and * stay in `data-starting-style` until the group calls `release('enter')`. */ private handleOpenGrouped() { this.cleanup(); this.shouldRender = true; this.transitionStatus = 'starting'; this.pendingRunId = ++this.runId; if (this.role === 'owner') this.opts.group?.requestEnter(); } /** * Grouped EXIT: cede to the group (RFC §8.1). Mark `ending` and WAIT — the * group runs the tree's exit in `when` order and calls `unmount()` here only * after the whole exit settles (DOM retention). Only the owner triggers it. */ private handleCloseGrouped() { this.cleanup(); const enabled = this.opts.enabled ?? true; if (!enabled) { this.unmount(); return; } // Stay in the OPEN visual state (no `data-ending-style` yet) until the group // RELEASES this surface. That is what makes `when: 'after'` exit the children // before the owner — and the owner stay visibly intact while its children // leave — instead of everyone's exit animation firing at once. `release` // stamps `ending` when our turn comes (RFC §8.1). this.pendingRunId = ++this.runId; if (this.role === 'owner') this.opts.group?.requestExit(); } /** This surface's role within its group (`PresenceMember`). */ get role(): PresenceRole { return this.opts.groupRole ?? 'child'; } /** * Release this surface's motion for the phase — the group calls this in * coordinated order (`PresenceMember`). Removes `data-starting-style` on enter, * starts the JS motion, and resolves when this surface's own animation finishes * (CSS via `getAnimations()` + any JS `finished`). The visual stagger is CSS * (eidos); soma only awaits. */ release(phase: PresencePhase): Promise { const runId = this.pendingRunId; return new Promise((resolve) => { this.requestFrame(() => { if (runId !== this.runId) { resolve(); return; } // Enter: drop `starting` → the CSS transition plays. Exit: stamp // `ending` NOW (not at close) so the surface holds its open state until // the group reaches it, giving `when: 'after'` its sequencing (§8.1). if (phase === 'enter') this.transitionStatus = undefined; else this.transitionStatus = 'ending'; const extra = this.startMotion(phase); this.waitForAnimations(runId, extra, () => { if (phase === 'enter') this.opts.onComplete?.(true); resolve(); }); }); }); } /** * Tear down after the group's coordinated exit settles (`PresenceMember`, RFC * §8.1). Mirrors the island unmount: drop the surface and notify. The group * calls this once the whole tree's exit has aggregated — never before, so the * owner's DOM (and any retained subtree) survives the children's exit. */ unmount(): void { this.shouldRender = false; this.transitionStatus = undefined; this.opts.onComplete?.(false); } /** * Cancel in-flight motion (interruption — RFC §8.3, M4). `cleanup()` bumps the * runId so the in-flight async tail (frames, `waitForAnimations`) becomes a * no-op, and `motion.cancel(node)` stops any JS-driven run so a reversal does * not stack a second animation on the node (CSS presets are untracked → no-op; * the reversed CSS transition continues from its current value). The group calls * this on every member when a reversal supersedes the in-flight phase. */ cancel(): void { this.cleanup(); const node = this.opts.ref.current; if (node) this.opts.motion?.cancel(node); } // ── Animation waiting ──────────────────────────────────────────────────── /** Start the JS-driven motion for this phase, if a `motion` engine is wired. */ private startMotion(phase: 'enter' | 'exit'): Promise | undefined { const node = this.opts.ref.current; if (!node) return undefined; return this.opts.motion?.run(node, phase)?.finished; } private waitForAnimations( runId: number, extra: Promise | undefined, onDone: () => void ) { // getAnimations() needs a frame to see the active animations this.requestFrame(() => { if (runId !== this.runId) return; const node = this.opts.ref.current; if (!node) { onDone(); return; } // CSS animations (via getAnimations) + any JS-driven `finished`. A spring // is invisible to getAnimations(), so its `finished` joins the wait here. const finishers: Promise[] = node.getAnimations().map((a) => a.finished); if (extra) finishers.push(extra); if (finishers.length === 0) { onDone(); return; } Promise.all(finishers) .then(() => { if (runId !== this.runId) return; onDone(); }) .catch(() => { // An awaited animation was CANCELLED (its `.finished` rejects). // This is not only rapid toggling (stale runId → no-op below): a // visual exit animation can be interrupted WITHOUT a state change — // e.g. sema unstamps the `data-event-*` that drives the close // `dismiss-fade` when its hold ends BEFORE the animation's own // duration, cancelling it. A cancelled exit MUST still finalize the // lifecycle; otherwise a closed overlay stays mounted forever and // its FocusScope traps focus (no block can be focused to type). // Stale runs are filtered by the runId guard, exactly as in `then`. if (runId !== this.runId) return; onDone(); }); }); } // ── Cleanup ────────────────────────────────────────────────────────────── private requestFrame(callback: () => void) { const frame = this.opts.dom.requestFrame(() => { this.frameIds.delete(frame); callback(); }, this.opts.ref.current); this.frameIds.add(frame); } private cleanup() { this.runId++; for (const frame of this.frameIds) { this.opts.dom.cancelFrame(frame, this.opts.ref.current); } this.frameIds.clear(); } }