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.
217 lines
6.6 KiB
217 lines
6.6 KiB
<script lang="ts">
|
|
import { cubicOut, cubicIn } from 'svelte/easing';
|
|
|
|
/*
|
|
* Fase 0 — Svelte-native menu cascade prototype (THROWAWAY).
|
|
*
|
|
* Close choreography (RFC `when: { exit: 'after' }` bracket):
|
|
* 1. the items leave in a reversed cascade (opacity+transform) — you SEE them
|
|
* go; they keep their space, so the cascade isn't masked;
|
|
* 2. THEN the panel collapses like a blind (its OWN close firma — the size
|
|
* channel of `emerge.close` of the panel), delayed so it "drags" the items.
|
|
* The blind is CSS grid `1fr → 0fr` (no JS measuring; visual stays in eidos).
|
|
* The panel's delay is tunable below so the drag feel can be dialled in.
|
|
*/
|
|
|
|
let open = $state(false);
|
|
let opening = $state(false);
|
|
let closing = $state(false);
|
|
let openTimer: ReturnType<typeof setTimeout> | undefined;
|
|
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
let items = $state(['Perfil', 'Facturación', 'Equipo', 'Integraciones', 'Ajustes', 'Cerrar sesión']);
|
|
let added = 0;
|
|
|
|
// Panel-blind tuning. delay = how long after the items start leaving the panel
|
|
// begins to collapse → the "drag" feel. Tune it live with the slider.
|
|
let panelDelay = $state(250);
|
|
const panelDur = 280;
|
|
|
|
function toggle() {
|
|
if (open) {
|
|
closing = true;
|
|
open = false;
|
|
clearTimeout(closeTimer);
|
|
closeTimer = setTimeout(() => (closing = false), 1200);
|
|
return;
|
|
}
|
|
closing = false;
|
|
opening = true;
|
|
open = true;
|
|
clearTimeout(openTimer);
|
|
openTimer = setTimeout(() => (opening = false), 800);
|
|
}
|
|
|
|
function addItem() {
|
|
added += 1;
|
|
items.push('Nuevo ' + added);
|
|
}
|
|
function removeLast() {
|
|
if (items.length) items.pop();
|
|
}
|
|
|
|
function readMs(node: Element, prop: string, fallback: number): number {
|
|
const raw = getComputedStyle(node).getPropertyValue(prop).trim();
|
|
if (!raw) return fallback;
|
|
if (raw.endsWith('ms')) return parseFloat(raw);
|
|
if (raw.endsWith('s')) return parseFloat(raw) * 1000;
|
|
const n = parseFloat(raw);
|
|
return Number.isFinite(n) ? n : fallback;
|
|
}
|
|
|
|
function readPx(node: Element, prop: string, fallback: number): number {
|
|
const n = parseFloat(getComputedStyle(node).getPropertyValue(prop));
|
|
return Number.isFinite(n) ? n : fallback;
|
|
}
|
|
|
|
/**
|
|
* Item lifecycle shim — opacity + transform only (items keep their space so the
|
|
* cascade is visible). Stagger only during the open/close burst; an individual
|
|
* add/remove enters/leaves with delay 0. Values from eidos tokens.
|
|
*/
|
|
function cascade(
|
|
node: Element,
|
|
{ index, count, dir }: { index: number; count: number; dir: 'in' | 'out' }
|
|
) {
|
|
const dur = readMs(node, '--motion-cascade-duration', 240);
|
|
const each = readMs(node, '--motion-stagger-each', 55);
|
|
const shift = readPx(node, '--proto-cascade-shift', -14);
|
|
let delay = 0;
|
|
if (dir === 'in' && opening) delay = index * each;
|
|
else if (dir === 'out' && closing) delay = (count - 1 - index) * each;
|
|
return {
|
|
delay,
|
|
duration: dur,
|
|
easing: dir === 'out' ? cubicIn : cubicOut,
|
|
css: (t: number, u: number) => `opacity:${t};transform:translateX(${u * shift}px)`
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Panel blind — the panel's OWN close firma. CSS grid `1fr → 0fr` collapses the
|
|
* height with NO JS measurement. Delayed so the items leave first and the panel
|
|
* appears to drag them down.
|
|
*/
|
|
function blind(node: Element, { delay, duration }: { delay: number; duration: number }) {
|
|
// Roll-up: collapse the panel's measured height to 0. `height` IS WAAPI-
|
|
// animatable; `grid-template-rows` is NOT (Svelte produced no animation for
|
|
// it). Real browsers compute layout, so getBoundingClientRect is reliable.
|
|
const h = node.getBoundingClientRect().height;
|
|
return {
|
|
delay,
|
|
duration,
|
|
easing: cubicIn,
|
|
css: (t: number) => `overflow:hidden;height:${t * h}px`
|
|
};
|
|
}
|
|
</script>
|
|
|
|
<div class="page">
|
|
<h1>Fase 0 — cierre con arrastre (persiana del panel)</h1>
|
|
<p class="note">
|
|
Al cerrar: los ítems salen en cascada (se ven) y <strong>luego</strong> el panel colapsa como persiana
|
|
(<code>grid 1fr→0fr</code>, sin medir en JS), con retardo → el panel los «arrastra». Es la firma de cierre
|
|
propia del panel (<code>when: exit: 'after'</code>). Ajusta el retardo:
|
|
</p>
|
|
|
|
<label class="slider">
|
|
Retardo de la persiana: <strong>{panelDelay}ms</strong>
|
|
<input type="range" min="0" max="700" step="10" bind:value={panelDelay} />
|
|
</label>
|
|
|
|
<div class="controls">
|
|
<button class="trigger" onclick={toggle} aria-expanded={open}>
|
|
{open ? 'Cerrar' : 'Abrir'} menú
|
|
</button>
|
|
{#if open}
|
|
<button class="add" onclick={addItem}>Añadir ítem</button>
|
|
<button class="remove" onclick={removeLast}>Quitar último</button>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if open}
|
|
<div class="panel" data-proto-panel out:blind={{ delay: panelDelay, duration: panelDur }}>
|
|
{#each items as label, i (label)}
|
|
<div
|
|
class="item"
|
|
data-proto-item
|
|
data-index={i}
|
|
in:cascade|global={{ index: i, count: items.length, dir: 'in' }}
|
|
out:cascade|global={{ index: i, count: items.length, dir: 'out' }}
|
|
>
|
|
{label}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-4, 1rem);
|
|
max-inline-size: 32rem;
|
|
padding: var(--space-6, 2rem);
|
|
color: var(--color-content-primary, #111);
|
|
}
|
|
|
|
.note {
|
|
color: var(--color-content-muted, #666);
|
|
font-size: var(--font-size-sm, 0.875rem);
|
|
line-height: var(--leading-ui, 1.5);
|
|
}
|
|
|
|
.slider {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-1, 0.25rem);
|
|
align-items: flex-start;
|
|
font-size: var(--font-size-sm, 0.875rem);
|
|
color: var(--color-content-muted, #666);
|
|
}
|
|
|
|
.controls {
|
|
display: flex;
|
|
gap: var(--space-2, 0.5rem);
|
|
}
|
|
|
|
button {
|
|
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
|
border: 1px solid var(--color-border-default, #ccc);
|
|
border-radius: var(--radius-md, 0.5rem);
|
|
background: var(--color-surface-raised, #fff);
|
|
color: inherit;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.panel {
|
|
--motion-cascade-duration: var(--duration-moderate, 240ms);
|
|
--motion-stagger-each: 55ms;
|
|
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
inline-size: 14rem;
|
|
padding: var(--space-1, 0.25rem);
|
|
border: 1px solid var(--color-border-default, #ccc);
|
|
border-radius: var(--radius-md, 0.5rem);
|
|
background: var(--color-surface-overlay, #fff);
|
|
box-shadow: var(--shadow-overlay, 0 8px 24px rgba(0, 0, 0, 0.12));
|
|
}
|
|
|
|
.item {
|
|
--proto-cascade-shift: -14px;
|
|
|
|
box-sizing: border-box;
|
|
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
|
border-radius: var(--radius-sm, 0.25rem);
|
|
font-size: var(--font-size-sm, 0.875rem);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.item:hover {
|
|
background: var(--color-primary-element, #eef);
|
|
}
|
|
</style>
|