From d797a0cebc2e92d58af8cdad170d6109f2396c3a Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 15 Jun 2026 23:47:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(motion):=20M9=20F1c+F2=20=E2=80=94=20dropd?= =?UTF-8?q?own-menu=20item=20cascade=20(children-DOM)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../dropdown-menu/dropdown-menu.css | 62 ++++- src/uix/morfo/components/dropdown-menu.ts | 6 +- .../components/dropdown-menu.svelte | 4 +- .../dropdown-menu-provider.svelte.test.ts | 3 +- .../dropdown-menu-provider.svelte.ts | 80 +++++- .../soma/components/dropdown-menu/types.ts | 8 + src/uix/soma/layers/dom-cascade.svelte.ts | 123 +++++++++- src/uix/soma/layers/dom-cascade.test.ts | 89 ++++++- src/uix/soma/layers/floating/shell.ts | 11 +- src/uix/soma/layers/presence.svelte.ts | 39 ++- .../temas/animations/dom-cascade/+page.svelte | 34 ++- .../animations/dropdown-menu/+page.svelte | 229 ++++++++++++++++++ 12 files changed, 650 insertions(+), 38 deletions(-) create mode 100644 web/routes/temas/animations/dropdown-menu/+page.svelte diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu.css b/src/uix/eidos/components/dropdown-menu/dropdown-menu.css index 0063a1fa3..ba700bb4a 100644 --- a/src/uix/eidos/components/dropdown-menu/dropdown-menu.css +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu.css @@ -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; +} diff --git a/src/uix/morfo/components/dropdown-menu.ts b/src/uix/morfo/components/dropdown-menu.ts index 93556a1cf..3ef45aead 100644 --- a/src/uix/morfo/components/dropdown-menu.ts +++ b/src/uix/morfo/components/dropdown-menu.ts @@ -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', diff --git a/src/uix/soma/components/dropdown-menu/components/dropdown-menu.svelte b/src/uix/soma/components/dropdown-menu/components/dropdown-menu.svelte index f2e1c63af..5f2a8afd9 100644 --- a/src/uix/soma/components/dropdown-menu/components/dropdown-menu.svelte +++ b/src/uix/soma/components/dropdown-menu/components/dropdown-menu.svelte @@ -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) }); diff --git a/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.test.ts b/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.test.ts index 5623ec86b..a5e15075c 100644 --- a/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.test.ts +++ b/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.test.ts @@ -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(undefined) }; } diff --git a/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts b/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts index afe3e4964..76c22399d 100644 --- a/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts +++ b/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts @@ -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; + 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(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(); }), diff --git a/src/uix/soma/components/dropdown-menu/types.ts b/src/uix/soma/components/dropdown-menu/types.ts index 3817b42d6..a834fbe14 100644 --- a/src/uix/soma/components/dropdown-menu/types.ts +++ b/src/uix/soma/components/dropdown-menu/types.ts @@ -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; }; diff --git a/src/uix/soma/layers/dom-cascade.svelte.ts b/src/uix/soma/layers/dom-cascade.svelte.ts index 4babadc84..c9fc5746c 100644 --- a/src/uix/soma/layers/dom-cascade.svelte.ts +++ b/src/uix/soma/layers/dom-cascade.svelte.ts @@ -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 { + const items = this.applied; + return new Promise((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]); + }); + } } diff --git a/src/uix/soma/layers/dom-cascade.test.ts b/src/uix/soma/layers/dom-cascade.test.ts index 445bf37f4..6ff0eebb4 100644 --- a/src/uix/soma/layers/dom-cascade.test.ts +++ b/src/uix/soma/layers/dom-cascade.test.ts @@ -7,7 +7,8 @@ import type { ActiveDom } from '$adom'; type ApplyCall = { target: HTMLElement; attrs?: Record }; 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[]) => + ({ 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((r) => (doneA = r)); + const b = new Promise((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); + }); }); diff --git a/src/uix/soma/layers/floating/shell.ts b/src/uix/soma/layers/floating/shell.ts index 8b5595968..a184b4148 100644 --- a/src/uix/soma/layers/floating/shell.ts +++ b/src/uix/soma/layers/floating/shell.ts @@ -42,6 +42,14 @@ export interface FloatingShellRootOpts { * is a no-op. */ onOpenChangeComplete?: Active | 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 | 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 }; diff --git a/src/uix/soma/layers/presence.svelte.ts b/src/uix/soma/layers/presence.svelte.ts index 764a80952..296428ec0 100644 --- a/src/uix/soma/layers/presence.svelte.ts +++ b/src/uix/soma/layers/presence.svelte.ts @@ -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 | 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 | 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 | undefined, diff --git a/web/routes/temas/animations/dom-cascade/+page.svelte b/web/routes/temas/animations/dom-cascade/+page.svelte index 03e811650..4e034ca21 100644 --- a/web/routes/temas/animations/dom-cascade/+page.svelte +++ b/web/routes/temas/animations/dom-cascade/+page.svelte @@ -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(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 @@ - DomCascade · children-DOM (M9 F1b) + DomCascade · children-DOM (M9 F1c)
← Motion -

DomCascade children-DOM · M9 F1b

+

DomCascade children-DOM · M9 F1c

La segunda coordinación del servicio (RFC §M9). A diferencia de Reveal / Rail @@ -71,8 +79,12 @@ coordinado de eidos sin cambiarlo. Es la grieta que el menú destapó, ya pavimentada.

- F1b valida la entrada en cascada. La salida aún es abrupta (el owner no espera - a los ítems — sin subtree): eso es F1c (exit-heavy). + F1b validó la entrada; F1c cierra la + salida: el owner ahora espera a los ítems antes de + desmontar. Su getAnimations() no ve las transiciones de los ítems —sin + subtree— así que el DomCascade expone un pending() que el + Presence agrega. Ciérrala: los ítems salen en cascada antes de que + el contenedor desaparezca.

+ {/each} +
+ + +
+ + +
+ + Cuenta ▾ + + + Cuenta + Perfil + Ajustes + Facturación (no disponible) + + + + Notificaciones + + + + Tema + Claro + Oscuro + Sistema + + + + Más opciones + + Exportar… + Importar… + + + + Salir + + + +

+ Última selección: {lastSelected ?? '—'} +

+
+ + +