You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/soma/layers/presence.svelte.ts

339 lines
12 KiB

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<TransitionStatus>(undefined);
private runId = 0;
private frameIds = new Set<number>();
/** 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<void> {
const runId = this.pendingRunId;
return new Promise<void>((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<void> | 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<void> | 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<unknown>[] = 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();
}
}

Powered by TurnKey Linux.