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
parent
db1aa14d1d
commit
d797a0cebc
@ -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><DropdownMenu></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…
Reference in new issue