refactor: consolidate acel utilities, fix floating positioning bug, normalize types

- Merge internal/ and shared/ into utilities/ with semantic subdirs:
  floating/, focus/, keyboard/, datetime/, presence/, typeahead/
- Fix tooltip/popover positioning: wrapperProps.style was a plain object,
  causing Svelte 5 spread to serialize it as "[object Object]" — wrapped
  with styleToString() so CSS is applied correctly
- Fix WithChild<T,{},Ref> producing Snippet<[{}]> instead of Snippet:
  use keyof check so empty SnippetProps yields plain Snippet (no args)
- Fix TooltipContentProps.children: switch to WithChildNoChildrenSnippetProps
  so children is Snippet while child retains { open: boolean } props
- Fix field component: move field.svelte.ts to root dir, add exports.ts,
  simplify index.ts, normalize import paths across all field components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
dev 6 months ago
parent 9084d2783a
commit a8ca6b0b4d

@ -7,7 +7,16 @@
"Bash(npx tsc:*)",
"Bash(grep -v \"^$\")",
"Bash(grep -n export.*Mutable g:/dev/svelte/vicen/src/lib/glob/lib/date/utils.ts)",
"Bash(grep -rn export.*Mutable g:/dev/svelte/vicen/src/lib/glob/lib/date/ --include=*.ts)"
"Bash(grep -rn export.*Mutable g:/dev/svelte/vicen/src/lib/glob/lib/date/ --include=*.ts)",
"Bash(wc -l \"g:/dev/svelte/vicen/src/uix/uisv/lib/bits/calendar/components/\"*.svelte)",
"Bash(grep -n \"from \"\"@internationalized\\\\|from ''.*shared/date\\\\|from \"\".*shared/date\\\\|getDocument\\\\|isBrowser\\\\|styleToString\\\\|afterTick\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/utils.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/formatter.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/announcer.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/calendar-helpers.svelte.ts\")",
"Bash(grep -r from.*[''].*internal/ g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte -l)",
"Bash(grep -r from.*[''].*shared/ g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte -l)",
"Bash(grep -rh \"from.*[''''].*\\\\\\(internal\\\\|shared\\\\\\)/\" g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte)",
"Bash(grep -v \"^//\")",
"Bash(grep -r \"from.*[''''].*\\\\\\(internal\\\\|shared\\\\\\)/\" g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte)",
"Bash(grep -v '^\\\\s*//')",
"Bash(git -C g:/dev/svelte/vicen status --short -- src/uix/acel/date-field/)"
]
}
}

@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(grep -v \"$lib/internal\")",
"Bash(grep \"from.*label\" g:/dev/svelte/vicen/src/uix/acel/field/components/*.svelte)",
"Bash(grep -n \"from\" g:/dev/svelte/vicen/src/uix/acel/field/components/*.svelte)",
"Bash(sed -i 's|from \"\"\\\\./field\\\\.svelte\"\"|from \"\"../field.svelte\"\"|g' *.svelte)",
"Bash(sed -i 's|from \"\"\\\\.\\\\.\\\\/\\\\.\\\\.\\\\/utilities\"\"|from \"\"../../utilities/index\"\"|g' *.svelte)",
"Bash(sed -i 's|from \"\"\\\\.\\\\/\\\\.\\\\.\\\\/types\"\"|from \"\"../types\"\"|g' *.svelte)"
]
}
}

@ -1,2 +1,4 @@
import "./layout.css"
export const ssr = false;
export const prerender = false;

@ -56,6 +56,74 @@
<main>
<!-- ── COMPONENTES ──────────────────────────────────────────────────── -->
<div class="comp-section">
<h2 class="comp-title">Componentes UI</h2>
<div class="comp-groups">
<div class="comp-group">
<span class="comp-group-label">Primitivas</span>
<div class="comp-links">
<a href="/test/separator" class="comp-link">Separator</a>
<a href="/test/label" class="comp-link">Label</a>
<a href="/test/progress" class="comp-link">Progress</a>
<a href="/test/meter" class="comp-link">Meter</a>
<a href="/test/toggle" class="comp-link">Toggle</a>
<a href="/test/toggle-group" class="comp-link">ToggleGroup</a>
<a href="/test/pagination" class="comp-link">Pagination</a>
<a href="/test/slider" class="comp-link">Slider</a>
<a href="/test/scroll-area" class="comp-link">ScrollArea</a>
</div>
</div>
<div class="comp-group">
<span class="comp-group-label">Formulario</span>
<div class="comp-links">
<a href="/test/switch" class="comp-link">Switch</a>
<a href="/test/checkbox" class="comp-link">Checkbox</a>
<a href="/test/radio-group" class="comp-link">RadioGroup</a>
<a href="/test/rating-group" class="comp-link">RatingGroup</a>
<a href="/test/toolbar" class="comp-link">Toolbar</a>
<a href="/test/pin-input" class="comp-link">PinInput</a>
</div>
</div>
<div class="comp-group">
<span class="comp-group-label">Compuestos</span>
<div class="comp-links">
<a href="/test/button" class="comp-link">Button</a>
<a href="/test/avatar" class="comp-link">Avatar</a>
<a href="/test/aspect-ratio" class="comp-link">AspectRatio</a>
<a href="/test/collapsible" class="comp-link">Collapsible</a>
<a href="/test/accordion" class="comp-link">Accordion</a>
<a href="/test/tabs" class="comp-link">Tabs</a>
</div>
</div>
<div class="comp-group">
<span class="comp-group-label">Floating</span>
<div class="comp-links">
<a href="/test/tooltip" class="comp-link">Tooltip</a>
<a href="/test/popover" class="comp-link">Popover</a>
<a href="/test/dropdown-menu" class="comp-link">DropdownMenu</a>
<a href="/test/context-menu" class="comp-link">ContextMenu</a>
<a href="/test/menubar" class="comp-link">Menubar</a>
<a href="/test/command" class="comp-link">Command</a>
</div>
</div>
<div class="comp-group">
<span class="comp-group-label">Overlays</span>
<div class="comp-links">
<a href="/test/alert-dialog" class="comp-link">AlertDialog</a>
<a href="/test/dialog" class="comp-link">Dialog</a>
<a href="/test/modal" class="comp-link">Modal</a>
</div>
</div>
<div class="comp-group">
<span class="comp-group-label">Fechas</span>
<div class="comp-links">
<a href="/test/calendar" class="comp-link">Calendar</a>
</div>
</div>
</div>
</div>
<!-- ── GRID ─────────────────────────────────────────────────────────── -->
<div class="grid">
@ -378,7 +446,35 @@
.theme-btn:hover { background: rgba(255,255,255,.1); }
/* ── MAIN GRID ───────────────────────────────────────────────────────────── */
main { max-width: 1400px; margin: 0 auto; padding: 2rem 1.5rem; }
main { max-width: 1400px; margin: 0 auto; padding: 2rem 1.5rem; display: flex; flex-direction: column; gap: 1.5rem; }
/* ── COMPONENTS SECTION ──────────────────────────────────────────────────── */
.comp-section {
background: #fff;
border: 1.5px solid #e0ddd6;
border-top: 3px solid #6366f1;
border-radius: 4px;
padding: 1.25rem;
}
.page.dark .comp-section { background: #16161d; border-color: #2a2a35; border-top-color: #6366f1; }
.comp-title {
font-size: .65rem; font-weight: 700; letter-spacing: .2em;
text-transform: uppercase; color: #999; margin-bottom: .85rem;
}
.comp-groups { display: flex; gap: 1.5rem; flex-wrap: wrap; }
.comp-group { display: flex; flex-direction: column; gap: .4rem; }
.comp-group-label {
font-size: .6rem; font-weight: 700; letter-spacing: .15em;
text-transform: uppercase; color: #bbb;
}
.comp-links { display: flex; flex-direction: column; gap: .2rem; }
.comp-link {
font-size: .78rem; font-weight: 500;
color: #6366f1; text-decoration: none;
padding: .1rem 0;
transition: opacity .15s;
}
.comp-link:hover { opacity: .7; text-decoration: underline; }
.grid {
display: grid;

@ -0,0 +1,249 @@
<script lang="ts">
import { AspectRatio } from '$uix/acel';
</script>
<div class="page">
<header class="ph">
<div class="ph-inner">
<div class="ph-brand">
<span class="ph-dot"></span>
<span class="ph-title">aspect<em>-ratio</em></span>
</div>
<span class="ph-sub">acel · proporción de aspecto</span>
</div>
</header>
<main>
<div class="grid">
<!-- ── 1. 16/9 ────────────────────────────────────────────────── -->
<section class="card" data-color="teal">
<header class="card-hd">
<span class="tag">01</span>
<span class="card-title">16 / 9 — Panorámico</span>
</header>
<p class="card-desc">
Proporción estándar de vídeo HD. El contenedor reserva el espacio
exacto antes de que cargue el contenido.
</p>
<AspectRatio.Root ratio={16 / 9} class="ar-box">
<div class="ar-fill ar-fill--teal">
<span class="ar-label">16 / 9</span>
</div>
</AspectRatio.Root>
</section>
<!-- ── 2. 4/3 ─────────────────────────────────────────────────── -->
<section class="card" data-color="indigo">
<header class="card-hd">
<span class="tag">02</span>
<span class="card-title">4 / 3 — Clásico</span>
</header>
<p class="card-desc">
Formato de pantalla anterior a la era HD. Usado en fotografía y
presentaciones tradicionales.
</p>
<AspectRatio.Root ratio={4 / 3} class="ar-box">
<div class="ar-fill ar-fill--indigo">
<span class="ar-label">4 / 3</span>
</div>
</AspectRatio.Root>
</section>
<!-- ── 3. 1/1 ─────────────────────────────────────────────────── -->
<section class="card" data-color="amber">
<header class="card-hd">
<span class="tag">03</span>
<span class="card-title">1 / 1 — Cuadrado</span>
</header>
<p class="card-desc">
Proporción cuadrada. Ideal para avatares, miniaturas de producto
y grids de redes sociales.
</p>
<AspectRatio.Root ratio={1} class="ar-box">
<div class="ar-fill ar-fill--amber">
<span class="ar-label">1 / 1</span>
</div>
</AspectRatio.Root>
</section>
<!-- ── 4. 9/16 ────────────────────────────────────────────────── -->
<section class="card" data-color="rose">
<header class="card-hd">
<span class="tag">04</span>
<span class="card-title">9 / 16 — Vertical</span>
</header>
<p class="card-desc">
Formato vertical para stories, reels y contenido móvil.
Panorámico rotado 90°.
</p>
<div style="max-width: 180px; margin: 0 auto;">
<AspectRatio.Root ratio={9 / 16} class="ar-box">
<div class="ar-fill ar-fill--rose">
<span class="ar-label">9 / 16</span>
</div>
</AspectRatio.Root>
</div>
</section>
<!-- ── 5. Con imagen ──────────────────────────────────────────── -->
<section class="card" data-color="violet">
<header class="card-hd">
<span class="tag">05</span>
<span class="card-title">Con imagen real</span>
</header>
<p class="card-desc">
<code>object-fit: cover</code> garantiza que la imagen rellene
el espacio sin deformarse, independientemente de su resolución original.
</p>
<AspectRatio.Root ratio={16 / 9} class="ar-box">
<img
src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&q=80"
alt="Montaña nevada"
class="ar-img"
/>
</AspectRatio.Root>
</section>
<!-- ── 6. child snippet ───────────────────────────────────────── -->
<section class="card" data-color="green">
<header class="card-hd">
<span class="tag">06</span>
<span class="card-title">Slot <code>child</code></span>
</header>
<p class="card-desc">
Con el snippet <code>child</code> obtienes acceso directo a los
props del elemento raíz para composición avanzada.
</p>
<AspectRatio.Root ratio={21 / 9}>
{#snippet child({ props })}
<div {...props} class="ar-custom">
<span class="ar-label">21 / 9 · CinemaScope</span>
<span class="ar-sub">via child snippet</span>
</div>
{/snippet}
</AspectRatio.Root>
</section>
</div>
</main>
</div>
<style>
:global(*, *::before, *::after) { box-sizing: border-box; margin: 0; padding: 0; }
.page {
min-height : 100vh;
background : #0e0e14;
color : #d4d2cc;
font-family: 'DM Mono', 'Fira Code', monospace;
font-size : 13px;
}
/* ── PAGE HEADER ─────────────────────────────────────────────────────── */
.ph { border-bottom: 2px solid #2dd4bf; background: #080810; }
.ph-inner {
max-width: 1200px; margin: 0 auto;
padding : .75rem 1.5rem;
display : flex; align-items: center; justify-content: space-between;
}
.ph-brand { display: flex; align-items: center; gap: .5rem; }
.ph-dot { width: 8px; height: 8px; border-radius: 50%; background: #2dd4bf; }
.ph-title {
font-size: .85rem; font-weight: 700;
letter-spacing: .15em; text-transform: uppercase; color: #f4f3ef;
}
.ph-title em { color: #2dd4bf; font-style: normal; }
.ph-sub { font-size: .65rem; color: #555; letter-spacing: .1em; }
/* ── MAIN ────────────────────────────────────────────────────────────── */
main { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: .75rem;
}
/* ── CARDS ───────────────────────────────────────────────────────────── */
.card {
background : #16161f;
border : 1px solid #26262f;
border-radius: 6px;
padding : 1.1rem;
display : flex; flex-direction: column; gap: .75rem;
border-top : 3px solid transparent;
}
.card[data-color="teal"] { border-top-color: #2dd4bf; }
.card[data-color="indigo"] { border-top-color: #818cf8; }
.card[data-color="amber"] { border-top-color: #e5a100; }
.card[data-color="rose"] { border-top-color: #fb7185; }
.card[data-color="violet"] { border-top-color: #a78bfa; }
.card[data-color="green"] { border-top-color: #4ade80; }
.card-hd {
display : flex; align-items: center; gap: .5rem;
padding-bottom: .6rem;
border-bottom : 1px solid #26262f;
}
.tag { font-size: .6rem; font-weight: 700; letter-spacing: .15em; color: #555; }
.card-title { font-size: .75rem; font-weight: 600; color: #aaa; }
.card-desc { font-size: .72rem; color: #555; line-height: 1.5; }
code {
background: #26262f; padding: .1rem .35rem;
border-radius: 2px; font-size: .68rem;
color: #a78bfa; font-family: inherit;
}
/* ── ASPECT RATIO DEMOS ──────────────────────────────────────────────── */
:global(.ar-box) {
border-radius: 4px;
overflow : hidden;
}
.ar-fill {
position : absolute;
inset : 0;
display : flex; align-items: center; justify-content: center;
border-radius : 4px;
}
.ar-fill--teal { background: #0d2d2a; border: 1px solid #134e4a; }
.ar-fill--indigo { background: #1e1b4b; border: 1px solid #312e81; }
.ar-fill--amber { background: #2d1a00; border: 1px solid #4d2d00; }
.ar-fill--rose { background: #2d0a0a; border: 1px solid #4d1a1a; }
.ar-label {
font-size : 1rem; font-weight: 700;
letter-spacing: .1em; color: #888;
}
.ar-img {
position : absolute;
inset : 0;
width : 100%; height: 100%;
object-fit: cover;
border-radius: 4px;
}
/* child snippet demo */
.ar-custom {
position : absolute;
inset : 0;
display : flex; flex-direction: column;
align-items : center; justify-content: center; gap: .35rem;
background : #1a0e2e;
border : 1px solid #3b1d6e;
border-radius : 4px;
}
.ar-sub {
font-size: .65rem; color: #555; letter-spacing: .1em;
}
</style>

@ -0,0 +1,364 @@
<script lang="ts">
import { Avatar } from '$uix/acel';
import type { AvatarImageLoadingStatus } from '$uix/acel';
let dynamicSrc = $state("https://i.pravatar.cc/150?img=3");
let statusLog = $state<string[]>([]);
let liveStatus = $state<AvatarImageLoadingStatus>("loading");
const BAD_SRC = "https://this-domain-does-not-exist.invalid/avatar.png";
const GOOD_SRC = "https://i.pravatar.cc/150?img=3";
const ALT_SRC = "https://i.pravatar.cc/150?img=44";
function logStatus(v: AvatarImageLoadingStatus) {
statusLog = [`→ ${v}`, ...statusLog].slice(0, 6);
}
const INITIALS = [
{ name: "Ana García", init: "AG", bg: "#1e1b4b", color: "#818cf8" },
{ name: "Carlos Ruiz", init: "CR", bg: "#0d2d2a", color: "#2dd4bf" },
{ name: "María López", init: "ML", bg: "#2d1a00", color: "#e5a100" },
{ name: "Paco Torres", init: "PT", bg: "#2d0a14", color: "#fb7185" },
];
</script>
<div class="page">
<header class="ph">
<div class="ph-inner">
<div class="ph-brand">
<span class="ph-dot"></span>
<span class="ph-title">av<em>atar</em></span>
</div>
<span class="ph-sub">acel · imagen con fallback</span>
</div>
</header>
<main>
<div class="grid">
<!-- ── 1. BÁSICO ──────────────────────────────────────────────── -->
<section class="card" data-color="teal">
<header class="card-hd">
<span class="tag">01</span>
<span class="card-title">Básico — imagen y fallback</span>
</header>
<p class="card-desc">
Mientras carga la imagen se muestra el fallback.
Una vez cargada, el fallback desaparece.
</p>
<div class="av-row">
<Avatar.Root class="avatar">
<Avatar.Image
src="https://i.pravatar.cc/150?img=5"
alt="Usuario 5"
class="av-img"
/>
<Avatar.Fallback class="av-fb">US</Avatar.Fallback>
</Avatar.Root>
<Avatar.Root class="avatar">
<Avatar.Image
src="https://i.pravatar.cc/150?img=12"
alt="Usuario 12"
class="av-img"
/>
<Avatar.Fallback class="av-fb">U2</Avatar.Fallback>
</Avatar.Root>
<Avatar.Root class="avatar">
<Avatar.Image
src="https://i.pravatar.cc/150?img=33"
alt="Usuario 33"
class="av-img"
/>
<Avatar.Fallback class="av-fb">U3</Avatar.Fallback>
</Avatar.Root>
</div>
</section>
<!-- ── 2. ERROR DE CARGA ───────────────────────────────────────── -->
<section class="card" data-color="rose">
<header class="card-hd">
<span class="tag">02</span>
<span class="card-title">Error de carga</span>
</header>
<p class="card-desc">
Con una URL inválida el status pasa a <code>"error"</code>
y el fallback permanece visible indefinidamente.
</p>
<div class="av-row">
<Avatar.Root class="avatar">
<Avatar.Image src={BAD_SRC} alt="Imagen rota" class="av-img" />
<Avatar.Fallback class="av-fb av-fb--error">!</Avatar.Fallback>
</Avatar.Root>
<Avatar.Root class="avatar">
<Avatar.Image src={BAD_SRC} alt="Imagen rota" class="av-img" />
<Avatar.Fallback class="av-fb av-fb--error">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</Avatar.Fallback>
</Avatar.Root>
</div>
</section>
<!-- ── 3. SIN IMAGEN (solo fallback) ─────────────────────────── -->
<section class="card" data-color="amber">
<header class="card-hd">
<span class="tag">03</span>
<span class="card-title">Iniciales — solo fallback</span>
</header>
<p class="card-desc">
Sin <code>Avatar.Image</code> o con <code>src</code> nulo,
el fallback actúa como representación permanente.
</p>
<div class="av-row">
{#each INITIALS as user}
<Avatar.Root class="avatar">
<Avatar.Fallback
class="av-fb"
style="background:{user.bg};color:{user.color}"
>
{user.init}
</Avatar.Fallback>
</Avatar.Root>
{/each}
</div>
</section>
<!-- ── 4. DELAY + CALLBACK ────────────────────────────────────── -->
<section class="card" data-color="violet">
<header class="card-hd">
<span class="tag">04</span>
<span class="card-title">delayMs + onLoadingStatusChange</span>
</header>
<p class="card-desc">
<code>delayMs=800</code> retrasa la transición a "loaded" para evitar
parpadeos. El callback registra cada cambio de estado.
</p>
<Avatar.Root
class="avatar avatar--lg"
delayMs={800}
bind:loadingStatus={liveStatus}
onLoadingStatusChange={logStatus}
>
<Avatar.Image
src="https://i.pravatar.cc/300?img=60"
alt="Usuario con delay"
class="av-img"
/>
<Avatar.Fallback class="av-fb av-fb--lg">...</Avatar.Fallback>
</Avatar.Root>
<div class="state-row">
<span class="state-label">status:</span>
<span class="state-val state-val--{liveStatus}">{liveStatus}</span>
</div>
<div class="log-panel">
<span class="log-label">onLoadingStatusChange</span>
{#each statusLog as entry}
<span class="log-entry">{entry}</span>
{:else}
<span class="log-empty">— sin eventos aún</span>
{/each}
</div>
</section>
<!-- ── 5. SRC DINÁMICO ────────────────────────────────────────── -->
<section class="card" data-color="indigo">
<header class="card-hd">
<span class="tag">05</span>
<span class="card-title">src dinámico</span>
</header>
<p class="card-desc">
Cambia la URL en runtime. El estado vuelve a <code>"loading"</code>
y el fallback reaparece mientras carga la nueva imagen.
</p>
<Avatar.Root class="avatar avatar--lg">
<Avatar.Image src={dynamicSrc} alt="Dinámico" class="av-img" />
<Avatar.Fallback class="av-fb av-fb--lg">↺</Avatar.Fallback>
</Avatar.Root>
<div class="btn-row">
<button class="btn btn--indigo" onclick={() => dynamicSrc = GOOD_SRC}>
imagen A
</button>
<button class="btn btn--indigo" onclick={() => dynamicSrc = ALT_SRC}>
imagen B
</button>
<button class="btn btn--rose" onclick={() => dynamicSrc = BAD_SRC}>
URL rota
</button>
</div>
<p class="src-display">{dynamicSrc.slice(-30)}</p>
</section>
<!-- ── 6. TAMAÑOS ─────────────────────────────────────────────── -->
<section class="card" data-color="green">
<header class="card-hd">
<span class="tag">06</span>
<span class="card-title">Tamaños</span>
</header>
<p class="card-desc">
El componente no impone tamaño. Se controla completamente por CSS.
</p>
<div class="av-row av-row--center">
{#each [24, 32, 40, 56, 72] as size}
<Avatar.Root class="avatar" style="width:{size}px;height:{size}px">
<Avatar.Image
src="https://i.pravatar.cc/{size * 2}?img=7"
alt="Avatar {size}px"
class="av-img"
/>
<Avatar.Fallback
class="av-fb"
style="font-size:{Math.max(8, size * 0.35)}px"
>
U
</Avatar.Fallback>
</Avatar.Root>
{/each}
</div>
</section>
</div>
</main>
</div>
<style>
:global(*, *::before, *::after) { box-sizing: border-box; margin: 0; padding: 0; }
.page {
min-height : 100vh;
background : #0e0e14;
color : #d4d2cc;
font-family: 'DM Mono', 'Fira Code', monospace;
font-size : 13px;
}
.ph { border-bottom: 2px solid #818cf8; background: #080810; }
.ph-inner {
max-width: 1200px; margin: 0 auto;
padding : .75rem 1.5rem;
display : flex; align-items: center; justify-content: space-between;
}
.ph-brand { display: flex; align-items: center; gap: .5rem; }
.ph-dot { width: 8px; height: 8px; border-radius: 50%; background: #818cf8; }
.ph-title {
font-size: .85rem; font-weight: 700;
letter-spacing: .15em; text-transform: uppercase; color: #f4f3ef;
}
.ph-title em { color: #818cf8; font-style: normal; }
.ph-sub { font-size: .65rem; color: #555; letter-spacing: .1em; }
main { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: .75rem;
}
.card {
background : #16161f;
border : 1px solid #26262f;
border-radius: 6px;
padding : 1.1rem;
display : flex; flex-direction: column; gap: .75rem;
border-top : 3px solid transparent;
}
.card[data-color="teal"] { border-top-color: #2dd4bf; }
.card[data-color="rose"] { border-top-color: #fb7185; }
.card[data-color="amber"] { border-top-color: #e5a100; }
.card[data-color="violet"] { border-top-color: #a78bfa; }
.card[data-color="indigo"] { border-top-color: #818cf8; }
.card[data-color="green"] { border-top-color: #4ade80; }
.card-hd {
display: flex; align-items: center; gap: .5rem;
padding-bottom: .6rem; border-bottom: 1px solid #26262f;
}
.tag { font-size: .6rem; font-weight: 700; letter-spacing: .15em; color: #555; }
.card-title { font-size: .75rem; font-weight: 600; color: #aaa; }
.card-desc { font-size: .72rem; color: #555; line-height: 1.5; }
code {
background: #26262f; padding: .1rem .35rem;
border-radius: 2px; font-size: .68rem;
color: #a78bfa; font-family: inherit;
}
/* ── AVATAR ───────────────────────────────────────────────────────────── */
:global(.avatar) {
display : inline-flex; align-items: center; justify-content: center;
width : 40px; height: 40px;
border-radius: 50%;
overflow : hidden;
position : relative;
background : #26262f;
flex-shrink : 0;
}
:global(.avatar--lg) { width: 72px; height: 72px; }
:global(.av-img) {
width: 100%; height: 100%; object-fit: cover;
}
:global(.av-fb) {
display : flex; align-items: center; justify-content: center;
width : 100%; height: 100%;
font-size : .68rem; font-weight: 700;
color : #888; background: #26262f;
user-select: none;
}
:global(.av-fb--lg) { font-size: 1.1rem; }
:global(.av-fb--error) { color: #fb7185; background: #2d0a14; }
/* ── LAYOUT ───────────────────────────────────────────────────────────── */
.av-row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.av-row--center { align-items: flex-end; }
.state-row { display: flex; align-items: center; gap: .5rem; font-size: .72rem; }
.state-label { color: #555; }
.state-val { font-weight: 700; color: #555; }
.state-val--loading { color: #e5a100; }
.state-val--loaded { color: #4ade80; }
.state-val--error { color: #fb7185; }
.log-panel {
display: flex; flex-direction: column; gap: .2rem;
padding: .6rem; background: #0e0e14;
border: 1px solid #26262f; border-radius: 3px;
}
.log-label { font-size: .6rem; color: #444; letter-spacing: .1em; text-transform: uppercase; margin-bottom: .1rem; }
.log-entry { font-size: .68rem; color: #818cf8; }
.log-empty { font-size: .68rem; color: #333; font-style: italic; }
.btn-row { display: flex; gap: .4rem; flex-wrap: wrap; }
.btn {
padding : .3rem .65rem; border: none; border-radius: 3px;
font-family: inherit; font-size: .68rem; font-weight: 700;
cursor : pointer; transition: opacity .12s;
}
.btn:hover { opacity: .8; }
.btn--indigo { background: #1e1b4b; color: #818cf8; }
.btn--rose { background: #2d0a14; color: #fb7185; }
.src-display {
font-size: .65rem; color: #444;
overflow : hidden; text-overflow: ellipsis; white-space: nowrap;
}
.src-display::before { content: "…"; }
</style>

@ -0,0 +1,850 @@
<script lang="ts">
import { Calendar } from '$uix/acel';
import type { DateValue } from '$lib/glob/lib/date';
import { today, getLocalTimeZone, CalendarDate, isSameDay } from '$lib/glob/lib/date';
// ── Estado ──────────────────────────────────────────────────────────────────
let singleValue = $state<DateValue | undefined>(undefined);
let multipleValues = $state<DateValue[]>([]);
let disabledValue = $state<DateValue | undefined>(undefined);
let rangeValue = $state<DateValue | undefined>(undefined);
let twoMonthValue = $state<DateValue | undefined>(undefined);
const todayDate = today(getLocalTimeZone());
// Fechas deshabilitadas: fines de semana (0 = dom, 6 = sáb en getDay())
function isWeekend(date: DateValue): boolean {
const d = date.toDate(getLocalTimeZone());
const day = d.getDay();
return day === 0 || day === 6;
}
// No disponibles: 10–15 del mes actual
function isUnavailable(date: DateValue): boolean {
return date.month === todayDate.month &&
date.year === todayDate.year &&
date.day >= 10 && date.day <= 15;
}
// Límites para el ejemplo 03
const minDate = todayDate.subtract({ months: 1 });
const maxDate = todayDate.add({ months: 3 });
// Rango de fechas
let rangeStart = $state<DateValue | undefined>(undefined);
let rangeEnd = $state<DateValue | undefined>(undefined);
// Inicializar a todayDate — nunca undefined, evita fallos en comparaciones
let rangeStartPlaceholder = $state<DateValue>(todayDate);
let rangeEndPlaceholder = $state<DateValue>(todayDate);
function monthIdx(d: DateValue) { return d.year * 12 + d.month; }
function onRangeStartPlaceholderChange(p: DateValue) {
rangeStartPlaceholder = p;
// Si el inicio navega por delante del fin (por mes), arrastrar el fin
if (monthIdx(p) > monthIdx(rangeEndPlaceholder)) {
rangeEndPlaceholder = p;
}
}
function onRangeEndPlaceholderChange(p: DateValue) {
rangeEndPlaceholder = p;
// Si el fin navega por detrás del inicio (por mes), arrastrar el inicio
if (monthIdx(p) < monthIdx(rangeStartPlaceholder)) {
rangeStartPlaceholder = p;
}
}
function rangeAttrs(date: DateValue) {
const isStart = !!rangeStart && isSameDay(date, rangeStart);
const isEnd = !!rangeEnd && isSameDay(date, rangeEnd);
const inside = !!rangeStart && !!rangeEnd
&& date.compare(rangeStart) > 0 && date.compare(rangeEnd) < 0;
return {
"data-range-start" : isStart ? "" : undefined,
"data-range-end" : isEnd ? "" : undefined,
"data-in-range" : inside ? "" : undefined,
};
}
const rangeDays = $derived.by(() => {
if (!rangeStart || !rangeEnd) return 0;
const s = rangeStart.toDate(getLocalTimeZone());
const e = rangeEnd.toDate(getLocalTimeZone());
return Math.round((e.getTime() - s.getTime()) / (1000 * 60 * 60 * 24));
});
// Múltiple pre-seleccionado
const preSelected: DateValue[] = [
new CalendarDate(todayDate.year, todayDate.month, 3),
new CalendarDate(todayDate.year, todayDate.month, 7),
new CalendarDate(todayDate.year, todayDate.month, 14),
];
let multiPre = $state<DateValue[]>(preSelected);
function fmt(v: DateValue | undefined) {
if (!v) return "—";
return `${v.year}-${String(v.month).padStart(2,'0')}-${String(v.day).padStart(2,'0')}`;
}
function fmtArr(arr: DateValue[]) {
if (!arr.length) return "—";
return arr.map(fmt).join(", ");
}
</script>
<div class="page">
<header class="ph">
<div class="ph-inner">
<div class="ph-brand">
<span class="ph-dot"></span>
<span class="ph-title">cal<em>endar</em></span>
</div>
<span class="ph-sub">acel · selección de fecha</span>
</div>
</header>
<main>
<div class="grid">
<!-- ── 1. BÁSICO ─────────────────────────────────────────────────── -->
<section class="card" data-color="teal">
<header class="card-hd">
<span class="tag">01</span>
<span class="card-title">Single — básico</span>
</header>
<p class="card-desc">Selección de una fecha. Haz clic en un día para seleccionarlo.</p>
<Calendar.Root type="single" bind:value={singleValue} class="cal">
{#snippet children({ months, weekdays })}
<Calendar.Header class="cal-hd">
<Calendar.PrevButton class="cal-btn">‹</Calendar.PrevButton>
<Calendar.Heading class="cal-heading" />
<Calendar.NextButton class="cal-btn">›</Calendar.NextButton>
</Calendar.Header>
{#each months as month}
<Calendar.Grid class="cal-grid">
<Calendar.GridHead class="cal-thead">
<Calendar.GridRow class="cal-tr">
{#each weekdays as day}
<Calendar.HeadCell class="cal-th">{day}</Calendar.HeadCell>
{/each}
</Calendar.GridRow>
</Calendar.GridHead>
<Calendar.GridBody class="cal-tbody">
{#each month.weeks as week}
<Calendar.GridRow class="cal-tr">
{#each week as date}
<Calendar.Cell {date} month={month.value} class="cal-td">
<Calendar.Day class="cal-day" />
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
{/each}
{/snippet}
</Calendar.Root>
<div class="val-row">
<span class="val-label">value:</span>
<span class="val-val">{fmt(singleValue)}</span>
</div>
</section>
<!-- ── 2. MÚLTIPLE ──────────────────────────────────────────────── -->
<section class="card" data-color="violet">
<header class="card-hd">
<span class="tag">02</span>
<span class="card-title">Multiple — preseleccionado</span>
</header>
<p class="card-desc">
Selección de múltiples fechas. Máximo <code>5</code> días.
Pre-cargado con 3 fechas del mes actual.
</p>
<Calendar.Root
type="multiple"
bind:value={multiPre}
maxDays={5}
class="cal"
>
{#snippet children({ months, weekdays })}
<Calendar.Header class="cal-hd">
<Calendar.PrevButton class="cal-btn">‹</Calendar.PrevButton>
<Calendar.Heading class="cal-heading" />
<Calendar.NextButton class="cal-btn">›</Calendar.NextButton>
</Calendar.Header>
{#each months as month}
<Calendar.Grid class="cal-grid">
<Calendar.GridHead class="cal-thead">
<Calendar.GridRow class="cal-tr">
{#each weekdays as day}
<Calendar.HeadCell class="cal-th">{day}</Calendar.HeadCell>
{/each}
</Calendar.GridRow>
</Calendar.GridHead>
<Calendar.GridBody class="cal-tbody">
{#each month.weeks as week}
<Calendar.GridRow class="cal-tr">
{#each week as date}
<Calendar.Cell {date} month={month.value} class="cal-td">
<Calendar.Day class="cal-day" />
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
{/each}
{/snippet}
</Calendar.Root>
<div class="val-row">
<span class="val-label">values:</span>
<span class="val-val val-val--sm">{fmtArr(multiPre)}</span>
</div>
</section>
<!-- ── 3. MIN/MAX + DESHABILITADOS ──────────────────────────────── -->
<section class="card" data-color="amber">
<header class="card-hd">
<span class="tag">03</span>
<span class="card-title">minValue / maxValue + isDateDisabled</span>
</header>
<p class="card-desc">
Rango permitido: ±1 mes. Fines de semana deshabilitados.
Días 10–15 marcados como no disponibles.
</p>
<Calendar.Root
type="single"
bind:value={disabledValue}
minValue={minDate}
maxValue={maxDate}
isDateDisabled={isWeekend}
isDateUnavailable={isUnavailable}
class="cal"
>
{#snippet children({ months, weekdays })}
<Calendar.Header class="cal-hd">
<Calendar.PrevButton class="cal-btn">‹</Calendar.PrevButton>
<Calendar.Heading class="cal-heading" />
<Calendar.NextButton class="cal-btn">›</Calendar.NextButton>
</Calendar.Header>
{#each months as month}
<Calendar.Grid class="cal-grid">
<Calendar.GridHead class="cal-thead">
<Calendar.GridRow class="cal-tr">
{#each weekdays as day}
<Calendar.HeadCell class="cal-th">{day}</Calendar.HeadCell>
{/each}
</Calendar.GridRow>
</Calendar.GridHead>
<Calendar.GridBody class="cal-tbody">
{#each month.weeks as week}
<Calendar.GridRow class="cal-tr">
{#each week as date}
<Calendar.Cell {date} month={month.value} class="cal-td">
<Calendar.Day class="cal-day" />
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
{/each}
{/snippet}
</Calendar.Root>
<div class="val-row">
<span class="val-label">value:</span>
<span class="val-val">{fmt(disabledValue)}</span>
</div>
</section>
<!-- ── 4. CON SELECTORES ─────────────────────────────────────────── -->
<section class="card" data-color="indigo">
<header class="card-hd">
<span class="tag">04</span>
<span class="card-title">MonthSelect + YearSelect</span>
</header>
<p class="card-desc">
Navegación rápida usando selectores de mes y año.
</p>
<Calendar.Root type="single" bind:value={rangeValue} class="cal">
{#snippet children({ months, weekdays })}
<Calendar.Header class="cal-hd cal-hd--sel">
<Calendar.PrevButton class="cal-btn">‹</Calendar.PrevButton>
<div class="sel-group">
<Calendar.MonthSelect class="cal-select" />
<Calendar.YearSelect class="cal-select" />
</div>
<Calendar.NextButton class="cal-btn">›</Calendar.NextButton>
</Calendar.Header>
{#each months as month}
<Calendar.Grid class="cal-grid">
<Calendar.GridHead class="cal-thead">
<Calendar.GridRow class="cal-tr">
{#each weekdays as day}
<Calendar.HeadCell class="cal-th">{day}</Calendar.HeadCell>
{/each}
</Calendar.GridRow>
</Calendar.GridHead>
<Calendar.GridBody class="cal-tbody">
{#each month.weeks as week}
<Calendar.GridRow class="cal-tr">
{#each week as date}
<Calendar.Cell {date} month={month.value} class="cal-td">
<Calendar.Day class="cal-day" />
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
{/each}
{/snippet}
</Calendar.Root>
<div class="val-row">
<span class="val-label">value:</span>
<span class="val-val">{fmt(rangeValue)}</span>
</div>
</section>
<!-- ── 5. DOS MESES + fixedWeeks ────────────────────────────────── -->
<section class="card card--wide" data-color="rose">
<header class="card-hd">
<span class="tag">05</span>
<span class="card-title">numberOfMonths=2 + fixedWeeks + weekStartsOn=1</span>
</header>
<p class="card-desc">
Dos meses visibles simultáneamente. Semanas fijas (siempre 6 filas).
Semana empieza el lunes.
</p>
<Calendar.Root
type="single"
bind:value={twoMonthValue}
numberOfMonths={2}
fixedWeeks
weekStartsOn={1}
weekdayFormat="short"
class="cal cal--two"
>
{#snippet children({ months, weekdays })}
<div class="two-month-layout">
{#each months as month, i}
<div class="month-col">
<div class="month-nav">
{#if i === 0}
<Calendar.PrevButton class="cal-btn">‹</Calendar.PrevButton>
{:else}
<span></span>
{/if}
<Calendar.Heading class="cal-heading" />
{#if i === months.length - 1}
<Calendar.NextButton class="cal-btn">›</Calendar.NextButton>
{:else}
<span></span>
{/if}
</div>
<Calendar.Grid class="cal-grid">
<Calendar.GridHead class="cal-thead">
<Calendar.GridRow class="cal-tr">
{#each weekdays as day}
<Calendar.HeadCell class="cal-th">{day}</Calendar.HeadCell>
{/each}
</Calendar.GridRow>
</Calendar.GridHead>
<Calendar.GridBody class="cal-tbody">
{#each month.weeks as week}
<Calendar.GridRow class="cal-tr">
{#each week as date}
<Calendar.Cell {date} month={month.value} class="cal-td" {...rangeAttrs(date)}>
<Calendar.Day class="cal-day" />
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
</div>
{/each}
</div>
{/snippet}
</Calendar.Root>
<div class="val-row">
<span class="val-label">value:</span>
<span class="val-val">{fmt(twoMonthValue)}</span>
</div>
</section>
<!-- ── 6. READONLY + DISABLED ───────────────────────────────────── -->
<section class="card" data-color="green">
<header class="card-hd">
<span class="tag">06</span>
<span class="card-title">readonly / disabled</span>
</header>
<p class="card-desc">
Modo <code>readonly</code>: navegable pero no seleccionable.
Modo <code>disabled</code>: completamente inactivo.
</p>
<div class="pair-row">
<div>
<p class="pair-label">readonly</p>
<Calendar.Root
type="single"
readonly
value={todayDate}
class="cal cal--sm"
>
{#snippet children({ months, weekdays })}
<Calendar.Header class="cal-hd">
<Calendar.PrevButton class="cal-btn">‹</Calendar.PrevButton>
<Calendar.Heading class="cal-heading" />
<Calendar.NextButton class="cal-btn">›</Calendar.NextButton>
</Calendar.Header>
{#each months as month}
<Calendar.Grid class="cal-grid">
<Calendar.GridHead class="cal-thead">
<Calendar.GridRow class="cal-tr">
{#each weekdays as day}
<Calendar.HeadCell class="cal-th">{day}</Calendar.HeadCell>
{/each}
</Calendar.GridRow>
</Calendar.GridHead>
<Calendar.GridBody class="cal-tbody">
{#each month.weeks as week}
<Calendar.GridRow class="cal-tr">
{#each week as date}
<Calendar.Cell {date} month={month.value} class="cal-td" {...rangeAttrs(date)}>
<Calendar.Day class="cal-day" />
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
{/each}
{/snippet}
</Calendar.Root>
</div>
<div>
<p class="pair-label">disabled</p>
<Calendar.Root
type="single"
disabled
value={todayDate}
class="cal cal--sm"
>
{#snippet children({ months, weekdays })}
<Calendar.Header class="cal-hd">
<Calendar.PrevButton class="cal-btn">‹</Calendar.PrevButton>
<Calendar.Heading class="cal-heading" />
<Calendar.NextButton class="cal-btn">›</Calendar.NextButton>
</Calendar.Header>
{#each months as month}
<Calendar.Grid class="cal-grid">
<Calendar.GridHead class="cal-thead">
<Calendar.GridRow class="cal-tr">
{#each weekdays as day}
<Calendar.HeadCell class="cal-th">{day}</Calendar.HeadCell>
{/each}
</Calendar.GridRow>
</Calendar.GridHead>
<Calendar.GridBody class="cal-tbody">
{#each month.weeks as week}
<Calendar.GridRow class="cal-tr">
{#each week as date}
<Calendar.Cell {date} month={month.value} class="cal-td" {...rangeAttrs(date)}>
<Calendar.Day class="cal-day" />
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
{/each}
{/snippet}
</Calendar.Root>
</div>
</div>
</section>
<!-- ── 7. RANGO DE FECHAS ─────────────────────────────────────── -->
<section class="card card--wide" data-color="cyan">
<header class="card-hd">
<span class="tag">07</span>
<span class="card-title">Intervalo de fechas — inicio / fin</span>
</header>
<p class="card-desc">
Dos calendarios vinculados: el fin no puede ser anterior al inicio y viceversa.
<code>maxValue</code> del inicio = fecha fin · <code>minValue</code> del fin = fecha inicio.
</p>
<div class="range-layout">
<div class="range-col">
<p class="pair-label">inicio</p>
<Calendar.Root
type="single"
bind:value={rangeStart}
bind:placeholder={rangeStartPlaceholder}
onPlaceholderChange={onRangeStartPlaceholderChange}
maxValue={rangeEnd}
preventDeselect
class="cal"
>
{#snippet children({ months, weekdays })}
<Calendar.Header class="cal-hd">
<Calendar.PrevButton class="cal-btn">‹</Calendar.PrevButton>
<Calendar.Heading class="cal-heading" />
<Calendar.NextButton class="cal-btn">›</Calendar.NextButton>
</Calendar.Header>
{#each months as month}
<Calendar.Grid class="cal-grid">
<Calendar.GridHead class="cal-thead">
<Calendar.GridRow class="cal-tr">
{#each weekdays as day}
<Calendar.HeadCell class="cal-th">{day}</Calendar.HeadCell>
{/each}
</Calendar.GridRow>
</Calendar.GridHead>
<Calendar.GridBody class="cal-tbody">
{#each month.weeks as week}
<Calendar.GridRow class="cal-tr">
{#each week as date}
<Calendar.Cell {date} month={month.value} class="cal-td" {...rangeAttrs(date)}>
<Calendar.Day class="cal-day" />
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
{/each}
{/snippet}
</Calendar.Root>
</div>
<div class="range-sep">
<span class="range-arrow">→</span>
{#if rangeStart && rangeEnd}
<span class="range-days">{rangeDays}d</span>
{/if}
</div>
<div class="range-col">
<p class="pair-label">fin</p>
<Calendar.Root
type="single"
bind:value={rangeEnd}
bind:placeholder={rangeEndPlaceholder}
onPlaceholderChange={onRangeEndPlaceholderChange}
minValue={rangeStart}
preventDeselect
class="cal"
>
{#snippet children({ months, weekdays })}
<Calendar.Header class="cal-hd">
<Calendar.PrevButton class="cal-btn">‹</Calendar.PrevButton>
<Calendar.Heading class="cal-heading" />
<Calendar.NextButton class="cal-btn">›</Calendar.NextButton>
</Calendar.Header>
{#each months as month}
<Calendar.Grid class="cal-grid">
<Calendar.GridHead class="cal-thead">
<Calendar.GridRow class="cal-tr">
{#each weekdays as day}
<Calendar.HeadCell class="cal-th">{day}</Calendar.HeadCell>
{/each}
</Calendar.GridRow>
</Calendar.GridHead>
<Calendar.GridBody class="cal-tbody">
{#each month.weeks as week}
<Calendar.GridRow class="cal-tr">
{#each week as date}
<Calendar.Cell {date} month={month.value} class="cal-td" {...rangeAttrs(date)}>
<Calendar.Day class="cal-day" />
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
{/each}
{/snippet}
</Calendar.Root>
</div>
</div>
<div class="val-row">
<span class="val-label">rango:</span>
<span class="val-val">{fmt(rangeStart)} → {fmt(rangeEnd)}</span>
</div>
</section>
</div>
</main>
</div>
<style>
:global(*, *::before, *::after) { box-sizing: border-box; margin: 0; padding: 0; }
.page {
min-height : 100vh;
background : #0e0e14;
color : #d4d2cc;
font-family: 'DM Mono', 'Fira Code', monospace;
font-size : 13px;
}
.ph { border-bottom: 2px solid #818cf8; background: #080810; }
.ph-inner {
max-width: 1200px; margin: 0 auto;
padding : .75rem 1.5rem;
display : flex; align-items: center; justify-content: space-between;
}
.ph-brand { display: flex; align-items: center; gap: .5rem; }
.ph-dot { width: 8px; height: 8px; border-radius: 50%; background: #818cf8; }
.ph-title {
font-size: .85rem; font-weight: 700;
letter-spacing: .15em; text-transform: uppercase; color: #f4f3ef;
}
.ph-title em { color: #818cf8; font-style: normal; }
.ph-sub { font-size: .65rem; color: #555; letter-spacing: .1em; }
main { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
gap: .75rem;
}
.card {
background : #16161f;
border : 1px solid #26262f;
border-radius: 6px;
padding : 1.1rem;
display : flex; flex-direction: column; gap: .75rem;
border-top : 3px solid transparent;
}
.card--wide { grid-column: 1 / -1; }
.card[data-color="teal"] { border-top-color: #2dd4bf; }
.card[data-color="violet"] { border-top-color: #a78bfa; }
.card[data-color="amber"] { border-top-color: #e5a100; }
.card[data-color="indigo"] { border-top-color: #818cf8; }
.card[data-color="rose"] { border-top-color: #fb7185; }
.card[data-color="green"] { border-top-color: #4ade80; }
.card[data-color="cyan"] { border-top-color: #22d3ee; }
.card-hd {
display: flex; align-items: center; gap: .5rem;
padding-bottom: .6rem; border-bottom: 1px solid #26262f;
}
.tag { font-size: .6rem; font-weight: 700; letter-spacing: .15em; color: #555; }
.card-title { font-size: .75rem; font-weight: 600; color: #aaa; }
.card-desc { font-size: .72rem; color: #555; line-height: 1.5; }
code {
background: #26262f; padding: .1rem .35rem;
border-radius: 2px; font-size: .68rem;
color: #a78bfa; font-family: inherit;
}
/* ── Calendar ──────────────────────────────────────────────────────────── */
:global(.cal) {
background : #1a1a24;
border : 1px solid #26262f;
border-radius: 6px;
padding : .75rem;
display : inline-block;
width : 100%;
}
:global(.cal--sm) { font-size: .68rem; }
:global(.cal--two) { width: 100%; }
:global(.cal-hd) {
display : flex;
align-items : center;
justify-content: space-between;
margin-bottom : .5rem;
}
:global(.cal-hd--sel) { gap: .3rem; }
:global(.cal-heading) {
font-size : .72rem;
font-weight: 700;
color : #d4d2cc;
text-align : center;
}
:global(.cal-btn) {
background : transparent;
border : 1px solid #26262f;
border-radius: 3px;
color : #aaa;
cursor : pointer;
width : 22px; height: 22px;
display : flex; align-items: center; justify-content: center;
font-size : .85rem;
transition : background .1s, color .1s;
flex-shrink : 0;
}
:global(.cal-btn:hover:not([disabled])) { background: #26262f; color: #f4f3ef; }
:global(.cal-btn[disabled]) { opacity: .35; cursor: not-allowed; }
:global(.cal-grid) {
width : 100%;
border-collapse: collapse;
}
:global(.cal-th) {
width : 14.28%;
text-align : center;
font-size : .6rem;
font-weight: 600;
color : #555;
padding : .2rem 0;
}
:global(.cal-td) {
padding : 2px;
text-align: center;
}
:global(.cal-day) {
display : flex;
align-items : center;
justify-content: center;
width : 28px; height: 28px;
margin : 0 auto;
border-radius : 50%;
font-size : .72rem;
cursor : pointer;
color : #d4d2cc;
transition : background .1s, color .1s;
position : relative;
}
:global(.cal-day:hover:not([aria-disabled="true"]):not([data-outside-visible-months])) {
background: #26262f;
}
:global(.cal-day[data-selected]) {
background: #818cf8;
color : #fff;
font-weight: 700;
}
:global(.cal-day[data-today]:not([data-selected])) {
border: 1px solid #818cf8;
color : #818cf8;
}
:global(.cal-day[data-focused]:not([data-selected])) {
background: #26262f;
}
:global(.cal-day[data-outside-visible-months]) {
color : #333;
cursor : default;
}
:global(.cal-day[data-disabled]),
:global(.cal-day[aria-disabled="true"]) {
color : #333;
cursor : not-allowed;
pointer-events: none;
}
:global(.cal-day[data-unavailable]) {
color : #fb7185;
text-decoration: line-through;
}
/* ── Two months layout ─────────────────────────────────────────────────── */
.two-month-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: .75rem;
}
.month-col { display: flex; flex-direction: column; gap: .4rem; }
.month-nav {
display : flex;
align-items : center;
justify-content: space-between;
}
/* ── Selects ────────────────────────────────────────────────────────────── */
:global(.sel-group) { display: flex; gap: .3rem; flex: 1; justify-content: center; }
:global(.cal-select) {
background : #26262f;
border : 1px solid #333;
border-radius: 3px;
color : #d4d2cc;
font-family : inherit;
font-size : .68rem;
padding : .15rem .3rem;
cursor : pointer;
}
:global(.cal-select:disabled) { opacity: .4; }
/* ── Readonly/Disabled visuals ──────────────────────────────────────────── */
:global(.cal[data-readonly]) { opacity: .75; }
:global(.cal[data-disabled]) { opacity: .45; pointer-events: none; }
/* ── Value display ─────────────────────────────────────────────────────── */
.val-row { display: flex; align-items: center; gap: .5rem; font-size: .72rem; }
.val-label { color: #555; }
.val-val { font-weight: 700; color: #818cf8; font-size: .72rem; }
.val-val--sm { font-size: .62rem; word-break: break-all; }
/* ── Pair row ──────────────────────────────────────────────────────────── */
.pair-row { display: flex; gap: 1rem; flex-wrap: wrap; }
.pair-label { font-size: .62rem; color: #555; margin-bottom: .3rem; letter-spacing: .1em; }
/* ── Range day highlighting ─────────────────────────────────────────────── */
:global(.cal-td[data-in-range]) {
background: rgba(34, 211, 238, 0.08);
}
:global(.cal-td[data-range-start]) {
background: linear-gradient(to right, transparent 50%, rgba(34, 211, 238, 0.08) 50%);
}
:global(.cal-td[data-range-end]) {
background: linear-gradient(to left, transparent 50%, rgba(34, 211, 238, 0.08) 50%);
}
:global(.cal-td[data-range-start] .cal-day),
:global(.cal-td[data-range-end] .cal-day) {
background : #22d3ee;
color : #0a0a0a;
font-weight: 700;
}
/* ── Range layout ───────────────────────────────────────────────────────── */
.range-layout {
display : flex;
align-items: flex-start;
gap : .75rem;
}
.range-col { flex: 1; min-width: 0; }
.range-sep {
display : flex;
flex-direction : column;
align-items : center;
justify-content: center;
gap : .4rem;
padding-top : 2.2rem;
color : #555;
flex-shrink : 0;
}
.range-arrow { font-size: 1.1rem; color: #22d3ee; }
.range-days {
font-size : .6rem;
font-weight: 700;
color : #22d3ee;
letter-spacing: .08em;
background : #0d2a2e;
padding : .1rem .35rem;
border-radius: 3px;
}
</style>

@ -0,0 +1,185 @@
<script lang="ts">
import { Checkbox } from '$uix/acel';
let acepto = $state(false);
let recordar = $state(true);
let indeterminate = $state(false);
let groupValue = $state<string[]>(['js', 'ts']);
let allChecked = $derived(groupValue.length === 4);
let someChecked = $derived(groupValue.length > 0 && groupValue.length < 4);
</script>
<div class="page">
<h1>Checkbox</h1>
<section class="card">
<h2>Básico</h2>
<label class="cb-label">
<Checkbox.Root bind:checked={acepto} class="cb {acepto ? 'cb--on' : ''}">
{#snippet children({ checked })}
{#if checked}<span class="cb-check">✓</span>{/if}
{/snippet}
</Checkbox.Root>
Acepto los términos y condiciones
</label>
<label class="cb-label">
<Checkbox.Root bind:checked={recordar} class="cb {recordar ? 'cb--on' : ''}">
{#snippet children({ checked })}
{#if checked}<span class="cb-check">✓</span>{/if}
{/snippet}
</Checkbox.Root>
Recordar en este dispositivo
</label>
<p class="hint">acepto={acepto} · recordar={recordar}</p>
</section>
<section class="card">
<h2>Indeterminado</h2>
<label class="cb-label">
<Checkbox.Root
bind:indeterminate
checked={indeterminate ? false : undefined}
class="cb {indeterminate ? 'cb--indeterminate' : ''}"
>
{#snippet children({ checked, indeterminate: ind })}
{#if ind}<span class="cb-check cb-dash">—</span>
{:else if checked}<span class="cb-check">✓</span>{/if}
{/snippet}
</Checkbox.Root>
Estado intermedio
</label>
<div class="btn-row">
<button class="btn" onclick={() => { indeterminate = true; }}>Set indeterminate</button>
<button class="btn" onclick={() => { indeterminate = false; }}>Clear</button>
</div>
<p class="hint">indeterminate={indeterminate}</p>
</section>
<section class="card">
<h2>Grupo (múltiple)</h2>
<Checkbox.Group bind:value={groupValue} class="cb-group">
<Checkbox.GroupLabel class="group-label">
<label class="cb-label">
<Checkbox.Root
value="all"
checked={allChecked}
indeterminate={someChecked}
onCheckedChange={(v) => { groupValue = v ? ['js','ts','py','go'] : []; }}
class="cb {allChecked ? 'cb--on' : someChecked ? 'cb--indeterminate' : ''}"
>
{#snippet children({ checked, indeterminate: ind })}
{#if ind}<span class="cb-check cb-dash">—</span>
{:else if checked}<span class="cb-check">✓</span>{/if}
{/snippet}
</Checkbox.Root>
Todos los lenguajes
</label>
</Checkbox.GroupLabel>
<div class="group-items">
{#each [['js','JavaScript'],['ts','TypeScript'],['py','Python'],['go','Go']] as [val, label]}
<label class="cb-label cb-label--indent">
<Checkbox.Root value={val} class="cb {groupValue.includes(val) ? 'cb--on' : ''}">
{#snippet children({ checked })}
{#if checked}<span class="cb-check">✓</span>{/if}
{/snippet}
</Checkbox.Root>
{label}
</label>
{/each}
</div>
</Checkbox.Group>
<p class="hint">seleccionados: [{groupValue.join(', ')}]</p>
</section>
<section class="card">
<h2>Desactivados</h2>
<div class="row-gap">
<label class="cb-label">
<Checkbox.Root checked={true} disabled class="cb cb--on cb--disabled">
{#snippet children({ checked })}
{#if checked}<span class="cb-check">✓</span>{/if}
{/snippet}
</Checkbox.Root>
Seleccionado desactivado
</label>
<label class="cb-label">
<Checkbox.Root checked={false} disabled class="cb cb--disabled">
</Checkbox.Root>
Sin seleccionar desactivado
</label>
</div>
</section>
</div>
<style>
.page {
max-width: 520px;
margin: 0 auto;
padding: 2rem 1.5rem;
font-family: 'DM Mono', monospace;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: .05em; }
.card {
border: 1.5px solid #e0ddd6;
border-radius: 4px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
h2 { font-size: .65rem; font-weight: 700; letter-spacing: .2em; text-transform: uppercase; color: #999; }
.hint { font-size: .68rem; color: #aaa; }
/* Checkbox */
.cb-label {
display: flex;
align-items: center;
gap: .6rem;
font-size: .85rem;
cursor: pointer;
user-select: none;
}
.cb-label--indent { padding-left: 1.5rem; }
:global(.cb) {
width: 18px; height: 18px;
border: 2px solid #d0cdc6;
border-radius: 3px;
background: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
flex-shrink: 0;
transition: border-color .15s, background .15s;
}
:global(.cb--on) { background: #6366f1; border-color: #6366f1; }
:global(.cb--indeterminate) { background: #6366f1; border-color: #6366f1; }
:global(.cb--disabled) { opacity: .4; cursor: not-allowed; }
.cb-check {
color: #fff;
font-size: .7rem;
font-weight: 700;
line-height: 1;
}
.cb-dash { font-size: .9rem; }
/* Group */
:global(.cb-group) { display: flex; flex-direction: column; gap: .1rem; }
:global(.group-label) { padding-bottom: .4rem; border-bottom: 1px solid #f0ede8; margin-bottom: .2rem; display: block; }
.group-items { display: flex; flex-direction: column; gap: .5rem; }
.row-gap { display: flex; flex-direction: column; gap: .6rem; }
.btn-row { display: flex; gap: .4rem; }
.btn {
padding: .25rem .6rem;
background: #1a1a1a; color: #f7f6f3;
border: none; border-radius: 2px;
font-family: inherit; font-size: .68rem; font-weight: 600;
cursor: pointer;
}
.btn:hover { opacity: .75; }
</style>

@ -0,0 +1,348 @@
<script lang="ts">
import { Command } from '$uix/acel';
let value = $state('');
let search = $state('');
let lastSelected = $state('—');
const fruits = ['Apple', 'Banana', 'Blueberry', 'Cherry', 'Grape', 'Mango', 'Orange', 'Peach', 'Pear', 'Strawberry'];
const tools = ['Calculator', 'Calendar', 'Camera', 'Clock', 'Compass', 'Notes', 'Settings', 'Terminal'];
let loading = $state(false);
let loadingProgress = $state(0);
function simulateLoad() {
loading = true;
loadingProgress = 0;
const interval = setInterval(() => {
loadingProgress += 20;
if (loadingProgress >= 100) { loading = false; clearInterval(interval); }
}, 300);
}
</script>
<div class="page">
<h1>Command</h1>
<!-- Demo 1: básico con búsqueda y grupos -->
<section class="demo">
<h2>Básico — búsqueda con grupos</h2>
<div class="palette">
<Command.Root bind:value onValueChange={(v) => (lastSelected = v || '—')}>
<Command.Input placeholder="Buscar..." bind:value={search} />
<Command.List>
<Command.Empty>Sin resultados para «{search}»</Command.Empty>
<Command.Group>
<Command.GroupHeading>Frutas</Command.GroupHeading>
<Command.GroupItems>
{#each fruits as fruit}
<Command.Item value={fruit} onSelect={() => (lastSelected = fruit)}>
{fruit}
</Command.Item>
{/each}
</Command.GroupItems>
</Command.Group>
<Command.Separator />
<Command.Group>
<Command.GroupHeading>Herramientas</Command.GroupHeading>
<Command.GroupItems>
{#each tools as tool}
<Command.Item value={tool} onSelect={() => (lastSelected = tool)}>
{tool}
</Command.Item>
{/each}
</Command.GroupItems>
</Command.Group>
</Command.List>
</Command.Root>
</div>
<p class="status">Seleccionado: <strong>{lastSelected}</strong></p>
</section>
<!-- Demo 2: con viewport (altura limitada) -->
<section class="demo">
<h2>Con Viewport (altura limitada)</h2>
<div class="palette">
<Command.Root loop>
<Command.Input placeholder="Filtrar apps..." />
<Command.List class="list-limited">
<Command.Viewport>
<Command.Empty>Sin resultados</Command.Empty>
{#each ['Agenda', 'Ajustes', 'App Store', 'Archivos', 'Calculadora', 'Cámara', 'Correo', 'FaceTime', 'Finder', 'Fotos', 'Maps', 'Mensajes', 'Music', 'News', 'Notas', 'Podcasts', 'Safari', 'Salud', 'Wallet'] as app}
<Command.Item value={app}>{app}</Command.Item>
{/each}
</Command.Viewport>
</Command.List>
</Command.Root>
</div>
</section>
<!-- Demo 3: items deshabilitados -->
<section class="demo">
<h2>Items deshabilitados + keywords</h2>
<div class="palette">
<Command.Root>
<Command.Input placeholder="Busca 'delete' o 'remove'..." />
<Command.List>
<Command.Empty>Sin resultados</Command.Empty>
<Command.Item value="Copiar" keywords={['copy', 'duplicate']}>Copiar</Command.Item>
<Command.Item value="Pegar" keywords={['paste', 'insert']}>Pegar</Command.Item>
<Command.Item value="Eliminar" keywords={['delete', 'remove', 'trash']}>Eliminar</Command.Item>
<Command.Item value="Archivar" disabled keywords={['archive', 'store']}>
Archivar (deshabilitado)
</Command.Item>
<Command.Separator />
<Command.Item value="Deshacer" keywords={['undo', 'revert']}>Deshacer</Command.Item>
</Command.List>
</Command.Root>
</div>
</section>
<!-- Demo 4: sin filtrado automático (shouldFilter=false) -->
<section class="demo">
<h2>shouldFilter=false — filtrado manual</h2>
<div class="palette">
<Command.Root shouldFilter={false}>
<Command.Input placeholder="Filtra manualmente..." />
<Command.List>
<Command.Empty>Sin resultados</Command.Empty>
{#each ['Rojo', 'Verde', 'Azul', 'Amarillo', 'Naranja', 'Morado'] as item}
<Command.Item value={item} forceMount>{item}</Command.Item>
{/each}
</Command.List>
</Command.Root>
</div>
</section>
<!-- Demo 5: loading state -->
<section class="demo">
<h2>Loading state</h2>
<div class="palette">
<Command.Root>
<Command.Input placeholder="Buscar..." />
<Command.List>
{#if loading}
<Command.Loading progress={loadingProgress}>
<div class="loading-bar">
<div class="loading-fill" style="width: {loadingProgress}%"></div>
</div>
</Command.Loading>
{:else}
<Command.Empty>Pulsa el botón para cargar</Command.Empty>
<Command.Item value="item1">Resultado 1</Command.Item>
<Command.Item value="item2">Resultado 2</Command.Item>
{/if}
</Command.List>
</Command.Root>
</div>
<button class="btn" onclick={simulateLoad} disabled={loading}>
{loading ? `Cargando ${loadingProgress}%...` : 'Simular carga'}
</button>
</section>
<!-- Demo 6: link items -->
<section class="demo">
<h2>LinkItem (elementos &lt;a&gt;)</h2>
<div class="palette">
<Command.Root>
<Command.Input placeholder="Buscar enlaces..." />
<Command.List>
<Command.Empty>Sin resultados</Command.Empty>
{#each [
{ label: 'GitHub', href: 'https://github.com', keywords: ['git', 'code', 'repo'] },
{ label: 'MDN Web Docs', href: 'https://developer.mozilla.org', keywords: ['docs', 'web', 'api'] },
{ label: 'SvelteKit', href: 'https://kit.svelte.dev', keywords: ['svelte', 'framework'] },
] as link}
<Command.LinkItem value={link.label} keywords={link.keywords} href={link.href} target="_blank">
{link.label} ↗
</Command.LinkItem>
{/each}
</Command.List>
</Command.Root>
</div>
</section>
</div>
<style>
.page {
max-width: 700px;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, sans-serif;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: #1a1a1a;
}
h2 {
font-size: .85rem;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: #888;
margin-bottom: .75rem;
}
.demo {
background: #fff;
border: 1.5px solid #e5e7eb;
border-radius: 6px;
padding: 1.5rem;
margin-bottom: 1.25rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
/* Command palette container */
.palette {
border: 1.5px solid #e5e7eb;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0,0,0,.07);
}
/* Root */
:global([data-command-root]) {
display: flex;
flex-direction: column;
background: #fff;
}
/* Input */
:global([data-command-input]) {
width: 100%;
padding: .65rem .85rem;
border: none;
border-bottom: 1.5px solid #f3f4f6;
font-size: .88rem;
font-family: inherit;
color: #1f2937;
background: transparent;
outline: none;
}
:global([data-command-input]::placeholder) {
color: #9ca3af;
}
/* List */
:global([data-command-list]) {
max-height: 320px;
overflow-y: auto;
}
/* Limited list for viewport demo */
:global(.list-limited) {
height: 200px;
overflow: hidden;
}
/* Viewport */
:global([data-command-viewport]) {
overflow-y: auto;
height: var(--acel-command-list-height, 200px);
}
/* Group */
:global([data-command-group]) {
padding: .3rem 0;
}
/* Group heading */
:global([data-command-group-heading]) {
padding: .35rem .75rem .2rem;
font-size: .7rem;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: #9ca3af;
}
/* Group items */
:global([data-command-group-items]) {
display: flex;
flex-direction: column;
}
/* Items */
:global([data-command-item]) {
padding: .48rem .75rem;
font-size: .85rem;
cursor: pointer;
color: #1f2937;
font-family: inherit;
border-radius: 0;
display: flex;
align-items: center;
gap: .5rem;
transition: background .08s;
user-select: none;
}
:global([data-command-item][data-selected]) {
background: #f3f4f6;
color: #111827;
}
:global([data-command-item][data-disabled]) {
opacity: .4;
cursor: default;
}
/* Separator */
:global([data-command-separator]) {
height: 1px;
background: #f3f4f6;
margin: .2rem 0;
}
/* Empty */
:global([data-command-empty]) {
padding: 1.25rem .75rem;
font-size: .82rem;
color: #9ca3af;
text-align: center;
}
/* Loading */
:global([data-command-loading]) {
padding: .75rem;
}
.loading-bar {
height: 4px;
background: #f3f4f6;
border-radius: 2px;
overflow: hidden;
}
.loading-fill {
height: 100%;
background: #6366f1;
transition: width .3s;
}
.status {
font-size: .8rem;
color: #6b7280;
background: #f9fafb;
border: 1px solid #e5e7eb;
padding: .3rem .65rem;
border-radius: 4px;
margin: 0;
}
.status strong { color: #374151; }
.btn {
padding: .4rem .9rem;
background: #1f2937;
color: #f9fafb;
border: none;
border-radius: 4px;
font-size: .82rem;
font-family: inherit;
cursor: pointer;
align-self: flex-start;
transition: opacity .15s;
}
.btn:hover { opacity: .8; }
.btn:disabled { opacity: .4; cursor: default; }
</style>

@ -0,0 +1,426 @@
<script lang="ts">
import { ContextMenu } from '$uix/acel';
let lastAction = $state('—');
let checkedA = $state(false);
let checkedB = $state(true);
let selectedView = $state('list');
</script>
<div class="page">
<h1>ContextMenu</h1>
<!-- Demo 1: básico (clic derecho sobre área) -->
<section class="demo">
<h2>Básico — clic derecho</h2>
<ContextMenu.Root>
<ContextMenu.Trigger>
<div class="trigger-area">
Haz clic derecho aquí
</div>
</ContextMenu.Trigger>
<ContextMenu.Content>
<div class="menu">
<ContextMenu.Item onclick={() => (lastAction = 'Cortar')}>
Cortar
</ContextMenu.Item>
<ContextMenu.Item onclick={() => (lastAction = 'Copiar')}>
Copiar
</ContextMenu.Item>
<ContextMenu.Item onclick={() => (lastAction = 'Pegar')}>
Pegar
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item onclick={() => (lastAction = 'Eliminar')}>
Eliminar
</ContextMenu.Item>
</div>
</ContextMenu.Content>
</ContextMenu.Root>
{#if lastAction !== '—'}
<p class="action-log">Última acción: <strong>{lastAction}</strong></p>
{/if}
</section>
<!-- Demo 2: checkbox items -->
<section class="demo">
<h2>CheckboxItem</h2>
<ContextMenu.Root>
<ContextMenu.Trigger>
<div class="trigger-area">
Clic derecho para opciones de visualización
</div>
</ContextMenu.Trigger>
<ContextMenu.Content>
<div class="menu">
<ContextMenu.CheckboxItem bind:checked={checkedA}>
Mostrar miniaturas
</ContextMenu.CheckboxItem>
<ContextMenu.CheckboxItem bind:checked={checkedB}>
Mostrar barra lateral
</ContextMenu.CheckboxItem>
</div>
</ContextMenu.Content>
</ContextMenu.Root>
<p class="action-log">
Miniaturas: <strong>{checkedA}</strong> · Barra lateral: <strong>{checkedB}</strong>
</p>
</section>
<!-- Demo 3: radio items -->
<section class="demo">
<h2>RadioGroup + RadioItem</h2>
<ContextMenu.Root>
<ContextMenu.Trigger>
<div class="trigger-area">
Clic derecho para cambiar vista
</div>
</ContextMenu.Trigger>
<ContextMenu.Content>
<div class="menu">
<ContextMenu.Group>
<ContextMenu.GroupHeading>Vista</ContextMenu.GroupHeading>
<ContextMenu.RadioGroup bind:value={selectedView}>
{#each ['list', 'grid', 'columns'] as view}
<ContextMenu.RadioItem value={view}>
{view}
</ContextMenu.RadioItem>
{/each}
</ContextMenu.RadioGroup>
</ContextMenu.Group>
</div>
</ContextMenu.Content>
</ContextMenu.Root>
<p class="action-log">Vista actual: <strong>{selectedView}</strong></p>
</section>
<!-- Demo 4: submenú -->
<section class="demo">
<h2>Submenú anidado</h2>
<ContextMenu.Root>
<ContextMenu.Trigger>
<div class="trigger-area wide">
Clic derecho — menú con submenú
</div>
</ContextMenu.Trigger>
<ContextMenu.Content>
<div class="menu">
<ContextMenu.Item onclick={() => (lastAction = 'Renombrar')}>
Renombrar
</ContextMenu.Item>
<ContextMenu.Item onclick={() => (lastAction = 'Duplicar')}>
Duplicar
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Sub>
<ContextMenu.SubTrigger>Mover a</ContextMenu.SubTrigger>
<ContextMenu.SubContent>
<div class="menu">
<ContextMenu.Item onclick={() => (lastAction = 'Escritorio')}>
Escritorio
</ContextMenu.Item>
<ContextMenu.Item onclick={() => (lastAction = 'Documentos')}>
Documentos
</ContextMenu.Item>
<ContextMenu.Sub>
<ContextMenu.SubTrigger>Proyectos</ContextMenu.SubTrigger>
<ContextMenu.SubContent>
<div class="menu">
<ContextMenu.Item onclick={() => (lastAction = '2024')}>
2024
</ContextMenu.Item>
<ContextMenu.Item onclick={() => (lastAction = '2025')}>
2025
</ContextMenu.Item>
</div>
</ContextMenu.SubContent>
</ContextMenu.Sub>
</div>
</ContextMenu.SubContent>
</ContextMenu.Sub>
<ContextMenu.Separator />
<ContextMenu.Item onclick={() => (lastAction = 'Eliminar')}>
Eliminar
</ContextMenu.Item>
</div>
</ContextMenu.Content>
</ContextMenu.Root>
{#if lastAction !== '—'}
<p class="action-log">Última acción: <strong>{lastAction}</strong></p>
{/if}
</section>
<!-- Demo 5: múltiples zonas -->
<section class="demo">
<h2>Múltiples zonas independientes</h2>
<div class="zones-row">
{#each ['Zona A', 'Zona B', 'Zona C'] as zona}
<ContextMenu.Root>
<ContextMenu.Trigger>
<div class="trigger-zone">
{zona}<br />
<small>clic derecho</small>
</div>
</ContextMenu.Trigger>
<ContextMenu.Content>
<div class="menu">
<ContextMenu.Item onclick={() => (lastAction = `${zona} → Editar`)}>
Editar {zona}
</ContextMenu.Item>
<ContextMenu.Item onclick={() => (lastAction = `${zona} → Eliminar`)}>
Eliminar {zona}
</ContextMenu.Item>
</div>
</ContextMenu.Content>
</ContextMenu.Root>
{/each}
</div>
{#if lastAction !== '—'}
<p class="action-log">Última acción: <strong>{lastAction}</strong></p>
{/if}
</section>
</div>
<style>
.page {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, sans-serif;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: #1a1a1a;
}
h2 {
font-size: .85rem;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: #888;
margin-bottom: .75rem;
}
.demo {
background: #fff;
border: 1.5px solid #e5e7eb;
border-radius: 6px;
padding: 1.5rem;
margin-bottom: 1.25rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
/* Trigger areas */
.trigger-area {
padding: 1.5rem 2rem;
background: #f9fafb;
border: 2px dashed #d1d5db;
border-radius: 6px;
font-size: .85rem;
color: #6b7280;
cursor: context-menu;
text-align: center;
transition: background .15s, border-color .15s;
user-select: none;
}
.trigger-area.wide {
padding: 1.5rem 3rem;
}
.trigger-area:hover {
background: #f3f4f6;
border-color: #9ca3af;
}
/* Floating wrapper */
:global([data-acel-floating-content-wrapper]) {
z-index: 9999;
}
/* Menu box */
.menu {
background: #fff;
border: 1.5px solid #e5e7eb;
border-radius: 6px;
padding: .3rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, .12);
min-width: 160px;
display: flex;
flex-direction: column;
gap: 1px;
}
/* Items */
:global([data-context-menu-item]) {
padding: .45rem .7rem;
border-radius: 3px;
font-size: .84rem;
cursor: pointer;
color: #1f2937;
font-family: inherit;
display: flex;
align-items: center;
gap: .5rem;
transition: background .1s;
user-select: none;
}
:global([data-context-menu-item]:hover),
:global([data-context-menu-item][data-highlighted]) {
background: #f3f4f6;
}
:global([data-context-menu-item][data-disabled]) {
opacity: .4;
cursor: default;
}
/* Checkbox item */
:global([data-context-menu-checkbox-item]) {
padding: .45rem .7rem;
border-radius: 3px;
font-size: .84rem;
cursor: pointer;
color: #1f2937;
font-family: inherit;
display: flex;
align-items: center;
gap: .5rem;
transition: background .1s;
user-select: none;
}
:global([data-context-menu-checkbox-item]:hover),
:global([data-context-menu-checkbox-item][data-highlighted]) {
background: #f3f4f6;
}
:global([data-context-menu-checkbox-item][data-state="checked"])::before {
content: "✓";
font-size: .75rem;
color: #7c3aed;
min-width: .9rem;
}
:global([data-context-menu-checkbox-item][data-state="unchecked"])::before {
content: " ";
min-width: .9rem;
display: inline-block;
}
/* Radio item */
:global([data-context-menu-radio-item]) {
padding: .45rem .7rem;
border-radius: 3px;
font-size: .84rem;
cursor: pointer;
color: #1f2937;
font-family: inherit;
display: flex;
align-items: center;
gap: .5rem;
transition: background .1s;
user-select: none;
}
:global([data-context-menu-radio-item]:hover),
:global([data-context-menu-radio-item][data-highlighted]) {
background: #f3f4f6;
}
:global([data-context-menu-radio-item][data-state="checked"])::before {
content: "●";
font-size: .6rem;
color: #7c3aed;
min-width: .9rem;
}
:global([data-context-menu-radio-item][data-state="unchecked"])::before {
content: "○";
font-size: .6rem;
color: #ccc;
min-width: .9rem;
}
/* Group heading */
:global([data-context-menu-group-heading]) {
padding: .3rem .7rem .15rem;
font-size: .7rem;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: #9ca3af;
}
/* Separator */
:global([data-context-menu-separator]) {
height: 1px;
background: #f3f4f6;
margin: .25rem .3rem;
}
/* Sub trigger */
:global([data-context-menu-sub-trigger]) {
padding: .45rem .7rem;
border-radius: 3px;
font-size: .84rem;
cursor: pointer;
color: #1f2937;
font-family: inherit;
display: flex;
align-items: center;
justify-content: space-between;
gap: .5rem;
transition: background .1s;
user-select: none;
}
:global([data-context-menu-sub-trigger]:hover),
:global([data-context-menu-sub-trigger][data-state="open"]),
:global([data-context-menu-sub-trigger][data-highlighted]) {
background: #f3f4f6;
}
:global([data-context-menu-sub-trigger])::after {
content: "›";
font-size: 1rem;
color: #9ca3af;
margin-left: auto;
}
.action-log {
font-size: .8rem;
color: #6b7280;
background: #f9fafb;
border: 1px solid #e5e7eb;
padding: .35rem .7rem;
border-radius: 4px;
margin: 0;
}
.action-log strong {
color: #374151;
}
.zones-row {
display: flex;
gap: .75rem;
flex-wrap: wrap;
}
.trigger-zone {
padding: 1.25rem 1.5rem;
background: #f9fafb;
border: 2px dashed #d1d5db;
border-radius: 6px;
font-size: .85rem;
font-weight: 600;
color: #4b5563;
cursor: context-menu;
text-align: center;
flex: 1;
min-width: 100px;
transition: background .15s, border-color .15s;
user-select: none;
line-height: 1.5;
}
.trigger-zone small {
font-weight: 400;
color: #9ca3af;
font-size: .72rem;
}
.trigger-zone:hover {
background: #f3f4f6;
border-color: #9ca3af;
}
</style>

@ -0,0 +1,411 @@
<script lang="ts">
import { DropdownMenu } from '$uix/acel';
let openState = $state(false);
let checkedA = $state(true);
let checkedB = $state(false);
let selectedColor = $state('blue');
let lastAction = $state('—');
</script>
<div class="page">
<h1>DropdownMenu</h1>
<!-- Demo 1: básico -->
<section class="demo">
<h2>Básico</h2>
<div class="row">
<DropdownMenu.Root>
<DropdownMenu.Trigger>Abrir menú</DropdownMenu.Trigger>
<DropdownMenu.Content sideOffset={6}>
<div class="menu">
<DropdownMenu.Item onclick={() => (lastAction = 'Nuevo')}>
Nuevo archivo
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => (lastAction = 'Abrir')}>
Abrir...
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => (lastAction = 'Guardar')}>
Guardar
</DropdownMenu.Item>
<DropdownMenu.Item disabled onclick={() => (lastAction = 'Guardar como')}>
Guardar como...
</DropdownMenu.Item>
</div>
</DropdownMenu.Content>
</DropdownMenu.Root>
{#if lastAction !== '—'}
<span class="badge">Acción: <strong>{lastAction}</strong></span>
{/if}
</div>
</section>
<!-- Demo 2: controlado -->
<section class="demo">
<h2>Controlado (bind:open)</h2>
<div class="row">
<DropdownMenu.Root bind:open={openState}>
<DropdownMenu.Trigger>Toggle menú</DropdownMenu.Trigger>
<DropdownMenu.Content sideOffset={6}>
<div class="menu">
<DropdownMenu.Item>Opción 1</DropdownMenu.Item>
<DropdownMenu.Item>Opción 2</DropdownMenu.Item>
<DropdownMenu.Item>Opción 3</DropdownMenu.Item>
</div>
</DropdownMenu.Content>
</DropdownMenu.Root>
<span class="state-badge" class:open={openState}>
{openState ? 'Abierto' : 'Cerrado'}
</span>
</div>
</section>
<!-- Demo 3: checkbox items -->
<section class="demo">
<h2>CheckboxItem</h2>
<div class="row">
<DropdownMenu.Root>
<DropdownMenu.Trigger>Preferencias</DropdownMenu.Trigger>
<DropdownMenu.Content sideOffset={6}>
<div class="menu">
<DropdownMenu.CheckboxItem bind:checked={checkedA}>
Modo oscuro
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem bind:checked={checkedB}>
Notificaciones
</DropdownMenu.CheckboxItem>
</div>
</DropdownMenu.Content>
</DropdownMenu.Root>
<span class="badge">A: {checkedA} · B: {checkedB}</span>
</div>
</section>
<!-- Demo 4: radio items -->
<section class="demo">
<h2>RadioGroup + RadioItem</h2>
<div class="row">
<DropdownMenu.Root>
<DropdownMenu.Trigger>Color: {selectedColor}</DropdownMenu.Trigger>
<DropdownMenu.Content sideOffset={6}>
<div class="menu">
<DropdownMenu.RadioGroup bind:value={selectedColor}>
{#each ['red', 'green', 'blue', 'purple'] as color}
<DropdownMenu.RadioItem value={color}>
{color}
</DropdownMenu.RadioItem>
{/each}
</DropdownMenu.RadioGroup>
</div>
</DropdownMenu.Content>
</DropdownMenu.Root>
<span class="badge">Seleccionado: <strong>{selectedColor}</strong></span>
</div>
</section>
<!-- Demo 5: grupos y heading -->
<section class="demo">
<h2>Groups + Separators</h2>
<div class="row">
<DropdownMenu.Root>
<DropdownMenu.Trigger>Cuenta</DropdownMenu.Trigger>
<DropdownMenu.Content sideOffset={6}>
<div class="menu">
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>Mi cuenta</DropdownMenu.GroupHeading>
<DropdownMenu.Item>Perfil</DropdownMenu.Item>
<DropdownMenu.Item>Configuración</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>Equipo</DropdownMenu.GroupHeading>
<DropdownMenu.Item>Miembros</DropdownMenu.Item>
<DropdownMenu.Item>Invitar</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item>Cerrar sesión</DropdownMenu.Item>
</div>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</section>
<!-- Demo 6: submenú -->
<section class="demo">
<h2>Submenú</h2>
<div class="row">
<DropdownMenu.Root>
<DropdownMenu.Trigger>Editar</DropdownMenu.Trigger>
<DropdownMenu.Content sideOffset={6}>
<div class="menu">
<DropdownMenu.Item>Deshacer</DropdownMenu.Item>
<DropdownMenu.Item>Rehacer</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Compartir</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent sideOffset={6}>
<div class="menu">
<DropdownMenu.Item>Por correo</DropdownMenu.Item>
<DropdownMenu.Item>Por enlace</DropdownMenu.Item>
<DropdownMenu.Item>Por mensaje</DropdownMenu.Item>
</div>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Separator />
<DropdownMenu.Item>Eliminar</DropdownMenu.Item>
</div>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</section>
<!-- Demo 7: alineación -->
<section class="demo">
<h2>Posición (side + align)</h2>
<div class="row">
{#each [['bottom','start'], ['bottom','center'], ['bottom','end'], ['top','start']] as [side, align]}
<DropdownMenu.Root>
<DropdownMenu.Trigger>{side}/{align}</DropdownMenu.Trigger>
<DropdownMenu.Content side={side as any} align={align as any} sideOffset={6}>
<div class="menu small">
<DropdownMenu.Item>Opción A</DropdownMenu.Item>
<DropdownMenu.Item>Opción B</DropdownMenu.Item>
</div>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/each}
</div>
</section>
</div>
<style>
.page {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, sans-serif;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: #1a1a1a;
}
h2 {
font-size: .85rem;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: #888;
margin-bottom: .75rem;
}
.demo {
background: #fff;
border: 1.5px solid #e5e7eb;
border-radius: 6px;
padding: 1.5rem;
margin-bottom: 1.25rem;
}
.row {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
/* Trigger */
:global([data-dropdown-menu-trigger]) {
padding: .4rem .9rem;
background: #f3f4f6;
border: 1.5px solid #d1d5db;
border-radius: 4px;
font-size: .85rem;
cursor: pointer;
font-family: inherit;
color: #374151;
transition: background .15s, border-color .15s;
}
:global([data-dropdown-menu-trigger]:hover) {
background: #e5e7eb;
}
:global([data-dropdown-menu-trigger][data-state="open"]) {
background: #ede9fe;
border-color: #7c3aed;
color: #5b21b6;
}
/* Floating wrapper */
:global([data-acel-floating-content-wrapper]) {
z-index: 9999;
}
/* Menu box */
.menu {
background: #fff;
border: 1.5px solid #e5e7eb;
border-radius: 6px;
padding: .3rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, .12);
min-width: 160px;
display: flex;
flex-direction: column;
gap: 1px;
}
.menu.small {
min-width: 120px;
}
/* Items */
:global([data-dropdown-menu-item]) {
padding: .45rem .7rem;
border-radius: 3px;
font-size: .84rem;
cursor: pointer;
color: #1f2937;
font-family: inherit;
display: flex;
align-items: center;
gap: .5rem;
transition: background .1s;
user-select: none;
}
:global([data-dropdown-menu-item]:hover),
:global([data-dropdown-menu-item][data-highlighted]) {
background: #f3f4f6;
}
:global([data-dropdown-menu-item][data-disabled]) {
opacity: .4;
cursor: default;
}
/* Checkbox item */
:global([data-dropdown-menu-checkbox-item]) {
padding: .45rem .7rem;
border-radius: 3px;
font-size: .84rem;
cursor: pointer;
color: #1f2937;
font-family: inherit;
display: flex;
align-items: center;
gap: .5rem;
transition: background .1s;
user-select: none;
}
:global([data-dropdown-menu-checkbox-item]:hover),
:global([data-dropdown-menu-checkbox-item][data-highlighted]) {
background: #f3f4f6;
}
:global([data-dropdown-menu-checkbox-item][data-state="checked"])::before {
content: "✓";
font-size: .75rem;
color: #7c3aed;
min-width: .9rem;
}
:global([data-dropdown-menu-checkbox-item][data-state="unchecked"])::before {
content: " ";
min-width: .9rem;
display: inline-block;
}
/* Radio item */
:global([data-dropdown-menu-radio-item]) {
padding: .45rem .7rem;
border-radius: 3px;
font-size: .84rem;
cursor: pointer;
color: #1f2937;
font-family: inherit;
display: flex;
align-items: center;
gap: .5rem;
transition: background .1s;
user-select: none;
}
:global([data-dropdown-menu-radio-item]:hover),
:global([data-dropdown-menu-radio-item][data-highlighted]) {
background: #f3f4f6;
}
:global([data-dropdown-menu-radio-item][data-state="checked"])::before {
content: "●";
font-size: .6rem;
color: #7c3aed;
min-width: .9rem;
}
:global([data-dropdown-menu-radio-item][data-state="unchecked"])::before {
content: "○";
font-size: .6rem;
color: #ccc;
min-width: .9rem;
}
/* Group heading */
:global([data-dropdown-menu-group-heading]) {
padding: .3rem .7rem .15rem;
font-size: .7rem;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: #9ca3af;
}
/* Separator */
:global([data-dropdown-menu-separator]) {
height: 1px;
background: #f3f4f6;
margin: .25rem .3rem;
}
/* Sub trigger */
:global([data-dropdown-menu-sub-trigger]) {
padding: .45rem .7rem;
border-radius: 3px;
font-size: .84rem;
cursor: pointer;
color: #1f2937;
font-family: inherit;
display: flex;
align-items: center;
justify-content: space-between;
gap: .5rem;
transition: background .1s;
user-select: none;
}
:global([data-dropdown-menu-sub-trigger]:hover),
:global([data-dropdown-menu-sub-trigger][data-state="open"]),
:global([data-dropdown-menu-sub-trigger][data-highlighted]) {
background: #f3f4f6;
}
:global([data-dropdown-menu-sub-trigger])::after {
content: "›";
font-size: 1rem;
color: #9ca3af;
margin-left: auto;
}
.badge {
font-size: .78rem;
color: #6b7280;
background: #f9fafb;
border: 1px solid #e5e7eb;
padding: .2rem .6rem;
border-radius: 4px;
}
.badge strong { color: #374151; }
.state-badge {
padding: .25rem .65rem;
border-radius: 999px;
font-size: .75rem;
font-weight: 600;
background: #f3f4f6;
color: #6b7280;
border: 1px solid #e5e7eb;
}
.state-badge.open {
background: #ede9fe;
color: #5b21b6;
border-color: #c4b5fd;
}
</style>

@ -0,0 +1,304 @@
<script lang="ts">
import { Field, type FieldRootSnippetProps } from "$uix/acel/field";
let emailValue = $state("");
let emailState = $state<"idle" | "error" | "loading">("idle");
let emailError = $state("");
let searchValue = $state("");
let passwordValue = $state("");
let showPassword = $state(false);
let usernameValue = $state("");
let usernameState = $state<"idle" | "loading" | "error">("idle");
function validateEmail() {
if (!emailValue) {
emailState = "error";
emailError = "El email es obligatorio.";
return;
}
if (!emailValue.includes("@")) {
emailState = "error";
emailError = "Introduce un email válido.";
return;
}
emailState = "idle";
emailError = "";
}
async function checkUsername() {
if (!usernameValue) return;
usernameState = "loading";
await new Promise((r) => setTimeout(r, 1500));
usernameState = usernameValue === "admin" ? "error" : "idle";
}
</script>
<div class="min-h-screen bg-gray-50 p-10">
<div class="mx-auto max-w-lg space-y-10">
<h1 class="text-2xl font-semibold text-gray-900">Field — Demo</h1>
<!-- ─── 1. Básico con validación ─────────────────────────────── -->
<section class="space-y-4">
<h2 class="text-sm font-medium uppercase tracking-wide text-gray-400">Validación</h2>
<Field.Root state={emailState}>
<Field.Label class="mb-1.5 block text-sm font-medium text-gray-700
data-[error]:text-red-600
data-[disabled]:text-gray-400">
Correo electrónico
</Field.Label>
<Field.Control class="relative flex items-center">
<Field.Input
type="email"
bind:value={emailValue}
placeholder="tu@email.com"
name="email"
required
onblur={validateEmail}
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm
text-gray-900 placeholder:text-gray-400 outline-none transition
focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20
data-[state=error]:border-red-500 data-[state=error]:focus:border-red-500
data-[state=error]:focus:ring-red-500/20
data-[state=loading]:cursor-wait data-[state=loading]:opacity-60
disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-400"
/>
</Field.Control>
<Field.HelperText class="mt-1.5 text-xs text-gray-500">
Nunca compartiremos tu email.
</Field.HelperText>
<Field.Error class="mt-1.5 flex items-center gap-1 text-xs text-red-600">
<svg class="size-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm.75 4.25a.75.75 0 0 0-1.5 0v3.5a.75.75 0 0 0 1.5 0v-3.5zm-.75 6a.875.875 0 1 0 0-1.75.875.875 0 0 0 0 1.75z"/>
</svg>
{emailError}
</Field.Error>
</Field.Root>
<button
onclick={validateEmail}
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white
hover:bg-indigo-700 active:bg-indigo-800 transition"
>
Validar
</button>
</section>
<!-- ─── 2. Búsqueda con prefix y suffix ──────────────────────── -->
<section class="space-y-4">
<h2 class="text-sm font-medium uppercase tracking-wide text-gray-400">Prefix / Suffix</h2>
<Field.Root>
<Field.Label class="mb-1.5 block text-sm font-medium text-gray-700">
Búsqueda
</Field.Label>
<Field.Control class="relative flex items-center">
<Field.Prefix class="pointer-events-none absolute left-3 text-gray-400">
<svg class="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="6.5" cy="6.5" r="4"/>
<path d="M10 10l3 3" stroke-linecap="round"/>
</svg>
</Field.Prefix>
<Field.Input
bind:value={searchValue}
placeholder="Buscar..."
name="search"
type="search"
class="w-full rounded-lg border border-gray-300 py-2 pr-3 pl-9 text-sm
text-gray-900 placeholder:text-gray-400 outline-none transition
focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20"
/>
{#if searchValue}
<Field.Suffix aria-hidden="false" class="absolute right-2.5">
<button
aria-label="Limpiar búsqueda"
onclick={() => (searchValue = "")}
class="rounded p-0.5 text-gray-400 hover:text-gray-600 transition"
>
<svg class="size-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3l10 10M13 3L3 13" stroke-linecap="round"/>
</svg>
</button>
</Field.Suffix>
{/if}
</Field.Control>
</Field.Root>
</section>
<!-- ─── 3. Password con toggle ───────────────────────────────── -->
<section class="space-y-4">
<h2 class="text-sm font-medium uppercase tracking-wide text-gray-400">Password</h2>
<Field.Root>
<Field.Label class="mb-1.5 block text-sm font-medium text-gray-700">
Contraseña
</Field.Label>
<Field.Control class="relative flex items-center">
<Field.Input
type={showPassword ? "text" : "password"}
bind:value={passwordValue}
placeholder="Mínimo 8 caracteres"
name="password"
required
class="w-full rounded-lg border border-gray-300 py-2 pr-10 pl-3 text-sm
text-gray-900 placeholder:text-gray-400 outline-none transition
focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20"
/>
<Field.Suffix aria-hidden="false" class="absolute right-2.5">
<button
aria-label={showPassword ? "Ocultar contraseña" : "Mostrar contraseña"}
onclick={() => (showPassword = !showPassword)}
class="rounded p-0.5 text-gray-400 hover:text-gray-600 transition"
>
{#if showPassword}
<svg class="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M2 8s2.5-5 6-5 6 5 6 5-2.5 5-6 5-6-5-6-5z"/>
<path d="M3 3l10 10" stroke-linecap="round"/>
</svg>
{:else}
<svg class="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M2 8s2.5-5 6-5 6 5 6 5-2.5 5-6 5-6-5-6-5z"/>
<circle cx="8" cy="8" r="1.75"/>
</svg>
{/if}
</button>
</Field.Suffix>
</Field.Control>
<Field.HelperText class="mt-1.5 text-xs text-gray-500">
Mínimo 8 caracteres, una mayúscula y un número.
</Field.HelperText>
</Field.Root>
</section>
<!-- ─── 4. Username con loading async ────────────────────────── -->
<section class="space-y-4">
<h2 class="text-sm font-medium uppercase tracking-wide text-gray-400">Loading async</h2>
<Field.Root state={usernameState}>
<Field.Label class="mb-1.5 block text-sm font-medium text-gray-700
data-[error]:text-red-600">
Nombre de usuario
</Field.Label>
<Field.Control class="relative flex items-center">
<Field.Input
bind:value={usernameValue}
placeholder="Elige un nombre único"
name="username"
onblur={checkUsername}
class="w-full rounded-lg border border-gray-300 px-3 py-2 pr-9 text-sm
text-gray-900 placeholder:text-gray-400 outline-none transition
focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20
data-[state=error]:border-red-500 data-[state=error]:focus:ring-red-500/20
data-[state=loading]:opacity-60"
/>
<Field.Suffix class="absolute right-2.5 text-gray-400">
{#if usernameState === "loading"}
<svg class="size-4 animate-spin" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M8 2a6 6 0 1 0 6 6" stroke-linecap="round"/>
</svg>
{:else if usernameState === "error"}
<svg class="size-4 text-red-500" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm.75 4.25a.75.75 0 0 0-1.5 0v3.5a.75.75 0 0 0 1.5 0v-3.5zm-.75 6a.875.875 0 1 0 0-1.75.875.875 0 0 0 0 1.75z"/>
</svg>
{:else if usernameValue}
<svg class="size-4 text-green-500" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 8l3.5 3.5L13 4.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{/if}
</Field.Suffix>
</Field.Control>
<Field.HelperText class="mt-1.5 text-xs text-gray-500
data-[disabled]:text-gray-300">
Prueba con "admin" para ver el error.
</Field.HelperText>
<Field.Error class="mt-1.5 flex items-center gap-1 text-xs text-red-600">
Ese nombre de usuario ya está en uso.
</Field.Error>
</Field.Root>
</section>
<!-- ─── 5. Disabled ──────────────────────────────────────────── -->
<section class="space-y-4">
<h2 class="text-sm font-medium uppercase tracking-wide text-gray-400">Disabled / Readonly</h2>
<Field.Root state="disabled">
<Field.Label class="mb-1.5 block text-sm font-medium
text-gray-400 data-[disabled]:cursor-not-allowed">
Plan actual
</Field.Label>
<Field.Control class="relative">
<Field.Input
value="Pro anual"
name="plan"
class="w-full cursor-not-allowed rounded-lg border border-gray-200
bg-gray-50 px-3 py-2 text-sm text-gray-400 outline-none"
/>
</Field.Control>
<Field.HelperText class="mt-1.5 text-xs text-gray-400">
Para cambiar de plan contacta con soporte.
</Field.HelperText>
</Field.Root>
<!-- ─── Readonly con prefix ─── -->
<Field.Root state="readonly">
<Field.Label class="mb-1.5 block text-sm font-medium text-gray-700">
ID de usuario
</Field.Label>
<Field.Control class="relative flex items-center">
<Field.Prefix class="pointer-events-none absolute left-3 text-xs font-mono text-gray-400">
usr_
</Field.Prefix>
<Field.Input
value="a1b2c3d4"
name="user-id"
class="w-full rounded-lg border border-gray-200 bg-gray-50 py-2
pr-10 pl-10 font-mono text-sm text-gray-600 outline-none"
/>
<Field.Suffix aria-hidden="false" class="absolute right-2.5">
<button
aria-label="Copiar ID"
onclick={() => navigator.clipboard.writeText("usr_a1b2c3d4")}
class="rounded p-0.5 text-gray-400 hover:text-gray-600 transition"
>
<svg class="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="5" y="5" width="8" height="9" rx="1.5"/>
<path d="M11 5V3.5A1.5 1.5 0 0 0 9.5 2h-6A1.5 1.5 0 0 0 2 3.5v8A1.5 1.5 0 0 0 3.5 13H5"/>
</svg>
</button>
</Field.Suffix>
</Field.Control>
</Field.Root>
</section>
<!-- ─── 6. Render delegation con child ───────────────────────── -->
<section class="space-y-4">
<h2 class="text-sm font-medium uppercase tracking-wide text-gray-400">Render delegation</h2>
<Field.Root state="error">
{#snippet child({ props, isError }: FieldRootSnippetProps & { props: Record<string, unknown> })}
<fieldset {...props} class="rounded-xl border border-red-200 bg-red-50/50 p-4">
<Field.Label class="mb-1.5 block text-sm font-medium text-red-700">
Nombre de usuario
</Field.Label>
<Field.Control class="relative">
<Field.Input
name="username-alt"
placeholder="No disponible"
class="w-full rounded-lg border border-red-300 bg-white px-3 py-2
text-sm text-gray-900 placeholder:text-gray-400 outline-none
focus:border-red-500 focus:ring-2 focus:ring-red-500/20"
/>
</Field.Control>
{#if isError}
<Field.Error class="mt-1.5 text-xs text-red-600">
Este nombre de usuario ya está en uso.
</Field.Error>
{/if}
</fieldset>
{/snippet}
</Field.Root>
</section>
</div>
</div>

@ -0,0 +1,124 @@
<script lang="ts">
import { Label } from '$uix/acel';
let name = $state('');
let email = $state('');
let checked = $state(false);
let fruit = $state('apple');
</script>
<div class="page">
<h1>Label</h1>
<section class="card">
<h2>Básico — label + input</h2>
<div class="field">
<Label.Root for="name" class="lbl">Nombre completo</Label.Root>
<input id="name" bind:value={name} class="inp" placeholder="Ej: Ana García" />
</div>
<div class="field">
<Label.Root for="email" class="lbl">Correo electrónico</Label.Root>
<input id="email" type="email" bind:value={email} class="inp" placeholder="ana@ejemplo.com" />
</div>
<p class="hint">Valor: nombre=<code>{name || '—'}</code> email=<code>{email || '—'}</code></p>
</section>
<section class="card">
<h2>Label + Checkbox</h2>
<div class="inline-field">
<input id="terms" type="checkbox" bind:checked={checked} class="chk" />
<Label.Root for="terms" class="lbl lbl--inline">
Acepto los términos y condiciones
</Label.Root>
</div>
<p class="hint">Checked: <code>{checked}</code></p>
<p class="hint">El doble clic sobre el label NO selecciona texto (preventDefault en detail &gt; 1)</p>
</section>
<section class="card">
<h2>Label + Radio group</h2>
<div class="radio-group">
{#each [['apple','Manzana'], ['banana','Plátano'], ['cherry','Cereza']] as [val, label]}
<div class="inline-field">
<input id="fruit-{val}" type="radio" name="fruit" value={val} bind:group={fruit} class="chk" />
<Label.Root for="fruit-{val}" class="lbl lbl--inline">{label}</Label.Root>
</div>
{/each}
</div>
<p class="hint">Seleccionado: <code>{fruit}</code></p>
</section>
<section class="card">
<h2>Label como wrapper (sin for)</h2>
<Label.Root class="lbl lbl--wrap">
<span>Suscribirse al boletín</span>
<input type="checkbox" class="chk" />
</Label.Root>
<p class="hint">El click en cualquier parte del label activa el input.</p>
</section>
</div>
<style>
.page {
max-width: 540px;
margin: 0 auto;
padding: 2rem 1.5rem;
font-family: 'DM Mono', monospace;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: .05em; }
.card {
border: 1.5px solid #e0ddd6;
border-radius: 4px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
h2 {
font-size: .65rem;
font-weight: 700;
letter-spacing: .2em;
text-transform: uppercase;
color: #999;
}
.hint { font-size: .7rem; color: #aaa; }
.field { display: flex; flex-direction: column; gap: .3rem; }
.inline-field { display: flex; align-items: center; gap: .5rem; }
.radio-group { display: flex; flex-direction: column; gap: .4rem; }
:global(.lbl) {
font-size: .75rem;
font-weight: 600;
letter-spacing: .04em;
color: #444;
cursor: pointer;
user-select: none;
}
:global(.lbl--inline) { font-size: .82rem; font-weight: 500; }
:global(.lbl--wrap) {
display: flex;
align-items: center;
justify-content: space-between;
padding: .5rem .75rem;
border: 1.5px solid #e0ddd6;
border-radius: 4px;
cursor: pointer;
font-size: .82rem;
font-weight: 500;
}
.inp {
padding: .45rem .65rem;
border: 1.5px solid #d0cdc6;
border-radius: 3px;
font-family: inherit;
font-size: .8rem;
outline: none;
}
.inp:focus { border-color: #6366f1; }
.chk { cursor: pointer; accent-color: #6366f1; }
</style>

@ -0,0 +1,416 @@
<script lang="ts">
import { Menubar } from '$uix/acel';
let openMenu = $state('');
let lastAction = $state('—');
let checkedA = $state(true);
let checkedB = $state(false);
let selectedFont = $state('sans-serif');
</script>
<div class="page">
<h1>Menubar</h1>
<!-- Demo 1: básico -->
<section class="demo">
<h2>Básico — barra horizontal</h2>
<Menubar.Root>
<Menubar.Menu value="file">
<Menubar.Trigger>Archivo</Menubar.Trigger>
<Menubar.Content sideOffset={4} align="start">
<div class="menu">
<Menubar.Item onclick={() => (lastAction = 'Nuevo')}>Nuevo</Menubar.Item>
<Menubar.Item onclick={() => (lastAction = 'Abrir')}>Abrir...</Menubar.Item>
<Menubar.Separator />
<Menubar.Item onclick={() => (lastAction = 'Guardar')}>Guardar</Menubar.Item>
<Menubar.Item onclick={() => (lastAction = 'Guardar como')}>Guardar como...</Menubar.Item>
<Menubar.Separator />
<Menubar.Item disabled>Imprimir</Menubar.Item>
</div>
</Menubar.Content>
</Menubar.Menu>
<Menubar.Menu value="edit">
<Menubar.Trigger>Editar</Menubar.Trigger>
<Menubar.Content sideOffset={4} align="start">
<div class="menu">
<Menubar.Item onclick={() => (lastAction = 'Deshacer')}>Deshacer</Menubar.Item>
<Menubar.Item onclick={() => (lastAction = 'Rehacer')}>Rehacer</Menubar.Item>
<Menubar.Separator />
<Menubar.Item onclick={() => (lastAction = 'Cortar')}>Cortar</Menubar.Item>
<Menubar.Item onclick={() => (lastAction = 'Copiar')}>Copiar</Menubar.Item>
<Menubar.Item onclick={() => (lastAction = 'Pegar')}>Pegar</Menubar.Item>
</div>
</Menubar.Content>
</Menubar.Menu>
<Menubar.Menu value="view">
<Menubar.Trigger>Vista</Menubar.Trigger>
<Menubar.Content sideOffset={4} align="start">
<div class="menu">
<Menubar.CheckboxItem bind:checked={checkedA}>
Barra lateral
</Menubar.CheckboxItem>
<Menubar.CheckboxItem bind:checked={checkedB}>
Panel inferior
</Menubar.CheckboxItem>
<Menubar.Separator />
<Menubar.Group>
<Menubar.GroupHeading>Fuente</Menubar.GroupHeading>
<Menubar.RadioGroup bind:value={selectedFont}>
{#each ['sans-serif', 'serif', 'monospace'] as font}
<Menubar.RadioItem value={font}>{font}</Menubar.RadioItem>
{/each}
</Menubar.RadioGroup>
</Menubar.Group>
</div>
</Menubar.Content>
</Menubar.Menu>
<Menubar.Menu value="help">
<Menubar.Trigger>Ayuda</Menubar.Trigger>
<Menubar.Content sideOffset={4} align="start">
<div class="menu">
<Menubar.Sub>
<Menubar.SubTrigger>Documentación</Menubar.SubTrigger>
<Menubar.SubContent sideOffset={6}>
<div class="menu">
<Menubar.Item onclick={() => (lastAction = 'Guía de inicio')}>
Guía de inicio
</Menubar.Item>
<Menubar.Item onclick={() => (lastAction = 'API Reference')}>
API Reference
</Menubar.Item>
<Menubar.Item onclick={() => (lastAction = 'Ejemplos')}>
Ejemplos
</Menubar.Item>
</div>
</Menubar.SubContent>
</Menubar.Sub>
<Menubar.Separator />
<Menubar.Item onclick={() => (lastAction = 'Acerca de')}>Acerca de</Menubar.Item>
</div>
</Menubar.Content>
</Menubar.Menu>
</Menubar.Root>
<div class="status-row">
<span class="badge">Acción: <strong>{lastAction}</strong></span>
<span class="badge">Barra: {checkedA} · Panel: {checkedB}</span>
<span class="badge">Fuente: <strong>{selectedFont}</strong></span>
</div>
</section>
<!-- Demo 2: controlado (bind:value) -->
<section class="demo">
<h2>Controlado (bind:value)</h2>
<Menubar.Root bind:value={openMenu}>
{#each ['Archivo', 'Editar', 'Formato'] as name, i}
{@const id = ['file2', 'edit2', 'format2'][i]}
<Menubar.Menu value={id}>
<Menubar.Trigger>{name}</Menubar.Trigger>
<Menubar.Content sideOffset={4} align="start">
<div class="menu">
<Menubar.Item>Opción 1 de {name}</Menubar.Item>
<Menubar.Item>Opción 2 de {name}</Menubar.Item>
</div>
</Menubar.Content>
</Menubar.Menu>
{/each}
</Menubar.Root>
<p class="hint">
Menú activo: <strong>{openMenu || '(ninguno)'}</strong>
</p>
</section>
<!-- Demo 3: con trigger deshabilitado -->
<section class="demo">
<h2>Trigger deshabilitado</h2>
<Menubar.Root>
<Menubar.Menu value="active">
<Menubar.Trigger>Activo</Menubar.Trigger>
<Menubar.Content sideOffset={4} align="start">
<div class="menu">
<Menubar.Item>Opción A</Menubar.Item>
<Menubar.Item>Opción B</Menubar.Item>
</div>
</Menubar.Content>
</Menubar.Menu>
<Menubar.Menu value="disabled">
<Menubar.Trigger disabled>Deshabilitado</Menubar.Trigger>
<Menubar.Content sideOffset={4} align="start">
<div class="menu">
<Menubar.Item>No debería aparecer</Menubar.Item>
</div>
</Menubar.Content>
</Menubar.Menu>
<Menubar.Menu value="other">
<Menubar.Trigger>Otro</Menubar.Trigger>
<Menubar.Content sideOffset={4} align="start">
<div class="menu">
<Menubar.Item>Opción C</Menubar.Item>
<Menubar.Item>Opción D</Menubar.Item>
</div>
</Menubar.Content>
</Menubar.Menu>
</Menubar.Root>
</section>
</div>
<style>
.page {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, sans-serif;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: #1a1a1a;
}
h2 {
font-size: .85rem;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: #888;
margin-bottom: .75rem;
}
.demo {
background: #fff;
border: 1.5px solid #e5e7eb;
border-radius: 6px;
padding: 1.5rem;
margin-bottom: 1.25rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
/* Menubar root */
:global([data-menubar-root]) {
display: flex;
align-items: center;
gap: 2px;
background: #f9fafb;
border: 1.5px solid #e5e7eb;
border-radius: 6px;
padding: .2rem .3rem;
}
/* Triggers */
:global([data-menubar-trigger]) {
padding: .35rem .7rem;
border-radius: 4px;
font-size: .84rem;
font-family: inherit;
cursor: pointer;
color: #374151;
background: transparent;
border: none;
transition: background .12s;
}
:global([data-menubar-trigger]:hover),
:global([data-menubar-trigger][data-highlighted]) {
background: #e5e7eb;
}
:global([data-menubar-trigger][data-state="open"]) {
background: #ede9fe;
color: #5b21b6;
}
:global([data-menubar-trigger][data-disabled]) {
opacity: .4;
cursor: default;
}
/* Floating wrapper */
:global([data-acel-floating-content-wrapper]) {
z-index: 9999;
}
/* Menu box */
.menu {
background: #fff;
border: 1.5px solid #e5e7eb;
border-radius: 6px;
padding: .3rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, .12);
min-width: 180px;
display: flex;
flex-direction: column;
gap: 1px;
}
/* Items */
:global([data-menubar-item]),
:global([data-dropdown-menu-item]) {
padding: .45rem .7rem;
border-radius: 3px;
font-size: .84rem;
cursor: pointer;
color: #1f2937;
font-family: inherit;
display: flex;
align-items: center;
gap: .5rem;
transition: background .1s;
user-select: none;
}
:global([data-menubar-item]:hover),
:global([data-menubar-item][data-highlighted]),
:global([data-dropdown-menu-item]:hover),
:global([data-dropdown-menu-item][data-highlighted]) {
background: #f3f4f6;
}
:global([data-menubar-item][data-disabled]),
:global([data-dropdown-menu-item][data-disabled]) {
opacity: .4;
cursor: default;
}
/* Checkbox item */
:global([data-menubar-checkbox-item]),
:global([data-dropdown-menu-checkbox-item]) {
padding: .45rem .7rem;
border-radius: 3px;
font-size: .84rem;
cursor: pointer;
color: #1f2937;
font-family: inherit;
display: flex;
align-items: center;
gap: .5rem;
transition: background .1s;
user-select: none;
}
:global([data-menubar-checkbox-item]:hover),
:global([data-menubar-checkbox-item][data-highlighted]),
:global([data-dropdown-menu-checkbox-item]:hover),
:global([data-dropdown-menu-checkbox-item][data-highlighted]) {
background: #f3f4f6;
}
:global([data-menubar-checkbox-item][data-state="checked"])::before,
:global([data-dropdown-menu-checkbox-item][data-state="checked"])::before {
content: "✓";
font-size: .75rem;
color: #7c3aed;
min-width: .9rem;
}
:global([data-menubar-checkbox-item][data-state="unchecked"])::before,
:global([data-dropdown-menu-checkbox-item][data-state="unchecked"])::before {
content: " ";
min-width: .9rem;
display: inline-block;
}
/* Radio item */
:global([data-menubar-radio-item]),
:global([data-dropdown-menu-radio-item]) {
padding: .45rem .7rem;
border-radius: 3px;
font-size: .84rem;
cursor: pointer;
color: #1f2937;
font-family: inherit;
display: flex;
align-items: center;
gap: .5rem;
transition: background .1s;
user-select: none;
}
:global([data-menubar-radio-item]:hover),
:global([data-menubar-radio-item][data-highlighted]),
:global([data-dropdown-menu-radio-item]:hover),
:global([data-dropdown-menu-radio-item][data-highlighted]) {
background: #f3f4f6;
}
:global([data-menubar-radio-item][data-state="checked"])::before,
:global([data-dropdown-menu-radio-item][data-state="checked"])::before {
content: "●";
font-size: .6rem;
color: #7c3aed;
min-width: .9rem;
}
:global([data-menubar-radio-item][data-state="unchecked"])::before,
:global([data-dropdown-menu-radio-item][data-state="unchecked"])::before {
content: "○";
font-size: .6rem;
color: #ccc;
min-width: .9rem;
}
/* Group heading */
:global([data-menubar-group-heading]),
:global([data-dropdown-menu-group-heading]) {
padding: .3rem .7rem .15rem;
font-size: .7rem;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: #9ca3af;
}
/* Separator */
:global([data-menubar-separator]),
:global([data-dropdown-menu-separator]) {
height: 1px;
background: #f3f4f6;
margin: .25rem .3rem;
}
/* Sub trigger */
:global([data-menubar-sub-trigger]),
:global([data-dropdown-menu-sub-trigger]) {
padding: .45rem .7rem;
border-radius: 3px;
font-size: .84rem;
cursor: pointer;
color: #1f2937;
font-family: inherit;
display: flex;
align-items: center;
justify-content: space-between;
gap: .5rem;
transition: background .1s;
user-select: none;
}
:global([data-menubar-sub-trigger]:hover),
:global([data-menubar-sub-trigger][data-state="open"]),
:global([data-menubar-sub-trigger][data-highlighted]),
:global([data-dropdown-menu-sub-trigger]:hover),
:global([data-dropdown-menu-sub-trigger][data-state="open"]),
:global([data-dropdown-menu-sub-trigger][data-highlighted]) {
background: #f3f4f6;
}
:global([data-menubar-sub-trigger])::after,
:global([data-dropdown-menu-sub-trigger])::after {
content: "›";
font-size: 1rem;
color: #9ca3af;
margin-left: auto;
}
.status-row {
display: flex;
gap: .5rem;
flex-wrap: wrap;
}
.badge {
font-size: .78rem;
color: #6b7280;
background: #f9fafb;
border: 1px solid #e5e7eb;
padding: .2rem .6rem;
border-radius: 4px;
}
.badge strong { color: #374151; }
.hint {
font-size: .8rem;
color: #6b7280;
margin: 0;
}
.hint strong { color: #374151; }
</style>

@ -0,0 +1,123 @@
<script lang="ts">
import { Meter } from '$uix/acel';
let battery = $state(72);
let cpu = $state(38);
let memory = $state(61);
let disk = $state(88);
function getMeterColor(v: number): string {
if (v < 40) return '#22c55e';
if (v < 75) return '#f59e0b';
return '#ef4444';
}
</script>
<div class="page">
<h1>Meter</h1>
<section class="card">
<h2>Nivel de batería</h2>
<Meter.Root value={battery} min={0} max={100} class="track">
<div class="fill" style:width="{battery}%" style:background={getMeterColor(100 - battery)}></div>
</Meter.Root>
<div class="row-between">
<input type="range" min="0" max="100" bind:value={battery} class="slider" />
<span class="badge badge--{battery < 20 ? 'red' : battery < 50 ? 'yellow' : 'green'}">{battery}%</span>
</div>
</section>
<section class="card">
<h2>Recursos del sistema</h2>
{#each [['CPU', cpu, getMeterColor(cpu)], ['Memoria', memory, getMeterColor(memory)], ['Disco', disk, getMeterColor(disk)]] as [label, val, color]}
<div class="resource-row">
<span class="resource-label">{label}</span>
<Meter.Root value={val as number} min={0} max={100} class="track track--sm">
<div class="fill" style:width="{val}%" style:background={color}></div>
</Meter.Root>
<span class="resource-val" style:color={color as string}>{val}%</span>
</div>
{/each}
<div class="btn-row">
<button class="btn" onclick={() => { cpu = Math.round(Math.random() * 100); memory = Math.round(Math.random() * 100); disk = Math.round(Math.random() * 100); }}>
Randomizar
</button>
</div>
</section>
<section class="card">
<h2>Diferencia con Progress</h2>
<p class="hint">Meter usa <code>role="meter"</code> (valor actual de algo medible) mientras que Progress usa <code>role="progressbar"</code> (progreso de una tarea). Meter no tiene estado indeterminado.</p>
</section>
<section class="card">
<h2>Con rango personalizado (pH 0–14)</h2>
<Meter.Root value={7.4} min={0} max={14} class="track">
<div class="fill fill--ph" style:width="{(7.4 / 14) * 100}%"></div>
</Meter.Root>
<p class="hint">Valor: 7.4 / 14 — rango min=0, max=14</p>
</section>
</div>
<style>
.page {
max-width: 580px;
margin: 0 auto;
padding: 2rem 1.5rem;
font-family: 'DM Mono', monospace;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: .05em; }
.card {
border: 1.5px solid #e0ddd6;
border-radius: 4px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
h2 { font-size: .65rem; font-weight: 700; letter-spacing: .2em; text-transform: uppercase; color: #999; }
.hint { font-size: .7rem; color: #aaa; line-height: 1.5; }
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.resource-row { display: flex; align-items: center; gap: .75rem; }
.resource-label { font-size: .7rem; min-width: 4rem; color: #888; }
.resource-val { font-size: .7rem; font-weight: 700; min-width: 2.5rem; text-align: right; }
.btn-row { display: flex; }
.btn {
padding: .3rem .7rem;
background: #1a1a1a;
color: #fff;
border: none;
border-radius: 3px;
font-family: inherit;
font-size: .72rem;
font-weight: 600;
cursor: pointer;
}
.badge {
font-size: .7rem; font-weight: 700;
padding: .2rem .5rem; border-radius: 2px; min-width: 3rem; text-align: center;
}
.badge--green { background: #dcfce7; color: #166534; }
.badge--yellow { background: #fef3c7; color: #78350f; }
.badge--red { background: #fee2e2; color: #7f1d1d; }
:global(.track) {
width: 100%;
height: 8px;
background: #f0ede8;
border-radius: 999px;
overflow: hidden;
}
:global(.track--sm) { height: 5px; flex: 1; }
.fill {
height: 100%;
border-radius: 999px;
transition: width .3s ease, background .3s ease;
}
.fill--ph { background: linear-gradient(to right, #ef4444, #f59e0b, #22c55e, #3b82f6, #8b5cf6); }
.slider { flex: 1; accent-color: #6366f1; cursor: pointer; }
</style>

@ -0,0 +1,215 @@
<script lang="ts">
import { Pagination } from '$uix/acel';
import type { PageItem } from '$uix/acel/pagination/pagination.svelte';
let page1 = $state(1);
let page2 = $state(5);
let page3 = $state(1);
let perPage = $state(10);
const TOTAL_ITEMS = 247;
// Simulated records for the table example
const allRecords = Array.from({ length: TOTAL_ITEMS }, (_, i) => ({
id: i + 1,
name: `Usuario ${i + 1}`,
email: `user${i + 1}@ejemplo.com`,
status: i % 3 === 0 ? 'activo' : i % 3 === 1 ? 'inactivo' : 'pendiente',
}));
const records = $derived(
allRecords.slice((page3 - 1) * perPage, page3 * perPage)
);
</script>
<div class="page">
<h1>Pagination</h1>
<section class="card">
<h2>Básica — 50 elementos, 10 por página</h2>
<Pagination.Root count={50} perPage={10} bind:page={page1} class="pg">
{#snippet children({ pages, totalPages })}
<Pagination.PrevButton class="pg-btn pg-nav">‹</Pagination.PrevButton>
{#each pages as item (item.type === 'page' ? item.value : `e-${pages.indexOf(item)}`)}
{#if item.type === 'ellipsis'}
<span class="pg-ellipsis">…</span>
{:else}
<Pagination.Page page={item.value} class="pg-btn {page1 === item.value ? 'pg-btn--active' : ''}">
{item.value}
</Pagination.Page>
{/if}
{/each}
<Pagination.NextButton class="pg-btn pg-nav">›</Pagination.NextButton>
<span class="pg-info">Página {page1} de {totalPages}</span>
{/snippet}
</Pagination.Root>
</section>
<section class="card">
<h2>Con muchas páginas (sibling=1)</h2>
<Pagination.Root count={500} perPage={10} siblingCount={1} bind:page={page2} class="pg">
{#snippet children({ pages, totalPages })}
<Pagination.PrevButton class="pg-btn pg-nav">‹ Anterior</Pagination.PrevButton>
{#each pages as item (item.type === 'page' ? item.value : `e-${pages.indexOf(item)}`)}
{#if item.type === 'ellipsis'}
<span class="pg-ellipsis">…</span>
{:else}
<Pagination.Page page={item.value} class="pg-btn {page2 === item.value ? 'pg-btn--active' : ''}">
{item.value}
</Pagination.Page>
{/if}
{/each}
<Pagination.NextButton class="pg-btn pg-nav">Siguiente ›</Pagination.NextButton>
{/snippet}
</Pagination.Root>
<p class="hint">Página actual: <code>{page2}</code> / 50 páginas totales</p>
</section>
<section class="card">
<h2>Tabla con paginación real ({TOTAL_ITEMS} registros)</h2>
<div class="table-toolbar">
<span class="hint">Mostrando {(page3 - 1) * perPage + 1}–{Math.min(page3 * perPage, TOTAL_ITEMS)} de {TOTAL_ITEMS}</span>
<select class="sel" bind:value={perPage} onchange={() => (page3 = 1)}>
<option value={5}>5 / página</option>
<option value={10}>10 / página</option>
<option value={20}>20 / página</option>
</select>
</div>
<table class="tbl">
<thead>
<tr>
<th>#</th>
<th>Nombre</th>
<th>Email</th>
<th>Estado</th>
</tr>
</thead>
<tbody>
{#each records as rec}
<tr>
<td class="td-id">{rec.id}</td>
<td>{rec.name}</td>
<td class="td-email">{rec.email}</td>
<td><span class="status status--{rec.status}">{rec.status}</span></td>
</tr>
{/each}
</tbody>
</table>
<Pagination.Root count={TOTAL_ITEMS} {perPage} bind:page={page3} class="pg pg--compact">
{#snippet children({ pages, totalPages })}
<Pagination.PrevButton class="pg-btn pg-nav">‹</Pagination.PrevButton>
{#each pages as item (item.type === 'page' ? item.value : `e-${pages.indexOf(item)}`)}
{#if item.type === 'ellipsis'}
<span class="pg-ellipsis">…</span>
{:else}
<Pagination.Page page={item.value} class="pg-btn {page3 === item.value ? 'pg-btn--active' : ''}">
{item.value}
</Pagination.Page>
{/if}
{/each}
<Pagination.NextButton class="pg-btn pg-nav">›</Pagination.NextButton>
{/snippet}
</Pagination.Root>
</section>
</div>
<style>
.page {
max-width: 720px;
margin: 0 auto;
padding: 2rem 1.5rem;
font-family: 'DM Mono', monospace;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: .05em; }
.card {
border: 1.5px solid #e0ddd6;
border-radius: 4px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
h2 { font-size: .65rem; font-weight: 700; letter-spacing: .2em; text-transform: uppercase; color: #999; }
.hint { font-size: .7rem; color: #aaa; }
/* Pagination */
:global(.pg) {
display: flex;
align-items: center;
gap: .25rem;
flex-wrap: wrap;
}
:global(.pg--compact) { justify-content: flex-end; }
:global(.pg-btn) {
min-width: 2rem;
height: 2rem;
padding: 0 .5rem;
border: 1.5px solid #d0cdc6;
border-radius: 3px;
background: #fff;
font-family: inherit;
font-size: .75rem;
font-weight: 600;
cursor: pointer;
transition: all .15s;
display: inline-flex;
align-items: center;
justify-content: center;
}
:global(.pg-btn:hover:not(:disabled)) { border-color: #6366f1; color: #6366f1; }
:global(.pg-btn--active) { background: #6366f1 !important; border-color: #6366f1 !important; color: #fff !important; }
:global(.pg-btn:disabled) { opacity: .35; cursor: not-allowed; }
:global(.pg-nav) { font-size: .9rem; }
.pg-ellipsis { font-size: .75rem; color: #aaa; padding: 0 .25rem; }
.pg-info { font-size: .7rem; color: #888; margin-left: .5rem; }
/* Table */
.table-toolbar { display: flex; align-items: center; justify-content: space-between; }
.tbl { width: 100%; border-collapse: collapse; font-size: .75rem; }
.tbl th {
text-align: left;
padding: .4rem .6rem;
background: #f7f6f3;
font-size: .65rem;
font-weight: 700;
letter-spacing: .1em;
text-transform: uppercase;
color: #888;
border-bottom: 1.5px solid #e0ddd6;
}
.tbl td { padding: .4rem .6rem; border-bottom: 1px solid #f0ede8; }
.tbl tr:last-child td { border-bottom: none; }
.td-id { color: #aaa; width: 3rem; }
.td-email { color: #888; font-size: .7rem; }
.status {
display: inline-block;
padding: .1rem .4rem;
border-radius: 2px;
font-size: .65rem;
font-weight: 700;
letter-spacing: .05em;
}
.status--activo { background: #dcfce7; color: #166534; }
.status--inactivo { background: #f3f4f6; color: #6b7280; }
.status--pendiente { background: #fef3c7; color: #78350f; }
.sel {
padding: .25rem .5rem;
border: 1.5px solid #d0cdc6;
border-radius: 3px;
font-family: inherit;
font-size: .72rem;
background: #fff;
cursor: pointer;
}
</style>

@ -0,0 +1,187 @@
<script lang="ts">
import * as PinInput from '$uix/acel/pin-input';
import { REGEXP_ONLY_DIGITS } from '$uix/acel/pin-input';
let value6 = $state('');
let value4 = $state('');
let valueAlpha = $state('');
let completed = $state('');
function handleComplete(v: string) {
completed = v;
}
</script>
<div class="page">
<a href="/test" class="back">← volver</a>
<h1>PinInput</h1>
<!-- 6 dígitos -->
<section>
<h2>6 dígitos (value: "{value6}")</h2>
<PinInput.Root
maxlength={6}
bind:value={value6}
pattern={REGEXP_ONLY_DIGITS}
>
{#snippet children({ cells })}
<div class="cells">
{#each cells as cell}
<PinInput.Cell {cell}>
{#snippet children()}
<span class="char">
{#if cell.hasFakeCaret}
<span class="caret"></span>
{:else}
{cell.char ?? ''}
{/if}
</span>
{/snippet}
</PinInput.Cell>
{/each}
</div>
{/snippet}
</PinInput.Root>
</section>
<!-- 4 dígitos con onComplete -->
<section>
<h2>4 dígitos con onComplete</h2>
{#if completed}
<p class="success">¡Completado: <strong>{completed}</strong>!</p>
{/if}
<PinInput.Root
maxlength={4}
bind:value={value4}
pattern={REGEXP_ONLY_DIGITS}
onComplete={handleComplete}
>
{#snippet children({ cells })}
<div class="cells">
{#each cells as cell}
<PinInput.Cell {cell}>
{#snippet children()}
<span class="char large">
{#if cell.hasFakeCaret}
<span class="caret"></span>
{:else}
{cell.char ?? ''}
{/if}
</span>
{/snippet}
</PinInput.Cell>
{/each}
</div>
{/snippet}
</PinInput.Root>
</section>
<!-- Solo letras -->
<section>
<h2>Solo letras (A-Z)</h2>
<PinInput.Root
maxlength={5}
bind:value={valueAlpha}
pattern="^[a-zA-Z]+$"
>
{#snippet children({ cells, isFocused })}
<div class="cells" class:focused={isFocused}>
{#each cells as cell}
<PinInput.Cell {cell}>
{#snippet children()}
<span class="char upper">
{#if cell.hasFakeCaret}
<span class="caret"></span>
{:else}
{(cell.char ?? '').toUpperCase()}
{/if}
</span>
{/snippet}
</PinInput.Cell>
{/each}
</div>
{/snippet}
</PinInput.Root>
</section>
<!-- Disabled -->
<section>
<h2>Disabled</h2>
<PinInput.Root maxlength={6} value="123456" disabled>
{#snippet children({ cells })}
<div class="cells disabled">
{#each cells as cell}
<PinInput.Cell {cell}>
{#snippet children()}
<span class="char">{cell.char ?? ''}</span>
{/snippet}
</PinInput.Cell>
{/each}
</div>
{/snippet}
</PinInput.Root>
</section>
</div>
<style>
.page { max-width: 600px; margin: 2rem auto; padding: 0 1.5rem; font-family: sans-serif; }
.back { font-size: .85rem; color: #6366f1; text-decoration: none; }
h1 { margin: 1rem 0 2rem; }
section { margin-bottom: 2.5rem; }
h2 { font-size: .9rem; font-weight: 600; margin-bottom: 1rem; color: #374151; }
.cells {
display: flex;
gap: .5rem;
width: fit-content;
}
.cells.disabled { opacity: .5; pointer-events: none; }
:global([data-acel-pin-input-cell]) {
width: 44px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
}
:global([data-acel-pin-input-cell][data-active]) .char {
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99,102,241,.15);
}
.char {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #d1d5db;
border-radius: .5rem;
font-size: 1.5rem;
font-weight: 600;
font-family: monospace;
background: white;
transition: border-color .15s, box-shadow .15s;
}
.char.large { font-size: 1.8rem; }
.char.upper { text-transform: uppercase; }
.caret {
display: block;
width: 2px;
height: 1.2em;
background: #6366f1;
animation: blink 1s step-end infinite;
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
.success {
font-size: .9rem;
color: #16a34a;
background: #f0fdf4;
border: 1px solid #86efac;
border-radius: .5rem;
padding: .5rem .75rem;
margin-bottom: 1rem;
}
</style>

@ -0,0 +1,267 @@
<script lang="ts">
import { Popover } from '$uix/acel';
let openState = $state(false);
</script>
<div class="page">
<h1>Popover</h1>
<!-- Demo 1: básico -->
<section class="demo">
<h2>Básico</h2>
<div class="row">
<Popover.Root>
<Popover.Trigger>Abrir popover</Popover.Trigger>
<Popover.Content sideOffset={8}>
<div class="popover-box">
<p>Contenido del popover</p>
<Popover.Close>Cerrar ✕</Popover.Close>
</div>
</Popover.Content>
</Popover.Root>
</div>
</section>
<!-- Demo 2: lados -->
<section class="demo">
<h2>Posiciones</h2>
<div class="row">
{#each ['top', 'right', 'bottom', 'left'] as side}
<Popover.Root>
<Popover.Trigger>{side}</Popover.Trigger>
<Popover.Content side={side as any} sideOffset={10}>
<div class="popover-box small">
<strong>Popover {side}</strong>
<Popover.Close>✕</Popover.Close>
</div>
</Popover.Content>
</Popover.Root>
{/each}
</div>
</section>
<!-- Demo 3: alineación -->
<section class="demo">
<h2>Alineación</h2>
<div class="row">
{#each ['start', 'center', 'end'] as align}
<Popover.Root>
<Popover.Trigger>align={align}</Popover.Trigger>
<Popover.Content side="bottom" align={align as any} sideOffset={8}>
<div class="popover-box">
<p>align: {align}</p>
<Popover.Close>Cerrar</Popover.Close>
</div>
</Popover.Content>
</Popover.Root>
{/each}
</div>
</section>
<!-- Demo 4: controlado -->
<section class="demo">
<h2>Controlado (bind:open)</h2>
<div class="row">
<Popover.Root bind:open={openState}>
<Popover.Trigger>Toggle popover</Popover.Trigger>
<Popover.Content sideOffset={8}>
<div class="popover-box">
<p>Estado: <strong>{openState ? 'abierto' : 'cerrado'}</strong></p>
<Popover.Close>Cerrar</Popover.Close>
</div>
</Popover.Content>
</Popover.Root>
<span class="state-badge" class:open={openState}>
{openState ? 'Abierto' : 'Cerrado'}
</span>
</div>
</section>
<!-- Demo 5: contenido rico -->
<section class="demo">
<h2>Contenido rico</h2>
<Popover.Root>
<Popover.Trigger>⚙️ Configuración</Popover.Trigger>
<Popover.Content sideOffset={10} side="bottom" align="end">
<div class="popover-settings">
<h3>Configuración</h3>
<label class="setting-row">
<span>Modo oscuro</span>
<input type="checkbox" />
</label>
<label class="setting-row">
<span>Notificaciones</span>
<input type="checkbox" checked />
</label>
<label class="setting-row">
<span>Idioma</span>
<select><option>Español</option><option>English</option></select>
</label>
<div class="setting-actions">
<Popover.Close>Cancelar</Popover.Close>
<Popover.Close>Guardar</Popover.Close>
</div>
</div>
</Popover.Content>
</Popover.Root>
</section>
</div>
<style>
.page {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, sans-serif;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: #1a1a1a;
}
h2 {
font-size: .85rem;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: #888;
margin-bottom: .75rem;
}
.demo {
background: #fff;
border: 1.5px solid #e5e7eb;
border-radius: 6px;
padding: 1.5rem;
margin-bottom: 1.25rem;
}
.row {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
/* Triggers */
:global([data-popover-trigger]) {
padding: .4rem .9rem;
background: #f3f4f6;
border: 1.5px solid #d1d5db;
border-radius: 4px;
font-size: .85rem;
cursor: pointer;
font-family: inherit;
color: #374151;
transition: background .15s, border-color .15s;
}
:global([data-popover-trigger]:hover) {
background: #e5e7eb;
}
:global([data-popover-trigger][data-state="open"]) {
background: #ede9fe;
border-color: #7c3aed;
color: #5b21b6;
}
/* Floating wrapper */
:global([data-acel-floating-content-wrapper]) {
z-index: 9999;
}
/* Content */
:global([data-popover-content]) {
outline: none;
}
.popover-box {
background: #fff;
border: 1.5px solid #e5e7eb;
border-radius: 8px;
padding: .85rem 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, .12);
display: flex;
flex-direction: column;
gap: .6rem;
min-width: 160px;
font-size: .85rem;
}
.popover-box.small {
min-width: 120px;
}
.popover-box p {
color: #374151;
margin: 0;
}
/* Close button */
:global([data-popover-close]) {
padding: .3rem .7rem;
background: #1f2937;
color: #f9fafb;
border: none;
border-radius: 4px;
font-size: .78rem;
cursor: pointer;
font-family: inherit;
align-self: flex-start;
transition: opacity .15s;
}
:global([data-popover-close]:hover) {
opacity: .8;
}
.state-badge {
padding: .25rem .65rem;
border-radius: 999px;
font-size: .75rem;
font-weight: 600;
background: #f3f4f6;
color: #6b7280;
border: 1px solid #e5e7eb;
}
.state-badge.open {
background: #ede9fe;
color: #5b21b6;
border-color: #c4b5fd;
}
.popover-settings {
background: #fff;
border: 1.5px solid #e5e7eb;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, .12);
min-width: 200px;
display: flex;
flex-direction: column;
gap: .75rem;
}
.popover-settings h3 {
font-size: .85rem;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: .82rem;
color: #374151;
gap: .75rem;
}
.setting-row select {
font-size: .78rem;
border: 1px solid #d1d5db;
border-radius: 3px;
padding: .1rem .3rem;
}
.setting-actions {
display: flex;
gap: .4rem;
justify-content: flex-end;
padding-top: .25rem;
border-top: 1px solid #f3f4f6;
}
</style>

@ -0,0 +1,149 @@
<script lang="ts">
import { Progress } from '$uix/acel';
let progress = $state(65);
let isLoading = $state(false);
let loadValue = $state<number | null>(null);
function startLoading() {
isLoading = true;
loadValue = 0;
const interval = setInterval(() => {
loadValue = (loadValue ?? 0) + 8;
if ((loadValue ?? 0) >= 100) {
loadValue = 100;
isLoading = false;
clearInterval(interval);
}
}, 200);
}
</script>
<div class="page">
<h1>Progress</h1>
<section class="card">
<h2>Controlado con slider</h2>
<Progress.Root value={progress} max={100} class="track">
<div class="fill" style:width="{progress}%"></div>
</Progress.Root>
<div class="row-between">
<input type="range" min="0" max="100" bind:value={progress} class="slider" />
<span class="badge">{progress}%</span>
</div>
<p class="hint">data-state: <code>{progress === 100 ? 'loaded' : 'loading'}</code></p>
</section>
<section class="card">
<h2>Indeterminado (value=null)</h2>
<Progress.Root value={null} max={100} class="track">
<div class="fill fill--indeterminate"></div>
</Progress.Root>
<p class="hint">data-state: <code>indeterminate</code> — sin valor conocido</p>
</section>
<section class="card">
<h2>Animado — simulación de carga</h2>
<Progress.Root value={loadValue} max={100} class="track">
{#if loadValue !== null}
<div class="fill fill--green" style:width="{loadValue}%"></div>
{:else}
<div class="fill fill--indeterminate"></div>
{/if}
</Progress.Root>
<div class="row-between">
<button class="btn" onclick={startLoading} disabled={isLoading}>
{isLoading ? 'Cargando…' : 'Iniciar carga'}
</button>
<span class="badge">{loadValue !== null ? `${loadValue}%` : '—'}</span>
</div>
</section>
<section class="card">
<h2>Múltiples variantes</h2>
{#each [[20, '#6366f1'], [45, '#22c55e'], [70, '#f59e0b'], [100, '#ef4444']] as [val, color]}
<div class="variant-row">
<span class="variant-label">{val}%</span>
<Progress.Root value={val} max={100} class="track track--sm">
<div class="fill" style:width="{val}%" style:background={color}></div>
</Progress.Root>
</div>
{/each}
</section>
</div>
<style>
.page {
max-width: 580px;
margin: 0 auto;
padding: 2rem 1.5rem;
font-family: 'DM Mono', monospace;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: .05em; }
.card {
border: 1.5px solid #e0ddd6;
border-radius: 4px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
h2 { font-size: .65rem; font-weight: 700; letter-spacing: .2em; text-transform: uppercase; color: #999; }
.hint { font-size: .7rem; color: #aaa; }
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.badge {
font-size: .7rem;
font-weight: 700;
background: #f3f4f6;
padding: .2rem .5rem;
border-radius: 2px;
min-width: 3rem;
text-align: center;
}
.variant-row { display: flex; align-items: center; gap: .75rem; }
.variant-label { font-size: .7rem; min-width: 2.5rem; color: #888; text-align: right; }
:global(.track) {
width: 100%;
height: 8px;
background: #f0ede8;
border-radius: 999px;
overflow: hidden;
position: relative;
}
:global(.track--sm) {
height: 5px;
}
.fill {
height: 100%;
background: #6366f1;
border-radius: 999px;
transition: width .3s ease;
}
.fill--green { background: #22c55e; }
@keyframes indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
.fill--indeterminate {
width: 30%;
background: #6366f1;
animation: indeterminate 1.4s ease infinite;
}
.slider { flex: 1; accent-color: #6366f1; cursor: pointer; }
.btn {
padding: .35rem .8rem;
background: #1a1a1a;
color: #fff;
border: none;
border-radius: 3px;
font-family: inherit;
font-size: .75rem;
font-weight: 600;
cursor: pointer;
}
.btn:disabled { opacity: .5; cursor: not-allowed; }
</style>

@ -0,0 +1,187 @@
<script lang="ts">
import { RadioGroup } from '$uix/acel';
let plan = $state('pro');
let payment = $state('card');
let orientation = $state<'horizontal' | 'vertical'>('vertical');
let size = $state('md');
</script>
<div class="page">
<h1>Radio Group</h1>
<section class="card">
<h2>Selección de plan</h2>
<RadioGroup.Root bind:value={plan} class="rg-list">
{#each [
{ value: 'free', label: 'Free', desc: '1 usuario · 5 GB', price: '$0' },
{ value: 'pro', label: 'Pro', desc: '5 usuarios · 50 GB', price: '$12/mes' },
{ value: 'enterprise', label: 'Enterprise', desc: 'Ilimitado · SLA 99.99%', price: 'Contactar' },
] as item}
<label class="plan-row {plan === item.value ? 'plan-row--active' : ''}">
<RadioGroup.Item value={item.value} class="radio {plan === item.value ? 'radio--on' : ''}">
{#snippet children({ checked })}
{#if checked}<span class="radio-dot"></span>{/if}
{/snippet}
</RadioGroup.Item>
<div class="plan-info">
<span class="plan-name">{item.label}</span>
<span class="plan-desc">{item.desc}</span>
</div>
<span class="plan-price">{item.price}</span>
</label>
{/each}
</RadioGroup.Root>
<p class="hint">plan seleccionado: {plan}</p>
</section>
<section class="card">
<h2>Método de pago</h2>
<RadioGroup.Root bind:value={payment} orientation="horizontal" class="rg-row">
{#each [
{ value: 'card', label: 'Tarjeta' },
{ value: 'paypal', label: 'PayPal' },
{ value: 'crypto', label: 'Cripto' },
] as opt}
<label class="pay-option {payment === opt.value ? 'pay-option--active' : ''}">
<RadioGroup.Item value={opt.value} class="radio {payment === opt.value ? 'radio--on' : ''}">
{#snippet children({ checked })}
{#if checked}<span class="radio-dot"></span>{/if}
{/snippet}
</RadioGroup.Item>
{opt.label}
</label>
{/each}
</RadioGroup.Root>
<p class="hint">pago: {payment}</p>
</section>
<section class="card">
<h2>Con opción desactivada</h2>
<RadioGroup.Root bind:value={size} class="rg-list">
{#each [
{ value: 'sm', label: 'Pequeño', disabled: false },
{ value: 'md', label: 'Mediano', disabled: false },
{ value: 'lg', label: 'Grande', disabled: false },
{ value: 'xl', label: 'XL (agotado)', disabled: true },
] as opt}
<label class="simple-row {opt.disabled ? 'simple-row--disabled' : ''}">
<RadioGroup.Item value={opt.value} disabled={opt.disabled} class="radio {size === opt.value ? 'radio--on' : ''} {opt.disabled ? 'radio--disabled' : ''}">
{#snippet children({ checked })}
{#if checked}<span class="radio-dot"></span>{/if}
{/snippet}
</RadioGroup.Item>
{opt.label}
</label>
{/each}
</RadioGroup.Root>
<p class="hint">talla: {size}</p>
</section>
<section class="card">
<h2>Grupo desactivado</h2>
<RadioGroup.Root value="b" disabled class="rg-row">
{#each ['a','b','c'] as v}
<label class="simple-row simple-row--disabled">
<RadioGroup.Item value={v} class="radio {'b' === v ? 'radio--on' : ''}">
{#snippet children({ checked })}
{#if checked}<span class="radio-dot"></span>{/if}
{/snippet}
</RadioGroup.Item>
Opción {v.toUpperCase()}
</label>
{/each}
</RadioGroup.Root>
</section>
</div>
<style>
.page {
max-width: 520px;
margin: 0 auto;
padding: 2rem 1.5rem;
font-family: 'DM Mono', monospace;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: .05em; }
.card {
border: 1.5px solid #e0ddd6;
border-radius: 4px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
h2 { font-size: .65rem; font-weight: 700; letter-spacing: .2em; text-transform: uppercase; color: #999; }
.hint { font-size: .68rem; color: #aaa; }
/* Radio */
:global(.radio) {
width: 18px; height: 18px;
border: 2px solid #d0cdc6;
border-radius: 50%;
background: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
flex-shrink: 0;
transition: border-color .15s;
}
:global(.radio--on) { border-color: #6366f1; }
:global(.radio--disabled) { opacity: .4; cursor: not-allowed; }
.radio-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: #6366f1;
}
/* List layout */
:global(.rg-list) { display: flex; flex-direction: column; gap: .4rem; }
:global(.rg-row) { display: flex; gap: .8rem; flex-wrap: wrap; }
/* Plan rows */
.plan-row {
display: flex;
align-items: center;
gap: .75rem;
padding: .75rem;
border: 1.5px solid #e0ddd6;
border-radius: 4px;
cursor: pointer;
transition: border-color .15s;
}
.plan-row--active { border-color: #6366f1; background: rgba(99,102,241,.04); }
.plan-info { flex: 1; display: flex; flex-direction: column; }
.plan-name { font-size: .82rem; font-weight: 600; }
.plan-desc { font-size: .67rem; color: #aaa; }
.plan-price { font-size: .78rem; font-weight: 700; color: #6366f1; }
/* Pay options */
.pay-option {
display: flex;
align-items: center;
gap: .5rem;
padding: .4rem .7rem;
border: 1.5px solid #e0ddd6;
border-radius: 3px;
cursor: pointer;
font-size: .82rem;
transition: border-color .15s;
}
.pay-option--active { border-color: #6366f1; color: #6366f1; }
/* Simple rows */
.simple-row {
display: flex;
align-items: center;
gap: .6rem;
font-size: .85rem;
cursor: pointer;
padding: .2rem 0;
}
.simple-row--disabled { opacity: .45; cursor: not-allowed; }
</style>

@ -0,0 +1,146 @@
<script lang="ts">
import { RatingGroup } from '$uix/acel';
let movieRating = $state(3);
let halfRating = $state(3.5);
let readonlyRating = 4;
let productRating = $state(0);
const stars = ['★','★','★','★','★'];
</script>
<div class="page">
<h1>Rating Group</h1>
<section class="card">
<h2>Valoración de película</h2>
<RatingGroup.Root bind:value={movieRating} max={5} class="rg">
{#snippet children({ items })}
{#each items as { index, state }}
<RatingGroup.Item {index} class="star star--{state}">
★
</RatingGroup.Item>
{/each}
{/snippet}
</RatingGroup.Root>
<p class="hint">{movieRating} / 5 estrellas</p>
</section>
<section class="card">
<h2>Media estrella (allowHalf)</h2>
<RatingGroup.Root bind:value={halfRating} max={5} allowHalf class="rg">
{#snippet children({ items })}
{#each items as { index, state }}
<RatingGroup.Item {index} class="star star--{state}">
{#if state === 'partial'}
<span class="half-star">★</span>
{:else}
★
{/if}
</RatingGroup.Item>
{/each}
{/snippet}
</RatingGroup.Root>
<p class="hint">{halfRating} / 5</p>
</section>
<section class="card">
<h2>Solo lectura</h2>
<RatingGroup.Root value={readonlyRating} max={5} readonly class="rg">
{#snippet children({ items })}
{#each items as { index, state }}
<RatingGroup.Item {index} class="star star--{state}">★</RatingGroup.Item>
{/each}
{/snippet}
</RatingGroup.Root>
<p class="hint">readonly · valor fijo: {readonlyRating}</p>
</section>
<section class="card">
<h2>Valoración de producto</h2>
<RatingGroup.Root bind:value={productRating} max={5} class="rg">
{#snippet children({ items, value, max })}
<div class="product-rating">
<div class="stars-row">
{#each items as { index, state }}
<RatingGroup.Item {index} class="star star--lg star--{state}">★</RatingGroup.Item>
{/each}
</div>
<span class="rating-label">
{value === 0 ? 'Sin valorar' : `${value} de ${max} estrellas`}
</span>
</div>
{/snippet}
</RatingGroup.Root>
</section>
<section class="card">
<h2>Desactivado</h2>
<RatingGroup.Root value={3} max={5} disabled class="rg">
{#snippet children({ items })}
{#each items as { index, state }}
<RatingGroup.Item {index} class="star star--{state} star--disabled">★</RatingGroup.Item>
{/each}
{/snippet}
</RatingGroup.Root>
<p class="hint">disabled · no interactivo</p>
</section>
</div>
<style>
.page {
max-width: 520px;
margin: 0 auto;
padding: 2rem 1.5rem;
font-family: 'DM Mono', monospace;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: .05em; }
.card {
border: 1.5px solid #e0ddd6;
border-radius: 4px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
h2 { font-size: .65rem; font-weight: 700; letter-spacing: .2em; text-transform: uppercase; color: #999; }
.hint { font-size: .68rem; color: #aaa; }
/* Rating group */
:global(.rg) { display: flex; gap: .15rem; }
/* Stars */
:global(.star) {
font-size: 1.5rem;
cursor: pointer;
border: none;
background: none;
padding: 0 .1rem;
line-height: 1;
color: #ddd;
transition: color .1s, transform .1s;
}
:global(.star:hover) { transform: scale(1.1); }
:global(.star--active) { color: #f59e0b; }
:global(.star--partial) { color: #f59e0b; opacity: .5; }
:global(.star--inactive) { color: #ddd; }
:global(.star--disabled) { cursor: not-allowed; opacity: .5; }
:global(.star--lg) { font-size: 2rem; }
/* Half star */
.half-star {
display: inline-block;
background: linear-gradient(to right, #f59e0b 50%, #ddd 50%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Product rating */
.product-rating { display: flex; flex-direction: column; gap: .4rem; }
.stars-row { display: flex; gap: .1rem; }
.rating-label { font-size: .8rem; color: #666; font-weight: 500; }
</style>

@ -0,0 +1,164 @@
<script lang="ts">
import * as ScrollArea from '$uix/acel/scroll-area';
const items = Array.from({ length: 50 }, (_, i) => `Elemento ${i + 1}`);
const longText = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. `.repeat(5);
</script>
<div class="page">
<a href="/test" class="back">← volver</a>
<h1>ScrollArea</h1>
<!-- Scroll vertical -->
<section>
<h2>Scroll vertical (hover)</h2>
<ScrollArea.Root class="scroll-root">
<ScrollArea.Viewport class="scroll-viewport">
<ul class="list">
{#each items as item}
<li class="list-item">{item}</li>
{/each}
</ul>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical">
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
<ScrollArea.Corner />
</ScrollArea.Root>
</section>
<!-- Scroll horizontal -->
<section>
<h2>Scroll horizontal (scroll)</h2>
<ScrollArea.Root type="scroll" class="scroll-root-h">
<ScrollArea.Viewport class="scroll-viewport-h">
<div class="wide-content">
{#each items.slice(0, 20) as item}
<div class="wide-item">{item}</div>
{/each}
</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="horizontal">
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</section>
<!-- Scroll ambos ejes -->
<section>
<h2>Ambos ejes (auto)</h2>
<ScrollArea.Root type="auto" class="scroll-root">
<ScrollArea.Viewport class="scroll-viewport">
<div class="both-content">{longText}</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical">
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
<ScrollArea.Scrollbar orientation="horizontal">
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
<ScrollArea.Corner />
</ScrollArea.Root>
</section>
<!-- Always visible -->
<section>
<h2>Siempre visible (always)</h2>
<ScrollArea.Root type="always" class="scroll-root">
<ScrollArea.Viewport class="scroll-viewport">
<ul class="list">
{#each items.slice(0, 20) as item}
<li class="list-item">{item}</li>
{/each}
</ul>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical">
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</section>
</div>
<style>
.page { max-width: 700px; margin: 2rem auto; padding: 0 1.5rem; font-family: sans-serif; }
.back { font-size: .85rem; color: #6366f1; text-decoration: none; }
h1 { margin: 1rem 0 2rem; }
section { margin-bottom: 2.5rem; }
h2 { font-size: .9rem; font-weight: 600; margin-bottom: 1rem; color: #374151; }
:global(.scroll-root) {
width: 100%;
height: 260px;
border: 1px solid #e5e7eb;
border-radius: .5rem;
overflow: hidden;
}
:global(.scroll-root-h) {
width: 100%;
height: 80px;
border: 1px solid #e5e7eb;
border-radius: .5rem;
overflow: hidden;
}
:global(.scroll-viewport), :global(.scroll-viewport-h) {
width: 100%;
height: 100%;
}
.list { list-style: none; margin: 0; padding: 0; }
.list-item {
padding: .5rem 1rem;
border-bottom: 1px solid #f3f4f6;
font-size: .875rem;
color: #374151;
}
.wide-content { display: flex; gap: .5rem; padding: 1rem; width: max-content; }
.wide-item {
min-width: 120px;
padding: .5rem 1rem;
background: #f3f4f6;
border-radius: .375rem;
font-size: .875rem;
white-space: nowrap;
}
.both-content {
padding: 1rem;
width: 800px;
font-size: .875rem;
line-height: 1.7;
color: #374151;
}
/* Scrollbar track */
:global([data-acel-scroll-area-scrollbar]) {
display: flex;
padding: 2px;
background: transparent;
transition: background .2s;
}
:global([data-acel-scroll-area-scrollbar][data-orientation="vertical"]) {
width: 10px;
}
:global([data-acel-scroll-area-scrollbar][data-orientation="horizontal"]) {
height: 10px;
flex-direction: column;
}
:global([data-acel-scroll-area-scrollbar]:hover) { background: rgba(0,0,0,.05); }
:global([data-acel-scroll-area-scrollbar][data-state="hidden"]) { display: none; }
/* Thumb */
:global([data-acel-scroll-area-thumb]) {
flex: 1;
background: rgba(0,0,0,.25);
border-radius: 9999px;
position: relative;
cursor: pointer;
transition: background .2s;
}
:global([data-acel-scroll-area-thumb]:hover) { background: rgba(0,0,0,.4); }
/* Corner */
:global([data-acel-scroll-area-corner]) { background: #f3f4f6; }
</style>

@ -0,0 +1,119 @@
<script lang="ts">
import { Separator } from '$uix/acel';
</script>
<div class="page">
<h1>Separator</h1>
<section class="card">
<h2>Horizontal (decorativo, default)</h2>
<p class="text">Elemento de arriba</p>
<Separator.Root class="sep sep--h" />
<p class="text">Elemento de abajo</p>
</section>
<section class="card">
<h2>Vertical (decorativo)</h2>
<div class="row-center">
<span class="text">Izquierda</span>
<Separator.Root orientation="vertical" class="sep sep--v" />
<span class="text">Centro</span>
<Separator.Root orientation="vertical" class="sep sep--v" />
<span class="text">Derecha</span>
</div>
</section>
<section class="card">
<h2>No decorativo (semántico, role="separator")</h2>
<p class="text">Sección de navegación</p>
<Separator.Root decorative={false} class="sep sep--h sep--semantic" />
<p class="text">Sección de contenido</p>
<p class="hint">Inspecciona el DOM: tiene <code>role="separator"</code> y <code>aria-orientation</code></p>
</section>
<section class="card">
<h2>Estilos personalizados</h2>
<p class="text">Con color</p>
<Separator.Root class="sep sep--h sep--indigo" />
<p class="text">Grueso</p>
<Separator.Root class="sep sep--h sep--thick" />
<p class="text">Punteado</p>
<Separator.Root class="sep sep--h sep--dashed" />
</section>
</div>
<style>
.page {
max-width: 640px;
margin: 0 auto;
padding: 2rem 1.5rem;
font-family: 'DM Mono', monospace;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
h1 {
font-size: 1.4rem;
font-weight: 700;
letter-spacing: .05em;
}
.card {
border: 1.5px solid #e0ddd6;
border-radius: 4px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
h2 {
font-size: .65rem;
font-weight: 700;
letter-spacing: .2em;
text-transform: uppercase;
color: #999;
}
.text { font-size: .85rem; }
.hint {
font-size: .7rem;
color: #aaa;
}
.row-center {
display: flex;
align-items: center;
gap: .75rem;
}
/* separator styles */
:global(.sep--h) {
height: 1px;
width: 100%;
background: #e0ddd6;
}
:global(.sep--v) {
width: 1px;
height: 1.2rem;
background: #e0ddd6;
flex-shrink: 0;
}
:global(.sep--semantic) {
background: #6366f1;
}
:global(.sep--indigo) {
background: #6366f1;
height: 1px;
width: 100%;
}
:global(.sep--thick) {
height: 3px;
width: 100%;
background: #1a1a1a;
border-radius: 2px;
}
:global(.sep--dashed) {
height: 1px;
width: 100%;
border: none;
border-top: 1.5px dashed #ccc;
background: none;
}
</style>

@ -0,0 +1,176 @@
<script lang="ts">
import * as Slider from '$uix/acel/slider';
let singleValue = $state(40);
let rangeValue = $state([20, 70]);
let stepValue = $state(0);
let verticalValue = $state(60);
let disabledValue = $state(30);
</script>
<div class="page">
<a href="/test" class="back">← volver</a>
<h1>Slider</h1>
<!-- Single -->
<section>
<h2>Single (value: {singleValue})</h2>
<div class="track-wrap">
<Slider.Root type="single" bind:value={singleValue} min={0} max={100} step={1}>
{#snippet children({ thumbs })}
<Slider.Range />
{#each thumbs as i}
<Slider.Thumb index={i} />
{/each}
{/snippet}
</Slider.Root>
</div>
</section>
<!-- Multiple / range -->
<section>
<h2>Range (values: {rangeValue.join(' – ')})</h2>
<div class="track-wrap">
<Slider.Root type="multiple" bind:value={rangeValue} min={0} max={100} step={5}>
{#snippet children({ thumbs })}
<Slider.Range />
{#each thumbs as i}
<Slider.Thumb index={i} />
{/each}
{/snippet}
</Slider.Root>
</div>
</section>
<!-- Con ticks -->
<section>
<h2>Con ticks (step: 20, value: {stepValue})</h2>
<div class="track-wrap ticks-wrap">
<Slider.Root type="single" bind:value={stepValue} min={0} max={100} step={20}>
{#snippet children({ thumbs, ticks })}
<Slider.Range />
{#each thumbs as i}
<Slider.Thumb index={i} />
{/each}
{#each ticks as i}
<Slider.Tick index={i}>
{#snippet children()}
<span class="tick-mark"></span>
{/snippet}
</Slider.Tick>
{/each}
{/snippet}
</Slider.Root>
</div>
</section>
<!-- Vertical -->
<section>
<h2>Vertical (value: {verticalValue})</h2>
<div class="vertical-wrap">
<Slider.Root
type="single"
bind:value={verticalValue}
orientation="vertical"
min={0} max={100} step={1}
>
{#snippet children({ thumbs })}
<Slider.Range />
{#each thumbs as i}
<Slider.Thumb index={i} />
{/each}
{/snippet}
</Slider.Root>
</div>
</section>
<!-- Disabled -->
<section>
<h2>Disabled (value: {disabledValue})</h2>
<div class="track-wrap">
<Slider.Root type="single" bind:value={disabledValue} disabled>
{#snippet children({ thumbs })}
<Slider.Range />
{#each thumbs as i}
<Slider.Thumb index={i} />
{/each}
{/snippet}
</Slider.Root>
</div>
</section>
</div>
<style>
.page { max-width: 600px; margin: 2rem auto; padding: 0 1.5rem; font-family: sans-serif; }
.back { font-size: .85rem; color: #6366f1; text-decoration: none; }
h1 { margin: 1rem 0 2rem; }
section { margin-bottom: 2.5rem; }
h2 { font-size: .9rem; font-weight: 600; margin-bottom: 1.2rem; color: #374151; }
/* Los wrappers centran verticalmente el thumb que desborda el track */
.track-wrap { display: flex; align-items: center; height: 20px; }
.ticks-wrap { display: flex; align-items: center; height: 40px; }
.vertical-wrap { display: inline-flex; justify-content: center; height: 200px; width: 20px; }
/* Slider root — es un <span>, necesita display:block */
:global([data-slider-root]) {
display: block;
position: relative;
width: 100%;
height: 6px;
background: #e5e7eb;
border-radius: 9999px;
cursor: pointer;
}
:global([data-slider-root][data-orientation="vertical"]) {
width: 6px;
height: 100%;
}
:global([data-slider-root][data-disabled]) { opacity: .5; cursor: default; }
/* Range */
:global([data-slider-range]) {
background: #6366f1;
border-radius: 9999px;
height: 100%; /* horizontal: rellena los 6px del track */
}
:global([data-slider-root][data-orientation="vertical"] [data-slider-range]) {
width: 100%;
height: auto; /* vertical: top/bottom inline controlan la altura */
}
/* Thumb — top:50% + margin-top negativo para centrar verticalmente */
:global([data-slider-thumb]) {
display: block;
width: 18px;
height: 18px;
background: white;
border: 2px solid #6366f1;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0,0,0,.2);
cursor: grab;
top: 50%;
margin-top: -9px;
}
:global([data-slider-thumb]:focus-visible) {
outline: 2px solid #6366f1;
outline-offset: 2px;
}
:global([data-slider-root][data-orientation="vertical"] [data-slider-thumb]) {
top: auto; /* dejar que el estado controle bottom: X% */
margin-top: 0;
left: 50%;
margin-left: -9px;
}
/* Tick */
:global([data-slider-tick]) { top: 50%; }
.tick-mark {
width: 2px;
height: 8px;
background: #9ca3af;
border-radius: 1px;
margin-top: 2px;
}
:global([data-slider-tick][data-bounded]) .tick-mark { background: #6366f1; }
</style>

@ -0,0 +1,204 @@
<script lang="ts">
import { Switch, Label } from '$uix/acel';
let notifications = $state(true);
let marketing = $state(false);
let security = $state(true);
let updates = $state(false);
let darkMode = $state(false);
let controlled = $state(false);
</script>
<div class="page">
<h1>Switch</h1>
<section class="card">
<h2>Preferencias de notificaciones</h2>
<div class="setting-list">
<div class="setting-row">
<div class="setting-info">
<span class="setting-name">Notificaciones push</span>
<span class="setting-desc">Recibir alertas en tiempo real</span>
</div>
<Switch.Root bind:checked={notifications} class="sw {notifications ? 'sw--on' : ''}">
<Switch.Thumb class="sw-thumb" />
</Switch.Root>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-name">Emails de marketing</span>
<span class="setting-desc">Ofertas y novedades del producto</span>
</div>
<Switch.Root bind:checked={marketing} class="sw {marketing ? 'sw--on' : ''}">
<Switch.Thumb class="sw-thumb" />
</Switch.Root>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-name">Alertas de seguridad</span>
<span class="setting-desc">Inicios de sesión y accesos nuevos</span>
</div>
<Switch.Root bind:checked={security} class="sw {security ? 'sw--on' : ''}">
<Switch.Thumb class="sw-thumb" />
</Switch.Root>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-name">Actualizaciones del sistema</span>
<span class="setting-desc">Cambios en la versión del app</span>
</div>
<Switch.Root bind:checked={updates} class="sw {updates ? 'sw--on' : ''}">
<Switch.Thumb class="sw-thumb" />
</Switch.Root>
</div>
</div>
<p class="hint">
notificaciones={notifications} · marketing={marketing} · seguridad={security} · actualizaciones={updates}
</p>
</section>
<section class="card">
<h2>Con Label</h2>
<div class="labeled-row">
<Label.Root for="dark-mode-switch" class="sw-label">Modo oscuro</Label.Root>
<Switch.Root id="dark-mode-switch" bind:checked={darkMode} class="sw {darkMode ? 'sw--on' : ''}">
<Switch.Thumb class="sw-thumb" />
</Switch.Root>
</div>
<p class="hint">El label activa el switch al hacer clic</p>
</section>
<section class="card">
<h2>Estados</h2>
<div class="states-row">
<div class="state-item">
<Switch.Root checked={true} class="sw sw--on">
<Switch.Thumb class="sw-thumb" />
</Switch.Root>
<span class="state-label">on</span>
</div>
<div class="state-item">
<Switch.Root checked={false} class="sw">
<Switch.Thumb class="sw-thumb" />
</Switch.Root>
<span class="state-label">off</span>
</div>
<div class="state-item">
<Switch.Root checked={true} disabled class="sw sw--on sw--disabled">
<Switch.Thumb class="sw-thumb" />
</Switch.Root>
<span class="state-label">on + disabled</span>
</div>
<div class="state-item">
<Switch.Root checked={false} disabled class="sw sw--disabled">
<Switch.Thumb class="sw-thumb" />
</Switch.Root>
<span class="state-label">off + disabled</span>
</div>
</div>
</section>
<section class="card">
<h2>Controlado</h2>
<div class="ctrl-row">
<button class="btn" onclick={() => (controlled = !controlled)}>
{controlled ? 'Desactivar' : 'Activar'}
</button>
<Switch.Root bind:checked={controlled} class="sw {controlled ? 'sw--on' : ''}">
<Switch.Thumb class="sw-thumb" />
</Switch.Root>
</div>
<p class="hint">checked={controlled}</p>
</section>
</div>
<style>
.page {
max-width: 520px;
margin: 0 auto;
padding: 2rem 1.5rem;
font-family: 'DM Mono', monospace;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: .05em; }
.card {
border: 1.5px solid #e0ddd6;
border-radius: 4px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
h2 { font-size: .65rem; font-weight: 700; letter-spacing: .2em; text-transform: uppercase; color: #999; }
.hint { font-size: .68rem; color: #aaa; }
/* Setting list */
.setting-list { display: flex; flex-direction: column; }
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: .6rem 0;
border-bottom: 1px solid #f0ede8;
}
.setting-row:last-child { border-bottom: none; }
.setting-info { display: flex; flex-direction: column; gap: .1rem; }
.setting-name { font-size: .82rem; font-weight: 600; }
.setting-desc { font-size: .68rem; color: #aaa; }
/* Switch */
:global(.sw) {
width: 42px; height: 24px;
border-radius: 999px;
background: #d0cdc6;
border: none;
cursor: pointer;
position: relative;
transition: background .2s;
padding: 0;
flex-shrink: 0;
}
:global(.sw--on) { background: #6366f1; }
:global(.sw--disabled) { opacity: .4; cursor: not-allowed; }
:global(.sw-thumb) {
display: block;
position: absolute;
top: 3px; left: 3px;
width: 18px; height: 18px;
border-radius: 50%;
background: #fff;
transition: transform .2s;
box-shadow: 0 1px 3px rgba(0,0,0,.2);
}
:global(.sw--on .sw-thumb) { transform: translateX(18px); }
/* Label */
.labeled-row {
display: flex;
align-items: center;
justify-content: space-between;
}
:global(.sw-label) {
font-size: .85rem;
font-weight: 600;
cursor: pointer;
}
/* States */
.states-row { display: flex; gap: 1.5rem; align-items: flex-end; flex-wrap: wrap; }
.state-item { display: flex; flex-direction: column; align-items: center; gap: .4rem; }
.state-label { font-size: .65rem; color: #aaa; }
/* Controlled */
.ctrl-row { display: flex; align-items: center; gap: 1rem; }
.btn {
padding: .3rem .7rem;
background: #1a1a1a; color: #f7f6f3;
border: none; border-radius: 2px;
font-family: inherit; font-size: .7rem; font-weight: 600;
cursor: pointer;
}
.btn:hover { opacity: .75; }
</style>

@ -0,0 +1,145 @@
<script lang="ts">
import { ToggleGroup } from '$uix/acel';
let align = $state<string | undefined>('left');
let textStyle = $state<string[]>([]);
let view = $state<string | undefined>('grid');
let days = $state<string[]>([]);
</script>
<div class="page">
<h1>Toggle Group</h1>
<section class="card">
<h2>Single — Alineación de texto</h2>
<ToggleGroup.Root type="single" bind:value={align} class="tg">
<ToggleGroup.Item value="left" class="tgi {align === 'left' ? 'tgi--on' : ''}">
⬅ Izq
</ToggleGroup.Item>
<ToggleGroup.Item value="center" class="tgi {align === 'center' ? 'tgi--on' : ''}">
↔ Centro
</ToggleGroup.Item>
<ToggleGroup.Item value="right" class="tgi {align === 'right' ? 'tgi--on' : ''}">
➡ Der
</ToggleGroup.Item>
<ToggleGroup.Item value="justify" class="tgi {align === 'justify' ? 'tgi--on' : ''}">
≡ Just
</ToggleGroup.Item>
</ToggleGroup.Root>
<p class="preview" style:text-align={align ?? 'left'}>
El rápido zorro marrón salta sobre el perro perezoso. Este texto cambia de alineación según el botón seleccionado.
</p>
<p class="hint">value: <code>{align ?? '(ninguno)'}</code></p>
</section>
<section class="card">
<h2>Multiple — Estilo de texto</h2>
<ToggleGroup.Root type="multiple" bind:value={textStyle} class="tg">
<ToggleGroup.Item value="bold" class="tgi {textStyle.includes('bold') ? 'tgi--on' : ''}">
<strong>B</strong>
</ToggleGroup.Item>
<ToggleGroup.Item value="italic" class="tgi {textStyle.includes('italic') ? 'tgi--on' : ''}">
<em>I</em>
</ToggleGroup.Item>
<ToggleGroup.Item value="underline" class="tgi {textStyle.includes('underline') ? 'tgi--on' : ''}">
<span style:text-decoration="underline">U</span>
</ToggleGroup.Item>
<ToggleGroup.Item value="strike" class="tgi {textStyle.includes('strike') ? 'tgi--on' : ''}">
<span style:text-decoration="line-through">S</span>
</ToggleGroup.Item>
</ToggleGroup.Root>
<p class="preview"
style:font-weight={textStyle.includes('bold') ? 700 : 400}
style:font-style={textStyle.includes('italic') ? 'italic' : 'normal'}
style:text-decoration={[textStyle.includes('underline') && 'underline', textStyle.includes('strike') && 'line-through'].filter(Boolean).join(' ') || 'none'}
>
Texto de ejemplo con múltiples estilos combinables.
</p>
<p class="hint">value: <code>[{textStyle.join(', ')}]</code></p>
</section>
<section class="card">
<h2>Single — Vista</h2>
<ToggleGroup.Root type="single" bind:value={view} class="tg">
<ToggleGroup.Item value="list" class="tgi {view === 'list' ? 'tgi--on' : ''}">☰ Lista</ToggleGroup.Item>
<ToggleGroup.Item value="grid" class="tgi {view === 'grid' ? 'tgi--on' : ''}">⊞ Cuadrícula</ToggleGroup.Item>
<ToggleGroup.Item value="kanban" class="tgi {view === 'kanban' ? 'tgi--on' : ''}">⊟ Kanban</ToggleGroup.Item>
</ToggleGroup.Root>
<p class="hint">Vista activa: <code>{view ?? '(ninguna)'}</code></p>
</section>
<section class="card">
<h2>Multiple — Días laborables</h2>
<ToggleGroup.Root type="multiple" bind:value={days} class="tg">
{#each [['L','lun'],['M','mar'],['X','mié'],['J','jue'],['V','vie'],['S','sáb'],['D','dom']] as [key, label]}
<ToggleGroup.Item
value={label}
class="tgi tgi--day {days.includes(label) ? 'tgi--on' : ''}"
>
{key}
</ToggleGroup.Item>
{/each}
</ToggleGroup.Root>
<p class="hint">Seleccionados: <code>{days.length ? days.join(', ') : '(ninguno)'}</code></p>
</section>
<section class="card">
<h2>Con items desactivados</h2>
<ToggleGroup.Root type="single" bind:value={align} class="tg">
<ToggleGroup.Item value="xs" class="tgi {align === 'xs' ? 'tgi--on' : ''}">XS</ToggleGroup.Item>
<ToggleGroup.Item value="sm" class="tgi {align === 'sm' ? 'tgi--on' : ''}">SM</ToggleGroup.Item>
<ToggleGroup.Item value="md" disabled class="tgi tgi--disabled">MD</ToggleGroup.Item>
<ToggleGroup.Item value="lg" class="tgi {align === 'lg' ? 'tgi--on' : ''}">LG</ToggleGroup.Item>
<ToggleGroup.Item value="xl" disabled class="tgi tgi--disabled">XL</ToggleGroup.Item>
</ToggleGroup.Root>
<p class="hint">MD y XL están desactivados (sin stock)</p>
</section>
</div>
<style>
.page {
max-width: 580px;
margin: 0 auto;
padding: 2rem 1.5rem;
font-family: 'DM Mono', monospace;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: .05em; }
.card {
border: 1.5px solid #e0ddd6;
border-radius: 4px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
h2 { font-size: .65rem; font-weight: 700; letter-spacing: .2em; text-transform: uppercase; color: #999; }
.hint { font-size: .7rem; color: #aaa; }
.preview { font-size: .85rem; line-height: 1.6; padding: .5rem; background: #fafafa; border-radius: 3px; }
:global(.tg) {
display: flex;
gap: 2px;
background: #f0ede8;
border-radius: 4px;
padding: 2px;
}
:global(.tgi) {
padding: .3rem .65rem;
border: none;
border-radius: 3px;
background: transparent;
font-family: inherit;
font-size: .78rem;
font-weight: 600;
cursor: pointer;
transition: all .15s;
color: #555;
}
:global(.tgi:hover) { background: rgba(255,255,255,.6); color: #1a1a1a; }
:global(.tgi--on) { background: #fff !important; color: #1a1a1a !important; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
:global(.tgi--day) { padding: .3rem .4rem; min-width: 2rem; text-align: center; }
:global(.tgi--disabled) { opacity: .35; cursor: not-allowed; }
</style>

@ -0,0 +1,188 @@
<script lang="ts">
import { Toggle } from '$uix/acel';
let bold = $state(false);
let italic = $state(false);
let underline = $state(false);
let muted = $state(false);
let wifi = $state(true);
let bluetooth = $state(false);
let airplane = $state(false);
</script>
<div class="page">
<h1>Toggle</h1>
<section class="card">
<h2>Formato de texto</h2>
<div class="toolbar">
<Toggle.Root bind:pressed={bold} class="tool {bold ? 'tool--on' : ''}">
<strong>B</strong>
</Toggle.Root>
<Toggle.Root bind:pressed={italic} class="tool {italic ? 'tool--on' : ''}">
<em>I</em>
</Toggle.Root>
<Toggle.Root bind:pressed={underline} class="tool {underline ? 'tool--on' : ''}">
<span style:text-decoration="underline">U</span>
</Toggle.Root>
</div>
<p class="preview" style:font-weight={bold ? 700 : 400} style:font-style={italic ? 'italic' : 'normal'} style:text-decoration={underline ? 'underline' : 'none'}>
El rápido zorro marrón salta sobre el perro perezoso.
</p>
<p class="hint">bold={bold} italic={italic} underline={underline}</p>
</section>
<section class="card">
<h2>Conectividad</h2>
<div class="toggle-list">
<div class="toggle-row">
<div class="toggle-info">
<span class="toggle-name">Wi-Fi</span>
<span class="toggle-desc">{wifi ? 'Conectado a Red_Hogar' : 'Desactivado'}</span>
</div>
<Toggle.Root bind:pressed={wifi} class="switch {wifi ? 'switch--on' : ''}">
<span class="thumb"></span>
</Toggle.Root>
</div>
<div class="toggle-row">
<div class="toggle-info">
<span class="toggle-name">Bluetooth</span>
<span class="toggle-desc">{bluetooth ? 'Visible para otros' : 'Desactivado'}</span>
</div>
<Toggle.Root bind:pressed={bluetooth} class="switch {bluetooth ? 'switch--on' : ''}">
<span class="thumb"></span>
</Toggle.Root>
</div>
<div class="toggle-row">
<div class="toggle-info">
<span class="toggle-name">Modo avión</span>
<span class="toggle-desc">{airplane ? 'Todas las radios desactivadas' : 'Normal'}</span>
</div>
<Toggle.Root bind:pressed={airplane} class="switch {airplane ? 'switch--on switch--amber' : ''}">
<span class="thumb"></span>
</Toggle.Root>
</div>
</div>
</section>
<section class="card">
<h2>Desactivado</h2>
<div class="toolbar">
<Toggle.Root pressed={true} disabled class="tool tool--on tool--disabled">
Activado
</Toggle.Root>
<Toggle.Root pressed={false} disabled class="tool tool--disabled">
Desactivado
</Toggle.Root>
</div>
<p class="hint">data-disabled="" cuando disabled=true</p>
</section>
<section class="card">
<h2>Silenciar</h2>
<Toggle.Root bind:pressed={muted} class="mute-btn {muted ? 'mute-btn--on' : ''}">
{muted ? '🔇 Silenciado' : '🔊 Sonido activo'}
</Toggle.Root>
</section>
</div>
<style>
.page {
max-width: 540px;
margin: 0 auto;
padding: 2rem 1.5rem;
font-family: 'DM Mono', monospace;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: .05em; }
.card {
border: 1.5px solid #e0ddd6;
border-radius: 4px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
h2 { font-size: .65rem; font-weight: 700; letter-spacing: .2em; text-transform: uppercase; color: #999; }
.hint { font-size: .7rem; color: #aaa; }
.preview { font-size: .9rem; line-height: 1.5; padding: .5rem; background: #fafafa; border-radius: 3px; }
/* Toolbar style */
.toolbar { display: flex; gap: .4rem; }
:global(.tool) {
padding: .35rem .65rem;
border: 1.5px solid #d0cdc6;
border-radius: 3px;
background: #fff;
font-family: inherit;
font-size: .85rem;
cursor: pointer;
transition: all .15s;
}
:global(.tool:hover) { border-color: #6366f1; }
:global(.tool--on) { background: #6366f1; border-color: #6366f1; color: #fff; }
:global(.tool--disabled) { opacity: .4; cursor: not-allowed; }
/* Toggle list */
.toggle-list { display: flex; flex-direction: column; gap: .1rem; }
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: .6rem 0;
border-bottom: 1px solid #f0ede8;
}
.toggle-row:last-child { border-bottom: none; }
.toggle-info { display: flex; flex-direction: column; gap: .1rem; }
.toggle-name { font-size: .82rem; font-weight: 600; }
.toggle-desc { font-size: .68rem; color: #aaa; }
/* Switch style */
:global(.switch) {
width: 40px;
height: 22px;
border-radius: 999px;
background: #d0cdc6;
border: none;
cursor: pointer;
position: relative;
transition: background .2s;
padding: 0;
flex-shrink: 0;
}
:global(.switch--on) { background: #6366f1; }
:global(.switch--amber.switch--on) { background: #f59e0b; }
:global(.switch .thumb) {
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
transition: transform .2s;
box-shadow: 0 1px 3px rgba(0,0,0,.2);
}
:global(.switch--on .thumb) { transform: translateX(18px); }
/* Mute button */
:global(.mute-btn) {
padding: .5rem 1rem;
border: 1.5px solid #d0cdc6;
border-radius: 3px;
background: #fff;
font-family: inherit;
font-size: .82rem;
font-weight: 600;
cursor: pointer;
transition: all .15s;
align-self: flex-start;
}
:global(.mute-btn--on) {
background: #1a1a1a;
color: #fff;
border-color: #1a1a1a;
}
</style>

@ -0,0 +1,212 @@
<script lang="ts">
import { Toolbar } from '$uix/acel';
let bold = $state(false);
let italic = $state(false);
let underline = $state(false);
let strikethrough = $state(false);
let align = $state('left');
let lists = $state<string[]>([]);
const previewStyle = $derived([
bold ? 'font-weight:700' : '',
italic ? 'font-style:italic' : '',
underline ? 'text-decoration:underline' : '',
strikethrough ? 'text-decoration:line-through' : '',
].filter(Boolean).join(';'));
</script>
<div class="page">
<h1>Toolbar</h1>
<section class="card">
<h2>Editor de texto</h2>
<Toolbar.Root class="toolbar" aria-label="Opciones de formato">
<!-- Formato de texto: single selection -->
<Toolbar.Group type="single" bind:value={bold} class="tb-group">
<Toolbar.GroupItem value={true} class="tb-btn {bold ? 'tb-btn--on' : ''}">
<strong>B</strong>
</Toolbar.GroupItem>
</Toolbar.Group>
<span class="divider"></span>
<!-- Formato: múltiple -->
<Toolbar.Group type="multiple" bind:value={lists} class="tb-group">
<Toolbar.GroupItem value="italic" class="tb-btn {lists.includes('italic') ? 'tb-btn--on' : ''}">
<em>I</em>
</Toolbar.GroupItem>
<Toolbar.GroupItem value="underline" class="tb-btn {lists.includes('underline') ? 'tb-btn--on' : ''}">
<span style:text-decoration="underline">U</span>
</Toolbar.GroupItem>
<Toolbar.GroupItem value="strike" class="tb-btn {lists.includes('strike') ? 'tb-btn--on' : ''}">
<span style:text-decoration="line-through">S</span>
</Toolbar.GroupItem>
</Toolbar.Group>
<span class="divider"></span>
<!-- Alineación: single -->
<Toolbar.Group type="single" bind:value={align} class="tb-group">
{#each [['left','⬤◻◻'],['center','◻⬤◻'],['right','◻◻⬤']] as [val, icon]}
<Toolbar.GroupItem value={val} class="tb-btn {align === val ? 'tb-btn--on' : ''}">
{icon}
</Toolbar.GroupItem>
{/each}
</Toolbar.Group>
<span class="divider"></span>
<!-- Botón de acción independiente -->
<Toolbar.Button class="tb-btn" onclick={() => alert('Compartir')}>
↑ Compartir
</Toolbar.Button>
<span class="spacer"></span>
<!-- Link -->
<Toolbar.Link href="#" class="tb-link">Ayuda</Toolbar.Link>
</Toolbar.Root>
<div class="preview">
<p style={previewStyle}>
El rápido zorro marrón salta sobre el perro perezoso.
</p>
<span class="align-badge">align: {align}</span>
{#if lists.length > 0}
<span class="align-badge">formatos: {lists.join(', ')}</span>
{/if}
</div>
</section>
<section class="card">
<h2>Vertical + desactivado parcial</h2>
<Toolbar.Root orientation="vertical" class="toolbar toolbar--vertical" aria-label="Herramientas">
<Toolbar.Group type="multiple" bind:value={lists} class="tb-group tb-group--vertical">
<Toolbar.GroupItem value="a" class="tb-btn tb-btn--wide {lists.includes('a') ? 'tb-btn--on' : ''}">
Opción A
</Toolbar.GroupItem>
<Toolbar.GroupItem value="b" class="tb-btn tb-btn--wide {lists.includes('b') ? 'tb-btn--on' : ''}">
Opción B
</Toolbar.GroupItem>
<Toolbar.GroupItem value="c" disabled class="tb-btn tb-btn--wide tb-btn--disabled">
Opción C (desactivada)
</Toolbar.GroupItem>
</Toolbar.Group>
<Toolbar.Button class="tb-btn tb-btn--wide" onclick={() => alert('Acción')}>
Acción
</Toolbar.Button>
</Toolbar.Root>
</section>
<section class="card">
<h2>Navegación con teclado</h2>
<p class="hint">Usa Tab para entrar, flechas para navegar entre ítems, Space/Enter para activar.</p>
<Toolbar.Root class="toolbar" aria-label="Demo navegación">
<Toolbar.Group type="single" bind:value={align} class="tb-group">
{#each ['left','center','right','justify'] as val}
<Toolbar.GroupItem value={val} class="tb-btn {align === val ? 'tb-btn--on' : ''}">
{val.slice(0, 4)}
</Toolbar.GroupItem>
{/each}
</Toolbar.Group>
<Toolbar.Button class="tb-btn">Guardar</Toolbar.Button>
<Toolbar.Button class="tb-btn">Descartar</Toolbar.Button>
</Toolbar.Root>
</section>
</div>
<style>
.page {
max-width: 620px;
margin: 0 auto;
padding: 2rem 1.5rem;
font-family: 'DM Mono', monospace;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: .05em; }
.card {
border: 1.5px solid #e0ddd6;
border-radius: 4px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
h2 { font-size: .65rem; font-weight: 700; letter-spacing: .2em; text-transform: uppercase; color: #999; }
.hint { font-size: .68rem; color: #aaa; }
/* Toolbar */
:global(.toolbar) {
display: flex;
align-items: center;
gap: .25rem;
padding: .35rem .5rem;
border: 1.5px solid #e0ddd6;
border-radius: 4px;
background: #fafaf8;
}
:global(.toolbar--vertical) {
flex-direction: column;
align-items: stretch;
width: fit-content;
}
:global(.tb-group) { display: flex; gap: .2rem; }
:global(.tb-group--vertical) { flex-direction: column; }
:global(.tb-btn) {
padding: .3rem .55rem;
border: 1px solid transparent;
border-radius: 3px;
background: transparent;
font-family: inherit;
font-size: .82rem;
cursor: pointer;
color: #444;
transition: background .1s, border-color .1s;
white-space: nowrap;
}
:global(.tb-btn:hover:not(.tb-btn--disabled)) { background: #f0ede8; }
:global(.tb-btn--on) { background: #6366f1; color: #fff; border-color: #6366f1; }
:global(.tb-btn--on:hover) { background: #5254cc; }
:global(.tb-btn--disabled) { opacity: .4; cursor: not-allowed; }
:global(.tb-btn--wide) { text-align: left; padding-left: .65rem; }
:global(.tb-link) {
font-size: .75rem;
color: #6366f1;
text-decoration: none;
padding: .2rem .4rem;
border-radius: 2px;
}
:global(.tb-link:hover) { text-decoration: underline; }
.divider {
width: 1px;
height: 20px;
background: #e0ddd6;
margin: 0 .15rem;
flex-shrink: 0;
}
.spacer { flex: 1; }
/* Preview */
.preview {
padding: .75rem;
background: #fafaf8;
border: 1px solid #e8e5e0;
border-radius: 3px;
display: flex;
flex-direction: column;
gap: .3rem;
}
.preview p { font-size: .9rem; line-height: 1.6; }
.align-badge {
font-size: .65rem;
color: #aaa;
}
</style>

@ -0,0 +1,219 @@
<script lang="ts">
import { Tooltip } from '$uix/acel';
</script>
<div class="page">
<h1>Tooltip</h1>
<section class="demo" style="height: 500px;">
<h2 class="pt-30">Ejemplo básico</h2>
<Tooltip.Provider>
<Tooltip.Root delayDuration={200}>
<Tooltip.Trigger
class="border-border-input bg-background-alt shadow-btn ring-dark ring-offset-background
hover:bg-muted focus-visible:ring-dark focus-visible:ring-offset-background focus-visible:outline-hidden inline-flex size-10 items-center justify-center rounded-full border focus-visible:ring-2 focus-visible:ring-offset-2"
>
<span>"Hover sobre mí"</span>
</Tooltip.Trigger>
<Tooltip.Content
sideOffset={8}
class="animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-tooltip-content-transform-origin)"
>
<div
class="rounded-input border-dark-10 bg-background shadow-popover outline-hidden z-0 flex items-center justify-center border p-3 text-sm font-medium"
>
Make some magic!
</div>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</section>
<!-- Demo 1: básico -->
<section class="demo">
<h2>Básico (delay 700ms)</h2>
<Tooltip.Provider>
<div class="row">
<Tooltip.Root>
<Tooltip.Trigger>Hover sobre mí</Tooltip.Trigger>
<Tooltip.Content>
<div class="tooltip-box">Soy un tooltip básico</div>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger>Otro trigger</Tooltip.Trigger>
<Tooltip.Content side="right">
<div class="tooltip-box">Tooltip a la derecha</div>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger disabled>Deshabilitado</Tooltip.Trigger>
<Tooltip.Content>
<div class="tooltip-box">No debería aparecer</div>
</Tooltip.Content>
</Tooltip.Root>
</div>
</Tooltip.Provider>
</section>
<!-- Demo 2: instant (sin delay) -->
<section class="demo h-54" >
<h2>Sin delay + skipDelay</h2>
<Tooltip.Provider delayDuration={0} skipDelayDuration={0}>
<div class="row">
{#each ['top', 'right', 'bottom', 'left'] as side}
<Tooltip.Root>
<Tooltip.Trigger>{side}</Tooltip.Trigger>
<Tooltip.Content side={side as any} sideOffset={8}>
<div class="tooltip-box">Tooltip {side}</div>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
</Tooltip.Provider>
</section>
<!-- Demo 3: contenido rico -->
<section class="demo">
<h2>Contenido rico</h2>
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root>
<Tooltip.Trigger>ℹ️ Más información</Tooltip.Trigger>
<Tooltip.Content sideOffset={8}>
<div class="tooltip-rich">
<strong>Información adicional</strong>
<p>Este tooltip contiene contenido más elaborado con múltiples líneas de texto.</p>
<code>data-side="top"</code>
</div>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</section>
<!-- Demo 4: disableHoverableContent -->
<section class="demo">
<h2>disableHoverableContent=true</h2>
<Tooltip.Provider disableHoverableContent={true} delayDuration={0}>
<Tooltip.Root>
<Tooltip.Trigger>Hover (cierra al salir del trigger)</Tooltip.Trigger>
<Tooltip.Content>
<div class="tooltip-box">Se cierra al salir del trigger</div>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</section>
</div>
<style>
.page {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, sans-serif;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: #1a1a1a;
}
h2 {
font-size: .85rem;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: #888;
margin-bottom: .75rem;
}
.demo {
background: #fff;
border: 1.5px solid #e5e7eb;
border-radius: 6px;
padding: 1.5rem;
margin-bottom: 1.25rem;
}
.row {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
/* Botones de trigger */
:global([data-tooltip-trigger]) {
padding: .4rem .9rem;
background: #f3f4f6;
border: 1.5px solid #d1d5db;
border-radius: 4px;
font-size: .85rem;
cursor: pointer;
font-family: inherit;
color: #374151;
transition: background .15s;
}
:global([data-tooltip-trigger]:hover) {
background: #e5e7eb;
}
:global([data-tooltip-trigger][data-disabled]) {
opacity: .4;
cursor: not-allowed;
}
:global([data-tooltip-trigger][data-state="delayed-open"]),
:global([data-tooltip-trigger][data-state="instant-open"]) {
background: #ede9fe;
border-color: #7c3aed;
color: #5b21b6;
}
/* Tooltip content wrapper */
:global([data-acel-floating-content-wrapper]) {
z-index: 9999;
pointer-events: none;
}
/* Cajas de contenido */
.tooltip-box {
background: #1f2937;
color: #f9fafb;
padding: .4rem .75rem;
border-radius: 5px;
font-size: .8rem;
white-space: nowrap;
pointer-events: none;
}
.tooltip-rich {
background: #1f2937;
color: #f9fafb;
padding: .75rem 1rem;
border-radius: 6px;
font-size: .8rem;
max-width: 220px;
pointer-events: none;
display: flex;
flex-direction: column;
gap: .4rem;
}
.tooltip-rich strong {
color: #a78bfa;
}
.tooltip-rich p {
line-height: 1.4;
color: #d1d5db;
}
.tooltip-rich code {
background: #374151;
padding: .1rem .3rem;
border-radius: 3px;
font-size: .75rem;
color: #6ee7b7;
}
:global([data-tooltip-content]) {
outline: none;
}
</style>

@ -12,13 +12,13 @@ import type {
AcelMouseEvent,
RefAttachment,
WithRefOpts,
} from "../internal/types";
import { boolToStr, boolToEmptyStrOrUndef, getDataOpenClosed, createAcelAttrs } from "../internal/attrs";
import { kbd } from "../internal/kbd";
import type { Orientation } from "../shared/index";
import { RovingFocusGroup } from "../internal/roving-focus-group";
} from "../utilities/types";
import { boolToStr, boolToEmptyStrOrUndef, getDataOpenClosed, createAcelAttrs } from "../utilities/attrs";
import { kbd } from "../utilities/keyboard/kbd";
import type { Orientation } from "../utilities/index";
import { RovingFocusGroup } from "../utilities/focus/roving-focus-group";
import { on } from "svelte/events";
import { PresenceManager } from "../internal/presence-manager.svelte";
import { PresenceManager } from "../utilities/presence/presence-manager.svelte";
const accordionAttrs = createAcelAttrs({
component: "accordion",

@ -2,7 +2,7 @@
import { mergeProps, boxWith } from "../../utilities/index";
import { AccordionContentState } from "../accordion.svelte";
import type { AccordionContentProps } from "../types";
import { createId } from "../../internal/create-id";
import { createId } from "../../utilities/create-id";
const uid = $props.id();

@ -2,7 +2,7 @@
import { boxWith, mergeProps } from "../../utilities/index";
import type { AccordionHeaderProps } from "../types";
import { AccordionHeaderState } from "../accordion.svelte";
import { createId } from "../../internal/create-id";
import { createId } from "../../utilities/create-id";
const uid = $props.id();

@ -2,7 +2,7 @@
import { boxWith, mergeProps } from "../../utilities/index";
import type { AccordionItemProps } from "../types";
import { AccordionItemState } from "../accordion.svelte";
import { createId } from "../../internal/create-id";
import { createId } from "../../utilities/create-id";
const uid = $props.id();
const defaultId = createId(uid);

@ -2,7 +2,7 @@
import { boxWith, mergeProps } from "../../utilities/index";
import type { AccordionTriggerProps } from "../types";
import { AccordionTriggerState } from "../accordion.svelte";
import { createId } from "../../internal/create-id";
import { createId } from "../../utilities/create-id";
const uid = $props.id();

@ -2,9 +2,9 @@
import { type WritableBox, boxWith, mergeProps } from "../../utilities/index";
import { AccordionRootState } from "../accordion.svelte";
import type { AccordionRootProps } from "../types";
import { noop } from "../../internal/noop";
import { noop } from "../../utilities/noop";
import { watch } from "runed";
import { createId } from "../../internal/create-id";
import { createId } from "../../utilities/create-id";
const uid = $props.id();

@ -3,12 +3,12 @@ import type {
WithChild,
WithChildNoChildrenSnippetProps,
Without,
} from "../internal/types";
} from "../utilities/types";
import type {
AcelPrimitiveButtonAttributes,
AcelPrimitiveDivAttributes,
} from "../shared/attributes";
import type { Orientation } from "../shared/index";
} from "../utilities/attributes";
import type { Orientation } from "../utilities/index";
export type BaseAccordionRootPropsWithoutHTML = {
/**

@ -2,7 +2,7 @@
import { boxWith, mergeProps } from "../../utilities/index";
import { DialogActionState } from "../../dialog/dialog.svelte";
import type { AlertDialogActionProps } from "../types";
import { createId } from "../../internal/create-id";
import { createId } from "../../utilities/create-id";
const uid = $props.id();

@ -2,7 +2,7 @@
import { boxWith, mergeProps } from "../../utilities/index";
import { AlertDialogCancelState } from "../../dialog/dialog.svelte";
import type { AlertDialogCancelProps } from "../types";
import { createId } from "../../internal/create-id";
import { createId } from "../../utilities/create-id";
const uid = $props.id();

@ -2,8 +2,8 @@
import { boxWith, mergeProps, afterSleep } from "../../utilities/index";
import { DialogContentState } from "../../dialog/dialog.svelte";
import type { AlertDialogContentProps } from "../types";
import { createId } from "../../internal/create-id";
import { noop } from "../../internal/noop";
import { createId } from "../../utilities/create-id";
import { noop } from "../../utilities/noop";
const uid = $props.id();

@ -2,7 +2,7 @@
import { boxWith } from "../../utilities/index";
import { DialogRootState } from "../../dialog/dialog.svelte";
import type { AlertDialogRootProps } from "../types";
import { noop } from "../../internal/noop";
import { noop } from "../../utilities/noop";
let {
open = $bindable(false),

@ -8,7 +8,7 @@ import type {
DialogCloseProps,
} from "../dialog/types";
import type { WithChild, Without } from "../utilities/types";
import type { AcelPrimitiveButtonAttributes } from "../shared/attributes";
import type { AcelPrimitiveButtonAttributes } from "../utilities/attributes";
// Alert-dialog re-usa los tipos del dialog en su mayoría

@ -0,0 +1,37 @@
import { attachRef, type ReadableBoxedValues } from "../utilities/index";
import type { RefAttachment, WithRefOpts } from "../utilities/types";
import { createAcelAttrs } from "../utilities/attrs";
const aspectRatioAttrs = createAcelAttrs({
component: "aspect-ratio",
parts: ["root"] as const,
});
interface AspectRatioRootStateOpts extends WithRefOpts, ReadableBoxedValues<{ ratio: number }> {}
export class AspectRatioRootState {
static create(opts: AspectRatioRootStateOpts) {
return new AspectRatioRootState(opts);
}
readonly opts: AspectRatioRootStateOpts;
readonly attachment: RefAttachment;
constructor(opts: AspectRatioRootStateOpts) {
this.opts = opts;
this.attachment = attachRef(this.opts.ref);
}
readonly props = $derived.by(() => ({
id: this.opts.id.current,
style: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
},
[aspectRatioAttrs.root]: "",
...this.attachment,
}) as const);
}

@ -0,0 +1,38 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities/index";
import type { AspectRatioRootProps } from "../types";
import { AspectRatioRootState } from "../aspect-ratio.svelte";
import { createId } from "../../utilities/create-id";
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
ratio = 1,
children,
child,
...restProps
}: AspectRatioRootProps = $props();
const rootState = AspectRatioRootState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
ratio: boxWith(() => ratio),
});
const mergedProps = $derived(mergeProps(restProps, rootState.props));
</script>
<div style:position="relative" style:width="100%" style:padding-bottom="{ratio ? 100 / ratio : 0}%">
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}
</div>

@ -0,0 +1,2 @@
export { default as Root } from "./components/aspect-ratio.svelte";
export type { AspectRatioRootProps as RootProps } from "./types";

@ -0,0 +1,2 @@
export * as AspectRatio from "./exports";
export * from "./exports";

@ -0,0 +1,15 @@
import type { WithChild, Without } from "../utilities/types";
import type { AcelPrimitiveDivAttributes } from "../utilities/attributes";
export type AspectRatioRootPropsWithoutHTML = WithChild<{
/**
* La proporción ancho/alto del contenido.
* Por ejemplo: 16/9, 4/3, 1 (cuadrado).
*
* @defaultValue 1
*/
ratio?: number;
}>;
export type AspectRatioRootProps = AspectRatioRootPropsWithoutHTML &
Without<AcelPrimitiveDivAttributes, AspectRatioRootPropsWithoutHTML>;

@ -0,0 +1,167 @@
import { attachRef, type ReadableBoxedValues, type WritableBoxedValues } from "../utilities/index";
import type { HTMLImgAttributes } from "svelte/elements";
import { Context, watch } from "runed";
import type { AvatarImageLoadingStatus } from "./types";
import type { RefAttachment, WithRefOpts } from "../utilities/types";
import { createAcelAttrs } from "../utilities/attrs";
const avatarAttrs = createAcelAttrs({
component: "avatar",
parts: ["root", "image", "fallback"] as const,
});
type CrossOrigin = HTMLImgAttributes["crossorigin"];
type ReferrerPolicy = HTMLImgAttributes["referrerpolicy"];
type AvatarImageSrc = string | null | undefined;
interface AvatarRootStateOpts
extends WithRefOpts,
ReadableBoxedValues<{ delayMs: number }>,
WritableBoxedValues<{ loadingStatus: AvatarImageLoadingStatus }> {}
const AvatarRootContext = new Context<AvatarRootState>("Avatar.Root");
export class AvatarRootState {
static create(opts: AvatarRootStateOpts) {
return AvatarRootContext.set(new AvatarRootState(opts));
}
readonly opts: AvatarRootStateOpts;
readonly attachment: RefAttachment;
constructor(opts: AvatarRootStateOpts) {
this.opts = opts;
this.attachment = attachRef(this.opts.ref);
}
/**
* Carga una imagen en segundo plano y actualiza loadingStatus.
* Retorna una función de limpieza para cancelar el timer pendiente.
*
* FIX vs uisv: se elimina el guard `if status === "loaded" return`
* para permitir recargar cuando cambia `src` en runtime.
*/
loadImage(
src: string,
crossorigin?: CrossOrigin,
referrerPolicy?: ReferrerPolicy
): (() => void) | void {
// Guarda SSR: Image no existe en el servidor
if (typeof window === "undefined") return;
const image = new Image();
let timerId: ReturnType<typeof setTimeout> | undefined;
image.src = src;
if (crossorigin !== undefined) image.crossOrigin = crossorigin as string;
if (referrerPolicy) image.referrerPolicy = referrerPolicy;
this.opts.loadingStatus.current = "loading";
image.onload = () => {
timerId = setTimeout(() => {
this.opts.loadingStatus.current = "loaded";
}, this.opts.delayMs.current);
};
image.onerror = () => {
this.opts.loadingStatus.current = "error";
};
// FIX vs uisv: el cleanup cancela el timer si src cambia antes de que expire
return () => {
image.onload = null;
image.onerror = null;
if (timerId !== undefined) clearTimeout(timerId);
};
}
readonly props = $derived.by(() => ({
id: this.opts.id.current,
[avatarAttrs.root]: "",
"data-status": this.opts.loadingStatus.current,
...this.attachment,
}) as const);
}
interface AvatarImageStateOpts
extends WithRefOpts,
ReadableBoxedValues<{
src: AvatarImageSrc;
crossOrigin: CrossOrigin;
referrerPolicy: ReferrerPolicy;
}> {}
export class AvatarImageState {
static create(opts: AvatarImageStateOpts) {
return new AvatarImageState(opts, AvatarRootContext.get());
}
readonly opts: AvatarImageStateOpts;
readonly root: AvatarRootState;
readonly attachment: RefAttachment;
constructor(opts: AvatarImageStateOpts, root: AvatarRootState) {
this.opts = opts;
this.root = root;
this.attachment = attachRef(this.opts.ref);
// FIX vs uisv: observa los 3 valores relevantes (src, crossOrigin, referrerPolicy)
// y retorna el cleanup de loadImage para cancelar timers pendientes al cambiar src.
watch.pre(
[
() => this.opts.src.current,
() => this.opts.crossOrigin.current,
() => this.opts.referrerPolicy.current,
],
([src, crossOrigin, referrerPolicy]) => {
if (!src) {
this.root.opts.loadingStatus.current = "error";
return;
}
return this.root.loadImage(src, crossOrigin, referrerPolicy);
}
);
}
readonly props = $derived.by(() => ({
id: this.opts.id.current,
// La imagen solo es visible cuando está completamente cargada
style: {
display: this.root.opts.loadingStatus.current === "loaded" ? "block" : "none",
},
"data-status": this.root.opts.loadingStatus.current,
[avatarAttrs.image]: "",
src: this.opts.src.current,
crossorigin: this.opts.crossOrigin.current,
referrerpolicy: this.opts.referrerPolicy.current,
...this.attachment,
}) as const);
}
interface AvatarFallbackStateOpts extends WithRefOpts {}
export class AvatarFallbackState {
static create(opts: AvatarFallbackStateOpts) {
return new AvatarFallbackState(opts, AvatarRootContext.get());
}
readonly opts: AvatarFallbackStateOpts;
readonly root: AvatarRootState;
readonly attachment: RefAttachment;
constructor(opts: AvatarFallbackStateOpts, root: AvatarRootState) {
this.opts = opts;
this.root = root;
this.attachment = attachRef(this.opts.ref);
}
readonly props = $derived.by(() => ({
// Oculto solo cuando la imagen está cargada, visible en "loading" y "error"
style: this.root.opts.loadingStatus.current === "loaded"
? { display: "none" }
: undefined,
"data-status": this.root.opts.loadingStatus.current,
[avatarAttrs.fallback]: "",
...this.attachment,
}) as const);
}

@ -0,0 +1,31 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities/index";
import type { AvatarFallbackProps } from "../types";
import { AvatarFallbackState } from "../avatar.svelte";
import { createId } from "../../utilities/create-id";
const uid = $props.id();
let {
children,
child,
id = createId(uid),
ref = $bindable(null),
...restProps
}: AvatarFallbackProps = $props();
const fallbackState = AvatarFallbackState.create({
id: boxWith(() => id),
ref: boxWith(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(restProps, fallbackState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<span {...mergedProps}>
{@render children?.()}
</span>
{/if}

@ -0,0 +1,36 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities/index";
import type { AvatarImageProps } from "../types";
import { AvatarImageState } from "../avatar.svelte";
import { createId } from "../../utilities/create-id";
const uid = $props.id();
let {
src,
child,
id = createId(uid),
ref = $bindable(null),
crossorigin = undefined,
referrerpolicy = undefined,
...restProps
}: AvatarImageProps = $props();
const imageState = AvatarImageState.create({
src: boxWith(() => src),
id: boxWith(() => id),
ref: boxWith(() => ref, (v) => (ref = v)),
crossOrigin: boxWith(() => crossorigin),
referrerPolicy: boxWith(() => referrerpolicy),
});
// FIX vs uisv: src ya viene dentro de mergedProps (imageState.props).
// No se repite {src} en el elemento para evitar el atributo duplicado.
const mergedProps = $derived(mergeProps(restProps, imageState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<img {...mergedProps} alt={restProps.alt ?? ""} />
{/if}

@ -0,0 +1,44 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities/index";
import type { AvatarRootProps } from "../types";
import { AvatarRootState } from "../avatar.svelte";
import { createId } from "../../utilities/create-id";
const uid = $props.id();
let {
delayMs = 0,
loadingStatus = $bindable("loading"),
onLoadingStatusChange,
child,
children,
id = createId(uid),
ref = $bindable(null),
...restProps
}: AvatarRootProps = $props();
const rootState = AvatarRootState.create({
delayMs: boxWith(() => delayMs),
loadingStatus: boxWith(
() => loadingStatus,
(v) => {
if (loadingStatus !== v) {
loadingStatus = v;
onLoadingStatusChange?.(v);
}
}
),
id: boxWith(() => id),
ref: boxWith(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(restProps, rootState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,10 @@
export { default as Root } from "./components/avatar.svelte";
export { default as Image } from "./components/avatar-image.svelte";
export { default as Fallback } from "./components/avatar-fallback.svelte";
export type {
AvatarRootProps as RootProps,
AvatarImageProps as ImageProps,
AvatarFallbackProps as FallbackProps,
AvatarImageLoadingStatus,
} from "./types";

@ -0,0 +1,2 @@
export * as Avatar from "./exports";
export * from "./exports";

@ -0,0 +1,38 @@
import type { OnChangeFn, WithChild, Without } from "../utilities/types";
import type {
AcelPrimitiveDivAttributes,
AcelPrimitiveImgAttributes,
AcelPrimitiveSpanAttributes,
} from "../utilities/attributes";
export type AvatarImageLoadingStatus = "loading" | "loaded" | "error";
export type AvatarRootPropsWithoutHTML = WithChild<{
/**
* Tiempo en ms a esperar antes de mostrar la imagen una vez cargada.
* Evita el parpadeo si la imagen carga muy rápido.
* @default 0
*/
delayMs?: number;
/**
* Estado de carga de la imagen. Puede usarse como bindable para observarlo
* externamente, o inicializarlo en `"loaded"` si se sabe que la imagen existe.
* @default "loading"
*/
loadingStatus?: AvatarImageLoadingStatus;
/** Callback invocado cuando cambia el estado de carga. */
onLoadingStatusChange?: OnChangeFn<AvatarImageLoadingStatus>;
}>;
export type AvatarRootProps = AvatarRootPropsWithoutHTML &
Without<AcelPrimitiveDivAttributes, AvatarRootPropsWithoutHTML>;
export type AvatarImagePropsWithoutHTML = WithChild;
export type AvatarImageProps = AvatarImagePropsWithoutHTML &
Without<AcelPrimitiveImgAttributes, AvatarImagePropsWithoutHTML>;
export type AvatarFallbackPropsWithoutHTML = WithChild;
export type AvatarFallbackProps = AvatarFallbackPropsWithoutHTML &
Without<AcelPrimitiveSpanAttributes, AvatarFallbackPropsWithoutHTML>;

File diff suppressed because it is too large Load Diff

@ -0,0 +1,33 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities";
import { CalendarCellState } from "../calendar.svelte";
import type { CalendarCellProps } from "../types";
import { useId } from "../../utilities/use-id";
let {
children,
child,
ref = $bindable(null),
id = useId(),
date,
month,
...restProps
}: CalendarCellProps = $props();
const cellState = CalendarCellState.create({
id: boxWith(() => id),
ref: boxWith(() => ref, (v) => (ref = v)),
date: boxWith(() => date),
month: boxWith(() => month),
});
const mergedProps = $derived(mergeProps(restProps, cellState.props));
</script>
{#if child}
{@render child({ props: mergedProps, ...cellState.snippetProps })}
{:else}
<td {...mergedProps}>
{@render children?.(cellState.snippetProps)}
</td>
{/if}

@ -0,0 +1,33 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities";
import { CalendarDayState } from "../calendar.svelte";
import type { CalendarDayProps } from "../types";
import { useId } from "../../utilities/use-id";
let {
children,
child,
ref = $bindable(null),
id = useId(),
...restProps
}: CalendarDayProps = $props();
const dayState = CalendarDayState.create({
id: boxWith(() => id),
ref: boxWith(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(restProps, dayState.props));
</script>
{#if child}
{@render child({ props: mergedProps, ...dayState.snippetProps })}
{:else}
<div {...mergedProps}>
{#if children}
{@render children?.(dayState.snippetProps)}
{:else}
{dayState.cell.opts.date.current.day}
{/if}
</div>
{/if}

@ -0,0 +1,29 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities";
import { CalendarGridBodyState } from "../calendar.svelte";
import type { CalendarGridBodyProps } from "../types";
import { useId } from "../../utilities/use-id";
let {
children,
child,
ref = $bindable(null),
id = useId(),
...restProps
}: CalendarGridBodyProps = $props();
const state = CalendarGridBodyState.create({
id: boxWith(() => id),
ref: boxWith(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<tbody {...mergedProps}>
{@render children?.()}
</tbody>
{/if}

@ -0,0 +1,29 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities";
import { CalendarGridHeadState } from "../calendar.svelte";
import type { CalendarGridHeadProps } from "../types";
import { useId } from "../../utilities/use-id";
let {
children,
child,
ref = $bindable(null),
id = useId(),
...restProps
}: CalendarGridHeadProps = $props();
const state = CalendarGridHeadState.create({
id: boxWith(() => id),
ref: boxWith(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<thead {...mergedProps}>
{@render children?.()}
</thead>
{/if}

@ -0,0 +1,29 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities";
import { CalendarGridRowState } from "../calendar.svelte";
import type { CalendarGridRowProps } from "../types";
import { useId } from "../../utilities/use-id";
let {
children,
child,
ref = $bindable(null),
id = useId(),
...restProps
}: CalendarGridRowProps = $props();
const state = CalendarGridRowState.create({
id: boxWith(() => id),
ref: boxWith(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<tr {...mergedProps}>
{@render children?.()}
</tr>
{/if}

@ -0,0 +1,29 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities";
import { CalendarGridState } from "../calendar.svelte";
import type { CalendarGridProps } from "../types";
import { useId } from "../../utilities/use-id";
let {
children,
child,
ref = $bindable(null),
id = useId(),
...restProps
}: CalendarGridProps = $props();
const state = CalendarGridState.create({
id: boxWith(() => id),
ref: boxWith(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<table {...mergedProps}>
{@render children?.()}
</table>
{/if}

@ -0,0 +1,29 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities";
import { CalendarHeadCellState } from "../calendar.svelte";
import type { CalendarHeadCellProps } from "../types";
import { useId } from "../../utilities/use-id";
let {
children,
child,
ref = $bindable(null),
id = useId(),
...restProps
}: CalendarHeadCellProps = $props();
const state = CalendarHeadCellState.create({
id: boxWith(() => id),
ref: boxWith(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<th {...mergedProps}>
{@render children?.()}
</th>
{/if}

@ -0,0 +1,29 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities";
import { CalendarHeaderState } from "../calendar.svelte";
import type { CalendarHeaderProps } from "../types";
import { useId } from "../../utilities/use-id";
let {
children,
child,
ref = $bindable(null),
id = useId(),
...restProps
}: CalendarHeaderProps = $props();
const state = CalendarHeaderState.create({
id: boxWith(() => id),
ref: boxWith(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<header {...mergedProps}>
{@render children?.()}
</header>
{/if}

@ -0,0 +1,34 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities";
import { CalendarHeadingState } from "../calendar.svelte";
import type { CalendarHeadingProps } from "../types";
import { useId } from "../../utilities/use-id";
let {
children,
child,
ref = $bindable(null),
id = useId(),
...restProps
}: CalendarHeadingProps = $props();
const state = CalendarHeadingState.create({
id: boxWith(() => id),
ref: boxWith(() => ref, (v) => (ref = v)),
});
const snippetProps = $derived({ headingValue: state.root.headingValue });
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps, ...snippetProps })}
{:else}
<div {...mergedProps}>
{#if children}
{@render children?.(snippetProps)}
{:else}
{state.root.headingValue}
{/if}
</div>
{/if}

@ -0,0 +1,43 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities";
import { CalendarMonthSelectState } from "../calendar.svelte";
import type { CalendarMonthSelectProps } from "../types";
import { useId } from "../../utilities/use-id";
const ALL_MONTHS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
let {
children,
child,
ref = $bindable(null),
id = useId(),
months = ALL_MONTHS,
monthFormat = "long",
disabled = false,
...restProps
}: CalendarMonthSelectProps = $props();
const state = CalendarMonthSelectState.create({
id: boxWith(() => id),
ref: boxWith(() => ref, (v) => (ref = v)),
months: boxWith(() => months),
monthFormat: boxWith(() => monthFormat),
disabled: boxWith(() => disabled),
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps, ...state.snippetProps })}
{:else}
<select {...mergedProps}>
{#if children}
{@render children?.(state.snippetProps)}
{:else}
{#each state.monthItems as item (item.value)}
<option value={item.value}>{item.label}</option>
{/each}
{/if}
</select>
{/if}

@ -0,0 +1,29 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities";
import { CalendarNextButtonState } from "../calendar.svelte";
import type { CalendarNextButtonProps } from "../types";
import { useId } from "../../utilities/use-id";
let {
children,
child,
ref = $bindable(null),
id = useId(),
...restProps
}: CalendarNextButtonProps = $props();
const state = CalendarNextButtonState.create({
id: boxWith(() => id),
ref: boxWith(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,29 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities";
import { CalendarPrevButtonState } from "../calendar.svelte";
import type { CalendarPrevButtonProps } from "../types";
import { useId } from "../../utilities/use-id";
let {
children,
child,
ref = $bindable(null),
id = useId(),
...restProps
}: CalendarPrevButtonProps = $props();
const state = CalendarPrevButtonState.create({
id: boxWith(() => id),
ref: boxWith(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,41 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities";
import { CalendarYearSelectState } from "../calendar.svelte";
import type { CalendarYearSelectProps } from "../types";
import { useId } from "../../utilities/use-id";
let {
children,
child,
ref = $bindable(null),
id = useId(),
years = undefined,
yearFormat = "numeric",
disabled = false,
...restProps
}: CalendarYearSelectProps = $props();
const state = CalendarYearSelectState.create({
id: boxWith(() => id),
ref: boxWith(() => ref, (v) => (ref = v)),
years: boxWith(() => years),
yearFormat: boxWith(() => yearFormat),
disabled: boxWith(() => disabled),
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps, ...state.snippetProps })}
{:else}
<select {...mergedProps}>
{#if children}
{@render children?.(state.snippetProps)}
{:else}
{#each state.yearItems as item (item.value)}
<option value={item.value}>{item.label}</option>
{/each}
{/if}
</select>
{/if}

@ -0,0 +1,126 @@
<script lang="ts">
import { watch } from "runed";
import { boxWith, mergeProps } from "../../utilities";
import type { DateValue } from "$lib/glob/lib/date";
import { CalendarRootState } from "../calendar.svelte";
import type { CalendarRootProps } from "../types";
import { useId } from "../../utilities/use-id";
import { noop } from "../../utilities/noop";
import { getDefaultDate } from "../../utilities/datetime/utils";
import { actx } from "$lib/actx/lib/instance_actx.svelte";
let {
child,
children,
id = useId(),
ref = $bindable(null),
value = $bindable(),
onValueChange = noop,
placeholder = $bindable(),
onPlaceholderChange = noop,
weekdayFormat = "narrow",
weekStartsOn = undefined,
pagedNavigation = false,
isDateDisabled = () => false,
isDateUnavailable = () => false,
fixedWeeks = false,
numberOfMonths = 1,
locale: localeProp = undefined,
calendarLabel = "",
disabled = false,
readonly = false,
minValue = undefined,
maxValue = undefined,
preventDeselect = false,
type,
disableDaysOutsideMonth = true,
initialFocus = false,
maxDays = undefined,
monthFormat = "long",
yearFormat = "numeric",
...restProps
}: CalendarRootProps = $props();
// Locale reactivo: usa el prop si se pasó, si no sigue actx.locale automáticamente
const locale = $derived(localeProp ?? actx.locale);
const defaultPlaceholder = getDefaultDate({ defaultValue: value, minValue, maxValue });
function handleDefaultPlaceholder() {
if (placeholder !== undefined) return;
placeholder = defaultPlaceholder;
}
// SSR
handleDefaultPlaceholder();
watch.pre(
() => placeholder,
() => { handleDefaultPlaceholder(); }
);
function handleDefaultValue() {
if (value !== undefined) return;
value = type === "single" ? undefined : [];
}
// SSR
handleDefaultValue();
watch.pre(
() => value,
() => { handleDefaultValue(); }
);
const rootState = CalendarRootState.create({
id: boxWith(() => id),
ref: boxWith(() => ref, (v) => (ref = v)),
weekdayFormat: boxWith(() => weekdayFormat),
weekStartsOn: boxWith(() => weekStartsOn),
pagedNavigation: boxWith(() => pagedNavigation),
isDateDisabled: boxWith(() => isDateDisabled),
isDateUnavailable: boxWith(() => isDateUnavailable),
fixedWeeks: boxWith(() => fixedWeeks),
numberOfMonths: boxWith(() => numberOfMonths),
locale: boxWith(() => locale),
calendarLabel: boxWith(() => calendarLabel),
readonly: boxWith(() => readonly),
disabled: boxWith(() => disabled),
minValue: boxWith(() => minValue),
maxValue: boxWith(() => maxValue),
disableDaysOutsideMonth: boxWith(() => disableDaysOutsideMonth),
initialFocus: boxWith(() => initialFocus),
maxDays: boxWith(() => maxDays),
placeholder: boxWith(
() => placeholder as DateValue,
(v) => {
placeholder = v;
onPlaceholderChange(v as DateValue);
}
),
preventDeselect: boxWith(() => preventDeselect),
value: boxWith(
() => value,
(v) => {
value = v;
// oxlint-disable-next-line no-explicit-any
onValueChange(v as any);
}
),
type: boxWith(() => type),
monthFormat: boxWith(() => monthFormat),
yearFormat: boxWith(() => yearFormat),
onDateSelect: boxWith(() => undefined),
defaultPlaceholder,
});
const mergedProps = $derived(mergeProps(restProps, rootState.props));
</script>
{#if child}
{@render child({ props: mergedProps, ...rootState.snippetProps })}
{:else}
<div {...mergedProps}>
{@render children?.(rootState.snippetProps)}
</div>
{/if}

@ -0,0 +1,37 @@
export { default as Root } from "./components/calendar.svelte";
export { default as Cell } from "./components/calendar-cell.svelte";
export { default as Day } from "./components/calendar-day.svelte";
export { default as Grid } from "./components/calendar-grid.svelte";
export { default as GridBody } from "./components/calendar-grid-body.svelte";
export { default as GridHead } from "./components/calendar-grid-head.svelte";
export { default as GridRow } from "./components/calendar-grid-row.svelte";
export { default as HeadCell } from "./components/calendar-head-cell.svelte";
export { default as Header } from "./components/calendar-header.svelte";
export { default as Heading } from "./components/calendar-heading.svelte";
export { default as NextButton } from "./components/calendar-next-button.svelte";
export { default as PrevButton } from "./components/calendar-prev-button.svelte";
export { default as MonthSelect } from "./components/calendar-month-select.svelte";
export { default as YearSelect } from "./components/calendar-year-select.svelte";
export type {
CalendarRootProps as RootProps,
CalendarCellProps as CellProps,
CalendarDayProps as DayProps,
CalendarGridProps as GridProps,
CalendarGridBodyProps as GridBodyProps,
CalendarGridHeadProps as GridHeadProps,
CalendarGridRowProps as GridRowProps,
CalendarHeadCellProps as HeadCellProps,
CalendarHeaderProps as HeaderProps,
CalendarHeadingProps as HeadingProps,
CalendarNextButtonProps as NextButtonProps,
CalendarPrevButtonProps as PrevButtonProps,
CalendarMonthSelectProps as MonthSelectProps,
CalendarYearSelectProps as YearSelectProps,
CalendarRootSnippetProps,
CalendarCellSnippetProps,
CalendarDaySnippetProps,
CalendarHeadingSnippetProps,
CalendarMonthSelectSnippetProps,
CalendarYearSelectSnippetProps,
} from "./types";

@ -0,0 +1,2 @@
export * as Calendar from "./exports";
export * from "./exports";

@ -0,0 +1,160 @@
import type { DateValue } from "$lib/glob/lib/date";
import type { OnChangeFn, WithChild, Without } from "../utilities/types";
import type {
BitsPrimitiveButtonAttributes,
BitsPrimitiveDivAttributes,
BitsPrimitiveHeaderAttributes,
BitsPrimitiveSelectAttributes,
BitsPrimitiveTableAttributes,
BitsPrimitiveTbodyAttributes,
BitsPrimitiveTdAttributes,
BitsPrimitiveThAttributes,
BitsPrimitiveTheadAttributes,
BitsPrimitiveTrAttributes,
} from "../utilities/attributes";
import type { DateMatcher, Month, WeekStartsOn } from "../utilities/datetime/date-types";
export type CalendarRootSnippetProps = {
months: Month<DateValue>[];
weekdays: string[];
};
export type CalendarCellSnippetProps = {
disabled: boolean;
unavailable: boolean;
selected: boolean;
};
export type CalendarDaySnippetProps = {
disabled: boolean;
unavailable: boolean;
selected: boolean;
day: string;
};
export type CalendarHeadingSnippetProps = {
headingValue: string;
};
export type CalendarMonthSelectSnippetProps = {
monthItems: Array<{ value: number; label: string }>;
selectedMonthItem: { value: number; label: string };
};
export type CalendarYearSelectSnippetProps = {
yearItems: Array<{ value: number; label: string }>;
selectedYearItem: { value: number; label: string };
};
// ─── Base props ───────────────────────────────────────────────────────────────
type CalendarBaseRootPropsWithoutHTML = {
placeholder?: DateValue;
onPlaceholderChange?: OnChangeFn<DateValue>;
preventDeselect?: boolean;
minValue?: DateValue;
maxValue?: DateValue;
disabled?: boolean;
pagedNavigation?: boolean;
weekStartsOn?: WeekStartsOn;
weekdayFormat?: Intl.DateTimeFormatOptions["weekday"];
isDateDisabled?: DateMatcher;
isDateUnavailable?: DateMatcher;
fixedWeeks?: boolean;
numberOfMonths?: number;
calendarLabel?: string;
locale?: string;
readonly?: boolean;
initialFocus?: boolean;
disableDaysOutsideMonth?: boolean;
maxDays?: number;
monthFormat?: Intl.DateTimeFormatOptions["month"] | ((month: number) => string);
yearFormat?: Intl.DateTimeFormatOptions["year"] | ((year: number) => string);
};
// ─── Single ───────────────────────────────────────────────────────────────────
export type CalendarSingleRootPropsWithoutHTML = {
type: "single";
value?: DateValue;
onValueChange?: OnChangeFn<DateValue | undefined>;
};
export type _CalendarSingleRootPropsWithoutHTML = CalendarBaseRootPropsWithoutHTML &
CalendarSingleRootPropsWithoutHTML;
export type CalendarSingleRootProps = WithChild<
_CalendarSingleRootPropsWithoutHTML,
CalendarRootSnippetProps
> &
Without<BitsPrimitiveDivAttributes, _CalendarSingleRootPropsWithoutHTML>;
// ─── Multiple ─────────────────────────────────────────────────────────────────
export type CalendarMultipleRootPropsWithoutHTML = {
type: "multiple";
value?: DateValue[];
onValueChange?: OnChangeFn<DateValue[]>;
};
export type _CalendarMultipleRootPropsWithoutHTML = CalendarBaseRootPropsWithoutHTML &
CalendarMultipleRootPropsWithoutHTML;
export type CalendarMultipleRootProps = WithChild<
_CalendarMultipleRootPropsWithoutHTML,
CalendarRootSnippetProps
> &
Without<BitsPrimitiveDivAttributes, _CalendarMultipleRootPropsWithoutHTML>;
export type CalendarRootProps = CalendarSingleRootProps | CalendarMultipleRootProps;
// ─── Sub-components ───────────────────────────────────────────────────────────
export type CalendarCellPropsWithoutHTML = WithChild<
{ date: DateValue; month: DateValue },
CalendarCellSnippetProps
>;
export type CalendarCellProps = CalendarCellPropsWithoutHTML &
Without<BitsPrimitiveTdAttributes, CalendarCellPropsWithoutHTML>;
export type CalendarDayPropsWithoutHTML = WithChild<{}, CalendarDaySnippetProps>;
export type CalendarDayProps = CalendarDayPropsWithoutHTML &
Without<BitsPrimitiveDivAttributes, CalendarDayPropsWithoutHTML>;
export type CalendarGridProps = WithChild & Without<BitsPrimitiveTableAttributes, WithChild>;
export type CalendarGridBodyProps = WithChild & Without<BitsPrimitiveTbodyAttributes, WithChild>;
export type CalendarGridHeadProps = WithChild & Without<BitsPrimitiveTheadAttributes, WithChild>;
export type CalendarGridRowProps = WithChild & Without<BitsPrimitiveTrAttributes, WithChild>;
export type CalendarHeadCellProps = WithChild & Without<BitsPrimitiveThAttributes, WithChild>;
export type CalendarHeaderProps = WithChild & Without<BitsPrimitiveHeaderAttributes, WithChild>;
export type CalendarHeadingPropsWithoutHTML = WithChild<{}, CalendarHeadingSnippetProps>;
export type CalendarHeadingProps = CalendarHeadingPropsWithoutHTML &
Without<BitsPrimitiveDivAttributes, CalendarHeadingPropsWithoutHTML>;
export type CalendarNextButtonProps = WithChild &
Without<BitsPrimitiveButtonAttributes, WithChild>;
export type CalendarPrevButtonProps = WithChild &
Without<BitsPrimitiveButtonAttributes, WithChild>;
export type CalendarMonthSelectPropsWithoutHTML = WithChild<
{
months?: number[];
monthFormat?: Intl.DateTimeFormatOptions["month"] | ((month: number) => string);
disabled?: boolean;
},
CalendarMonthSelectSnippetProps
>;
export type CalendarMonthSelectProps = CalendarMonthSelectPropsWithoutHTML &
Without<BitsPrimitiveSelectAttributes, CalendarMonthSelectPropsWithoutHTML>;
export type CalendarYearSelectPropsWithoutHTML = WithChild<
{
years?: number[];
yearFormat?: Intl.DateTimeFormatOptions["year"] | ((year: number) => string);
disabled?: boolean;
},
CalendarYearSelectSnippetProps
>;
export type CalendarYearSelectProps = CalendarYearSelectPropsWithoutHTML &
Without<BitsPrimitiveSelectAttributes, CalendarYearSelectPropsWithoutHTML>;

@ -0,0 +1,271 @@
import { attachRef, type ReadableBoxedValues, type WritableBoxedValues } from "../utilities/index";
import type { RefAttachment, WithRefOpts, AcelKeyboardEvent, AcelMouseEvent, AcelFocusEvent } from "../utilities/types";
import { createAcelAttrs, getAriaChecked, boolToStr, boolToEmptyStrOrUndef } from "../utilities/attrs";
import { Context, watch } from "runed";
import { kbd } from "../utilities/keyboard/kbd";
import { noop } from "../utilities/noop";
import { isHTMLElement } from "../utilities/dom/is-browser";
const checkboxAttrs = createAcelAttrs({
component: "checkbox",
parts: ["root", "group", "group-label", "input"] as const,
});
// ── Group ─────────────────────────────────────────────────────────────────────
export const CheckboxGroupContext = new Context<CheckboxGroupState>("Checkbox.Group");
interface CheckboxGroupStateOpts
extends WithRefOpts,
ReadableBoxedValues<{
name: string | undefined;
disabled: boolean;
required: boolean;
readonly: boolean;
onValueChange: (v: string[]) => void;
}>,
WritableBoxedValues<{ value: string[] }> {}
export class CheckboxGroupState {
static create(opts: CheckboxGroupStateOpts) {
return CheckboxGroupContext.set(new CheckboxGroupState(opts));
}
readonly opts: CheckboxGroupStateOpts;
readonly attachment: RefAttachment;
labelId = $state<string | undefined>(undefined);
constructor(opts: CheckboxGroupStateOpts) {
this.opts = opts;
this.attachment = attachRef(opts.ref);
}
addValue(checkboxValue: string | undefined) {
if (!checkboxValue) return;
if (!this.opts.value.current.includes(checkboxValue)) {
const newValue = [...this.opts.value.current, checkboxValue];
this.opts.value.current = newValue;
this.opts.onValueChange.current(newValue);
}
}
removeValue(checkboxValue: string | undefined) {
if (!checkboxValue) return;
const newValue = this.opts.value.current.filter((v) => v !== checkboxValue);
if (newValue.length === this.opts.value.current.length) return;
this.opts.value.current = newValue;
this.opts.onValueChange.current(newValue);
}
readonly props = $derived.by(() => ({
id: this.opts.id.current,
role: "group",
"aria-labelledby": this.labelId,
"data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current),
[checkboxAttrs.group]: "",
...this.attachment,
}) as const);
}
// ── Group Label ───────────────────────────────────────────────────────────────
interface CheckboxGroupLabelStateOpts extends WithRefOpts {}
export class CheckboxGroupLabelState {
static create(opts: CheckboxGroupLabelStateOpts) {
return new CheckboxGroupLabelState(opts, CheckboxGroupContext.get());
}
readonly opts: CheckboxGroupLabelStateOpts;
readonly group: CheckboxGroupState;
readonly attachment: RefAttachment;
constructor(opts: CheckboxGroupLabelStateOpts, group: CheckboxGroupState) {
this.opts = opts;
this.group = group;
this.group.labelId = opts.id.current;
this.attachment = attachRef(opts.ref);
watch.pre(
() => this.opts.id.current,
(id) => { this.group.labelId = id; }
);
}
readonly props = $derived.by(() => ({
id: this.opts.id.current,
"data-disabled": boolToEmptyStrOrUndef(this.group.opts.disabled.current),
[checkboxAttrs["group-label"]]: "",
...this.attachment,
}) as const);
}
// ── Root ──────────────────────────────────────────────────────────────────────
const CheckboxRootContext = new Context<CheckboxRootState>("Checkbox.Root");
interface CheckboxRootStateOpts
extends WithRefOpts,
ReadableBoxedValues<{
disabled: boolean;
required: boolean;
readonly: boolean;
name: string | undefined;
value: string | undefined;
onCheckedChange: (v: boolean) => void;
onIndeterminateChange: (v: boolean) => void;
}>,
WritableBoxedValues<{
checked: boolean;
indeterminate: boolean;
}> {}
export class CheckboxRootState {
static create(opts: CheckboxRootStateOpts, group: CheckboxGroupState | null = null) {
return CheckboxRootContext.set(new CheckboxRootState(opts, group));
}
readonly opts: CheckboxRootStateOpts;
readonly group: CheckboxGroupState | null;
readonly attachment: RefAttachment;
readonly trueName = $derived.by(() =>
this.group?.opts.name.current ?? this.opts.name.current
);
readonly trueRequired = $derived.by(() =>
!!(this.group?.opts.required.current || this.opts.required.current)
);
readonly trueDisabled = $derived.by(() =>
!!(this.group?.opts.disabled.current || this.opts.disabled.current)
);
readonly trueReadonly = $derived.by(() =>
!!(this.group?.opts.readonly.current || this.opts.readonly.current)
);
constructor(opts: CheckboxRootStateOpts, group: CheckboxGroupState | null) {
this.opts = opts;
this.group = group;
this.attachment = attachRef(opts.ref);
this.onkeydown = this.onkeydown.bind(this);
this.onclick = this.onclick.bind(this);
// Sync group value → checked
watch.pre(
[() => this.group?.opts.value.current, () => this.opts.value.current] as const,
([groupValue, value]) => {
if (!groupValue || !value) return;
this.opts.checked.current = groupValue.includes(value);
}
);
// Sync checked → group value
watch.pre(
() => this.opts.checked.current,
(checked) => {
if (!this.group) return;
if (checked) {
this.group.addValue(this.opts.value.current);
} else {
this.group.removeValue(this.opts.value.current);
}
}
);
}
#toggle() {
if (this.opts.indeterminate.current) {
this.opts.indeterminate.current = false;
this.opts.onIndeterminateChange.current(false);
this.opts.checked.current = true;
this.opts.onCheckedChange.current(true);
} else {
const next = !this.opts.checked.current;
this.opts.checked.current = next;
this.opts.onCheckedChange.current(next);
}
}
onkeydown(e: AcelKeyboardEvent<HTMLButtonElement>) {
if (this.trueDisabled || this.trueReadonly) return;
if (e.key === kbd.ENTER) {
e.preventDefault();
return;
}
if (e.key === kbd.SPACE) {
e.preventDefault();
this.#toggle();
}
}
onclick(e: AcelMouseEvent<HTMLButtonElement>) {
if (this.trueDisabled || this.trueReadonly) return;
e.preventDefault();
this.#toggle();
}
readonly snippetProps = $derived.by(() => ({
checked: this.opts.checked.current,
indeterminate: this.opts.indeterminate.current,
}));
readonly props = $derived.by(() => ({
id: this.opts.id.current,
role: "checkbox",
type: "button" as const,
disabled: this.trueDisabled,
"aria-checked": getAriaChecked(this.opts.checked.current, this.opts.indeterminate.current),
"aria-required": boolToStr(this.trueRequired),
"aria-readonly": boolToStr(this.trueReadonly),
"data-disabled": boolToEmptyStrOrUndef(this.trueDisabled),
"data-readonly": boolToEmptyStrOrUndef(this.trueReadonly),
"data-state": getCheckboxDataState(this.opts.checked.current, this.opts.indeterminate.current),
[checkboxAttrs.root]: "",
onclick: this.onclick,
onkeydown: this.onkeydown,
...this.attachment,
}) as const);
}
// ── Hidden Input ──────────────────────────────────────────────────────────────
export class CheckboxInputState {
static create() {
return new CheckboxInputState(CheckboxRootContext.get());
}
readonly root: CheckboxRootState;
readonly trueChecked = $derived.by(() => {
if (!this.root.group) return this.root.opts.checked.current;
const val = this.root.opts.value.current;
return val !== undefined && this.root.group.opts.value.current.includes(val);
});
readonly shouldRender = $derived.by(() => Boolean(this.root.trueName));
constructor(root: CheckboxRootState) {
this.root = root;
this.onfocus = this.onfocus.bind(this);
}
onfocus(_: AcelFocusEvent) {
if (isHTMLElement(this.root.opts.ref.current)) {
this.root.opts.ref.current.focus();
}
}
readonly props = $derived.by(() => ({
type: "checkbox" as const,
checked: this.trueChecked,
disabled: this.root.trueDisabled,
required: this.root.trueRequired,
name: this.root.trueName,
value: this.root.opts.value.current,
readonly: this.root.trueReadonly,
onfocus: this.onfocus,
}) as const);
}
function getCheckboxDataState(checked: boolean, indeterminate: boolean) {
if (indeterminate) return "indeterminate";
return checked ? "checked" : "unchecked";
}

@ -0,0 +1,31 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities/index";
import type { CheckboxGroupLabelProps } from "../types";
import { CheckboxGroupLabelState } from "../checkbox.svelte";
import { createId } from "../../utilities/create-id";
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
children,
child,
...restProps
}: CheckboxGroupLabelProps = $props();
const labelState = CheckboxGroupLabelState.create({
id: boxWith(() => id),
ref: boxWith(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(restProps, labelState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<span {...mergedProps}>
{@render children?.()}
</span>
{/if}

@ -0,0 +1,44 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities/index";
import type { CheckboxGroupProps } from "../types";
import { CheckboxGroupState } from "../checkbox.svelte";
import { createId } from "../../utilities/create-id";
import { noop } from "../../utilities/noop";
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
value = $bindable([]),
onValueChange = noop,
name = undefined,
disabled = false,
required = false,
readonly = false,
children,
child,
...restProps
}: CheckboxGroupProps = $props();
const groupState = CheckboxGroupState.create({
id: boxWith(() => id),
ref: boxWith(() => ref, (v) => (ref = v)),
value: boxWith(() => value, (v) => (value = v)),
onValueChange: boxWith(() => onValueChange),
name: boxWith(() => name),
disabled: boxWith(() => disabled),
required: boxWith(() => required),
readonly: boxWith(() => readonly),
});
const mergedProps = $derived(mergeProps(restProps, groupState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,61 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities/index";
import type { CheckboxRootProps } from "../types";
import { CheckboxRootState, CheckboxInputState, CheckboxGroupContext } from "../checkbox.svelte";
import { createId } from "../../utilities/create-id";
import { noop } from "../../utilities/noop";
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
checked = $bindable(false),
indeterminate = $bindable(false),
onCheckedChange = noop,
onIndeterminateChange = noop,
disabled = false,
required = false,
readonly = false,
name = undefined,
value = undefined,
children,
child,
...restProps
}: CheckboxRootProps = $props();
// Optional group context (null if used standalone)
const group = $derived(CheckboxGroupContext.getOr(null));
const rootState = CheckboxRootState.create(
{
id: boxWith(() => id),
ref: boxWith(() => ref, (v) => (ref = v)),
checked: boxWith(() => checked, (v) => (checked = v)),
indeterminate: boxWith(() => indeterminate, (v) => (indeterminate = v)),
onCheckedChange: boxWith(() => onCheckedChange),
onIndeterminateChange: boxWith(() => onIndeterminateChange),
disabled: boxWith(() => disabled),
required: boxWith(() => required),
readonly: boxWith(() => readonly),
name: boxWith(() => name),
value: boxWith(() => value),
},
group
);
const inputState = CheckboxInputState.create();
const mergedProps = $derived(mergeProps(restProps, rootState.props));
</script>
{#if child}
{@render child({ props: mergedProps, ...rootState.snippetProps })}
{:else}
<button {...mergedProps}>
{@render children?.(rootState.snippetProps)}
</button>
{/if}
{#if inputState.shouldRender}
<input {...inputState.props} style="display:none" />
{/if}

@ -0,0 +1,8 @@
export { default as Root } from "./components/checkbox.svelte";
export { default as Group } from "./components/checkbox-group.svelte";
export { default as GroupLabel } from "./components/checkbox-group-label.svelte";
export type {
CheckboxRootProps as RootProps,
CheckboxGroupProps as GroupProps,
CheckboxGroupLabelProps as GroupLabelProps,
} from "./types";

@ -0,0 +1,2 @@
export * as Checkbox from "./exports";
export * from "./exports";

@ -0,0 +1,34 @@
import type { WithChild, Without } from "../utilities/types";
import type { AcelPrimitiveButtonAttributes, AcelPrimitiveDivAttributes } from "../utilities/attributes";
export type CheckboxRootPropsWithoutHTML = WithChild<{
checked?: boolean;
indeterminate?: boolean;
onCheckedChange?: (checked: boolean) => void;
onIndeterminateChange?: (indeterminate: boolean) => void;
disabled?: boolean;
required?: boolean;
readonly?: boolean;
name?: string;
value?: string;
}, { checked: boolean; indeterminate: boolean }, HTMLButtonElement>;
export type CheckboxRootProps = CheckboxRootPropsWithoutHTML &
Without<AcelPrimitiveButtonAttributes, CheckboxRootPropsWithoutHTML>;
export type CheckboxGroupPropsWithoutHTML = WithChild<{
value?: string[];
onValueChange?: (value: string[]) => void;
name?: string;
disabled?: boolean;
required?: boolean;
readonly?: boolean;
}>;
export type CheckboxGroupProps = CheckboxGroupPropsWithoutHTML &
Without<AcelPrimitiveDivAttributes, CheckboxGroupPropsWithoutHTML>;
export type CheckboxGroupLabelPropsWithoutHTML = WithChild<{}>;
export type CheckboxGroupLabelProps = CheckboxGroupLabelPropsWithoutHTML &
Without<AcelPrimitiveDivAttributes, CheckboxGroupLabelPropsWithoutHTML>;

@ -11,17 +11,17 @@ import {
boolToStr,
boolToEmptyStrOrUndef,
getDataOpenClosed,
} from "../internal/attrs";
import { kbd } from "../internal/kbd";
} from "../utilities/attrs";
import { kbd } from "../utilities/keyboard/kbd";
import type {
AcelKeyboardEvent,
AcelMouseEvent,
OnChangeFn,
RefAttachment,
WithRefOpts,
} from "../internal/types";
} from "../utilities/types";
import { on } from "svelte/events";
import { PresenceManager } from "../internal/presence-manager.svelte";
import { PresenceManager } from "../utilities/presence/presence-manager.svelte";
const collapsibleAttrs = createAcelAttrs({
component: "collapsible",

@ -2,7 +2,7 @@
import { mergeProps, boxWith } from "../../utilities/index";
import { CollapsibleContentState } from "../collapsible.svelte";
import type { CollapsibleContentProps } from "../types";
import { createId } from "../../internal/create-id";
import { createId } from "../../utilities/create-id";
const uid = $props.id();

@ -2,7 +2,7 @@
import { boxWith, mergeProps } from "../../utilities/index";
import type { CollapsibleTriggerProps } from "../types";
import { CollapsibleTriggerState } from "../collapsible.svelte";
import { createId } from "../../internal/create-id";
import { createId } from "../../utilities/create-id";
const uid = $props.id();

@ -2,8 +2,8 @@
import { type WritableBox, boxWith, mergeProps } from "../../utilities/index";
import { CollapsibleRootState } from "../collapsible.svelte";
import type { CollapsibleRootProps } from "../types";
import { noop } from "../../internal/noop";
import { createId } from "../../internal/create-id";
import { noop } from "../../utilities/noop";
import { createId } from "../../utilities/create-id";
const uid = $props.id();

@ -3,11 +3,11 @@ import type {
WithChild,
WithChildNoChildrenSnippetProps,
Without,
} from "../internal/types";
} from "../utilities/types";
import type {
AcelPrimitiveButtonAttributes,
AcelPrimitiveDivAttributes,
} from "../shared/attributes";
} from "../utilities/attributes";
export type CollapsibleRootPropsWithoutHTML = WithChild<{
/**

File diff suppressed because it is too large Load Diff

@ -0,0 +1,28 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities/index";
import { CommandLabelState } from "../command.svelte";
import { useId } from "../../utilities/use-id";
import type { AcelPrimitiveLabelAttributes } from "../../utilities/attributes";
import type { WithChildren } from "../../utilities/types";
let {
id = useId(),
ref = $bindable(null),
children,
...restProps
}: WithChildren<AcelPrimitiveLabelAttributes & { ref?: HTMLLabelElement | null }> = $props();
const labelState = CommandLabelState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
});
const mergedProps = $derived(mergeProps(restProps, labelState.props));
</script>
<label {...mergedProps}>
{@render children?.()}
</label>

@ -0,0 +1,36 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities/index";
import type { CommandEmptyProps } from "../types";
import { CommandEmptyState } from "../command.svelte";
import { useId } from "../../utilities/use-id";
let {
id = useId(),
ref = $bindable(null),
children,
child,
forceMount = false,
...restProps
}: CommandEmptyProps = $props();
const emptyState = CommandEmptyState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
forceMount: boxWith(() => forceMount),
});
const mergedProps = $derived(mergeProps(emptyState.props, restProps));
</script>
{#if emptyState.shouldRender}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}
{/if}

@ -0,0 +1,32 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities/index";
import { CommandGroupHeadingState } from "../command.svelte";
import type { CommandGroupHeadingProps } from "../types";
import { useId } from "../../utilities/use-id";
let {
id = useId(),
ref = $bindable(null),
children,
child,
...restProps
}: CommandGroupHeadingProps = $props();
const headingState = CommandGroupHeadingState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
});
const mergedProps = $derived(mergeProps(restProps, headingState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,34 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities/index";
import type { CommandGroupItemsProps } from "../types";
import { CommandGroupItemsState } from "../command.svelte";
import { useId } from "../../utilities/use-id";
let {
id = useId(),
ref = $bindable(null),
children,
child,
...restProps
}: CommandGroupItemsProps = $props();
const groupItemsState = CommandGroupItemsState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
});
const mergedProps = $derived(mergeProps(restProps, groupItemsState.props));
</script>
<div style="display: contents;">
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}
</div>

@ -0,0 +1,36 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities/index";
import type { CommandGroupProps } from "../types";
import { CommandGroupContainerState } from "../command.svelte";
import { useId } from "../../utilities/use-id";
let {
id = useId(),
ref = $bindable(null),
value = "",
forceMount = false,
children,
child,
...restProps
}: CommandGroupProps = $props();
const groupState = CommandGroupContainerState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
forceMount: boxWith(() => forceMount),
value: boxWith(() => value),
});
const mergedProps = $derived(mergeProps(restProps, groupState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,36 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities/index";
import type { CommandInputProps } from "../types";
import { CommandInputState } from "../command.svelte";
import { useId } from "../../utilities/use-id";
let {
value = $bindable(""),
autofocus = false,
id = useId(),
ref = $bindable(null),
child,
...restProps
}: CommandInputProps = $props();
const inputState = CommandInputState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
value: boxWith(
() => value,
(v) => { value = v; }
),
autofocus: boxWith(() => autofocus ?? false),
});
const mergedProps = $derived(mergeProps(restProps, inputState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<input {...mergedProps} bind:value />
{/if}

@ -0,0 +1,49 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities/index";
import type { CommandItemProps } from "../types";
import { CommandItemState } from "../command.svelte";
import { noop } from "../../utilities/noop";
import { useId } from "../../utilities/use-id";
let {
id = useId(),
ref = $bindable(null),
value = "",
disabled = false,
children,
child,
onSelect = noop,
forceMount = false,
keywords = [],
...restProps
}: CommandItemProps = $props();
const itemState = CommandItemState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
value: boxWith(() => value),
disabled: boxWith(() => disabled),
onSelect: boxWith(() => onSelect),
forceMount: boxWith(() => forceMount),
keywords: boxWith(() => keywords),
});
const mergedProps = $derived(mergeProps(restProps, itemState.props));
</script>
{#key itemState.root.key}
<div style="display: contents;" data-item-wrapper data-value={itemState.trueValue}>
{#if itemState.shouldRender}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}
{/if}
</div>
{/key}

@ -0,0 +1,49 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities/index";
import type { CommandLinkItemProps } from "../types";
import { CommandItemState } from "../command.svelte";
import { noop } from "../../utilities/noop";
import { useId } from "../../utilities/use-id";
let {
id = useId(),
ref = $bindable(null),
value = "",
disabled = false,
children,
child,
onSelect = noop,
forceMount = false,
keywords = [],
...restProps
}: CommandLinkItemProps = $props();
const itemState = CommandItemState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
value: boxWith(() => value),
disabled: boxWith(() => disabled),
onSelect: boxWith(() => onSelect),
forceMount: boxWith(() => forceMount),
keywords: boxWith(() => keywords),
});
const mergedProps = $derived(mergeProps(restProps, itemState.props));
</script>
{#key itemState.root.key}
<div style="display: contents;">
{#if itemState.shouldRender}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<a {...mergedProps}>
{@render children?.()}
</a>
{/if}
{/if}
</div>
{/key}

@ -0,0 +1,36 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities/index";
import type { CommandListProps } from "../types";
import { CommandListState } from "../command.svelte";
import { useId } from "../../utilities/use-id";
let {
id = useId(),
ref = $bindable(null),
child,
children,
"aria-label": ariaLabel,
...restProps
}: CommandListProps = $props();
const listState = CommandListState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
ariaLabel: boxWith(() => ariaLabel ?? "Suggestions..."),
});
const mergedProps = $derived(mergeProps(restProps, listState.props));
</script>
{#key listState.root._commandState.search === ""}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}
{/key}

@ -0,0 +1,34 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities/index";
import type { CommandLoadingProps } from "../types";
import { CommandLoadingState } from "../command.svelte";
import { useId } from "../../utilities/use-id";
let {
progress = 0,
id = useId(),
ref = $bindable(null),
children,
child,
...restProps
}: CommandLoadingProps = $props();
const loadingState = CommandLoadingState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
progress: boxWith(() => progress),
});
const mergedProps = $derived(mergeProps(restProps, loadingState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,36 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities/index";
import type { CommandSeparatorProps } from "../types";
import { CommandSeparatorState } from "../command.svelte";
import { useId } from "../../utilities/use-id";
let {
id = useId(),
ref = $bindable(null),
forceMount = false,
children,
child,
...restProps
}: CommandSeparatorProps = $props();
const separatorState = CommandSeparatorState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
forceMount: boxWith(() => forceMount),
});
const mergedProps = $derived(mergeProps(restProps, separatorState.props));
</script>
{#if separatorState.shouldRender}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}
{/if}

@ -0,0 +1,32 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities/index";
import { CommandViewportState } from "../command.svelte";
import type { CommandViewportProps } from "../types";
import { useId } from "../../utilities/use-id";
let {
id = useId(),
ref = $bindable(null),
children,
child,
...restProps
}: CommandViewportProps = $props();
const listViewportState = CommandViewportState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
});
const mergedProps = $derived(mergeProps(restProps, listViewportState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,80 @@
<script lang="ts">
import { boxWith, mergeProps } from "../../utilities/index";
import { CommandRootState } from "../command.svelte";
import type { CommandRootProps } from "../types";
import CommandLabel from "./_command-label.svelte";
import { noop } from "../../utilities/noop";
import { useId } from "../../utilities/use-id";
import { computeCommandScore } from "../compute-command-score";
let {
id = useId(),
ref = $bindable(null),
value = $bindable(""),
onValueChange = noop,
onStateChange = noop,
loop = false,
shouldFilter = true,
filter = computeCommandScore,
label = "",
vimBindings = true,
disablePointerSelection = false,
disableInitialScroll = false,
columns = null,
children,
child,
...restProps
}: CommandRootProps = $props();
const rootState = CommandRootState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
filter: boxWith(() => filter),
shouldFilter: boxWith(() => shouldFilter),
loop: boxWith(() => loop),
value: boxWith(
() => value,
(v) => {
if (value !== v) {
value = v;
onValueChange(v);
}
}
),
vimBindings: boxWith(() => vimBindings),
disablePointerSelection: boxWith(() => disablePointerSelection),
disableInitialScroll: boxWith(() => disableInitialScroll),
onStateChange: boxWith(() => onStateChange),
columns: boxWith(() => columns),
});
// Imperative APIs
export const updateSelectedToIndex: (typeof rootState)["updateSelectedToIndex"] = (i) =>
rootState.updateSelectedToIndex(i);
export const updateSelectedByGroup: (typeof rootState)["updateSelectedByGroup"] = (c) =>
rootState.updateSelectedByGroup(c);
export const updateSelectedByItem: (typeof rootState)["updateSelectedByItem"] = (c) =>
rootState.updateSelectedByItem(c);
export const getValidItems = () => rootState.getValidItems();
const mergedProps = $derived(mergeProps(restProps, rootState.props));
</script>
{#snippet Label()}
<CommandLabel>
{label}
</CommandLabel>
{/snippet}
{#if child}
{@render Label()}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render Label()}
{@render children?.()}
</div>
{/if}

@ -0,0 +1,176 @@
// oxlint-disable ban-ts-comment
// @ts-nocheck
// The scores are arranged so that a continuous match of characters will
// result in a total score of 1.
//
// The best case, this character is a match, and either this is the start
// of the string, or the previous character was also a match.
const SCORE_CONTINUE_MATCH = 1;
// A new match at the start of a word scores better than a new match
// elsewhere as it's more likely that the user will type the starts
// of fragments.
// NOTE: We score word jumps between spaces slightly higher than slashes, brackets
// hyphens, etc.
const SCORE_SPACE_WORD_JUMP = 0.9;
const SCORE_NON_SPACE_WORD_JUMP = 0.8;
// Any other match isn't ideal, but we include it for completeness.
const SCORE_CHARACTER_JUMP = 0.17;
// If the user transposed two letters, it should be significantly penalized.
//
// i.e. "ouch" is more likely than "curtain" when "uc" is typed.
const SCORE_TRANSPOSITION = 0.1;
// The goodness of a match should decay slightly with each missing
// character.
//
// i.e. "bad" is more likely than "bard" when "bd" is typed.
//
// This will not change the order of suggestions based on SCORE_* until
// 100 characters are inserted between matches.
const PENALTY_SKIPPED = 0.999;
// The goodness of an exact-case match should be higher than a
// case-insensitive match by a small amount.
//
// i.e. "HTML" is more likely than "haml" when "HM" is typed.
//
// This will not change the order of suggestions based on SCORE_* until
// 1000 characters are inserted between matches.
const PENALTY_CASE_MISMATCH = 0.9999;
// Match higher for letters closer to the beginning of the word
// If the word has more characters than the user typed, it should
// be penalized slightly.
//
// i.e. "html" is more likely than "html5" if I type "html".
//
// However, it may well be the case that there's a sensible secondary
// ordering (like alphabetical) that it makes sense to rely on when
// there are many prefix matches, so we don't make the penalty increase
// with the number of tokens.
const PENALTY_NOT_COMPLETE = 0.99;
const IS_GAP_REGEXP = /[\\/_+.#"@[({&]/;
const COUNT_GAPS_REGEXP = /[\\/_+.#"@[({&]/g;
const IS_SPACE_REGEXP = /[\s-]/;
const COUNT_SPACE_REGEXP = /[\s-]/g;
function computeCommandScoreInner(
string,
abbreviation,
lowerString,
lowerAbbreviation,
stringIndex,
abbreviationIndex,
memoizedResults
) {
if (abbreviationIndex === abbreviation.length) {
if (stringIndex === string.length) return SCORE_CONTINUE_MATCH;
return PENALTY_NOT_COMPLETE;
}
const memoizeKey = `${stringIndex},${abbreviationIndex}`;
if (memoizedResults[memoizeKey] !== undefined) return memoizedResults[memoizeKey];
const abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex);
let index = lowerString.indexOf(abbreviationChar, stringIndex);
let highScore = 0;
let score, transposedScore, wordBreaks, spaceBreaks;
while (index >= 0) {
score = computeCommandScoreInner(
string,
abbreviation,
lowerString,
lowerAbbreviation,
index + 1,
abbreviationIndex + 1,
memoizedResults
);
if (score > highScore) {
if (index === stringIndex) {
score *= SCORE_CONTINUE_MATCH;
} else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) {
score *= SCORE_NON_SPACE_WORD_JUMP;
wordBreaks = string.slice(stringIndex, index - 1).match(COUNT_GAPS_REGEXP);
if (wordBreaks && stringIndex > 0) {
score *= PENALTY_SKIPPED ** wordBreaks.length;
}
} else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) {
score *= SCORE_SPACE_WORD_JUMP;
spaceBreaks = string.slice(stringIndex, index - 1).match(COUNT_SPACE_REGEXP);
if (spaceBreaks && stringIndex > 0) {
score *= PENALTY_SKIPPED ** spaceBreaks.length;
}
} else {
score *= SCORE_CHARACTER_JUMP;
if (stringIndex > 0) {
score *= PENALTY_SKIPPED ** (index - stringIndex);
}
}
if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) {
score *= PENALTY_CASE_MISMATCH;
}
}
if (
(score < SCORE_TRANSPOSITION &&
lowerString.charAt(index - 1) ===
lowerAbbreviation.charAt(abbreviationIndex + 1)) ||
(lowerAbbreviation.charAt(abbreviationIndex + 1) ===
lowerAbbreviation.charAt(abbreviationIndex) &&
lowerString.charAt(index - 1) !== lowerAbbreviation.charAt(abbreviationIndex))
) {
transposedScore = computeCommandScoreInner(
string,
abbreviation,
lowerString,
lowerAbbreviation,
index + 1,
abbreviationIndex + 2,
memoizedResults
);
if (transposedScore * SCORE_TRANSPOSITION > score) {
score = transposedScore * SCORE_TRANSPOSITION;
}
}
if (score > highScore) {
highScore = score;
}
index = lowerString.indexOf(abbreviationChar, index + 1);
}
memoizedResults[memoizeKey] = highScore;
return highScore;
}
function formatInput(string) {
return string.toLowerCase().replace(COUNT_SPACE_REGEXP, " ");
}
/**
* Given a command, a search query, and (optionally) a list of keywords for the command,
* computes a score between 0 and 1 that represents how well the search query matches the
* command and keywords. 1 is a perfect match, 0 is no match.
*/
export function computeCommandScore(
command: string,
search: string,
commandKeywords?: string[]
): number {
command =
commandKeywords && commandKeywords.length > 0
? `${`${command} ${commandKeywords?.join(" ")}`}`
: command;
return computeCommandScoreInner(
command,
search,
formatInput(command),
formatInput(search),
0,
0,
{}
);
}

@ -0,0 +1,30 @@
export { default as Root } from "./components/command.svelte";
export { default as Empty } from "./components/command-empty.svelte";
export { default as Group } from "./components/command-group.svelte";
export { default as GroupHeading } from "./components/command-group-heading.svelte";
export { default as GroupItems } from "./components/command-group-items.svelte";
export { default as Input } from "./components/command-input.svelte";
export { default as Item } from "./components/command-item.svelte";
export { default as LinkItem } from "./components/command-link-item.svelte";
export { default as List } from "./components/command-list.svelte";
export { default as Viewport } from "./components/command-viewport.svelte";
export { default as Loading } from "./components/command-loading.svelte";
export { default as Separator } from "./components/command-separator.svelte";
export { computeCommandScore } from "./compute-command-score";
export type {
CommandRootProps as RootProps,
CommandEmptyProps as EmptyProps,
CommandGroupProps as GroupProps,
CommandGroupHeadingProps as GroupHeadingProps,
CommandGroupItemsProps as GroupItemsProps,
CommandItemProps as ItemProps,
CommandLinkItemProps as LinkItemProps,
CommandInputProps as InputProps,
CommandSeparatorProps as SeparatorProps,
CommandListProps as ListProps,
CommandLoadingProps as LoadingProps,
CommandViewportProps as ViewportProps,
CommandState,
} from "./types";

@ -0,0 +1 @@
export * from "./exports";

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save

Powered by TurnKey Linux.