Servicio de motion cross-layer: morfo declara superficies animables +
coordinación; soma coordina presencia/lifecycle; eidos posee lo visual;
arts/motion ejecuta por-nodo. RFC en eidos/MOTION_SERVICE_RFC.md.
- M1 — contrato MorfoPart.animation (types/schema/compile); children
como { enter?, exit? }.
- M2 — PresenceGroup (soma/layers/presence-group.ts), rune-free;
Presence.group descubre el coordinador por context.
- M3 — exit con retención de DOM (§8.1).
- M4 — interrupción/reversa (§8.3): token de generación + motion.cancel
en flip + toHandle resuelve finished en cancel (sin AbortError suelto).
- M5 — prop `animation` enrutada a las parts surface:true del morfo
compilado (routeAnimation); Panel/Item emiten data-animation-style.
- M6 — stagger auto-derivado del orden de registro (--motion-stagger-*,
inversa en exit) + presets coordinados en la librería de eidos
(MotionConfig.coordinated; cascade-slide/-fade/-scale) que reaccionan
a data-starting/ending-style, NO a data-state.
Helper Coordination (soma/layers/coordination.ts) extraído y validado
por DOS consumidores reales: Reveal (raíz virtual + Panel owner) y Rail
(raíz=owner, together). Demos en /temas/animations/{reveal,rail,
presence-group}.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
parent
29729e27f5
commit
48b183672a
@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createEngineMotion } from './engine-motion';
|
||||||
|
import type { MotionDom, MotionHandle, StatePreset } from './types';
|
||||||
|
|
||||||
|
// Minimal MotionDom. The JS presets below return a MotionHandle directly, so the
|
||||||
|
// frame scheduler is never exercised; `dom` only needs to be non-null so `runJs`
|
||||||
|
// proceeds instead of short-circuiting to the settled handle.
|
||||||
|
const dom: MotionDom = {
|
||||||
|
requestFrame: (cb) => (cb(0), 0),
|
||||||
|
cancelFrame: () => {},
|
||||||
|
prefersReducedMotion: { matches: false }
|
||||||
|
};
|
||||||
|
|
||||||
|
// A node that names a preset via `data-animation-style` — all `run` reads from it.
|
||||||
|
const el = (name: string) =>
|
||||||
|
({
|
||||||
|
getAttribute: (k: string) => (k === 'data-animation-style' ? name : null)
|
||||||
|
}) as unknown as HTMLElement;
|
||||||
|
|
||||||
|
describe('EngineMotion — JS handle tracking + cancel semantics', () => {
|
||||||
|
it('pending() stays unsettled until the JS preset finished resolves', async () => {
|
||||||
|
let resolve!: () => void;
|
||||||
|
const preset: StatePreset = {
|
||||||
|
driver: 'spring',
|
||||||
|
enter: (): MotionHandle => ({
|
||||||
|
finished: new Promise<void>((r) => (resolve = r)),
|
||||||
|
cancel() {}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
const motion = createEngineMotion({ dom, presets: { gated: preset } });
|
||||||
|
const node = el('gated');
|
||||||
|
|
||||||
|
const handle = motion.run(node, 'enter');
|
||||||
|
let settled = false;
|
||||||
|
void handle.finished.then(() => (settled = true));
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
await handle.finished;
|
||||||
|
expect(settled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a cancelled (rejecting) handle resolves finished + pending — no unhandled rejection (RFC §8.3)', async () => {
|
||||||
|
// Simulate a WAAPI `Animation.cancel()` rejecting its `finished` with
|
||||||
|
// AbortError. `toHandle` must swallow it so a reversal does not leak an
|
||||||
|
// unhandled rejection through `track`/`pending`.
|
||||||
|
const preset: StatePreset = {
|
||||||
|
driver: 'waapi',
|
||||||
|
enter: (): MotionHandle => ({ finished: Promise.reject(new Error('cancelled')), cancel() {} })
|
||||||
|
};
|
||||||
|
const motion = createEngineMotion({ dom, presets: { rejecting: preset } });
|
||||||
|
const node = el('rejecting');
|
||||||
|
|
||||||
|
const handle = motion.run(node, 'enter');
|
||||||
|
await expect(handle.finished).resolves.toBeUndefined();
|
||||||
|
await expect(motion.pending(node)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel(el) stops the tracked JS motion handle', () => {
|
||||||
|
let cancelled = false;
|
||||||
|
const preset: StatePreset = {
|
||||||
|
driver: 'spring',
|
||||||
|
enter: (): MotionHandle => ({
|
||||||
|
finished: new Promise<void>(() => {}),
|
||||||
|
cancel() {
|
||||||
|
cancelled = true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
const motion = createEngineMotion({ dom, presets: { c: preset } });
|
||||||
|
const node = el('c');
|
||||||
|
|
||||||
|
motion.run(node, 'enter');
|
||||||
|
motion.cancel(node);
|
||||||
|
expect(cancelled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
import type { Morfo } from '../types';
|
||||||
|
import { v } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rail — a horizontal strip of items that CASCADE in/out, parent-controlled via
|
||||||
|
* `open` (no trigger, no events). The SECOND consumer of the coordinated-motion
|
||||||
|
* helper (`soma/layers/coordination.ts`), built to prove it is reusable across a
|
||||||
|
* DIFFERENT shape than `Reveal`:
|
||||||
|
*
|
||||||
|
* - the ROOT (`Provider`) IS the owner surface (vs Reveal's virtual root + separate
|
||||||
|
* Panel owner);
|
||||||
|
* - the relation is `{ enter: 'together', exit: 'together' }` (all parallel, the
|
||||||
|
* visual stagger is CSS only) — vs Reveal's `{ before, after }` bracket;
|
||||||
|
* - NO sema events — the parent owns `open`, the component is pure visual
|
||||||
|
* coordination. So `scope` is just `['soma']` (no events ⇒ no 'sema' needed) and
|
||||||
|
* there is no `channels: []`/`expression` to silence (nothing emits).
|
||||||
|
*
|
||||||
|
* RFC: eidos/MOTION_SERVICE_RFC.md.
|
||||||
|
*/
|
||||||
|
export const railMorfo = {
|
||||||
|
name: 'Rail',
|
||||||
|
kebab: 'rail',
|
||||||
|
scope: ['soma'],
|
||||||
|
texts: {
|
||||||
|
label: '#?components.rail.label|Rail'
|
||||||
|
},
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
name: 'Provider',
|
||||||
|
kebab: 'provider',
|
||||||
|
archetype: 'provider',
|
||||||
|
kind: 'public',
|
||||||
|
defaultElement: 'div',
|
||||||
|
role: 'list',
|
||||||
|
optional: false,
|
||||||
|
// ROOT + owner animable surface. `together` = rail + items enter/exit in
|
||||||
|
// parallel; the visual stagger is CSS only (data-starting/ending-style).
|
||||||
|
animation: { surface: true, children: { enter: 'together', exit: 'together' } },
|
||||||
|
data: [],
|
||||||
|
aria: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Item',
|
||||||
|
kebab: 'item',
|
||||||
|
archetype: 'item',
|
||||||
|
kind: 'public',
|
||||||
|
defaultElement: 'div',
|
||||||
|
role: 'listitem',
|
||||||
|
optional: false,
|
||||||
|
animation: { surface: true },
|
||||||
|
data: [],
|
||||||
|
aria: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} as const satisfies Morfo;
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
import type { Morfo } from '../types';
|
||||||
|
import { v } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reveal — disclosure list (Trigger + Panel + Items). The FIRST real consumer of
|
||||||
|
* the motion-coordination contract (RFC: eidos/MOTION_SERVICE_RFC.md). The Panel
|
||||||
|
* is an animable OWNER surface that coordinates its Item CHILD surfaces:
|
||||||
|
*
|
||||||
|
* panel.animation.children = { enter: 'before', exit: 'after' }
|
||||||
|
*
|
||||||
|
* — the panel appears, THEN the items cascade in; on close the items leave first
|
||||||
|
* while the panel RETAINS its DOM (exit-heavy, §8.1), then the panel goes. soma
|
||||||
|
* reads these compiled `animation.children` to build the `PresenceGroup` (it is
|
||||||
|
* the contract that drives the coordination, not hand-wiring). The visual stagger
|
||||||
|
* is CSS (the demo / eidos) — soma only sequences WHEN each surface enters/exits.
|
||||||
|
*
|
||||||
|
* Disclosure semantics (Trigger aria-expanded/aria-controls, Panel region) mirror
|
||||||
|
* Collapsible; the only addition is the parent↔children animation coordination.
|
||||||
|
* `emerge` family (transitional, no intent) for open/close.
|
||||||
|
*/
|
||||||
|
export const revealMorfo = {
|
||||||
|
name: 'Reveal',
|
||||||
|
kebab: 'reveal',
|
||||||
|
// 'sema' because the morfo declares events (open/close) — they participate in the
|
||||||
|
// sema layer even though `channels: []` silences their perceptual surface.
|
||||||
|
scope: ['soma', 'sema'],
|
||||||
|
// The reveal's perception IS the coordinated cascade (eidos transitions on the
|
||||||
|
// Presence's data-starting/ending-style) — NOT a generic sema signature. So the
|
||||||
|
// open/close events carry `channels: []` (no perceptual surface) and expression is
|
||||||
|
// 'none': otherwise the generic emerge `present-rise`/`dismiss-fade` signature
|
||||||
|
// fires on the event and FIGHTS the coordinated motion (a real bug the demo hit).
|
||||||
|
// Doctrine: a coordinated-motion component silences the generic sema signal.
|
||||||
|
expression: 'none',
|
||||||
|
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/',
|
||||||
|
texts: {
|
||||||
|
label: '#?components.reveal.label|Reveal'
|
||||||
|
},
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
name: 'open',
|
||||||
|
semantic: {
|
||||||
|
family: 'emerge',
|
||||||
|
verb: 'open',
|
||||||
|
target: v.partRef('panel'),
|
||||||
|
// State is set in the soma handler → `post`, so a (future) perceptual
|
||||||
|
// hold never blocks the functional open (CLAUDE.md sequencing doctrine).
|
||||||
|
sequence: 'post',
|
||||||
|
// No perceptual surface — the coordinated cascade IS the feedback.
|
||||||
|
channels: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'close',
|
||||||
|
semantic: {
|
||||||
|
family: 'emerge',
|
||||||
|
verb: 'close',
|
||||||
|
target: v.partRef('panel'),
|
||||||
|
sequence: 'post',
|
||||||
|
channels: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
name: 'Provider',
|
||||||
|
kebab: 'provider',
|
||||||
|
archetype: 'provider',
|
||||||
|
kind: 'virtual',
|
||||||
|
defaultElement: 'none',
|
||||||
|
optional: false,
|
||||||
|
data: [],
|
||||||
|
aria: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Trigger',
|
||||||
|
kebab: 'trigger',
|
||||||
|
archetype: 'trigger',
|
||||||
|
kind: 'public',
|
||||||
|
defaultElement: 'button',
|
||||||
|
role: 'button',
|
||||||
|
optional: false,
|
||||||
|
states: ['open', 'closed'],
|
||||||
|
data: [{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }],
|
||||||
|
aria: [
|
||||||
|
{ attr: 'type', value: v.literal('button') },
|
||||||
|
{ attr: 'aria-expanded', value: v.stateRef('open') },
|
||||||
|
{
|
||||||
|
attr: 'aria-controls',
|
||||||
|
value: v.partRef('panel'),
|
||||||
|
condition: { when: 'part-present', part: 'panel' },
|
||||||
|
severity: 'recommended'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Panel',
|
||||||
|
kebab: 'panel',
|
||||||
|
archetype: 'content',
|
||||||
|
kind: 'public',
|
||||||
|
defaultElement: 'div',
|
||||||
|
role: 'region',
|
||||||
|
optional: false,
|
||||||
|
states: ['open', 'closed'],
|
||||||
|
// OWNER animable surface — coordinates its Item children. `before` enter
|
||||||
|
// = panel in, then items cascade; `after` exit = items out first while the
|
||||||
|
// panel retains its DOM, then the panel (§8.1 exit-heavy). soma derives the
|
||||||
|
// PresenceGroup `when` from exactly this.
|
||||||
|
animation: { surface: true, children: { enter: 'before', exit: 'after' } },
|
||||||
|
data: [{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }],
|
||||||
|
aria: [
|
||||||
|
{
|
||||||
|
attr: 'aria-labelledby',
|
||||||
|
value: v.partRef('trigger'),
|
||||||
|
condition: { when: 'part-present', part: 'trigger' },
|
||||||
|
severity: 'recommended'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Item',
|
||||||
|
kebab: 'item',
|
||||||
|
archetype: 'item',
|
||||||
|
kind: 'public',
|
||||||
|
defaultElement: 'div',
|
||||||
|
optional: false,
|
||||||
|
// CHILD animable surface — the group releases it per the panel's
|
||||||
|
// `children` relation. No state/aria of its own; it is a coordinated leaf.
|
||||||
|
animation: { surface: true },
|
||||||
|
data: [],
|
||||||
|
aria: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} as const satisfies Morfo;
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { writableActive } from '$libs/reactive';
|
||||||
|
import { mergeProps } from '../../../props';
|
||||||
|
import { RailItemProvider } from '../rail-provider.svelte';
|
||||||
|
import type { RailItemProps } from '../types';
|
||||||
|
|
||||||
|
let { ref = $bindable(null), children, ...restProps }: RailItemProps = $props();
|
||||||
|
|
||||||
|
// CHILD surface — same shape as Reveal's item: `data-rail-item` marker, a child
|
||||||
|
// Presence (via the shared Coordination), and the auto `--motion-stagger-index`.
|
||||||
|
const state = RailItemProvider.create({
|
||||||
|
ref: writableActive(
|
||||||
|
() => ref,
|
||||||
|
(v) => (ref = v)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedProps = $derived(mergeProps(restProps, { 'data-rail-item': '' }));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if state.isPresent}
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
{...mergedProps}
|
||||||
|
{...state.transitionAttrs}
|
||||||
|
data-animation-style={state.animationStyle}
|
||||||
|
style:--motion-stagger-index={state.staggerIndex}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { readableActive, writableActive } from '$libs/reactive';
|
||||||
|
import { mergeProps } from '../../../props';
|
||||||
|
import { createId } from '$active-uix/id';
|
||||||
|
import { RailProvider } from '../rail-provider.svelte';
|
||||||
|
import type { RailProps } from '../types';
|
||||||
|
|
||||||
|
const uid = $props.id();
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
id = createId(uid, 'rail'),
|
||||||
|
open = $bindable(false),
|
||||||
|
onOpenChange = () => {},
|
||||||
|
animation,
|
||||||
|
children,
|
||||||
|
child,
|
||||||
|
...restProps
|
||||||
|
}: RailProps = $props();
|
||||||
|
|
||||||
|
// Root AND owner surface: the rail element coordinates its Item children. The ref
|
||||||
|
// is attached by the runtime part (in `state.props`); `state.isPresent` /
|
||||||
|
// `transitionAttrs` come from the owner Presence.
|
||||||
|
const state = RailProvider.create({
|
||||||
|
id: readableActive(() => id),
|
||||||
|
ref: writableActive(
|
||||||
|
() => ref,
|
||||||
|
(v) => (ref = v)
|
||||||
|
),
|
||||||
|
open: writableActive(
|
||||||
|
() => open,
|
||||||
|
(v) => {
|
||||||
|
open = v;
|
||||||
|
onOpenChange(v);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
animation: readableActive(() => animation)
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if state.isPresent}
|
||||||
|
{#if child}
|
||||||
|
{@render child({
|
||||||
|
props: {
|
||||||
|
...mergedProps,
|
||||||
|
...state.transitionAttrs,
|
||||||
|
'data-animation-style': state.animationStyle
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
{:else}
|
||||||
|
<div {...mergedProps} {...state.transitionAttrs} data-animation-style={state.animationStyle}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
export { default as Provider } from './components/rail.svelte';
|
||||||
|
export { default as Item } from './components/rail-item.svelte';
|
||||||
|
|
||||||
|
export type { RailProps as ProviderProps, RailItemProps as ItemProps } from './types';
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from './exports';
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createActiveDom } from '$adom';
|
||||||
|
import { state } from '$libs/reactive';
|
||||||
|
import { compileMorfo, type Morfo } from '$uix/morfo';
|
||||||
|
import { validateMorfo } from '$uix/morfo/schema';
|
||||||
|
import { railMorfo } from '$uix/morfo/components/rail';
|
||||||
|
import { createEngineMotion } from '$motion';
|
||||||
|
import { Soma } from '$soma/core/soma.svelte';
|
||||||
|
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
|
||||||
|
import { PresenceGroup } from '$soma/layers/presence-group';
|
||||||
|
|
||||||
|
import { RailItemProvider, RailProvider } from './rail-provider.svelte';
|
||||||
|
|
||||||
|
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
|
||||||
|
let result!: T;
|
||||||
|
const cleanup = $effect.root(() => {
|
||||||
|
result = fn();
|
||||||
|
});
|
||||||
|
return { result, cleanup };
|
||||||
|
}
|
||||||
|
|
||||||
|
function installSomaHarness() {
|
||||||
|
const dom = createActiveDom();
|
||||||
|
const motion = createEngineMotion();
|
||||||
|
const soma = {
|
||||||
|
dom,
|
||||||
|
motion,
|
||||||
|
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
||||||
|
createSomaRuntime(morfo, { dom, translate: (key) => key, ...sources })
|
||||||
|
} as unknown as Soma;
|
||||||
|
|
||||||
|
vi.spyOn(Soma, 'require').mockReturnValue(soma);
|
||||||
|
vi.spyOn(RailProvider.ctx, 'set').mockImplementation((value) => value as never);
|
||||||
|
vi.spyOn(PresenceGroup.ctx, 'set').mockImplementation((value) => value as never);
|
||||||
|
|
||||||
|
return { dom };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Rail — second consumer of the coordination helper (RFC: MOTION_SERVICE_RFC.md §7)', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the morfo is valid: the root Provider is the owner surface, Item is a child', () => {
|
||||||
|
expect(() => validateMorfo(railMorfo)).not.toThrow();
|
||||||
|
|
||||||
|
const compiled = compileMorfo(railMorfo);
|
||||||
|
// Provider (root) declares `children` → it is the owner; `together` relation.
|
||||||
|
expect(compiled.parts.byKebab.get('provider')?.animation?.children).toEqual({
|
||||||
|
enter: 'together',
|
||||||
|
exit: 'together'
|
||||||
|
});
|
||||||
|
expect(compiled.parts.byKebab.get('item')?.animation).toEqual({
|
||||||
|
surface: true,
|
||||||
|
children: undefined
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the root registers as owner; items as children; stagger + routing via the shared helper', () => {
|
||||||
|
installSomaHarness();
|
||||||
|
const railEl = document.createElement('div');
|
||||||
|
const itemEls = [
|
||||||
|
document.createElement('div'),
|
||||||
|
document.createElement('div'),
|
||||||
|
document.createElement('div')
|
||||||
|
];
|
||||||
|
|
||||||
|
const { result, cleanup } = withEffectRoot(() => {
|
||||||
|
const provider = RailProvider.create({
|
||||||
|
id: state('rail'),
|
||||||
|
ref: state<HTMLElement | null>(railEl),
|
||||||
|
open: state(true),
|
||||||
|
animation: state<string | undefined>('cascade-slide')
|
||||||
|
});
|
||||||
|
vi.spyOn(RailProvider, 'require').mockReturnValue(provider);
|
||||||
|
const items = itemEls.map((el) =>
|
||||||
|
RailItemProvider.create({ ref: state<HTMLElement | null>(el) })
|
||||||
|
);
|
||||||
|
return { provider, items };
|
||||||
|
});
|
||||||
|
|
||||||
|
// The root IS the owner → owner + 3 children = 4 members in ONE group.
|
||||||
|
expect(result.provider.coord.group.size).toBe(4);
|
||||||
|
// Items auto-derive their stagger (owner excluded from the child index).
|
||||||
|
expect(result.items.map((it) => it.staggerIndex)).toEqual([0, 1, 2]);
|
||||||
|
// Routing reaches both declared surfaces.
|
||||||
|
expect(result.provider.coord.routeAnimation('provider')).toBe('cascade-slide');
|
||||||
|
expect(result.provider.coord.routeAnimation('item')).toBe('cascade-slide');
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,121 @@
|
|||||||
|
import { context } from '../../provider';
|
||||||
|
import { type Active, type State, type StateProps } from '$libs/reactive';
|
||||||
|
import { Soma } from '../../core/soma.svelte';
|
||||||
|
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
|
||||||
|
import { Coordination, type CoordinatedSurface } from '../../layers/coordination';
|
||||||
|
import { railMorfo } from '../../../morfo/components/rail';
|
||||||
|
|
||||||
|
// ── Provider (root + owner surface) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RailOpts extends StateProps<{ open: boolean }> {
|
||||||
|
id: Active<string>;
|
||||||
|
ref: State<HTMLElement | null>;
|
||||||
|
/** DX preset name routed to the declared surfaces (RFC §5). `undefined` = none. */
|
||||||
|
animation: Active<string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rail root — unlike Reveal's virtual root, the Provider IS the owner surface: it
|
||||||
|
* holds the runtime, the open state, the `Coordination`, AND registers itself as the
|
||||||
|
* group's `owner` (the rail element coordinates its Item children). Parent-controlled
|
||||||
|
* via `open`; no trigger, no events.
|
||||||
|
*/
|
||||||
|
export class RailProvider {
|
||||||
|
static readonly ctx = context<RailProvider>('Rail');
|
||||||
|
static get(): RailProvider | undefined {
|
||||||
|
return this.ctx.getOr(undefined) as RailProvider | undefined;
|
||||||
|
}
|
||||||
|
static require(): RailProvider {
|
||||||
|
return this.ctx.get();
|
||||||
|
}
|
||||||
|
static create(opts: RailOpts) {
|
||||||
|
return new RailProvider(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly opts: RailOpts;
|
||||||
|
readonly soma: Soma;
|
||||||
|
readonly runtime: SomaRuntime;
|
||||||
|
readonly runtimePart: SomaRuntimePart;
|
||||||
|
readonly coord: Coordination;
|
||||||
|
/** OWNER member — the rail element; its presence triggers the coordinated enter/exit. */
|
||||||
|
readonly surface: CoordinatedSurface;
|
||||||
|
|
||||||
|
private constructor(opts: RailOpts) {
|
||||||
|
this.opts = opts;
|
||||||
|
RailProvider.ctx.set(this);
|
||||||
|
|
||||||
|
this.soma = Soma.require();
|
||||||
|
this.runtime = this.soma.runtime(railMorfo, {});
|
||||||
|
this.runtimePart = this.runtime.part('provider', {
|
||||||
|
id: opts.id,
|
||||||
|
ref: opts.ref,
|
||||||
|
owner: this,
|
||||||
|
context: RailProvider.ctx,
|
||||||
|
syncAttrs: true
|
||||||
|
});
|
||||||
|
|
||||||
|
this.coord = new Coordination({
|
||||||
|
morfo: railMorfo,
|
||||||
|
dom: this.soma.dom,
|
||||||
|
motion: this.soma.motion,
|
||||||
|
open: opts.open,
|
||||||
|
animation: opts.animation
|
||||||
|
});
|
||||||
|
this.surface = this.coord.surface('provider', opts.ref, 'owner');
|
||||||
|
}
|
||||||
|
|
||||||
|
get isPresent(): boolean {
|
||||||
|
return this.surface.isPresent;
|
||||||
|
}
|
||||||
|
|
||||||
|
get transitionAttrs() {
|
||||||
|
return this.surface.transitionAttrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
get animationStyle(): string | undefined {
|
||||||
|
return this.surface.animationStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly props = $derived.by(() => ({
|
||||||
|
...this.runtimePart.props
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Item (child surface) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RailItemOpts {
|
||||||
|
ref: State<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RailItemProvider {
|
||||||
|
static create(opts: RailItemOpts) {
|
||||||
|
return new RailItemProvider(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly opts: RailItemOpts;
|
||||||
|
readonly provider: RailProvider;
|
||||||
|
/** CHILD member — mounts and waits for the group to release it (cascade). */
|
||||||
|
readonly surface: CoordinatedSurface;
|
||||||
|
|
||||||
|
private constructor(opts: RailItemOpts) {
|
||||||
|
this.opts = opts;
|
||||||
|
this.provider = RailProvider.require();
|
||||||
|
this.surface = this.provider.coord.surface('item', opts.ref, 'child');
|
||||||
|
}
|
||||||
|
|
||||||
|
get isPresent(): boolean {
|
||||||
|
return this.surface.isPresent;
|
||||||
|
}
|
||||||
|
|
||||||
|
get transitionAttrs() {
|
||||||
|
return this.surface.transitionAttrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
get animationStyle(): string | undefined {
|
||||||
|
return this.surface.animationStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
get staggerIndex(): number {
|
||||||
|
return this.surface.staggerIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import type { WithChild, Without, OnChangeFn } from '../../types';
|
||||||
|
import type { PrimitiveDivAttributes } from '../../types';
|
||||||
|
|
||||||
|
export type RailProps = WithChild<{
|
||||||
|
/** Unique identifier. Auto-generated if omitted. */
|
||||||
|
id?: string;
|
||||||
|
/** Whether the rail is open. Bindable. @default false */
|
||||||
|
open?: boolean;
|
||||||
|
/** Callback fired when the open state changes. */
|
||||||
|
onOpenChange?: OnChangeFn<boolean>;
|
||||||
|
/** Motion preset name, routed to the declared surfaces (RFC §5). */
|
||||||
|
animation?: string;
|
||||||
|
}> &
|
||||||
|
Without<PrimitiveDivAttributes, { open: boolean }>;
|
||||||
|
|
||||||
|
export type RailItemProps = Without<PrimitiveDivAttributes, Record<never, never>> & {
|
||||||
|
/** Element reference. Bindable — the child Presence animates this node. */
|
||||||
|
ref?: HTMLElement | null;
|
||||||
|
/** Inline style (e.g. `--motion-stagger-index`, set automatically by the item). */
|
||||||
|
style?: string | Record<string, unknown> | null;
|
||||||
|
children?: Snippet;
|
||||||
|
};
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { writableActive } from '$libs/reactive';
|
||||||
|
import { mergeProps } from '../../../props';
|
||||||
|
import { RevealItemProvider } from '../reveal-provider.svelte';
|
||||||
|
import type { RevealItemProps } from '../types';
|
||||||
|
|
||||||
|
let { ref = $bindable(null), children, ...restProps }: RevealItemProps = $props();
|
||||||
|
|
||||||
|
// CHILD surface. No runtime part — it is a coordinated leaf; `data-reveal-item`
|
||||||
|
// (the morfo's part marker) + the child Presence are all it needs. `bind:this`
|
||||||
|
// feeds the ref the Presence animates; mergeProps normalises `style` for the div.
|
||||||
|
const state = RevealItemProvider.create({
|
||||||
|
ref: writableActive(
|
||||||
|
() => ref,
|
||||||
|
(v) => (ref = v)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedProps = $derived(mergeProps(restProps, { 'data-reveal-item': '' }));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if state.isPresent}
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
{...mergedProps}
|
||||||
|
{...state.transitionAttrs}
|
||||||
|
data-animation-style={state.animationStyle}
|
||||||
|
style:--motion-stagger-index={state.staggerIndex}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { readableActive, writableActive } from '$libs/reactive';
|
||||||
|
import { mergeProps } from '../../../props';
|
||||||
|
import { createId } from '$active-uix/id';
|
||||||
|
import { RevealPanelProvider } from '../reveal-provider.svelte';
|
||||||
|
import type { RevealPanelProps } from '../types';
|
||||||
|
|
||||||
|
const uid = $props.id();
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
id = createId(uid, 'reveal-panel'),
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: RevealPanelProps = $props();
|
||||||
|
|
||||||
|
// OWNER surface. The ref is attached by the runtime part (in `state.props`);
|
||||||
|
// `state.isPresent` / `state.transitionAttrs` come from the owner Presence, so
|
||||||
|
// the panel mounts on open and RETAINS its DOM through the children's exit.
|
||||||
|
const state = RevealPanelProvider.create({
|
||||||
|
id: readableActive(() => id),
|
||||||
|
ref: writableActive(
|
||||||
|
() => ref,
|
||||||
|
(v) => (ref = v)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if state.isPresent}
|
||||||
|
<div {...mergedProps} {...state.transitionAttrs} data-animation-style={state.animationStyle}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { readableActive, writableActive } from '$libs/reactive';
|
||||||
|
import { mergeProps } from '../../../props';
|
||||||
|
import { createId } from '$active-uix/id';
|
||||||
|
import { RevealTriggerProvider } from '../reveal-provider.svelte';
|
||||||
|
import type { RevealTriggerProps } from '../types';
|
||||||
|
|
||||||
|
const uid = $props.id();
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
id = createId(uid, 'reveal-trigger'),
|
||||||
|
children,
|
||||||
|
child,
|
||||||
|
...restProps
|
||||||
|
}: RevealTriggerProps = $props();
|
||||||
|
|
||||||
|
const state = RevealTriggerProvider.create({
|
||||||
|
id: readableActive(() => id),
|
||||||
|
ref: writableActive(
|
||||||
|
() => ref,
|
||||||
|
(v) => (ref = v)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if child}
|
||||||
|
{@render child({ props: mergedProps })}
|
||||||
|
{:else}
|
||||||
|
<button {...mergedProps}>{@render children?.()}</button>
|
||||||
|
{/if}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { readableActive, writableActive } from '$libs/reactive';
|
||||||
|
import { createId } from '$active-uix/id';
|
||||||
|
import { RevealProvider } from '../reveal-provider.svelte';
|
||||||
|
import type { RevealProps } from '../types';
|
||||||
|
|
||||||
|
const uid = $props.id();
|
||||||
|
|
||||||
|
let {
|
||||||
|
id = createId(uid, 'reveal'),
|
||||||
|
open = $bindable(false),
|
||||||
|
onOpenChange = () => {},
|
||||||
|
animation,
|
||||||
|
children
|
||||||
|
}: RevealProps = $props();
|
||||||
|
|
||||||
|
// Virtual root — owns the runtime + the coordination group, renders no element.
|
||||||
|
RevealProvider.create({
|
||||||
|
id: readableActive(() => id),
|
||||||
|
open: writableActive(
|
||||||
|
() => open,
|
||||||
|
(v) => {
|
||||||
|
open = v;
|
||||||
|
onOpenChange(v);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
animation: readableActive(() => animation)
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children?.()}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
export { default as Provider } from './components/reveal.svelte';
|
||||||
|
export { default as Trigger } from './components/reveal-trigger.svelte';
|
||||||
|
export { default as Panel } from './components/reveal-panel.svelte';
|
||||||
|
export { default as Item } from './components/reveal-item.svelte';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
RevealProps as ProviderProps,
|
||||||
|
RevealTriggerProps as TriggerProps,
|
||||||
|
RevealPanelProps as PanelProps,
|
||||||
|
RevealItemProps as ItemProps
|
||||||
|
} from './types';
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from './exports';
|
||||||
@ -0,0 +1,178 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createActiveDom } from '$adom';
|
||||||
|
import { state } from '$libs/reactive';
|
||||||
|
import { compileMorfo, type Morfo } from '$uix/morfo';
|
||||||
|
import { validateMorfo } from '$uix/morfo/schema';
|
||||||
|
import { revealMorfo } from '$uix/morfo/components/reveal';
|
||||||
|
import { createEngineMotion } from '$motion';
|
||||||
|
import { Soma } from '$soma/core/soma.svelte';
|
||||||
|
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
|
||||||
|
import { PresenceGroup } from '$soma/layers/presence-group';
|
||||||
|
|
||||||
|
import { RevealItemProvider, RevealPanelProvider, RevealProvider } from './reveal-provider.svelte';
|
||||||
|
|
||||||
|
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
|
||||||
|
let result!: T;
|
||||||
|
const cleanup = $effect.root(() => {
|
||||||
|
result = fn();
|
||||||
|
});
|
||||||
|
return { result, cleanup };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushRuntimeTrigger() {
|
||||||
|
await Promise.resolve();
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
function installSomaHarness() {
|
||||||
|
const dom = createActiveDom();
|
||||||
|
const motion = createEngineMotion();
|
||||||
|
const soma = {
|
||||||
|
dom,
|
||||||
|
motion,
|
||||||
|
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
||||||
|
createSomaRuntime(morfo, {
|
||||||
|
dom,
|
||||||
|
translate: (key) => key,
|
||||||
|
...sources
|
||||||
|
})
|
||||||
|
} as unknown as Soma;
|
||||||
|
|
||||||
|
vi.spyOn(Soma, 'require').mockReturnValue(soma);
|
||||||
|
// No component context in a bare effect root — stub the context publication
|
||||||
|
// (both the provider's own ctx and the group's, set by PresenceGroup.create).
|
||||||
|
vi.spyOn(RevealProvider.ctx, 'set').mockImplementation((value) => value as never);
|
||||||
|
vi.spyOn(PresenceGroup.ctx, 'set').mockImplementation((value) => value as never);
|
||||||
|
|
||||||
|
return { dom };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Reveal — contract → coordination wiring (RFC: MOTION_SERVICE_RFC.md §4)', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the morfo is valid and the Panel declares the bracket coordination', () => {
|
||||||
|
expect(() => validateMorfo(revealMorfo)).not.toThrow();
|
||||||
|
|
||||||
|
const panel = compileMorfo(revealMorfo).parts.byKebab.get('panel');
|
||||||
|
expect(panel?.animation?.surface).toBe(true);
|
||||||
|
// `{ enter: 'before', exit: 'after' }` — what the provider feeds the group.
|
||||||
|
expect(panel?.animation?.children).toEqual({ enter: 'before', exit: 'after' });
|
||||||
|
|
||||||
|
// The Item is a coordinated child surface with no relation of its own.
|
||||||
|
const item = compileMorfo(revealMorfo).parts.byKebab.get('item');
|
||||||
|
expect(item?.animation).toEqual({ surface: true, children: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the provider builds a PresenceGroup that the panel (owner) + items (children) register into', () => {
|
||||||
|
installSomaHarness();
|
||||||
|
const panelEl = document.createElement('div');
|
||||||
|
const itemEls = [document.createElement('div'), document.createElement('div')];
|
||||||
|
|
||||||
|
const { result, cleanup } = withEffectRoot(() => {
|
||||||
|
const provider = RevealProvider.create({
|
||||||
|
id: state('reveal'),
|
||||||
|
open: state(false),
|
||||||
|
animation: state<string | undefined>(undefined)
|
||||||
|
});
|
||||||
|
vi.spyOn(RevealProvider, 'require').mockReturnValue(provider);
|
||||||
|
const panel = RevealPanelProvider.create({
|
||||||
|
id: state('reveal-panel'),
|
||||||
|
ref: state<HTMLElement | null>(panelEl)
|
||||||
|
});
|
||||||
|
const items = itemEls.map((el) =>
|
||||||
|
RevealItemProvider.create({ ref: state<HTMLElement | null>(el) })
|
||||||
|
);
|
||||||
|
return { provider, panel, items };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.provider.group).toBeInstanceOf(PresenceGroup);
|
||||||
|
// owner + 2 children all registered as group members.
|
||||||
|
expect(result.provider.group.size).toBe(3);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes the `animation` DX prop ONLY to the declared surfaces (RFC §5)', () => {
|
||||||
|
installSomaHarness();
|
||||||
|
|
||||||
|
const { result, cleanup } = withEffectRoot(() => {
|
||||||
|
const provider = RevealProvider.create({
|
||||||
|
id: state('reveal'),
|
||||||
|
open: state(false),
|
||||||
|
animation: state<string | undefined>('slide')
|
||||||
|
});
|
||||||
|
return { provider };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Panel + Item are `surface: true` in the morfo → routed; Trigger is not.
|
||||||
|
// Routing now lives on the shared `Coordination` helper.
|
||||||
|
expect(result.provider.coord.routeAnimation('panel')).toBe('slide');
|
||||||
|
expect(result.provider.coord.routeAnimation('item')).toBe('slide');
|
||||||
|
expect(result.provider.coord.routeAnimation('trigger')).toBeUndefined();
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('items auto-derive their stagger index from the group order (M6)', () => {
|
||||||
|
installSomaHarness();
|
||||||
|
const panelEl = document.createElement('div');
|
||||||
|
const itemEls = [
|
||||||
|
document.createElement('div'),
|
||||||
|
document.createElement('div'),
|
||||||
|
document.createElement('div')
|
||||||
|
];
|
||||||
|
|
||||||
|
const { result, cleanup } = withEffectRoot(() => {
|
||||||
|
const provider = RevealProvider.create({
|
||||||
|
id: state('reveal'),
|
||||||
|
open: state(false),
|
||||||
|
animation: state<string | undefined>(undefined)
|
||||||
|
});
|
||||||
|
vi.spyOn(RevealProvider, 'require').mockReturnValue(provider);
|
||||||
|
RevealPanelProvider.create({
|
||||||
|
id: state('reveal-panel'),
|
||||||
|
ref: state<HTMLElement | null>(panelEl)
|
||||||
|
});
|
||||||
|
const items = itemEls.map((el) =>
|
||||||
|
RevealItemProvider.create({ ref: state<HTMLElement | null>(el) })
|
||||||
|
);
|
||||||
|
return { items };
|
||||||
|
});
|
||||||
|
|
||||||
|
// The dev numbers nothing — the cascade offsets itself from the registration order.
|
||||||
|
expect(result.items.map((it) => it.staggerIndex)).toEqual([0, 1, 2]);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggle flips the open state through the open/close events', async () => {
|
||||||
|
installSomaHarness();
|
||||||
|
const open = state(false);
|
||||||
|
|
||||||
|
const { result, cleanup } = withEffectRoot(() => {
|
||||||
|
const provider = RevealProvider.create({
|
||||||
|
id: state('reveal'),
|
||||||
|
open,
|
||||||
|
animation: state<string | undefined>(undefined)
|
||||||
|
});
|
||||||
|
return { provider };
|
||||||
|
});
|
||||||
|
|
||||||
|
result.provider.toggle();
|
||||||
|
await flushRuntimeTrigger();
|
||||||
|
expect(open.current).toBe(true);
|
||||||
|
|
||||||
|
result.provider.toggle();
|
||||||
|
await flushRuntimeTrigger();
|
||||||
|
expect(open.current).toBe(false);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,220 @@
|
|||||||
|
import { context } from '../../provider';
|
||||||
|
import { state, type Active, type State, type StateProps } from '$libs/reactive';
|
||||||
|
import { Soma } from '../../core/soma.svelte';
|
||||||
|
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
|
||||||
|
import type { PresenceGroup } from '../../layers/presence-group';
|
||||||
|
import { Coordination, type CoordinatedSurface } from '../../layers/coordination';
|
||||||
|
import { revealMorfo } from '../../../morfo/components/reveal';
|
||||||
|
|
||||||
|
// ── Provider (virtual root) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RevealOpts extends StateProps<{ open: boolean }> {
|
||||||
|
id: Active<string>;
|
||||||
|
/** DX preset name routed to the declared surfaces (RFC §5). `undefined` = none. */
|
||||||
|
animation: Active<string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reveal root — owns the morfo runtime, the open state, and the `Coordination`
|
||||||
|
* (the shared coordinated-motion wiring: PresenceGroup + animation routing + auto
|
||||||
|
* stagger, all derived from the morfo's `animation` contract). The Panel registers
|
||||||
|
* as the `owner` surface, each Item as a `child` — see `soma/layers/coordination.ts`.
|
||||||
|
*/
|
||||||
|
export class RevealProvider {
|
||||||
|
static readonly ctx = context<RevealProvider>('Reveal');
|
||||||
|
static get(): RevealProvider | undefined {
|
||||||
|
return this.ctx.getOr(undefined) as RevealProvider | undefined;
|
||||||
|
}
|
||||||
|
static require(): RevealProvider {
|
||||||
|
return this.ctx.get();
|
||||||
|
}
|
||||||
|
static create(opts: RevealOpts) {
|
||||||
|
return new RevealProvider(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly opts: RevealOpts;
|
||||||
|
readonly soma: Soma;
|
||||||
|
readonly runtime: SomaRuntime;
|
||||||
|
/** Shared coordination wiring, built from the morfo contract (RFC §7 / M5 / M6). */
|
||||||
|
readonly coord: Coordination;
|
||||||
|
|
||||||
|
// Cross-part id sources read by partRef('trigger') / partRef('panel') for the
|
||||||
|
// Trigger's aria-controls and the Panel's aria-labelledby.
|
||||||
|
triggerId = state('');
|
||||||
|
panelId = state('');
|
||||||
|
|
||||||
|
private constructor(opts: RevealOpts) {
|
||||||
|
this.opts = opts;
|
||||||
|
RevealProvider.ctx.set(this);
|
||||||
|
|
||||||
|
this.soma = Soma.require();
|
||||||
|
this.runtime = this.soma.runtime(revealMorfo, {
|
||||||
|
states: {
|
||||||
|
open: () => opts.open.current
|
||||||
|
},
|
||||||
|
parts: {
|
||||||
|
trigger: () => this.triggerId.current,
|
||||||
|
panel: () => this.panelId.current
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
// State is set in the HANDLER, so both are `sequence: 'post'` in the
|
||||||
|
// morfo (the perceptual hold must not block the functional change).
|
||||||
|
open: () => {
|
||||||
|
this.opts.open.current = true;
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
this.opts.open.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.coord = new Coordination({
|
||||||
|
morfo: revealMorfo,
|
||||||
|
dom: this.soma.dom,
|
||||||
|
motion: this.soma.motion,
|
||||||
|
open: opts.open,
|
||||||
|
animation: opts.animation
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The coordination group (Panel owner + Item children). */
|
||||||
|
get group(): PresenceGroup {
|
||||||
|
return this.coord.group;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
void this.runtime.trigger(this.opts.open.current ? 'close' : 'open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trigger ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RevealTriggerOpts {
|
||||||
|
id: Active<string>;
|
||||||
|
ref: State<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RevealTriggerProvider {
|
||||||
|
static create(opts: RevealTriggerOpts) {
|
||||||
|
return new RevealTriggerProvider(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly opts: RevealTriggerOpts;
|
||||||
|
readonly provider: RevealProvider;
|
||||||
|
readonly runtimePart: SomaRuntimePart;
|
||||||
|
|
||||||
|
private constructor(opts: RevealTriggerOpts) {
|
||||||
|
this.opts = opts;
|
||||||
|
this.provider = RevealProvider.require();
|
||||||
|
this.provider.triggerId.current = opts.id.current;
|
||||||
|
|
||||||
|
this.runtimePart = this.provider.runtime.part('trigger', {
|
||||||
|
id: opts.id,
|
||||||
|
ref: opts.ref,
|
||||||
|
owner: this,
|
||||||
|
syncAttrs: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly onclick = () => {
|
||||||
|
this.provider.toggle();
|
||||||
|
};
|
||||||
|
|
||||||
|
readonly props = $derived.by(() => ({
|
||||||
|
...this.runtimePart.props,
|
||||||
|
onclick: this.onclick
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Panel (owner surface) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RevealPanelOpts {
|
||||||
|
id: Active<string>;
|
||||||
|
ref: State<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RevealPanelProvider {
|
||||||
|
static create(opts: RevealPanelOpts) {
|
||||||
|
return new RevealPanelProvider(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly opts: RevealPanelOpts;
|
||||||
|
readonly provider: RevealProvider;
|
||||||
|
readonly runtimePart: SomaRuntimePart;
|
||||||
|
/** OWNER member of the group — its presence triggers the coordinated enter/exit. */
|
||||||
|
readonly surface: CoordinatedSurface;
|
||||||
|
|
||||||
|
private constructor(opts: RevealPanelOpts) {
|
||||||
|
this.opts = opts;
|
||||||
|
this.provider = RevealProvider.require();
|
||||||
|
this.provider.panelId.current = opts.id.current;
|
||||||
|
|
||||||
|
this.runtimePart = this.provider.runtime.part('panel', {
|
||||||
|
id: opts.id,
|
||||||
|
ref: opts.ref,
|
||||||
|
owner: this,
|
||||||
|
syncAttrs: true
|
||||||
|
});
|
||||||
|
|
||||||
|
this.surface = this.provider.coord.surface('panel', opts.ref, 'owner');
|
||||||
|
}
|
||||||
|
|
||||||
|
get isPresent(): boolean {
|
||||||
|
return this.surface.isPresent;
|
||||||
|
}
|
||||||
|
|
||||||
|
get transitionAttrs() {
|
||||||
|
return this.surface.transitionAttrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The routed `data-animation-style` for this surface (RFC §5). */
|
||||||
|
get animationStyle(): string | undefined {
|
||||||
|
return this.surface.animationStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly props = $derived.by(() => ({
|
||||||
|
...this.runtimePart.props
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Item (child surface) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RevealItemOpts {
|
||||||
|
ref: State<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RevealItemProvider {
|
||||||
|
static create(opts: RevealItemOpts) {
|
||||||
|
return new RevealItemProvider(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly opts: RevealItemOpts;
|
||||||
|
readonly provider: RevealProvider;
|
||||||
|
/** CHILD member — mounts and waits for the group to release it (cascade). */
|
||||||
|
readonly surface: CoordinatedSurface;
|
||||||
|
|
||||||
|
private constructor(opts: RevealItemOpts) {
|
||||||
|
this.opts = opts;
|
||||||
|
this.provider = RevealProvider.require();
|
||||||
|
|
||||||
|
this.surface = this.provider.coord.surface('item', opts.ref, 'child');
|
||||||
|
}
|
||||||
|
|
||||||
|
get isPresent(): boolean {
|
||||||
|
return this.surface.isPresent;
|
||||||
|
}
|
||||||
|
|
||||||
|
get transitionAttrs() {
|
||||||
|
return this.surface.transitionAttrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The routed `data-animation-style` for this surface (RFC §5). */
|
||||||
|
get animationStyle(): string | undefined {
|
||||||
|
return this.surface.animationStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The auto `--motion-stagger-index`, derived from the group order (M6). */
|
||||||
|
get staggerIndex(): number {
|
||||||
|
return this.surface.staggerIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import type { WithChild, Without, OnChangeFn } from '../../types';
|
||||||
|
import type { PrimitiveDivAttributes, PrimitiveButtonAttributes } from '../../types';
|
||||||
|
|
||||||
|
export type RevealProps = {
|
||||||
|
/** Unique identifier. Auto-generated if omitted. */
|
||||||
|
id?: string;
|
||||||
|
/** Whether the panel is open. Bindable. @default false */
|
||||||
|
open?: boolean;
|
||||||
|
/** Callback fired when the open state changes. */
|
||||||
|
onOpenChange?: OnChangeFn<boolean>;
|
||||||
|
/**
|
||||||
|
* Motion preset name, routed to the declared animable surfaces (Panel + Items)
|
||||||
|
* as `data-animation-style` — name it once here, the system applies it where the
|
||||||
|
* morfo says (RFC §5). `undefined` = no preset.
|
||||||
|
*/
|
||||||
|
animation?: string;
|
||||||
|
children?: Snippet;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RevealTriggerProps = WithChild<{
|
||||||
|
/** Unique identifier. Auto-generated if omitted. */
|
||||||
|
id?: string;
|
||||||
|
}> &
|
||||||
|
Without<PrimitiveButtonAttributes, Record<never, never>>;
|
||||||
|
|
||||||
|
export type RevealPanelProps = WithChild<{
|
||||||
|
/** Unique identifier. Auto-generated if omitted. */
|
||||||
|
id?: string;
|
||||||
|
}> &
|
||||||
|
Without<PrimitiveDivAttributes, Record<never, never>>;
|
||||||
|
|
||||||
|
export type RevealItemProps = Without<PrimitiveDivAttributes, Record<never, never>> & {
|
||||||
|
/** Element reference. Bindable — the child Presence animates this node. */
|
||||||
|
ref?: HTMLElement | null;
|
||||||
|
/** Inline style (e.g. `--i` for the CSS stagger index). */
|
||||||
|
style?: string | Record<string, unknown> | null;
|
||||||
|
children?: Snippet;
|
||||||
|
};
|
||||||
@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Coordination — the shared soma wiring for a COORDINATED-motion component
|
||||||
|
* (RFC: eidos/MOTION_SERVICE_RFC.md §7 + the M5/M6 increments). It reads a
|
||||||
|
* component's morfo `animation` contract and does, ONCE, what every coordinated
|
||||||
|
* component needs:
|
||||||
|
*
|
||||||
|
* - derives the parent↔children `when` (from the owner part's `animation.children`)
|
||||||
|
* and the set of `surface: true` parts — the CONTRACT drives the coordination;
|
||||||
|
* - creates the `PresenceGroup`;
|
||||||
|
* - routes the DX `animation` prop to the declared surfaces (`routeAnimation`);
|
||||||
|
* - mints per-surface members (`CoordinatedSurface`) — an owner/child `Presence`
|
||||||
|
* that exposes the routed `data-animation-style` and the auto stagger index.
|
||||||
|
*
|
||||||
|
* A coordinated component's ROOT provider creates ONE `Coordination`; each animable
|
||||||
|
* part provider creates a `CoordinatedSurface` via `coord.surface(...)`. This is
|
||||||
|
* what makes a second coordinated component a handful of lines instead of the full
|
||||||
|
* hand-wiring `Reveal` first did — both `Reveal` and `Rail` consume it.
|
||||||
|
*
|
||||||
|
* The opt-OUT doctrine (silence the generic sema signature, react to
|
||||||
|
* `data-starting/ending-style` not `data-state`) lives in the morfo (`channels: []`,
|
||||||
|
* `expression: 'none'`) + the component's CSS — NOT here. This helper only owns the
|
||||||
|
* lifecycle coordination.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { compileMorfo, type Morfo } from '$uix/morfo';
|
||||||
|
import { readableActive, type Active } from '$libs/reactive';
|
||||||
|
import type { ActiveDom } from '$adom';
|
||||||
|
import type { EngineMotion } from '$motion';
|
||||||
|
import { Presence } from './presence.svelte';
|
||||||
|
import { PresenceGroup, type PresenceRole, type PresenceWhen } from './presence-group';
|
||||||
|
|
||||||
|
interface CoordinationStructure {
|
||||||
|
readonly when: { readonly enter: PresenceWhen; readonly exit: PresenceWhen };
|
||||||
|
readonly surfaces: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cached by morfo identity (compileMorfo is itself cached) — the structure is a
|
||||||
|
// pure function of the contract.
|
||||||
|
const STRUCTURE_CACHE = new WeakMap<Morfo, CoordinationStructure>();
|
||||||
|
|
||||||
|
function structureOf(morfo: Morfo): CoordinationStructure {
|
||||||
|
const cached = STRUCTURE_CACHE.get(morfo);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
let when: { enter: PresenceWhen; exit: PresenceWhen } = { enter: 'together', exit: 'together' };
|
||||||
|
const surfaces = new Set<string>();
|
||||||
|
for (const [kebab, part] of compileMorfo(morfo).parts.byKebab) {
|
||||||
|
if (part.animation?.surface) surfaces.add(kebab);
|
||||||
|
// The owner is the part that declares `children` — its `when` is the tree's.
|
||||||
|
if (part.animation?.children) when = part.animation.children;
|
||||||
|
}
|
||||||
|
|
||||||
|
const structure: CoordinationStructure = { when, surfaces };
|
||||||
|
STRUCTURE_CACHE.set(morfo, structure);
|
||||||
|
return structure;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoordinationOptions {
|
||||||
|
readonly morfo: Morfo;
|
||||||
|
readonly dom: ActiveDom;
|
||||||
|
readonly motion: EngineMotion;
|
||||||
|
/** Shared open state — every surface (owner + children) observes the same flag. */
|
||||||
|
readonly open: Active<boolean>;
|
||||||
|
/** DX preset name routed to the declared surfaces. Omit for none. */
|
||||||
|
readonly animation?: Active<string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Coordination {
|
||||||
|
readonly group: PresenceGroup;
|
||||||
|
private readonly surfaces: ReadonlySet<string>;
|
||||||
|
private readonly opts: CoordinationOptions;
|
||||||
|
private readonly animation: Active<string | undefined>;
|
||||||
|
|
||||||
|
constructor(opts: CoordinationOptions) {
|
||||||
|
this.opts = opts;
|
||||||
|
const structure = structureOf(opts.morfo);
|
||||||
|
this.surfaces = structure.surfaces;
|
||||||
|
this.animation = opts.animation ?? readableActive((): string | undefined => undefined);
|
||||||
|
this.group = PresenceGroup.create({ when: structure.when, dom: opts.dom });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route the DX `animation` to a part ONLY if the morfo declares it a surface
|
||||||
|
* (RFC §5). A non-surface part gets `undefined`, so it can never be mis-targeted.
|
||||||
|
*/
|
||||||
|
routeAnimation(kebab: string): string | undefined {
|
||||||
|
return this.surfaces.has(kebab) ? this.animation.current : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mint a coordinated member (an animable surface) for a part. */
|
||||||
|
surface(kebab: string, ref: Active<HTMLElement | null>, role: PresenceRole): CoordinatedSurface {
|
||||||
|
const presence = new Presence({
|
||||||
|
dom: this.opts.dom,
|
||||||
|
motion: this.opts.motion,
|
||||||
|
open: this.opts.open,
|
||||||
|
ref,
|
||||||
|
group: this.group,
|
||||||
|
groupRole: role
|
||||||
|
});
|
||||||
|
return new CoordinatedSurface(this, presence, kebab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single animable surface of a `Coordination`: its registered `Presence`, the
|
||||||
|
* routed `data-animation-style`, and the auto `--motion-stagger-index`. The part
|
||||||
|
* provider exposes these to its svelte wrapper.
|
||||||
|
*/
|
||||||
|
export class CoordinatedSurface {
|
||||||
|
constructor(
|
||||||
|
private readonly coord: Coordination,
|
||||||
|
readonly presence: Presence,
|
||||||
|
private readonly kebab: string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get isPresent(): boolean {
|
||||||
|
return this.presence.isPresent;
|
||||||
|
}
|
||||||
|
|
||||||
|
get transitionAttrs() {
|
||||||
|
return this.presence.transitionAttrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The routed `data-animation-style` for this surface (RFC §5). */
|
||||||
|
get animationStyle(): string | undefined {
|
||||||
|
return this.coord.routeAnimation(this.kebab);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The canonical `--motion-stagger-index`, derived from this surface's position in
|
||||||
|
* the group's registration order (M6) — the cascade offsets itself; the dev never
|
||||||
|
* numbers the items. Owner surfaces are not children, so this is 0 for them.
|
||||||
|
*/
|
||||||
|
get staggerIndex(): number {
|
||||||
|
return this.coord.group.childIndex(this.presence);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,326 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { PresenceGroup, type PresenceMember, type PresencePhase } from './presence-group';
|
||||||
|
|
||||||
|
// Frame scheduler stub — runs the callback synchronously so the registration
|
||||||
|
// window collapses to "now" in tests. The coordination core (playEnter) does
|
||||||
|
// not touch the dom; this only feeds `requestEnter`.
|
||||||
|
const syncDom = { requestFrame: (cb: () => void) => (cb(), 0) };
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: () => void;
|
||||||
|
const promise = new Promise<void>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records release/cancel calls; an optional gate controls when `release` resolves. */
|
||||||
|
function member(
|
||||||
|
role: 'owner' | 'child',
|
||||||
|
id: string,
|
||||||
|
calls: string[],
|
||||||
|
gate?: { promise: Promise<void> }
|
||||||
|
): PresenceMember {
|
||||||
|
return {
|
||||||
|
role,
|
||||||
|
release(phase: PresencePhase) {
|
||||||
|
calls.push(`${id}:${phase}`);
|
||||||
|
return gate ? gate.promise : Promise.resolve();
|
||||||
|
},
|
||||||
|
unmount() {
|
||||||
|
calls.push(`${id}:unmount`);
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
calls.push(`${id}:cancel`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frame scheduler that QUEUES callbacks (unlike syncDom which runs them inline),
|
||||||
|
// so a test can interleave requestEnter/requestExit BEFORE the scheduled play runs
|
||||||
|
// — the timing the interruption guard (RFC §8.3) actually defends against.
|
||||||
|
function manualDom() {
|
||||||
|
const cbs: Array<() => void> = [];
|
||||||
|
return {
|
||||||
|
dom: {
|
||||||
|
requestFrame: (cb: () => void) => {
|
||||||
|
cbs.push(cb);
|
||||||
|
return cbs.length;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
flush() {
|
||||||
|
for (const cb of cbs.splice(0)) cb();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PresenceGroup — enter coordination (RFC: MOTION_SERVICE_RFC.md §7)', () => {
|
||||||
|
it('together: releases every member in parallel', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const group = new PresenceGroup({
|
||||||
|
when: { enter: 'together', exit: 'together' },
|
||||||
|
dom: syncDom
|
||||||
|
});
|
||||||
|
group.register(member('owner', 'owner', calls));
|
||||||
|
group.register(member('child', 'a', calls));
|
||||||
|
group.register(member('child', 'b', calls));
|
||||||
|
|
||||||
|
await group.playEnter();
|
||||||
|
|
||||||
|
expect([...calls].sort()).toEqual(['a:enter', 'b:enter', 'owner:enter']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('before: owner releases, children only after the owner finishes', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const ownerGate = deferred();
|
||||||
|
const group = new PresenceGroup({ when: { enter: 'before', exit: 'together' }, dom: syncDom });
|
||||||
|
group.register(member('owner', 'owner', calls, ownerGate));
|
||||||
|
group.register(member('child', 'a', calls));
|
||||||
|
|
||||||
|
const done = group.playEnter();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
// Owner released; the child must wait for the owner's finished.
|
||||||
|
expect(calls).toEqual(['owner:enter']);
|
||||||
|
|
||||||
|
ownerGate.resolve();
|
||||||
|
await done;
|
||||||
|
expect(calls).toEqual(['owner:enter', 'a:enter']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('after: children release, owner only after they finish', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const childGate = deferred();
|
||||||
|
const group = new PresenceGroup({ when: { enter: 'after', exit: 'together' }, dom: syncDom });
|
||||||
|
group.register(member('owner', 'owner', calls));
|
||||||
|
group.register(member('child', 'a', calls, childGate));
|
||||||
|
|
||||||
|
const done = group.playEnter();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
// Child released; the owner must wait for the children's finished.
|
||||||
|
expect(calls).toEqual(['a:enter']);
|
||||||
|
|
||||||
|
childGate.resolve();
|
||||||
|
await done;
|
||||||
|
expect(calls).toEqual(['a:enter', 'owner:enter']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('playEnter resolves only after every member finishes (aggregation §9)', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const gate = deferred();
|
||||||
|
const group = new PresenceGroup({
|
||||||
|
when: { enter: 'together', exit: 'together' },
|
||||||
|
dom: syncDom
|
||||||
|
});
|
||||||
|
group.register(member('owner', 'owner', calls, gate));
|
||||||
|
group.register(member('child', 'a', calls, gate));
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
const done = group.playEnter().then(() => {
|
||||||
|
settled = true;
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
|
||||||
|
gate.resolve();
|
||||||
|
await done;
|
||||||
|
expect(settled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('register returns a deregister that removes the member', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const group = new PresenceGroup({
|
||||||
|
when: { enter: 'together', exit: 'together' },
|
||||||
|
dom: syncDom
|
||||||
|
});
|
||||||
|
const off = group.register(member('child', 'a', calls));
|
||||||
|
expect(group.size).toBe(1);
|
||||||
|
off();
|
||||||
|
expect(group.size).toBe(0);
|
||||||
|
|
||||||
|
await group.playEnter();
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('childIndex returns each child position in registration order (M6)', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const group = new PresenceGroup({
|
||||||
|
when: { enter: 'together', exit: 'together' },
|
||||||
|
dom: syncDom
|
||||||
|
});
|
||||||
|
const owner = member('owner', 'owner', calls);
|
||||||
|
const a = member('child', 'a', calls);
|
||||||
|
const b = member('child', 'b', calls);
|
||||||
|
const c = member('child', 'c', calls);
|
||||||
|
group.register(owner);
|
||||||
|
group.register(a);
|
||||||
|
group.register(b);
|
||||||
|
group.register(c);
|
||||||
|
|
||||||
|
// Owner is skipped; children index from 0 in document (registration) order.
|
||||||
|
expect(group.childIndex(a)).toBe(0);
|
||||||
|
expect(group.childIndex(b)).toBe(1);
|
||||||
|
expect(group.childIndex(c)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel propagates to every member (RFC §8.3)', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const group = new PresenceGroup({
|
||||||
|
when: { enter: 'together', exit: 'together' },
|
||||||
|
dom: syncDom
|
||||||
|
});
|
||||||
|
group.register(member('owner', 'owner', calls));
|
||||||
|
group.register(member('child', 'a', calls));
|
||||||
|
|
||||||
|
group.cancel();
|
||||||
|
expect([...calls].sort()).toEqual(['a:cancel', 'owner:cancel']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requestEnter coordinates after the registration-window frame', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const group = new PresenceGroup({
|
||||||
|
when: { enter: 'together', exit: 'together' },
|
||||||
|
dom: syncDom
|
||||||
|
});
|
||||||
|
group.register(member('owner', 'owner', calls));
|
||||||
|
|
||||||
|
group.requestEnter();
|
||||||
|
// syncDom runs the frame synchronously; a macrotask drains the microtask
|
||||||
|
// queue so the triggered playEnter has fully settled.
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(calls).toEqual(['owner:enter']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PresenceGroup — exit coordination + DOM retention (RFC: MOTION_SERVICE_RFC.md §8.1)', () => {
|
||||||
|
it('after: children exit first while the owner is retained, then owner, then unmount', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const childGate = deferred();
|
||||||
|
const group = new PresenceGroup({ when: { enter: 'together', exit: 'after' }, dom: syncDom });
|
||||||
|
group.register(member('owner', 'owner', calls));
|
||||||
|
group.register(member('child', 'a', calls, childGate));
|
||||||
|
|
||||||
|
const done = group.playExit();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
// Child is exiting; the owner is RETAINED — it has neither exited nor
|
||||||
|
// unmounted (no owner:exit, no unmount yet).
|
||||||
|
expect(calls).toEqual(['a:exit']);
|
||||||
|
|
||||||
|
childGate.resolve();
|
||||||
|
await done;
|
||||||
|
// Owner exits only after the children, then the whole tree unmounts.
|
||||||
|
expect(calls).toEqual(['a:exit', 'owner:exit', 'owner:unmount', 'a:unmount']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('before: owner exits first, then children, then unmount', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const group = new PresenceGroup({ when: { enter: 'together', exit: 'before' }, dom: syncDom });
|
||||||
|
group.register(member('owner', 'owner', calls));
|
||||||
|
group.register(member('child', 'a', calls));
|
||||||
|
|
||||||
|
await group.playExit();
|
||||||
|
|
||||||
|
expect(calls).toEqual(['owner:exit', 'a:exit', 'owner:unmount', 'a:unmount']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('together: all exit in parallel; nobody unmounts before the exit aggregates', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const gate = deferred();
|
||||||
|
const group = new PresenceGroup({
|
||||||
|
when: { enter: 'together', exit: 'together' },
|
||||||
|
dom: syncDom
|
||||||
|
});
|
||||||
|
group.register(member('owner', 'owner', calls, gate));
|
||||||
|
group.register(member('child', 'a', calls, gate));
|
||||||
|
|
||||||
|
const done = group.playExit();
|
||||||
|
await Promise.resolve();
|
||||||
|
// Both released; nobody unmounts until the exit aggregates (retention).
|
||||||
|
expect([...calls].sort()).toEqual(['a:exit', 'owner:exit']);
|
||||||
|
|
||||||
|
gate.resolve();
|
||||||
|
await done;
|
||||||
|
// Unmounts come strictly after each surface's own exit.
|
||||||
|
expect(calls.indexOf('owner:unmount')).toBeGreaterThan(calls.indexOf('owner:exit'));
|
||||||
|
expect(calls.indexOf('a:unmount')).toBeGreaterThan(calls.indexOf('a:exit'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requestExit coordinates the exit after the next frame', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const group = new PresenceGroup({
|
||||||
|
when: { enter: 'together', exit: 'together' },
|
||||||
|
dom: syncDom
|
||||||
|
});
|
||||||
|
group.register(member('owner', 'owner', calls));
|
||||||
|
|
||||||
|
group.requestExit();
|
||||||
|
// Drain the microtask queue (macrotask) so playExit + the unmount sweep
|
||||||
|
// have fully settled, not just the first await.
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(calls).toEqual(['owner:exit', 'owner:unmount']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PresenceGroup — interruption / reversa (RFC: MOTION_SERVICE_RFC.md §8.3)', () => {
|
||||||
|
it('re-open mid-exit cancels the exit and does NOT tear the tree down', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const childGate = deferred();
|
||||||
|
const md = manualDom();
|
||||||
|
const group = new PresenceGroup({ when: { enter: 'together', exit: 'after' }, dom: md.dom });
|
||||||
|
group.register(member('owner', 'owner', calls));
|
||||||
|
group.register(member('child', 'a', calls, childGate));
|
||||||
|
|
||||||
|
// Start the exit; the child's release is gated → playExit is mid-flight,
|
||||||
|
// the owner is retained.
|
||||||
|
group.requestExit();
|
||||||
|
md.flush();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(calls).toEqual(['a:exit']);
|
||||||
|
|
||||||
|
// Re-open mid-exit: a reversal cancels the in-flight members and supersedes
|
||||||
|
// the exit's generation.
|
||||||
|
group.requestEnter();
|
||||||
|
expect(calls).toEqual(['a:exit', 'owner:cancel', 'a:cancel']);
|
||||||
|
|
||||||
|
// Resolve the gated child exit so the superseded playExit resumes — it must
|
||||||
|
// bail at its interruption guard instead of running the unmount loop.
|
||||||
|
childGate.resolve();
|
||||||
|
md.flush();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
expect(calls).not.toContain('owner:unmount');
|
||||||
|
expect(calls).not.toContain('a:unmount');
|
||||||
|
// The reversing enter ran instead.
|
||||||
|
expect(calls).toContain('owner:enter');
|
||||||
|
expect(calls).toContain('a:enter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('close mid-enter cancels the enter and proceeds to the exit (final intent wins)', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const gate = deferred();
|
||||||
|
const md = manualDom();
|
||||||
|
const group = new PresenceGroup({ when: { enter: 'together', exit: 'together' }, dom: md.dom });
|
||||||
|
group.register(member('owner', 'owner', calls, gate));
|
||||||
|
|
||||||
|
group.requestEnter();
|
||||||
|
md.flush();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(calls).toEqual(['owner:enter']);
|
||||||
|
|
||||||
|
// Close mid-enter: reversal cancels the enter, supersedes with an exit.
|
||||||
|
group.requestExit();
|
||||||
|
expect(calls).toEqual(['owner:enter', 'owner:cancel']);
|
||||||
|
|
||||||
|
// Resolve the gate; the superseded enter bails, the exit runs and tears down
|
||||||
|
// (the user's final intent is closed).
|
||||||
|
gate.resolve();
|
||||||
|
md.flush();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
expect(calls).toContain('owner:exit');
|
||||||
|
expect(calls).toContain('owner:unmount');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* PresenceGroup — coordinates the presence LIFECYCLE of a tree of animable
|
||||||
|
* surfaces (RFC: eidos/MOTION_SERVICE_RFC.md §7). This is the soma half of the
|
||||||
|
* motion service: pure state/lifecycle, NEVER visual. `Presence` manages one
|
||||||
|
* surface; `PresenceGroup` generalizes it to a parent surface + its child
|
||||||
|
* surfaces — sequencing WHEN each starts (per the morfo's
|
||||||
|
* `animation.children.{enter,exit}`) and aggregating their `finished`. The visual
|
||||||
|
* stagger/easing stays in eidos (CSS), the surface motion in `arts/motion`; the
|
||||||
|
* group only owns the timing.
|
||||||
|
*
|
||||||
|
* Discovery is by Svelte context, NOT DOM proximity (RFC §7.1): the parent
|
||||||
|
* surface's provider calls `PresenceGroup.create(...)`; child surfaces find it
|
||||||
|
* with `PresenceGroup.get()` and register. Non-animable wrappers in between are
|
||||||
|
* transparent — they neither create a group nor register, so the context flows
|
||||||
|
* through them.
|
||||||
|
*
|
||||||
|
* No runes here on purpose: the coordination core is plain async + a plain
|
||||||
|
* member set, so it is unit-testable without a component/DOM. The reactive
|
||||||
|
* trigger (open → playEnter) lives in the owner `Presence` and the provider —
|
||||||
|
* which is why this is a plain `.ts`, not a `.svelte.ts`.
|
||||||
|
*
|
||||||
|
* Scope — phases M2 + M3 + M4: ENTER coordination (M2) + EXIT with DOM retention
|
||||||
|
* (M3, §8.1) + interruption/reversa (M4, §8.3: a generation token supersedes a
|
||||||
|
* stale phase so a reversing enter never lets the exit tear down a surface it
|
||||||
|
* wants to keep — no unmount→remount flash) + registration + context +
|
||||||
|
* degradation (no group ⇒ each `Presence` is an island, exactly as before).
|
||||||
|
* Count-based registration close-out (§8.2) is still deferred to the first
|
||||||
|
* consumer that needs a fixed-cardinality barrier.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { context } from '../provider/context';
|
||||||
|
|
||||||
|
/** Parent↔child lifecycle relation, compiled from the morfo (RFC §4). */
|
||||||
|
export type PresenceWhen = 'before' | 'after' | 'together';
|
||||||
|
|
||||||
|
/** A member's place in the group: the coordinating surface vs a coordinated child. */
|
||||||
|
export type PresenceRole = 'owner' | 'child';
|
||||||
|
|
||||||
|
export type PresencePhase = 'enter' | 'exit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A surface the group coordinates. The leaf is a `Presence`; a nested
|
||||||
|
* `PresenceGroup` is also a member (a black box — RFC §9). The member owns the
|
||||||
|
* "what/how" (its own motion); the group owns the "when".
|
||||||
|
*/
|
||||||
|
export interface PresenceMember {
|
||||||
|
/** Coordinating surface (`owner`) or coordinated child (`child`). */
|
||||||
|
readonly role: PresenceRole;
|
||||||
|
/**
|
||||||
|
* Start this surface's motion for the phase and resolve when ITS OWN
|
||||||
|
* animation finishes. The group never reads visual values — it only awaits.
|
||||||
|
*/
|
||||||
|
release(phase: PresencePhase): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Tear down after the group's coordinated exit settles (RFC §8.1). The group
|
||||||
|
* calls this once the whole tree's exit has aggregated — never before, so a
|
||||||
|
* retained owner survives its children's exit.
|
||||||
|
*/
|
||||||
|
unmount(): void;
|
||||||
|
/** Cancel any in-flight motion on this surface (interruption — RFC §8.3, M4). */
|
||||||
|
cancel(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal frame scheduler the group needs for its registration window. Declared
|
||||||
|
* as a method (bivariant) so the real `ActiveDom` satisfies it structurally —
|
||||||
|
* the group never passes a frame timestamp, so the callback takes no args. Keeps
|
||||||
|
* this art-adjacent coordinator decoupled from the full `$adom` surface.
|
||||||
|
*/
|
||||||
|
export interface PresenceFrameScheduler {
|
||||||
|
requestFrame(callback: () => void): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresenceGroupOptions {
|
||||||
|
/**
|
||||||
|
* Parent↔child coordination per phase (from `morfo.animation.children`). Enter
|
||||||
|
* and exit are independent so a container can BRACKET its children —
|
||||||
|
* `{ enter: 'before', exit: 'after' }` appears before them and leaves after.
|
||||||
|
*/
|
||||||
|
readonly when: { readonly enter: PresenceWhen; readonly exit: PresenceWhen };
|
||||||
|
/**
|
||||||
|
* Frame scheduler for the registration window (RFC §8.2): children that mount
|
||||||
|
* in the same pass as the owner register before the scheduled frame, so the
|
||||||
|
* owner-triggered `requestEnter` sees the full member set. `ActiveDom`
|
||||||
|
* satisfies this — the group only needs `requestFrame`.
|
||||||
|
*/
|
||||||
|
readonly dom: PresenceFrameScheduler;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PresenceGroup {
|
||||||
|
/** Discovered by child surfaces via Svelte context (RFC §7.1). */
|
||||||
|
static readonly ctx = context<PresenceGroup>('PresenceGroup');
|
||||||
|
|
||||||
|
/** Optional read — `undefined` when there is no group ancestor (the common case). */
|
||||||
|
static get(): PresenceGroup | undefined {
|
||||||
|
return PresenceGroup.ctx.getOr(undefined) as PresenceGroup | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Required read — throws when called outside a group scope. */
|
||||||
|
static require(): PresenceGroup {
|
||||||
|
return PresenceGroup.ctx.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the group and publish it in context. Call from the parent surface's
|
||||||
|
* provider constructor (component init), so descendant surfaces discover it.
|
||||||
|
*/
|
||||||
|
static create(opts: PresenceGroupOptions): PresenceGroup {
|
||||||
|
const group = new PresenceGroup(opts);
|
||||||
|
PresenceGroup.ctx.set(group);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly opts: PresenceGroupOptions;
|
||||||
|
private readonly members = new Set<PresenceMember>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generation token (RFC §8.3) — the group-level analogue of `Presence`'s
|
||||||
|
* `runId`. Every `requestEnter`/`requestExit`/`cancel` bumps it; a scheduled
|
||||||
|
* `playEnter`/`playExit` captures the value at request time and bails at each
|
||||||
|
* await boundary once it goes stale. This is what stops a superseded exit from
|
||||||
|
* tearing down a surface the reversing enter wants to keep.
|
||||||
|
*/
|
||||||
|
private generation = 0;
|
||||||
|
/** The phase currently mid-flight — lets `requestEnter`/`requestExit` detect a reversal. */
|
||||||
|
private active: PresencePhase | undefined;
|
||||||
|
|
||||||
|
constructor(opts: PresenceGroupOptions) {
|
||||||
|
this.opts = opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a member. Returns a deregister function — call it from the
|
||||||
|
* member's cleanup (`$effect` teardown) so the set stays accurate as surfaces
|
||||||
|
* mount/unmount (RFC §7.1: dynamic member set).
|
||||||
|
*/
|
||||||
|
register(member: PresenceMember): () => void {
|
||||||
|
this.members.add(member);
|
||||||
|
return () => {
|
||||||
|
this.members.delete(member);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many members are registered (the owner uses this to decide whether to coordinate). */
|
||||||
|
get size(): number {
|
||||||
|
return this.members.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 0-based position of a CHILD among the children, in registration (document)
|
||||||
|
* order — the canonical stagger index. A coordinated child derives its
|
||||||
|
* `--motion-stagger-index` from this, so the cascade offsets itself from the
|
||||||
|
* coordination order and the dev never hand-numbers the items (RFC §15 / M6).
|
||||||
|
*/
|
||||||
|
childIndex(member: PresenceMember): number {
|
||||||
|
let index = 0;
|
||||||
|
for (const m of this.members) {
|
||||||
|
if (m === member) return index;
|
||||||
|
if (m.role === 'child') index++;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a coordinated enter after a registration-window frame, so children
|
||||||
|
* that registered in the owner's mount pass are included (RFC §8.2). The owner
|
||||||
|
* `Presence` calls this when its `open` flips true.
|
||||||
|
*/
|
||||||
|
requestEnter(): void {
|
||||||
|
// Reversal (RFC §8.3): an exit is in flight — cancel it so the two phases do
|
||||||
|
// not stack motion on the same surfaces, then supersede it via a fresh `gen`.
|
||||||
|
if (this.active === 'exit') this.cancel();
|
||||||
|
const gen = ++this.generation;
|
||||||
|
this.opts.dom.requestFrame(() => {
|
||||||
|
void this.playEnter(gen);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a coordinated exit on the next frame (RFC §8.1). Members are already
|
||||||
|
* registered (they mounted on enter), so no registration window is needed — the
|
||||||
|
* frame defer just lets every member record its `ending` intent first.
|
||||||
|
*/
|
||||||
|
requestExit(): void {
|
||||||
|
// Reversal (RFC §8.3): an enter is in flight — cancel it before superseding.
|
||||||
|
if (this.active === 'enter') this.cancel();
|
||||||
|
const gen = ++this.generation;
|
||||||
|
this.opts.dom.requestFrame(() => {
|
||||||
|
void this.playExit(gen);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinate the tree's ENTER (RFC §7, §9). Sequences each member's `release`
|
||||||
|
* per `when` and aggregates their `finished`: the owner releases before the
|
||||||
|
* children (`before`), after them (`after`), or all in parallel (`together` —
|
||||||
|
* the CSS stagger does the visual offset). Resolves when the whole tree settles.
|
||||||
|
*/
|
||||||
|
async playEnter(gen: number = this.generation): Promise<void> {
|
||||||
|
if (this.superseded(gen)) return;
|
||||||
|
this.active = 'enter';
|
||||||
|
const members = [...this.members];
|
||||||
|
const owner = members.filter((m) => m.role === 'owner');
|
||||||
|
const children = members.filter((m) => m.role === 'child');
|
||||||
|
|
||||||
|
switch (this.opts.when.enter) {
|
||||||
|
case 'before':
|
||||||
|
await releaseAll(owner, 'enter');
|
||||||
|
if (this.superseded(gen)) return;
|
||||||
|
await releaseAll(children, 'enter');
|
||||||
|
break;
|
||||||
|
case 'after':
|
||||||
|
await releaseAll(children, 'enter');
|
||||||
|
if (this.superseded(gen)) return;
|
||||||
|
await releaseAll(owner, 'enter');
|
||||||
|
break;
|
||||||
|
case 'together':
|
||||||
|
default:
|
||||||
|
await releaseAll(members, 'enter');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.superseded(gen)) return;
|
||||||
|
this.active = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinate the tree's EXIT (RFC §8.1). Sequences each member's `release` per
|
||||||
|
* `when` (the inverse intent of enter), then tears the tree down. For
|
||||||
|
* `when: 'after'` the children leave first while the owner RETAINS its DOM,
|
||||||
|
* then the owner runs its own exit — only after the whole exit aggregates does
|
||||||
|
* anyone unmount. The visual stagger is CSS (eidos); soma only sequences,
|
||||||
|
* awaits, and unmounts.
|
||||||
|
*/
|
||||||
|
async playExit(gen: number = this.generation): Promise<void> {
|
||||||
|
if (this.superseded(gen)) return;
|
||||||
|
this.active = 'exit';
|
||||||
|
const members = [...this.members];
|
||||||
|
const owner = members.filter((m) => m.role === 'owner');
|
||||||
|
const children = members.filter((m) => m.role === 'child');
|
||||||
|
|
||||||
|
switch (this.opts.when.exit) {
|
||||||
|
case 'before':
|
||||||
|
await releaseAll(owner, 'exit');
|
||||||
|
if (this.superseded(gen)) return;
|
||||||
|
await releaseAll(children, 'exit');
|
||||||
|
break;
|
||||||
|
case 'after':
|
||||||
|
// Children leave first; the owner RETAINS its DOM (stays mounted)
|
||||||
|
// until their exit aggregates, then runs its own exit (RFC §8.1).
|
||||||
|
await releaseAll(children, 'exit');
|
||||||
|
if (this.superseded(gen)) return;
|
||||||
|
await releaseAll(owner, 'exit');
|
||||||
|
break;
|
||||||
|
case 'together':
|
||||||
|
default:
|
||||||
|
await releaseAll(members, 'exit');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interruption guard (RFC §8.3): a reversing `requestEnter` bumps
|
||||||
|
// `generation`; if we were superseded mid-exit, DO NOT tear down — the new
|
||||||
|
// enter keeps the surfaces mounted (no unmount→remount flash). Only when we
|
||||||
|
// are still the current phase does the coordinated exit tear the tree down:
|
||||||
|
// the owner's unmount drops the retained subtree, siblings unmount themselves.
|
||||||
|
if (this.superseded(gen)) return;
|
||||||
|
this.active = undefined;
|
||||||
|
for (const member of members) member.unmount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propagate cancellation to every member (interruption — RFC §8.3). Bumps the
|
||||||
|
* generation so any in-flight `playEnter`/`playExit` bails at its next guard (no
|
||||||
|
* spurious unmount), clears the active phase, and stops each surface's motion via
|
||||||
|
* `member.cancel()` (→ `motion.cancel(node)`). Called automatically on a reversal
|
||||||
|
* by `requestEnter`/`requestExit`; also the public interruption entry point.
|
||||||
|
*/
|
||||||
|
cancel(): void {
|
||||||
|
this.generation++;
|
||||||
|
this.active = undefined;
|
||||||
|
for (const member of this.members) member.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a newer request has superseded the coordination tagged `gen` (RFC §8.3). */
|
||||||
|
private superseded(gen: number): boolean {
|
||||||
|
return gen !== this.generation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release every member for a phase in parallel; resolve when all finish (RFC §9 aggregation). */
|
||||||
|
function releaseAll(members: readonly PresenceMember[], phase: PresencePhase): Promise<void> {
|
||||||
|
if (members.length === 0) return Promise.resolve();
|
||||||
|
return Promise.all(members.map((m) => m.release(phase))).then(() => undefined);
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* One coordinated child surface. It DISCOVERS its group via Svelte context
|
||||||
|
* (RFC §7.1) — the parent `CoordList` published it; this item never receives
|
||||||
|
* the group explicitly. Pure CSS handles the visual (stagger via --i); the
|
||||||
|
* group only decides WHEN this surface is released / unmounted.
|
||||||
|
*/
|
||||||
|
import { Presence } from '$soma/layers/presence.svelte'
|
||||||
|
import { PresenceGroup } from '$soma/layers/presence-group'
|
||||||
|
import { readableActive } from '$libs/reactive'
|
||||||
|
import { ActiveEidos } from '$uix/eidos'
|
||||||
|
|
||||||
|
let { open, index }: { open: boolean; index: number } = $props()
|
||||||
|
|
||||||
|
const eidos = ActiveEidos.require()
|
||||||
|
const group = PresenceGroup.get()
|
||||||
|
let el = $state<HTMLElement | null>(null)
|
||||||
|
const presence = new Presence({
|
||||||
|
dom: eidos.dom,
|
||||||
|
open: readableActive(() => open),
|
||||||
|
ref: readableActive(() => el),
|
||||||
|
group,
|
||||||
|
groupRole: 'child'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if presence.isPresent}
|
||||||
|
<div class="coord-item" bind:this={el} style="--i: {index}" {...presence.transitionAttrs}>
|
||||||
|
ítem {index + 1}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.coord-item {
|
||||||
|
padding: 0.5rem 0.85rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-primary-solid, #4f46e5);
|
||||||
|
color: var(--color-primary-contrast, #fff);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
transition:
|
||||||
|
opacity 0.4s ease,
|
||||||
|
transform 0.4s ease;
|
||||||
|
transition-delay: calc(var(--i) * 0.08s);
|
||||||
|
}
|
||||||
|
.coord-item[data-starting-style] {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
.coord-item[data-ending-style] {
|
||||||
|
animation: coord-item-out 0.4s ease forwards;
|
||||||
|
animation-delay: calc(var(--i) * 0.1s);
|
||||||
|
}
|
||||||
|
@keyframes coord-item-out {
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* The owner surface of a coordinated list. It creates the `PresenceGroup`
|
||||||
|
* (with the `when` that a real morfo would declare) and publishes it in
|
||||||
|
* context, then renders its child items — which discover the group via
|
||||||
|
* context (RFC §7.1) and register. The group sequences enter/exit per `when`
|
||||||
|
* and, on exit, RETAINS this container's DOM until the items have left (§8.1).
|
||||||
|
*/
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { Presence } from '$soma/layers/presence.svelte';
|
||||||
|
import { PresenceGroup, type PresenceWhen } from '$soma/layers/presence-group';
|
||||||
|
import { readableActive } from '$libs/reactive';
|
||||||
|
import { ActiveEidos } from '$uix/eidos';
|
||||||
|
import CoordItem from './coord-item.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
enter,
|
||||||
|
exit,
|
||||||
|
open,
|
||||||
|
count = 5
|
||||||
|
}: { enter: PresenceWhen; exit: PresenceWhen; open: boolean; count?: number } = $props();
|
||||||
|
|
||||||
|
const eidos = ActiveEidos.require();
|
||||||
|
// `enter`/`exit` are fixed per instance (a real morfo declares them once); read
|
||||||
|
// them untracked so creating the group doesn't pretend to react to a changing prop.
|
||||||
|
const group = untrack(() => PresenceGroup.create({ when: { enter, exit }, dom: eidos.dom }));
|
||||||
|
let containerEl = $state<HTMLElement | null>(null);
|
||||||
|
const container = new Presence({
|
||||||
|
dom: eidos.dom,
|
||||||
|
open: readableActive(() => open),
|
||||||
|
ref: readableActive(() => containerEl),
|
||||||
|
group,
|
||||||
|
groupRole: 'owner'
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = $derived(Array.from({ length: count }, (_, i) => i));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if container.isPresent}
|
||||||
|
<div class="coord-container" bind:this={containerEl} {...container.transitionAttrs}>
|
||||||
|
{#each items as i (i)}
|
||||||
|
<CoordItem {open} index={i} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.coord-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.6rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--color-surface-overlay, rgba(99, 102, 241, 0.08));
|
||||||
|
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.12));
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
transition:
|
||||||
|
opacity 0.3s ease,
|
||||||
|
transform 0.3s ease;
|
||||||
|
}
|
||||||
|
.coord-container[data-starting-style] {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
.coord-container[data-ending-style] {
|
||||||
|
animation: coord-container-out 0.35s ease forwards;
|
||||||
|
}
|
||||||
|
@keyframes coord-container-out {
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,252 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Rail — the SECOND consumer of the coordinated-motion helper
|
||||||
|
* (`soma/layers/coordination.ts`). Same coordination as `Reveal`, DIFFERENT shape:
|
||||||
|
* the root IS the owner surface, the relation is `together` (all parallel, CSS
|
||||||
|
* stagger), it is PARENT-CONTROLLED via `open` (no trigger), and has NO sema events.
|
||||||
|
* Proves the helper is reusable — building this was a handful of lines.
|
||||||
|
* Inherits the ActiveUix + Soma + Eidos scope from `../+layout.svelte`.
|
||||||
|
*/
|
||||||
|
import * as Rail from '$soma/components/rail';
|
||||||
|
|
||||||
|
const ITEMS = ['Inicio', 'Buscar', 'Mensajes', 'Perfil', 'Ajustes'];
|
||||||
|
const STYLES = [
|
||||||
|
{ value: 'cascade-slide', label: 'slide' },
|
||||||
|
{ value: 'cascade-fade', label: 'fade' },
|
||||||
|
{ value: 'cascade-scale', label: 'scale' }
|
||||||
|
];
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let animStyle = $state('cascade-slide');
|
||||||
|
let duration = $state(300); // ms — per-surface transition speed (exposed as a CSS var)
|
||||||
|
let stagger = $state(45); // ms — the rhythm between items (`--motion-stagger-each`)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Rail · 2º consumidor coordinado</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="root">
|
||||||
|
<header>
|
||||||
|
<a class="back" href="/temas/animations">← Motion</a>
|
||||||
|
<h1>Rail <small>2º consumidor del helper coordinado</small></h1>
|
||||||
|
<p class="lede">
|
||||||
|
Misma coordinación que <a href="/temas/animations/reveal">Reveal</a>, otra forma: la raíz
|
||||||
|
<strong>ES</strong> la superficie owner, la relación es <code>together</code> (todo en
|
||||||
|
paralelo, el escalonado lo pone el CSS), es <strong>controlada por el padre</strong>
|
||||||
|
(<code>bind:open</code>, sin trigger) y <strong>no tiene eventos sema</strong>. Todo el wiring
|
||||||
|
—PresenceGroup + routing + auto-stagger— sale del helper compartido
|
||||||
|
<code>soma/layers/coordination.ts</code>; construir este componente fueron cuatro líneas.
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn primary" type="button" onclick={() => (open = !open)}>
|
||||||
|
{open ? 'Ocultar' : 'Mostrar'} rail
|
||||||
|
</button>
|
||||||
|
<div class="picker" role="group" aria-label="Estilo de animación">
|
||||||
|
{#each STYLES as s (s.value)}
|
||||||
|
<button
|
||||||
|
class="chip-btn"
|
||||||
|
class:on={animStyle === s.value}
|
||||||
|
type="button"
|
||||||
|
onclick={() => (animStyle = s.value)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sliders">
|
||||||
|
<label>
|
||||||
|
duración
|
||||||
|
<input type="range" min="80" max="700" step="20" bind:value={duration} />
|
||||||
|
<span class="val">{duration}ms</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
stagger
|
||||||
|
<input type="range" min="0" max="120" step="5" bind:value={stagger} />
|
||||||
|
<span class="val">{stagger}ms</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="stage"
|
||||||
|
style="--cascade-duration: {duration}ms; --motion-stagger-each: {stagger}ms; --motion-stagger-count: {ITEMS.length}"
|
||||||
|
>
|
||||||
|
<Rail.Provider bind:open animation={animStyle}>
|
||||||
|
{#each ITEMS as label (label)}
|
||||||
|
<Rail.Item class="chip">{label}</Rail.Item>
|
||||||
|
{/each}
|
||||||
|
</Rail.Provider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root {
|
||||||
|
min-height: 100dvh;
|
||||||
|
background: var(--color-surface-default, #fff);
|
||||||
|
color: var(--color-content-primary, #111);
|
||||||
|
font-family: var(--font-family-primary, 'Inter', system-ui, sans-serif);
|
||||||
|
padding: 2.5rem 1.5rem 4rem;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
max-width: 880px;
|
||||||
|
margin-inline: auto;
|
||||||
|
margin-block-end: 2rem;
|
||||||
|
}
|
||||||
|
.back {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-content-muted, #666);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.back:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0.5rem 0 0.75rem;
|
||||||
|
font-size: 1.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
h1 small {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-content-muted, #666);
|
||||||
|
}
|
||||||
|
.lede {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 64ch;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-content-secondary, #444);
|
||||||
|
}
|
||||||
|
.lede a {
|
||||||
|
color: var(--color-primary-solid, #4f46e5);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-block-start: 1.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border-default, rgba(0, 0, 0, 0.15));
|
||||||
|
background: var(--color-surface-default, #fff);
|
||||||
|
color: var(--color-content-primary, #111);
|
||||||
|
}
|
||||||
|
.btn.primary {
|
||||||
|
background: var(--color-primary-solid, #4f46e5);
|
||||||
|
color: var(--color-primary-contrast, #fff);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.picker {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.2rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--color-surface-muted, rgba(0, 0, 0, 0.05));
|
||||||
|
}
|
||||||
|
.chip-btn {
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-content-secondary, #555);
|
||||||
|
}
|
||||||
|
.chip-btn.on {
|
||||||
|
background: var(--color-primary-solid, #4f46e5);
|
||||||
|
color: var(--color-primary-contrast, #fff);
|
||||||
|
}
|
||||||
|
.stage {
|
||||||
|
max-width: 880px;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px dashed var(--color-border-subtle, rgba(0, 0, 0, 0.12));
|
||||||
|
background: var(--color-surface-raised, #f7f7f8);
|
||||||
|
min-height: 140px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.sliders {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-block-start: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sliders label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-content-secondary, #555);
|
||||||
|
}
|
||||||
|
.sliders input[type='range'] {
|
||||||
|
accent-color: var(--color-primary-solid, #4f46e5);
|
||||||
|
}
|
||||||
|
.sliders .val {
|
||||||
|
min-width: 3.5ch;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--color-content-muted, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The Rail surfaces — global (rendered by the soma component). HORIZONTAL row;
|
||||||
|
soma stamps data-starting/ending-style, the CSS only reacts. */
|
||||||
|
:global([data-rail]) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--color-surface-overlay, rgba(99, 102, 241, 0.08));
|
||||||
|
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.12));
|
||||||
|
transition:
|
||||||
|
opacity var(--cascade-duration, 0.3s) ease,
|
||||||
|
transform var(--cascade-duration, 0.3s) ease;
|
||||||
|
}
|
||||||
|
:global([data-rail-item]) {
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-surface-default, #fff);
|
||||||
|
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.1));
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
opacity var(--cascade-duration, 0.3s) ease,
|
||||||
|
transform var(--cascade-duration, 0.3s) ease;
|
||||||
|
transition-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
|
||||||
|
}
|
||||||
|
/* Close = open reversed (last chip leaves first). */
|
||||||
|
:global([data-rail-item][data-ending-style]) {
|
||||||
|
transition-delay: calc(
|
||||||
|
(var(--motion-stagger-count, 1) - 1 - var(--motion-stagger-index, 0)) *
|
||||||
|
var(--motion-stagger-each, 0ms)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `cascade-*` (non-eidos-preset names) — react to the Presence attrs, not data-state. */
|
||||||
|
:global([data-animation-style='cascade-slide'][data-starting-style]),
|
||||||
|
:global([data-animation-style='cascade-slide'][data-ending-style]) {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-12px);
|
||||||
|
}
|
||||||
|
:global([data-animation-style='cascade-fade'][data-starting-style]),
|
||||||
|
:global([data-animation-style='cascade-fade'][data-ending-style]) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
:global([data-animation-style='cascade-scale'][data-starting-style]),
|
||||||
|
:global([data-animation-style='cascade-scale'][data-ending-style]) {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in new issue