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

544 lines
18 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<script lang="ts">
import {
Spinner,
type SpinnerSize,
type SpinnerColor,
type SpinnerVariant
} from '$uix/eidos/components/spinner';
import { Box } from '$uix/eidos/components/box';
import { Stack } from '$uix/eidos/components/stack';
import { compileMorfo } from '$uix/morfo';
import { spinnerMorfo } from '@/uix/morfo/components/spinner';
import { getActiveUix } from '$active-uix';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
const uix = getActiveUix();
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
let variant = $state<SpinnerVariant>('ring');
let size = $state<SpinnerSize>('md');
let color = $state<SpinnerColor>('primary');
let withLabel = $state(false);
const compiled = compileMorfo(spinnerMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
const variants: SpinnerVariant[] = ['ring', 'dots', 'bars'];
const sizes: SpinnerSize[] = ['xs', 'sm', 'md', 'lg', 'xl'];
const colors: SpinnerColor[] = [
'primary',
'secondary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
];
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '—',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
// ── Code snippet ──────────────────────────────────────────────────────
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Spinner } from '$uix/eidos/components/spinner';",
'</' + 'script>',
'',
withLabel ? '<Spinner' : '<Spinner',
variant !== 'ring' && ` variant="${variant}"`,
size !== 'md' && ` size="${size}"`,
color !== 'primary' && ` color="${color}"`,
withLabel ? '>' : '/>',
withLabel && ' Loading…',
withLabel && '</Spinner>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Feedback · Spinner</div>
<h1 data-uix-page-title>Spinner</h1>
<p data-uix-page-lede>
Indeterminate progress indicator. Three visual variants
(<code>ring</code>, <code>dots</code>, <code>bars</code>), five sizes, eight color accents,
and an optional inline label. Eidos-native: no semantic events, no soma split — the morfo
declares two parts (Provider + decorative Track). Use
<a href="/uix/components/progress">&lt;Progress&gt;</a> instead when you have a known
percentage.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>events</span>0
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>variants</span>{variants.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>sizes</span>{sizes.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>scope</span>eidos
</span>
</div>
</header>
<!-- Live preview always rendered (between header and tabs). -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<Box
style="padding: var(--uix-space-6); display: flex; align-items: center; justify-content: center; inline-size: 100%;"
>
{#if withLabel}
<Spinner {variant} {size} {color}>Loading…</Spinner>
{:else}
<Spinner {variant} {size} {color} />
{/if}
</Box>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
<span>{trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`}</span>
<span style="color: var(--uix-text-faint)">·</span>
<span data-uix-stage-trace-key>variant</span>
<span>{variant}</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>size</span>
{size} · <span data-uix-stage-trace-key>color</span> {color}
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
API <span data-uix-tab-count>5</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · 0e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button
>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
Spinner is eidos-native — no <span data-uix-layer-badge="soma">soma</span> split. The
<code>variant</code> prop swaps the inner Track DOM (one arc vs three dots vs four bars);
<code>size</code> drives a single <code>--_spinner-size</code> token that all three
geometries share.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>variant</span>
<span data-uix-chips role="radiogroup">
{#each variants as v (v)}
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v)}
>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each sizes as s (s)}
<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each colors as c (c)}
<button data-uix-chip data-active={color === c} onclick={() => (color = c)}>{c}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>with label</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={withLabel} />
<span data-uix-switch-label>{withLabel ? 'on' : 'off'}</span>
</span>
</label>
</div>
<!-- ── Code snippet (eidos-native — no soma layer) ──────────────── -->
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · variant swaps Track DOM</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<p data-uix-section-desc>
Spinner exposes variant / size / color and an optional inline label snippet. Inherits
the full Box prop surface (gap, gridColumn, …) so it composes inline next to text.
</p>
<div data-uix-subsection-head>Spinner-specific props</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">variant</td>
<td class="type">'ring' | 'dots' | 'bars'</td>
<td>
<code>ring</code> (default) — rotating arc, lightest. <code>dots</code> —
three bouncing dots, medium weight. <code>bars</code> — four stretching bars,
most attention.
</td>
</tr>
<tr>
<td class="name">size</td>
<td class="type">'xs' | 'sm' | 'md' | 'lg' | 'xl'</td>
<td>Track diameter / dot size / bar height. Default <code>md</code>.</td>
</tr>
<tr>
<td class="name">color</td>
<td class="type">ColorRole</td>
<td>Accent palette. Default <code>primary</code>.</td>
</tr>
<tr>
<td class="name">aria-label</td>
<td class="type">string?</td>
<td>Override the translated 'Loading' default — use to give context.</td>
</tr>
<tr>
<td class="name">children</td>
<td class="type">Snippet?</td>
<td>Optional inline label rendered next to the spinner.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Library</th><th>Closest equivalent</th><th>Difference</th></tr></thead>
<tbody>
<tr>
<td class="name">chakra-ui</td>
<td><code>&lt;Spinner&gt;</code></td>
<td>Chakra has one variant (ring). UIX adds <code>dots</code> + <code>bars</code> for higher-weight contexts.</td>
</tr>
<tr>
<td class="name">mantine</td>
<td><code>&lt;Loader&gt;</code></td>
<td>Mantine's Loader exposes three variants by the same names. UIX keeps the API but uses <code>role="status"</code> over Mantine's plain div.</td>
</tr>
<tr>
<td class="name">radix-themes</td>
<td><code>&lt;Spinner&gt;</code></td>
<td>Radix only ships the ring. UIX matches the API surface, adds variants + the inline-label snippet.</td>
</tr>
<tr>
<td class="name">react-aria</td>
<td><code>&lt;ProgressBar isIndeterminate&gt;</code></td>
<td>React Aria reuses ProgressBar in indeterminate mode. UIX separates concerns — <code>&lt;Progress&gt;</code> for determinate, <code>&lt;Spinner&gt;</code> for indeterminate.</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td>{spinnerMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{spinnerMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{spinnerMorfo.scope.join(', ')}</td></tr>
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
<tr><td class="name">events</td><td>0</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr>
<th>kebab</th>
<th>marker</th>
<th>element</th>
<th>archetype</th>
<th>role</th>
<th>optional</th>
</tr>
</thead>
<tbody>
{#each partsList as part (part.kebab)}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.archetype ?? '—'}</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div data-uix-subsection-head>Provider · aria</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>attr</th><th>value</th><th>severity</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>role</code></td>
<td>status</td>
<td class="type">part default</td>
</tr>
<tr>
<td class="name"><code>aria-label</code></td>
<td>translationRef('components.spinner.label')</td>
<td class="type">recommended</td>
</tr>
<tr>
<td class="name"><code>aria-live</code></td>
<td>"polite"</td>
<td class="type">literal</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Track · aria</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>attr</th><th>value</th><th>severity</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>aria-hidden</code></td>
<td>"true"</td>
<td class="type">literal</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events
</h2>
<p data-uix-section-desc>
Spinner declares <strong>no semantic events</strong>. The loading state announces only
through ARIA — there is no commit/emerge/shift to react to. Mount the spinner when work
starts; unmount when it ends.
</p>
<p data-uix-section-desc>
If you need a perceptual cue when the work starts or finishes, attach it to the parent
action (the form submission that triggered the fetch, the route navigation that opened
the page) — not to Spinner.
</p>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Recipe lives in <code>src/uix/eidos/components/spinner/spinner.css</code>. All three
variants share private tokens (<code>--_spinner-size</code>,
<code>--_spinner-color</code>, <code>--_spinner-track</code>); each variant adds its own
keyframes block.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>[data-spinner]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Provider marker. Inline-flex container with label gap.</td>
</tr>
<tr>
<td class="name"><code>[data-spinner][data-size='xs'..'xl']</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Sets <code>--_spinner-size</code> + <code>--_spinner-thickness</code>.</td>
</tr>
<tr>
<td class="name"><code>[data-spinner][data-color='primary'..'loss']</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Maps to color-mix accent + track tokens.</td>
</tr>
<tr>
<td class="name"><code>[data-spinner-track]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Track part marker (aria-hidden decoration).</td>
</tr>
<tr>
<td class="name"><code>[data-spinner][data-variant='ring'] [data-spinner-track]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Rotating arc via border-top-color contrast + <code>spinner-rotate</code> keyframes.</td>
</tr>
<tr>
<td class="name"><code>[data-spinner][data-variant='dots'] [data-spinner-dot]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Three dots with staggered <code>spinner-bounce</code> delays.</td>
</tr>
<tr>
<td class="name"><code>[data-spinner][data-variant='bars'] [data-spinner-bar]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Four bars with staggered <code>spinner-stretch</code> delays.</td>
</tr>
<tr>
<td class="name"><code>@media (prefers-reduced-motion: reduce)</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Freezes all keyframes; ring loses the top-color contrast for low-distraction.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>All variants × sizes</div>
<Stack gap="var(--uix-space-4)">
{#each variants as v (v)}
<Box
style="display: flex; gap: var(--uix-space-6); align-items: center; padding: var(--uix-space-3); border: 1px solid var(--uix-border); border-radius: var(--uix-radius-md);"
>
<code style="min-inline-size: 4rem;">{v}</code>
{#each sizes as s (s)}
<Spinner variant={v} size={s} {color} />
{/each}
</Box>
{/each}
</Stack>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr>
<td class="name">Role</td>
<td>
<code>role="status"</code> on the Provider — a polite live region. Screen
readers announce the label when the spinner enters the DOM.
</td>
</tr>
<tr>
<td class="name">Live region</td>
<td>
<code>aria-live="polite"</code> — announcements wait for the user to finish
their current speech utterance before being read.
</td>
</tr>
<tr>
<td class="name">Label</td>
<td>
Defaults to the translated 'Loading' / 'Cargando'. Override with
<code>aria-label="Loading orders"</code> for context.
</td>
</tr>
<tr>
<td class="name">Track decoration</td>
<td>
<code>data-spinner-track</code>, <code>-dot</code>, <code>-bar</code> all carry
<code>aria-hidden="true"</code>. Only the Provider's label is announced.
</td>
</tr>
<tr>
<td class="name">Keyboard</td>
<td>Spinner is not focusable. Tab order skips it entirely.</td>
</tr>
<tr>
<td class="name">Focus visible</td>
<td>Spinner does not paint a focus ring — no interactive state.</td>
</tr>
<tr>
<td class="name">Reduced motion</td>
<td>
All three keyframes stop under
<code>prefers-reduced-motion: reduce</code>. The visual stays so the user still
sees the loading affordance.
</td>
</tr>
<tr>
<td class="name">When to use</td>
<td>
Spinner for unknown progress (network requests, async rendering). Switch to
<a href="/uix/components/progress">&lt;Progress&gt;</a> when you can report a
percentage.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

Powered by TurnKey Linux.