You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/web/routes/temas/animations/dropdown-menu/+page.svelte

230 lines
7.3 KiB

<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>

Powered by TurnKey Linux.