feat(motion): M9 F1c+F2 — dropdown-menu item cascade (children-DOM)

F1c (exit-heavy): DomCascade.pending() aggregates the items' finished so the owner Presence holds the subtree until the exit cascade settles (PresenceOptions.pending, Presence.startPhase). beginEnter drives the enter from the off-state with the transition suppressed (items mount in the on-state, so a passive mirror would ease toward off instead of jumping). pending() settles each finisher (then(noop,noop)) so one cancelled row can't collapse the wait.

F2: wire the children-DOM cascade into the real dropdown-menu. Content part declares animation.surface+staggerChildren; provider routes an opt-in 'animation' prop, runs a DomCascade over getCascadeRows (every visible row incl. disabled), forwards a pending hook through the floating shell. Coexistence fixes surfaced by the menu: item rows hand their transition to the cascade-* preset via a higher-specificity rule (longhands keep the stagger delay); disabled opacity gated off during the cascade; the panel's own dismiss-fade signature suppressed in cascade mode (data-cascade) so it doesn't fade the panel before the rows finish; the trigger is excluded from the menu's dismissal so the toggle closes (pre-existing bug). Verified in a real browser (Playwright): enter+exit cascade with reversed stagger, all four close methods, disabled row fades. dom-cascade 9/9, check 0 new errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent db1aa14d1d
commit d797a0cebc

@ -67,9 +67,30 @@
line-height: var(--leading-ui);
cursor: pointer;
user-select: none;
transition:
background var(--duration-fast) var(--ease-default),
color var(--duration-fast) var(--ease-default);
/* Longhands, NOT the `transition` shorthand: the shorthand also resets
`transition-delay` to 0s, which (component CSS loads after the generated
coordinated preset) would override the cascade's per-row stagger delay when
a row joins the cascade (RFC §M9). Longhands leave `transition-delay` for the
preset to own. */
transition-property: background, color;
transition-duration: var(--duration-fast);
transition-timing-function: var(--ease-default);
}
/* A row in the coordinated item cascade (RFC §M9) hands its `transition` to the
generated `cascade-*` preset: opacity + transform, with the stagger
`transition-delay` the preset owns (a separate longhand it still sets). This
rule (specificity 0,2,0) beats the row's base transition (0,1,0) so the cascade
is not dropped. The hover background/color does not fade WHILE cascading — the
menu is the staggered surface; hover snaps. Only the four item archetypes need
this (separators / headings carry no competing transition). */
[data-dropdown-menu-item][data-animation-style],
[data-dropdown-menu-checkbox-item][data-animation-style],
[data-dropdown-menu-radio-item][data-animation-style],
[data-dropdown-menu-sub-trigger][data-animation-style] {
transition-property: opacity, transform;
transition-duration: var(--motion-cascade-duration, var(--duration-moderate));
transition-timing-function: var(--motion-cascade-ease, var(--ease-out));
}
[data-dropdown-menu-item]:hover:not([data-disabled]),
@ -91,6 +112,18 @@
[data-dropdown-menu-radio-item][data-disabled],
[data-dropdown-menu-sub-trigger][data-disabled] {
cursor: default;
}
/* The dimmed disabled opacity must NOT fight the cascade off-state (RFC §M9): both are
`opacity` at the same specificity, and this component rule loads after the generated
preset, so it would win and pin a disabled row at its dim value — it could never fade
in/out. Gate it off while the row is in a coordinated enter/exit so the off-state
(opacity:0) shows through; at rest the row dims normally. (Value left as-is so the
theme agent's opacity-token canonicalisation stays the single source.) */
[data-dropdown-menu-item][data-disabled]:not([data-starting-style]):not([data-ending-style]),
[data-dropdown-menu-checkbox-item][data-disabled]:not([data-starting-style]):not([data-ending-style]),
[data-dropdown-menu-radio-item][data-disabled]:not([data-starting-style]):not([data-ending-style]),
[data-dropdown-menu-sub-trigger][data-disabled]:not([data-starting-style]):not([data-ending-style]) {
opacity: var(--dropdown-menu-item-disabled-opacity, 0.55);
}
@ -106,10 +139,10 @@
margin-inline-start: auto;
inline-size: 0.625rem;
block-size: 0.625rem;
border-inline-end: 2px solid currentColor;
border-block-start: 2px solid currentColor;
border-inline-end: var(--border-width-medium) solid currentColor;
border-block-start: var(--border-width-medium) solid currentColor;
transform: rotate(45deg);
opacity: 0.65;
opacity: var(--opacity-muted);
}
/* ── Group / GroupHeading / Separator ─────────────────────────────── */
@ -125,7 +158,7 @@
font-size: var(--dropdown-menu-heading-font-size, var(--font-size-xs));
font-weight: var(--style-label-font-weight, 500);
text-transform: uppercase;
letter-spacing: 0.04em; /* literal: micro-tracking for caps */
letter-spacing: var(--tracking-caps);
}
[data-dropdown-menu-separator] {
@ -170,3 +203,18 @@
transform: none;
}
}
/* ── Cascade mode (RFC §M9) ────────────────────────────────────────────
When the item cascade owns the menu's motion (the provider stamps `data-cascade`
on the panel iff an `animation` preset is routed), the PANEL must not run its own
signature animation — the close `dismiss-fade` / the open keyframe — nor collapse
to opacity:0 the instant `data-state` flips to closed. Either would fade the whole
panel, taking the rows cascading INSIDE it along, before the stagger finishes (and
the panel's early unmount cancels the cascade). The panel stays put until the
cascade settles (soma's `pending` hook), then unmounts. */
[data-dropdown-menu-content][data-cascade] {
animation: none !important;
}
[data-dropdown-menu-content][data-cascade][data-state='closed'][data-ending-style] {
opacity: 1;
}

@ -124,7 +124,11 @@ export const dropdownMenuMorfo = {
{ key: 'End', action: 'last-item' },
{ key: 'Enter', action: 'activate' },
{ key: ' ', action: 'activate' }
]
],
// RFC §M9 (children-DOM cascade): the content is an animable surface
// that staggers its menu rows. `staggerChildren` names the lead item
// part; the soma provider resolves the DOM node set (all visible rows).
animation: { surface: true, staggerChildren: 'item' }
},
{
name: 'Item',

@ -19,6 +19,7 @@
onOpenChangeComplete = () => {},
dir,
accessibleWhenDisabled = true,
animation,
children
}: MenuProps = $props();
@ -33,7 +34,8 @@
),
onOpenChangeComplete: readableActive(() => onOpenChangeComplete),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
accessibleWhenDisabled: readableActive(() => accessibleWhenDisabled)
accessibleWhenDisabled: readableActive(() => accessibleWhenDisabled),
animation: readableActive(() => animation)
});
</script>

@ -83,7 +83,8 @@ function createMenuOpts() {
open: state(false),
dir: state<'ltr' | 'rtl'>('ltr'),
accessibleWhenDisabled: state(true),
onOpenChangeComplete: state<(open: boolean) => void>(() => undefined)
onOpenChangeComplete: state<(open: boolean) => void>(() => undefined),
animation: state<string | undefined>(undefined)
};
}

@ -1,6 +1,6 @@
import { context, type ProviderOpts, type WithRefOpts } from '../../provider';
import { createAttrs } from '$uix/morfo';
import { boolToEmptyStrOrUndef, getDataOpenClosed } from '$adom';
import { boolToEmptyStrOrUndef, getDataOpenClosed, contains } from '$adom';
import {
readableActive,
state,
@ -8,6 +8,7 @@ import {
type ActiveProps,
type StateProps
} from '$libs/reactive';
import type { CoordinatedPresetName } from '$motion';
import type {
OnChangeFn,
SomaMouseEvent,
@ -21,6 +22,7 @@ import { Soma } from '../../core/soma.svelte';
import { Typeahead } from '../../typeahead';
import { Presence } from '../../layers/presence.svelte';
import { DomCascade } from '../../layers/dom-cascade.svelte';
import { FocusScope } from '../../layers/focus-scope.svelte';
import { Dismissal, type DismissalBehavior } from '../../layers/dismissal.svelte';
import { ScrollLock } from '../../layers/scroll-lock.svelte';
@ -54,6 +56,7 @@ interface MenuOpts
dir: Direction;
accessibleWhenDisabled: boolean;
onOpenChangeComplete: OnChangeFn<boolean>;
animation: CoordinatedPresetName | undefined;
}> {}
export class MenuProvider {
@ -76,6 +79,8 @@ export class MenuProvider {
readonly floatingProvider: FloatingProvider;
readonly contentPresence: Presence;
readonly typeahead: Typeahead;
/** RFC §M9 children-DOM cascade over the menu rows; inert until `animation` is set. */
readonly cascade: DomCascade;
contentId = state('');
triggerId = state('');
@ -102,11 +107,43 @@ export class MenuProvider {
dom: this.soma.dom,
open: opts.open,
contentRef: this.contentRef,
onOpenChangeComplete: opts.onOpenChangeComplete
onOpenChangeComplete: opts.onOpenChangeComplete,
// RFC §M9: on exit, hold the panel until the item cascade settles. The
// arrow is lazy (the cascade is built just below); `pending` only fires
// on a real close, long after construction. No `animation` ⇒ inert.
pending: () => this.cascade?.pending()
});
this.floatingProvider = shell.floatingProvider;
this.contentPresence = shell.contentPresence;
// RFC §M9 children-DOM cascade: mirror the content owner's lifecycle onto the
// menu rows so they stagger in/out, reusing eidos's coordinated preset
// unchanged. `animationStyle` undefined (no `animation` prop) ⇒ `sync`
// early-returns, so the menu behaves exactly as before until opted in.
this.cascade = new DomCascade({
dom: this.soma.dom,
animationStyle: opts.animation,
ownerTransitionAttrs: readableActive(() => this.contentPresence.transitionAttrs),
items: () => this.getCascadeRows(this.contentRef.current)
});
this.cascade.watch();
// When a cascade is routed, mark the panel so eidos can suppress its OWN
// signature animation (the `dismiss-fade` close keyframe / the open keyframe):
// otherwise the panel fades as a whole — taking the rows cascading inside it
// with it — before the stagger finishes, and its early unmount cancels the
// cascade. The eidos firma-neutralization only covers the rows (they carry
// `data-animation-style`); the owner panel needs this explicit marker.
$effect(() => {
const content = this.contentRef.current;
if (!content) return;
if (this.opts.animation.current) {
this.soma.dom.apply({ target: content, attrs: { 'data-cascade': '' } });
} else {
this.soma.dom.remove(content, ['data-cascade']);
}
});
// Cleanup typeahead timer on unmount
$effect(() => {
return () => this.typeahead.destroy();
@ -157,6 +194,30 @@ export class MenuProvider {
(el) => el.closest(`[${attrs.content}], [${attrs['sub-content']}]`) === container
);
}
/**
* The rows the children-DOM cascade animates (RFC §M9). BROADER than
* `getItems` (the keyboard-nav set): every visible row — all item archetypes
* AND separators AND group headings, disabled included — so the whole menu
* staggers as one (decision C). Scoped to this container's own rows (an open
* submenu's rows are excluded via `closest`), in document order.
*/
getCascadeRows(container: HTMLElement | null): HTMLElement[] {
if (!container) return [];
const selector = [
attrs.item,
attrs['checkbox-item'],
attrs['radio-item'],
attrs['sub-trigger'],
attrs.separator,
attrs['group-heading']
]
.map((a) => `[${a}]`)
.join(', ');
return Array.from(container.querySelectorAll<HTMLElement>(selector)).filter(
(el) => el.closest(`[${attrs.content}], [${attrs['sub-content']}]`) === container
);
}
}
// ── Trigger ──────────────────────────────────────────────────────────────────
@ -315,7 +376,13 @@ export class MenuContentProvider {
trap: opts.trapFocus,
loop: opts.loop,
ref: opts.ref,
enabled: readableActive(() => this.provider.contentPresence.isPresent)
// Bound to `open`, NOT `isPresent` (RFC §M9, decision A): focus is logical,
// not visual. When an exit cascade holds the panel in the DOM (`pending`),
// `isPresent` stays true for the whole stagger — binding the trap to it
// would delay focus-return to the trigger until the cascade settles and
// keep focus trapped in a visibly-leaving menu. `open` releases it at the
// close flip; the rows finish leaving without the trap.
enabled: readableActive(() => this.provider.opts.open.current)
});
// Dismissal — menus always close on outside click. For modal/blocking
@ -335,6 +402,13 @@ export class MenuContentProvider {
}),
escapeKeydownBehavior: opts.escapeKeydownBehavior,
onInteractOutside: readableActive(() => (e: PointerEvent) => {
// A pointerdown on the trigger itself must NOT dismiss here: the trigger's
// own onclick toggles open/closed. Without this guard the dismissal closes
// on pointerdown and the click reopens — the menu never closes from the
// trigger (pre-existing dropdown-menu behaviour, surfaced while testing the
// item cascade where the lingering open panel is obvious).
const trigger = this.provider.runtime.partRef('trigger');
if (trigger && contains(trigger, e.target as Node)) return;
opts.onInteractOutside.current(e);
if (!e.defaultPrevented) this.provider.handleClose();
}),

@ -1,4 +1,5 @@
import type { Snippet } from 'svelte';
import type { CoordinatedPresetName } from '$motion';
import type {
WithChild,
Without,
@ -29,6 +30,13 @@ export type MenuProps = {
* @default true
*/
accessibleWhenDisabled?: boolean;
/**
* Coordinated entrance/exit cascade for the menu rows (RFC §M9). Names a
* built-in coordinated preset (`'cascade-slide' | 'cascade-fade' |
* 'cascade-scale'`). When set, the rows stagger in/out and the panel holds
* open until the exit cascade settles. Omitted ⇒ no cascade (default chrome).
*/
animation?: CoordinatedPresetName;
children?: Snippet;
};

@ -64,14 +64,77 @@ export class DomCascade {
* a component (a server test drives them directly with a mock `dom`).
*/
watch(): void {
let hadItems = false;
// Logic effect: enter (beginEnter) / exit (mirror) / close (clear). It has NO
// `$effect` cleanup — clearing the items' attrs on every re-run is what cut the
// enter mid-transition (the owner's starting→settled re-run fires WHILE the enter
// transition is playing). beginEnter is synchronous, so by the time that re-run
// lands there is no phase to handle and the `else` branch leaves the items alone.
$effect(() => {
this.sync(
this.opts.items(),
this.opts.animationStyle.current,
this.opts.ownerTransitionAttrs.current
);
return () => this.clear();
const items = this.opts.items();
const style = this.opts.animationStyle.current;
const ownerAttrs = this.opts.ownerTransitionAttrs.current;
const ending = ownerAttrs['data-ending-style'] !== undefined;
const appeared = items.length > 0 && !hadItems;
hadItems = items.length > 0;
if (items.length === 0) {
// Owner closed / unmounted.
this.clear();
} else if (ending) {
// EXIT: ease the items on→off (reversed stagger). They are present and the
// preset transition is active, so `sync` interpolates — correct for exit.
// The owner awaits them via `pending` before unmounting (RFC §9).
this.sync(items, style, ownerAttrs);
} else if (style && appeared) {
// ENTER: the items just mounted in the on-state. `beginEnter` jumps them to
// the off-state with the transition SUPPRESSED, then releases → they ease in.
this.beginEnter(items, style);
}
// else: settled / no phase change. Leave the attrs as beginEnter left them — a
// re-sync would clear+rewrite and cut the in-flight enter transition (this
// effect re-runs when the owner goes starting→settled). A mid-open item-set
// change is not re-synced — acceptable for F2.
});
// Teardown on dispose only (no reactive reads ⇒ never re-runs).
$effect(() => () => this.clear());
}
/**
* Drive a just-mounted item set into the cascade. The items mount in the ON-state, so
* we cannot just stamp the off-state: with the preset's `transition` already active
* the change would EASE toward off (and, released a frame later, barely move — the
* dropdown-menu enter bug; verified in-browser, opacity crept 1 → 0.95 → 1). Instead
* stamp the off-state with the element transition SUPPRESSED so opacity/transform JUMP
* to it, force a reflow to commit that, then restore the transition and drop
* `data-starting-style` → the items ease IN with the preset's per-row stagger. (The
* `Presence` model needs none of this: its `data-starting-style` is in the initial
* render, i.e. the mount state, which never transitions.) Synchronous — the owner's
* later starting→settled re-run finds nothing to do.
*/
private beginEnter(items: readonly HTMLElement[], style: string): void {
const count = String(items.length);
items.forEach((item, i) => {
this.opts.dom.apply({
target: item,
attrs: { [ANIMATION_STYLE]: style, 'data-starting-style': '' }
});
this.opts.dom.writeProperty(item, STAGGER_INDEX, String(i));
this.opts.dom.writeProperty(item, STAGGER_COUNT, count);
this.opts.dom.writeProperty(item, 'transition', 'none');
});
this.applied = items;
this.forceReflow(items[0]);
for (const item of items) {
this.opts.dom.removeProperty(item, 'transition');
this.opts.dom.remove(item, ['data-starting-style']);
}
}
/** Read a layout property to flush the transition-suppressed off-state before release. */
private forceReflow(el: HTMLElement | undefined): void {
if (el) void this.opts.dom.getWindow(el).getComputedStyle(el).opacity;
}
/**
@ -104,4 +167,52 @@ export class DomCascade {
}
this.applied = [];
}
/**
* Aggregate the in-flight transitions of every cascaded item into ONE promise —
* the cabo of RFC §9 (children-DOM mode, §M9). The owner's `getAnimations()` does
* NOT see these: the items are DOM descendants, not the owner node, and the engine
* stays hierarchy-agnostic (no `{subtree:true}`). The owner `Presence` awaits this
* via `PresenceOptions.pending`, so on exit it holds the subtree until the whole
* cascade settles instead of dropping it mid-stagger.
*
* Deferred a frame so the mirrored `data-(starting|ending)-style` has triggered the
* items' CSS transitions before we read them (the same reason
* `Presence.waitForAnimations` waits a frame). Reads the items stamped by the last
* `sync` (stable during exit — the set does not change and the owner cannot unmount
* until this resolves). Resolves immediately when nothing is animating, and resolves
* (never rejects) if an item animation is cancelled — a cancelled exit must still
* finalize the owner's lifecycle, never hang it.
*/
pending(): Promise<void> {
const items = this.applied;
return new Promise<void>((resolve) => {
// TWO frames, not one: the exit transitions are written a microtask after
// close (the owner's ending → our mirror `sync`), but they only surface in
// getAnimations() a frame or two later — verified in-browser, the item's
// animation count is 0 at +1ms and 2 at +29ms. Reading after a single frame
// races ahead of them, so `getAnimations()` is empty and the owner unmounts
// before the staggered exit plays ("closes in one go"). A second frame lets
// every row's transition (including the long delay-phase ones) register.
const read = () => {
const finishers = items.flatMap((el) => el.getAnimations().map((a) => a.finished));
if (finishers.length === 0) {
resolve();
return;
}
// settle (resolve OR reject) every finisher — a single cancelled transition
// must not collapse the whole wait (Promise.all would reject early). We wait
// for the LAST row to finish or be cancelled.
Promise.all(
finishers.map((f) =>
f.then(
() => {},
() => {}
)
)
).then(() => resolve());
};
this.opts.dom.requestFrame(() => this.opts.dom.requestFrame(read, items[0]), items[0]);
});
}
}

@ -7,7 +7,8 @@ import type { ActiveDom } from '$adom';
type ApplyCall = { target: HTMLElement; attrs?: Record<string, unknown> };
type PropCall = { target: HTMLElement; property: string; value: string };
// A mock that records only the four DOM-writing methods DomCascade.sync/clear use.
// A mock that records the DOM-writing methods DomCascade.sync/clear use, and runs
// `requestFrame` synchronously so `pending()` is deterministic in a server test.
function mockDom() {
const applied: ApplyCall[] = [];
const removed: { target: HTMLElement; names: readonly string[] }[] = [];
@ -19,13 +20,25 @@ function mockDom() {
writeProperty: (target: HTMLElement, property: string, value: string) =>
props.push({ target, property, value }),
removeProperty: (target: HTMLElement, property: string) =>
removedProps.push({ target, property })
removedProps.push({ target, property }),
// Run the frame synchronously so `pending()` is deterministic in a server test.
requestFrame: (cb: () => void) => {
cb();
return 0;
},
// `beginEnter` reads a computed style to force a reflow between the suppressed
// off-state and its release; the mock just needs to be callable.
getWindow: () => ({ getComputedStyle: () => ({ opacity: '1' }) })
} as unknown as ActiveDom;
return { dom, applied, removed, props, removedProps };
}
const el = () => ({}) as HTMLElement;
/** A fake item whose `getAnimations()` returns animations with the given finishers. */
const animItem = (...finished: Promise<unknown>[]) =>
({ getAnimations: () => finished.map((f) => ({ finished: f })) }) as unknown as HTMLElement;
function make(dom: ActiveDom, items: HTMLElement[]) {
return new DomCascade({
dom,
@ -98,4 +111,76 @@ describe('DomCascade — children-DOM stagger (RFC §M9)', () => {
const last = m.applied[m.applied.length - 1];
expect(last.attrs).toEqual({ 'data-animation-style': 'cascade-slide' });
});
// ── beginEnter() — items mount in the on-state, must ease IN (F2) ────────────
it('beginEnter jumps to the off-state with transition suppressed, then releases', () => {
const m = mockDom();
const items = [el(), el()];
const cascade = make(m.dom, items) as unknown as {
beginEnter(i: readonly HTMLElement[], s: string): void;
};
cascade.beginEnter(items, 'cascade-slide');
// 1. off-state stamped on each item: data-animation-style + data-starting-style
expect(m.applied).toHaveLength(2);
expect(m.applied[0].attrs).toEqual({
'data-animation-style': 'cascade-slide',
'data-starting-style': ''
});
// 2. the element transition is suppressed inline so the off-state JUMPS (no ease)
expect(m.props.some((p) => p.property === 'transition' && p.value === 'none')).toBe(true);
// 3. release: transition restored + ONLY data-starting-style dropped (the routed
// style + stagger vars stay), so the items ease to the on-state.
expect(m.removedProps.some((p) => p.property === 'transition')).toBe(true);
expect(m.removed).toHaveLength(2);
expect(m.removed.every((r) => r.names.length === 1 && r.names[0] === 'data-starting-style')).toBe(
true
);
});
// ── pending() — the exit-heavy cabo (F1c, RFC §9 / §M9) ────────────────────
it('pending() resolves only after every cascaded item animation finishes', async () => {
const m = mockDom();
let doneA!: () => void;
let doneB!: () => void;
const a = new Promise<void>((r) => (doneA = r));
const b = new Promise<void>((r) => (doneB = r));
const items = [animItem(a), animItem(b)];
const cascade = make(m.dom, items);
cascade.sync(items, 'cascade-slide', { 'data-ending-style': '' });
let settled = false;
const p = cascade.pending().then(() => (settled = true));
await Promise.resolve();
expect(settled).toBe(false); // both items still animating
doneA();
await Promise.resolve();
expect(settled).toBe(false); // one still animating
doneB();
await p;
expect(settled).toBe(true);
});
it('pending() resolves immediately when nothing is animating', async () => {
const m = mockDom();
const items = [animItem(), animItem()]; // getAnimations() → []
const cascade = make(m.dom, items);
cascade.sync(items, 'cascade-slide', {});
let settled = false;
await cascade.pending().then(() => (settled = true)); // resolves — must not hang
expect(settled).toBe(true);
});
it('pending() resolves (never rejects) when an item animation is cancelled', async () => {
const m = mockDom();
const items = [animItem(Promise.reject(new Error('cancelled')))];
const cascade = make(m.dom, items);
cascade.sync(items, 'cascade-slide', { 'data-ending-style': '' });
// A cancelled exit must still finalize the owner's lifecycle, never hang it.
let settled = false;
await cascade.pending().then(() => (settled = true));
expect(settled).toBe(true);
});
});

@ -42,6 +42,14 @@ export interface FloatingShellRootOpts {
* is a no-op.
*/
onOpenChangeComplete?: Active<OnChangeFn<boolean> | undefined>;
/**
* Optional per-phase pending hook forwarded to the content `Presence`
* (RFC §M9). A consumer that staggers STATIC DOM children — e.g.
* dropdown-menu's item cascade — passes `() => domCascade.pending()` so the
* owner holds the subtree until the exit cascade settles. Omitted by the
* other floating consumers ⇒ the Presence stays an island, byte-identical.
*/
pending?: (phase: 'enter' | 'exit') => Promise<void> | undefined;
}
export interface FloatingShellRoot {
@ -63,7 +71,8 @@ export function createFloatingShellRoot(opts: FloatingShellRootOpts): FloatingSh
ref: opts.contentRef,
onComplete: opts.onOpenChangeComplete
? (open) => opts.onOpenChangeComplete!.current?.(open)
: undefined
: undefined,
pending: opts.pending
});
return { floatingProvider, contentPresence };

@ -35,6 +35,17 @@ export interface PresenceOptions extends ActiveProps<{ open: boolean; ref: HTMLE
* coordinated enter; children mount and wait to be released.
*/
groupRole?: PresenceRole;
/**
* External per-phase pending hook (RFC §M9 / §9). When this surface coordinates
* STATIC DOM children via a `DomCascade` (the children-DOM mode), the children's
* transitions are invisible to the owner's `getAnimations()` — they are DOM
* descendants, not the owner node, and the engine takes no `{subtree:true}` (it
* stays hierarchy-agnostic). The provider wires this to `DomCascade.pending` so the
* owner aggregates the items' `finished` alongside its own animations; otherwise the
* owner would drop the subtree mid-cascade on exit. Absent ⇒ no extra wait (the
* common case — an island surface with no DOM-child cascade).
*/
pending?: (phase: PresencePhase) => Promise<void> | undefined;
}
export type TransitionStatus = 'starting' | 'ending' | undefined;
@ -124,8 +135,8 @@ export class Presence implements PresenceMember {
if (runId !== this.runId) return;
this.transitionStatus = undefined;
// Start any JS-driven motion, then wait for CSS + JS to complete.
const extra = this.startMotion('enter');
// Start any JS-driven motion + DOM-child cascade, then wait for CSS + JS.
const extra = this.startPhase('enter');
this.waitForAnimations(runId, extra, () => {
this.opts.onComplete?.(true);
});
@ -156,8 +167,10 @@ export class Presence implements PresenceMember {
const runId = ++this.runId;
// Start any JS-driven exit motion, then wait for CSS + JS, then unmount.
const extra = this.startMotion('exit');
// Start any JS-driven exit motion + DOM-child cascade, then wait for CSS + JS,
// then unmount. With a `pending` hook this holds the subtree until the cascade
// settles (RFC §M9 exit-heavy) instead of dropping the items mid-stagger.
const extra = this.startPhase('exit');
this.waitForAnimations(runId, extra, () => {
if (runId !== this.runId) return;
this.shouldRender = false;
@ -229,7 +242,7 @@ export class Presence implements PresenceMember {
// the group reaches it, giving `when: 'after'` its sequencing (§8.1).
if (phase === 'enter') this.transitionStatus = undefined;
else this.transitionStatus = 'ending';
const extra = this.startMotion(phase);
const extra = this.startPhase(phase);
this.waitForAnimations(runId, extra, () => {
if (phase === 'enter') this.opts.onComplete?.(true);
resolve();
@ -273,6 +286,22 @@ export class Presence implements PresenceMember {
return this.opts.motion?.run(node, phase)?.finished;
}
/**
* Begin this phase's awaitable side effects and fold them into ONE promise to gate
* completion on: the JS-driven motion (`startMotion`) AND the children-DOM cascade's
* aggregated `finished` (`opts.pending` — RFC §M9). The owner node's own
* `getAnimations()` is awaited separately in `waitForAnimations`; this covers what
* that call cannot see — a JS spring (invisible to `getAnimations()`) and the
* DOM-child cascade (descendants, no `{subtree:true}`). With neither wired this is
* exactly `startMotion`, so an island surface behaves precisely as before.
*/
private startPhase(phase: PresencePhase): Promise<void> | undefined {
const motion = this.startMotion(phase);
const cascade = this.opts.pending?.(phase);
if (motion && cascade) return Promise.all([motion, cascade]).then(() => undefined);
return motion ?? cascade;
}
private waitForAnimations(
runId: number,
extra: Promise<void> | undefined,

@ -8,9 +8,11 @@
* reusing eidos's coordinated preset UNCHANGED (the items end up carrying the same
* attrs a child `Presence` would have produced).
*
* F1b validates the ENTER cascade (the items stagger in when the container opens).
* The EXIT is still abrupt: the owner `Presence` settles on its own animations and
* doesn't yet await the items (no subtree) — that's F1c (exit-heavy). Inherits the
* F1b validated the ENTER cascade (the items stagger in when the container opens).
* F1c closes the EXIT: the owner `Presence` cannot see the items' transitions on its
* own node (`getAnimations()`, no subtree), so the `DomCascade` exposes a `pending()`
* that the owner aggregates via `PresenceOptions.pending` — it now HOLDS the subtree
* until the cascade settles instead of dropping the items mid-stagger. Inherits the
* ActiveUix + Soma + Eidos scope from `../+layout.svelte`.
*/
import { ActiveEidos } from '$uix/eidos';
@ -32,17 +34,23 @@
let containerEl = $state<HTMLElement | null>(null);
// ONE Presence for the owner (the container). It owns the lifecycle; the cascade
// only mirrors it onto the DOM items.
const presence = new Presence({
// only mirrors it onto the DOM items. F1c: on exit the owner awaits the items'
// cascade (`pending`) before unmounting — their transitions are invisible to the
// owner's `getAnimations()` (no subtree), so without this the container would drop
// mid-stagger on close. `cascade` is referenced lazily (only when `pending` fires).
// Explicit annotations break the inference cycle: `presence.pending` reads
// `cascade`, whose `ownerTransitionAttrs` reads `presence.transitionAttrs`.
const presence: Presence = new Presence({
dom: eidos.dom,
open: readableActive(() => open),
ref: readableActive(() => containerEl)
ref: readableActive(() => containerEl),
pending: () => cascade.pending()
});
// The propagator: route `animStyle` to each DOM child, mirror the owner's
// transition attrs, number them by DOM order. `items()` is a plain selector read —
// the same shape a menu's `getItems` uses.
const cascade = new DomCascade({
const cascade: DomCascade = new DomCascade({
dom: eidos.dom,
animationStyle: readableActive(() => animStyle),
ownerTransitionAttrs: readableActive(() => presence.transitionAttrs),
@ -53,13 +61,13 @@
</script>
<svelte:head>
<title>DomCascade · children-DOM (M9 F1b)</title>
<title>DomCascade · children-DOM (M9 F1c)</title>
</svelte:head>
<div class="root">
<header>
<a class="back" href="/temas/animations">← Motion</a>
<h1>DomCascade <small>children-DOM · M9 F1b</small></h1>
<h1>DomCascade <small>children-DOM · M9 F1c</small></h1>
<p class="lede">
La <strong>segunda coordinación</strong> del servicio (RFC §M9). A diferencia de
<a href="/temas/animations/reveal">Reveal</a> / <a href="/temas/animations/rail">Rail</a>
@ -71,8 +79,12 @@
coordinado de eidos sin cambiarlo</strong>. Es la grieta que el menú destapó, ya pavimentada.
</p>
<p class="lede note">
F1b valida la <strong>entrada</strong> en cascada. La salida aún es abrupta (el owner no espera
a los ítems — sin <code>subtree</code>): eso es F1c (exit-heavy).
F1b validó la <strong>entrada</strong>; <strong>F1c</strong> cierra la
<strong>salida</strong>: el owner ahora <strong>espera</strong> a los ítems antes de
desmontar. Su <code>getAnimations()</code> no ve las transiciones de los ítems —sin
<code>subtree</code>— así que el <code>DomCascade</code> expone un <code>pending()</code> que el
<code>Presence</code> agrega. Ciérrala: los ítems salen en cascada <strong>antes</strong> de que
el contenedor desaparezca.
</p>
<div class="actions">
<button class="btn primary" type="button" onclick={() => (open = !open)}>

@ -0,0 +1,229 @@
<script lang="ts">
/**
* Dropdown-menu item cascade (RFC §M9 F2) — the children-DOM coordination mode
* on a REAL component. Unlike the `dom-cascade` isolation demo (static items in
* a bare container), here the cascade rides the actual `<DropdownMenu>`: the
* Content is the owner `Presence`, its rows are discovered by the provider
* (`getCascadeRows`), and the cascade COEXISTS with the focus-trap, dismissal,
* roving focus and a submenu. Opt-in via the `animation` prop; `undefined` ⇒ the
* menu behaves exactly as before (its plain `dropdown-menu-enter` chrome).
*
* Decision C: the WHOLE menu staggers — items, separators and group headings,
* disabled rows included. Decision A: focus returns to the trigger at close,
* not after the exit cascade. Submenus (decision B) keep their own animation.
* Inherits the ActiveUix + Soma + Eidos + sound scope from `../+layout.svelte`.
*/
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
type Preset = 'off' | 'cascade-slide' | 'cascade-fade' | 'cascade-scale';
const PRESETS: { value: Preset; label: string }[] = [
{ value: 'off', label: 'off' },
{ value: 'cascade-slide', label: 'slide' },
{ value: 'cascade-fade', label: 'fade' },
{ value: 'cascade-scale', label: 'scale' }
];
let open = $state(false);
let preset = $state<Preset>('cascade-slide');
let staggerEach = $state(45);
let cascadeDuration = $state(320);
// Bound menu state
let notifications = $state(true);
let theme = $state<'light' | 'dark' | 'system'>('system');
let lastSelected = $state<string | null>(null);
const animation = $derived(preset === 'off' ? undefined : preset);
const contentStyle = $derived(
`--motion-cascade-duration: ${cascadeDuration}ms; --motion-stagger-each: ${staggerEach}ms`
);
const pick = (label: string) => () => (lastSelected = label);
</script>
<svelte:head>
<title>DropdownMenu cascade · children-DOM (M9 F2)</title>
</svelte:head>
<div class="root">
<header>
<a class="back" href="/temas/animations">← Motion</a>
<h1>DropdownMenu <small>item cascade · M9 F2</small></h1>
<p class="lede">
El modo <strong>children-DOM</strong> sobre un componente real. El
<code>&lt;DropdownMenu&gt;</code> expone una prop <code>animation</code>: al activarla, las filas
del menú <strong>entran y salen en cascada</strong> reusando el preset coordinado de eidos sin
cambiarlo. El Content es el <code>Presence</code> dueño; el provider descubre las filas
(<code>getCascadeRows</code>) y propaga el lifecycle. Convive con focus-trap, dismissal, roving
focus y submenú.
</p>
<p class="lede note">
<strong>Ábrelo y ciérralo</strong>: en salida, el panel <strong>espera</strong> a que las filas
salgan (F1c · <code>pending</code>) antes de desmontar. El foco vuelve al botón al cerrar, no al
terminar la animación. Cascadea <strong>todo</strong>: items, separadores y cabeceras,
incluyendo deshabilitados.
</p>
<div class="controls">
<div class="picker" role="group" aria-label="Preset de animación">
{#each PRESETS as p (p.value)}
<button
class="chip-btn"
class:on={preset === p.value}
type="button"
onclick={() => (preset = p.value)}
>
{p.label}
</button>
{/each}
</div>
<label class="slider">
<span>stagger {staggerEach}ms</span>
<input type="range" min="10" max="120" step="5" bind:value={staggerEach} />
</label>
<label class="slider">
<span>duración {cascadeDuration}ms</span>
<input type="range" min="120" max="600" step="20" bind:value={cascadeDuration} />
</label>
</div>
</header>
<div class="stage">
<DropdownMenu bind:open {animation}>
<DropdownMenu.Trigger>Cuenta ▾</DropdownMenu.Trigger>
<DropdownMenu.Content side="bottom" align="start" sideOffset={6} style={contentStyle}>
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>Cuenta</DropdownMenu.GroupHeading>
<DropdownMenu.Item onSelect={pick('Perfil')}>Perfil</DropdownMenu.Item>
<DropdownMenu.Item onSelect={pick('Ajustes')}>Ajustes</DropdownMenu.Item>
<DropdownMenu.Item disabled>Facturación (no disponible)</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.CheckboxItem bind:checked={notifications}>
Notificaciones
</DropdownMenu.CheckboxItem>
<DropdownMenu.Separator />
<DropdownMenu.RadioGroup bind:value={theme}>
<DropdownMenu.GroupHeading>Tema</DropdownMenu.GroupHeading>
<DropdownMenu.RadioItem value="light">Claro</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="dark">Oscuro</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="system">Sistema</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
<DropdownMenu.Separator />
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Más opciones</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.Item onSelect={pick('Exportar')}>Exportar…</DropdownMenu.Item>
<DropdownMenu.Item onSelect={pick('Importar')}>Importar…</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={pick('Salir')}>Salir</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
<p class="echo">
Última selección: <strong>{lastSelected ?? '—'}</strong>
</p>
</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 0 0.75rem;
max-width: 66ch;
line-height: 1.6;
color: var(--color-content-secondary, #444);
}
.lede.note {
font-size: 0.85rem;
color: var(--color-content-muted, #777);
}
.lede code {
font-size: 0.85em;
}
.controls {
display: flex;
align-items: center;
gap: 1.25rem;
margin-block-start: 1.25rem;
flex-wrap: wrap;
}
.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);
}
.slider {
display: inline-flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.75rem;
color: var(--color-content-muted, #777);
}
.stage {
max-width: 880px;
margin-inline: auto;
padding: 2rem;
border-radius: 16px;
border: 1px dashed var(--color-border-subtle, rgba(0, 0, 0, 0.12));
background: var(--color-surface-raised, #f7f7f8);
min-height: 220px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1.25rem;
}
.echo {
margin: 0;
font-size: 0.85rem;
color: var(--color-content-muted, #777);
}
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.