- 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
parent
9084d2783a
commit
a8ca6b0b4d
@ -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;
|
||||
|
||||
@ -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,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 <a>)</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,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 > 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,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,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,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>;
|
||||
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…
Reference in new issue