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.
339 lines
12 KiB
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();
|
|
}
|
|
}
|