From 9d40ebe9c876c8ce7a5e6e59e5e664da65122e0d Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 20 Jun 2026 14:28:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(motion):=20event-driven=20item=20cascade?= =?UTF-8?q?=20=E2=80=94=20first=20real=20consumer=20(DropdownMenu)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Productizes the event-driven cascade on a real component (not the explicit `` orchestrator). When a DropdownMenu opens, its items fade in staggered by structural order — the appearance flows from the `open` (emerge) event, nothing in the app wires it. Architecture: a +1 GENERIC selector branch in the motion generator, NOT a per-item `data-state`. A `menuitem` isn't open/closed — the menu is — and eidos writing a state attr is a smell. The new container-driven form `[data-stagger][data-state='open'] > [data-animation-style='X']` fires each child's ENTER off the container's `data-state` (which soma already writes via the morfo `commits`); items carry only `data-animation-style`. ENTER only: the coordinated EXIT cascade (retain the container until children finish) is the deferred soma PresenceGroup work, so a closing container can't drive a child exit that would be cut off on unmount. - render-css.ts: `renderCssPresetRules` adds the container-driven enter branch (same declarations, shared comma selector) — reuses the existing presets + the foundation `:nth-child` stagger index; zero new system. - DropdownMenu: Content marks itself `[data-stagger]` + `--motion-stagger-each: var(--motion-stagger)` (20ms, themeable, no magic number); item/checkbox-item/radio-item carry `data-animation-style="fade"` (overridable). `fade` so item opacity doesn't fight the panel's own scale. - regen generated/base.css (8 css presets gain the container-driven enter). Verified in browser (frozen frame): index from `:nth-child`, `delay = idx × each`, opacity gradient in flight (Ruler 0.98 → Grid 0.89 → Guides 0.69, Log out 0.00). Honest limit (documented): only DIRECT-child items cascade — items in a / DOM wrapper aren't `:nth-child` of the panel, so they appear instantly (the "transparent intermediate"). Docs: dropdown README §Motion, cascade README §2 (concrete consumer), RFC §D.11.4. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/uix/eidos/MOTION_SERVICE_RFC.md | 24 ++++++++++-- src/uix/eidos/components/cascade/README.md | 17 +++++++-- .../eidos/components/dropdown-menu/README.md | 37 +++++++++++++++++++ .../dropdown-menu-checkbox-item.svelte | 2 +- .../dropdown-menu-content.svelte | 8 +++- .../dropdown-menu/dropdown-menu-item.svelte | 8 +++- .../dropdown-menu-radio-item.svelte | 2 +- .../dropdown-menu/dropdown-menu.css | 10 +++++ src/uix/eidos/generated/base.css | 24 ++++++++---- src/uix/eidos/lib/render-css.ts | 19 +++++++++- 10 files changed, 131 insertions(+), 20 deletions(-) diff --git a/src/uix/eidos/MOTION_SERVICE_RFC.md b/src/uix/eidos/MOTION_SERVICE_RFC.md index 06e7b9554..8eff0fc1b 100644 --- a/src/uix/eidos/MOTION_SERVICE_RFC.md +++ b/src/uix/eidos/MOTION_SERVICE_RFC.md @@ -1380,11 +1380,29 @@ La cascada NO es un sistema nuevo. Es el preset declarado + el stagger que ya ex lineal es el VALOR del token, en el estilo del componente. - **La animación:** el preset que el item lleva en `data-animation-style` (declarado en EidosConfig.motion). Keyframe + duración + reduced-motion: del preset, gratis. +- **Las DOS formas de selector (en cada preset, mismas declaraciones):** + - *self-driven* — `[data-animation-style='X'][data-state='Y']`: una superficie suelta que lleva su PROPIO + `data-state` (el panel Content, un dialog, un ``). + - *container-driven (solo ENTER)* — `[data-stagger][data-state='open'] > [data-animation-style='X']`: la + cascada event-driven. Los hijos llevan SOLO `data-animation-style`; **no** un `data-state` por-item (un + `menuitem` no es open/closed — la que abre es la lista). El `data-state` del **contenedor** —que soma ya + escribe vía el `commits` del morfo— dispara el enter de los hijos. Es **+1 rama de selector genérica** en + `render-css.ts`, no un sistema nuevo. Solo ENTER: los hijos montan juntos (enter sin coordinación); el EXIT + coordinado (retener el contenedor hasta que los hijos terminen) es trabajo de soma (`PresenceGroup`), aún + diferido — un contenedor que cierra NO debe disparar un exit de hijos que se cortaría al desmontar. **Dos dominios para usarlo:** (1) **explícito** — el componente `` (orquestador fino, sin recipe ni -`--cascade-*`): marca `[data-stagger]`, refleja `open`→`data-state`, pasa el preset a los items por contexto; -(2) **event-driven** — un componente real declara en SU morfo el evento `emerge` + `data-stagger` en el -contenedor; sus items llevan el preset + `data-state`. La aparición sale del evento declarado, sin wrapper. +`--cascade-*`): marca `[data-stagger]`, refleja `open`→`data-state`, pasa el preset a los items por contexto +(usa la forma self-driven + un `out:` por-item para el quitado individual); (2) **event-driven** — un componente +real declara en SU morfo `emerge` + marca su contenedor `[data-stagger]` en eidos; sus items llevan SOLO el +preset y cascadean por la forma container-driven. La aparición sale del evento declarado, sin wrapper. + +**Primer consumidor real (event-driven): ``** — los items hacen fade escalonado al abrir +(`src/uix/eidos/components/dropdown-menu/`: Content `[data-stagger]` + `--motion-stagger-each: var(--motion-stagger)`, +items `data-animation-style="fade"`). Verificado en navegador: índice desde `:nth-child`, `delay = idx × +each`, gradiente de opacidad en vuelo (Ruler 0.98 → Grid 0.89 → Guides 0.69, Log out 0.00). Limitación honesta: +solo cascadean los items que son **hijos directos** del panel — los envueltos en ``/`` (un +elemento DOM real) no son `:nth-child` del contenedor y aparecen instantáneos (el «intermedio transparente»). #### D.11.5 — El servicio `motion` es el motor (par/superior a Framer) diff --git a/src/uix/eidos/components/cascade/README.md b/src/uix/eidos/components/cascade/README.md index 7148bcda0..151796794 100644 --- a/src/uix/eidos/components/cascade/README.md +++ b/src/uix/eidos/components/cascade/README.md @@ -69,10 +69,19 @@ duration, then unmounts. Accepts any `HTMLAttributes`. semantic event. The content/intrinsic animation domain. 2. **Event-driven** — a real component (a menu, a list) declares its own `emerge` firma (the container flourish + sound) **and** marks its item container `[data-stagger]`; its items - carry a preset + the container's `data-state`. Same foundation stagger, no `` - wrapper — the appearance flows from the declared event. (The firma is *generic by event*: - "children of an emerging container present together, by structural order"; the concrete - timing — parallel/cascade, ms, keyframe — is the per-component realization.) + carry a preset (`data-animation-style`). Same foundation stagger, no `` wrapper — + the appearance flows from the declared event. The container-driven preset rule + `[data-stagger][data-state='open'] > [data-animation-style]` fires the children's enter off + the *container's* `data-state` (which soma already writes via the morfo `commits`), so the + items carry **no per-item `data-state`** — a `menuitem` isn't open/closed; the menu is. + (The firma is *generic by event*: "children of an emerging container present together, by + structural order"; the concrete timing — parallel/cascade, ms, keyframe — is the + per-component realization.) + + **First concrete consumer: ``** — items fade in staggered on open + (`src/uix/eidos/components/dropdown-menu/`). See its README → *Motion*. The container-driven + rule is **enter-only**; the coordinated *exit* cascade (retain the container until its + children finish) is the deferred soma `PresenceGroup` work. ## Known limitation — bulk removal diff --git a/src/uix/eidos/components/dropdown-menu/README.md b/src/uix/eidos/components/dropdown-menu/README.md index a9c68e237..87abfd504 100644 --- a/src/uix/eidos/components/dropdown-menu/README.md +++ b/src/uix/eidos/components/dropdown-menu/README.md @@ -76,6 +76,43 @@ Soma owns: - Modal / dismissal behaviour (overlay click, outside interact) - Sub-menu cross-axis navigation (ArrowRight opens, ArrowLeft closes) +## Motion — event-driven item cascade + +When the menu opens, its items **fade in staggered by structural order** — the +first concrete consumer of the framework's *event-driven* cascade (vs the +explicit `` orchestrator). Nothing in the app wires it; the appearance +**flows from the `open` (emerge) event**: + +- `` marks its panel `[data-stagger]`. +- each item carries `data-animation-style="fade"`. +- the panel sets the rhythm `--motion-stagger-each: var(--motion-stagger)` (the + canonical 20ms). + +The per-item **index comes from CSS structure** (`[data-stagger] > *:nth-child` +→ `--motion-stagger-index`) — **no JS writes it**, soma never touches a visual +var. The container-driven preset rule `[data-stagger][data-state='open'] > +[data-animation-style='fade']` fires each item's enter with +`delay = index × --motion-stagger-each`. The items reuse the same `fade` +state-preset + foundation stagger that `` rides — zero new machinery +beyond one generic selector branch in the motion generator. + +Tuning, per call-site, on ``: + +- `style="--motion-stagger-each: 0"` → **parallel** (all items fade together). +- `style="--motion-stagger-each: 40ms"` → a more deliberate ripple. +- a different preset per item: ``. + +Two honest limits (both documented framework-wide): + +- **Only direct-child items cascade.** Items wrapped in a `` / + `` (a real DOM element) are *not* `:nth-child` of the panel, so + they appear instantly (the "transparent intermediate" limitation). A flat menu + cascades fully; a grouped one cascades only its top-level items. +- **Enter only.** On close the panel exits as a unit (today an opacity snap); + per-item *exit* cascade needs the coordinated-retention `PresenceGroup` (soma + lifecycle), still deferred — so a closing container does **not** drive a child + exit that would be cut off on unmount. + ## Comparativa | Lib | Sub-menus | CheckboxItem / RadioItem | CheckboxGroup | Arrow | Modal toggle | diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu-checkbox-item.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu-checkbox-item.svelte index 08b421b12..fd48b1fbf 100644 --- a/src/uix/eidos/components/dropdown-menu/dropdown-menu-checkbox-item.svelte +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -18,7 +18,7 @@ }: DropdownMenuCheckboxItemProps = $props(); - + {#snippet children(snippetProps)} {@render bodyContent?.(snippetProps)} {/snippet} diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu-content.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu-content.svelte index 85d0adddd..0d08fd294 100644 --- a/src/uix/eidos/components/dropdown-menu/dropdown-menu-content.svelte +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu-content.svelte @@ -6,6 +6,12 @@ * * `children` is renamed to `bodyContent` so the inner snippet that * forwards `{ open }` doesn't shadow the prop name. + * + * `data-stagger` marks the panel as the event-driven cascade container: its + * direct-child items (which carry `data-animation-style`) get their structural + * index from `:nth-child` and enter together when the menu opens. The rhythm + * is the recipe's `--motion-stagger-each` (var(--motion-stagger), 0 = parallel). + * Nobody writes the index; the appearance flows from the `open` (emerge) event. */ import * as DropdownMenu from '$soma/components/dropdown-menu'; import type { DropdownMenuContentProps } from './types'; @@ -16,7 +22,7 @@ }: DropdownMenuContentProps = $props(); - + {#snippet children(snippetProps)} {@render bodyContent?.(snippetProps)} {/snippet} diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu-item.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu-item.svelte index c6796cb6c..2f0b3edd8 100644 --- a/src/uix/eidos/components/dropdown-menu/dropdown-menu-item.svelte +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu-item.svelte @@ -1,7 +1,13 @@ -{@render children?.()} +{@render children?.()} diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu-radio-item.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu-radio-item.svelte index e174a7647..03547215d 100644 --- a/src/uix/eidos/components/dropdown-menu/dropdown-menu-radio-item.svelte +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu-radio-item.svelte @@ -16,7 +16,7 @@ }: DropdownMenuRadioItemProps = $props(); - + {#snippet children(snippetProps)} {@render bodyContent?.(snippetProps)} {/snippet} diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu.css b/src/uix/eidos/components/dropdown-menu/dropdown-menu.css index 029406664..a824a4d20 100644 --- a/src/uix/eidos/components/dropdown-menu/dropdown-menu.css +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu.css @@ -50,6 +50,16 @@ outline: none; } +/* Event-driven item cascade rhythm. The Content wrapper marks itself + `[data-stagger]`; each direct-child item carries `data-animation-style` + and gets its structural index from `:nth-child`. This is the per-item + delay step — `var(--motion-stagger)` (canonical 20ms); 0 = parallel, + larger = a more deliberate ripple. The cascade flows from the `open` + (emerge) event — no JS writes the index, no consumer wiring. */ +[data-dropdown-menu-content] { + --motion-stagger-each: var(--motion-stagger); +} + /* ── Item rows ─────────────────────────────────────────────────────── */ [data-dropdown-menu-item], diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 3b2392180..72a729965 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -5112,7 +5112,8 @@ animation: announce-pulse-threat var(--duration-slower) var(--ease-spring); } -[data-animation-style='fade'][data-state='open'] { +[data-animation-style='fade'][data-state='open'], +[data-stagger][data-state='open'] > [data-animation-style='fade'] { animation: fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards; animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms)); } @@ -5122,7 +5123,8 @@ animation-delay: calc(var(--motion-stagger-index-rev, 0) * var(--motion-stagger-each, 0ms)); } -[data-animation-style='scale-fade'][data-state='open'] { +[data-animation-style='scale-fade'][data-state='open'], +[data-stagger][data-state='open'] > [data-animation-style='scale-fade'] { animation: scale-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards; animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms)); transform-origin: var(--floating-transform-origin); @@ -5154,7 +5156,8 @@ } } -[data-animation-style='slide-fade'][data-state='open'] { +[data-animation-style='slide-fade'][data-state='open'], +[data-stagger][data-state='open'] > [data-animation-style='slide-fade'] { animation: slide-from-bottom var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, scale-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards; animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms)); } @@ -5224,7 +5227,8 @@ } } -[data-animation-style='slide-full'][data-state='open'] { +[data-animation-style='slide-full'][data-state='open'], +[data-stagger][data-state='open'] > [data-animation-style='slide-full'] { animation: slide-from-right-full var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards; animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms)); } @@ -5294,7 +5298,8 @@ } } -[data-animation-style='collapse'][data-state='open'] { +[data-animation-style='collapse'][data-state='open'], +[data-stagger][data-state='open'] > [data-animation-style='collapse'] { animation: expand-height var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards; animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms)); } @@ -5324,7 +5329,8 @@ } } -[data-animation-style='shared-axis-x'][data-state='open'] { +[data-animation-style='shared-axis-x'][data-state='open'], +[data-stagger][data-state='open'] > [data-animation-style='shared-axis-x'] { animation: slide-axis-x-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards; animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms)); } @@ -5354,7 +5360,8 @@ } } -[data-animation-style='shared-axis-y'][data-state='open'] { +[data-animation-style='shared-axis-y'][data-state='open'], +[data-stagger][data-state='open'] > [data-animation-style='shared-axis-y'] { animation: slide-axis-y-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards; animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms)); } @@ -5384,7 +5391,8 @@ } } -[data-animation-style='fade-through'][data-state='open'] { +[data-animation-style='fade-through'][data-state='open'], +[data-stagger][data-state='open'] > [data-animation-style='fade-through'] { animation: scale-through var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards; animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms)); } diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts index 60a5a1a54..ae87b7a62 100644 --- a/src/uix/eidos/lib/render-css.ts +++ b/src/uix/eidos/lib/render-css.ts @@ -770,9 +770,26 @@ function renderCssPresetRules(name: string, preset: CssStatePreset): string[] { for (const { phase, state, fill, dur, ease } of MOTION_PHASES) { const cssPhase = preset[phase] if (!cssPhase) continue + // Two selector forms share the SAME declarations: + // · self-driven — a lone surface carries its own `data-state` (Content + // panel, dialog, a ``). + // · container-driven (ENTER only) — the EVENT-DRIVEN cascade: direct + // children of an opening `[data-stagger]` container that carry this + // preset enter together, indexed FROM STRUCTURE (`:nth-child` → + // `--motion-stagger-index`). No per-item `data-state` (a `menuitem` + // isn't open/closed); the container's state — which soma already writes + // via the morfo `commits` — drives the children. + // ENTER only: children mount together so enter needs no coordination; the + // coordinated EXIT (retain the container until its children finish) is a + // soma lifecycle concern (PresenceGroup), still deferred — so a closing + // container must NOT drive a child exit that would be cut off on unmount. + const selectors = [`[data-animation-style='${name}'][data-state='${state}']`] + if (phase === 'enter') { + selectors.push(`[data-stagger][data-state='${state}'] > [data-animation-style='${name}']`) + } rules.push( renderBlock( - `[data-animation-style='${name}'][data-state='${state}']`, + selectors.join(',\n'), phaseDeclarations(cssPhase, cssPhase.keyframes, fill, dur, ease, phase) ) )