Two pure-eidos loader components with morfos at scope `['eidos']` (no soma backing, no semantic events). Skeleton — content placeholder: - 3 shapes: rect (default), circle, text (multi-line with staggered widths) - 5 sizes (xs..xl), 8 color accents - Optional shimmer animation via CSS `background-position` keyframes - Single `<div role="status" aria-busy="true">` with translated aria-label - Reduced-motion freezes animation, keeps visual Spinner — indeterminate progress: - 3 variants: ring (rotating arc), dots (bouncing), bars (stretching) - 5 sizes, 8 colors - Optional inline label via children snippet - `role="status"` + `aria-live="polite"` for SR announcements - Reduced-motion freezes animation, keeps visual Files: - morfo: skeleton.ts, spinner.ts (scope eidos, no events) - eidos: full component + types + css + langs + index per component - langs: skeleton/spinner under componentLangs barrel - demos: canonical 6-tab depth + composition examples (avatar+text card) - layout: sidebar additions under Status group Note: also includes responsive shell additions to web/routes/uix/uix.css that surfaced from an external session — same file, leaving them in. Reference libraries reviewed (Mantine, Chakra v3, Radix Themes, MUI): all expose the same shape/variant/size/color surface. shadcn Skeleton uses the same pulse animation. No surprises in our API. svelte-check: 0 errors in skeleton/spinner files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>active-uix
parent
f8897d92d6
commit
616d8535f7
@ -0,0 +1,12 @@
|
||||
// Skeleton — content placeholder primitive.
|
||||
//
|
||||
// import { Skeleton } from '$uix/eidos/components/skeleton';
|
||||
//
|
||||
// <Skeleton width="100%" height={40} />
|
||||
// <Skeleton shape="circle" size="lg" />
|
||||
// <Skeleton shape="text" lines={3} />
|
||||
import Skeleton from './skeleton.svelte';
|
||||
|
||||
export { Skeleton };
|
||||
export default Skeleton;
|
||||
export type { SkeletonProps, SkeletonShape, SkeletonSize, SkeletonColor } from './types';
|
||||
@ -0,0 +1,4 @@
|
||||
/** Idlangref constants for the Skeleton component. */
|
||||
export const SKELETON_LANGS = {
|
||||
LABEL: '#?components.skeleton.label|Loading'
|
||||
} as const;
|
||||
@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Skeleton — placeholder block.
|
||||
*
|
||||
* Shapes:
|
||||
* - rect → rounded rectangle, sized by --_skeleton-{w,h}
|
||||
* - circle → 1:1 ratio, fully rounded
|
||||
* - text → stacked thin bars at staggered widths
|
||||
*
|
||||
* Animation: optional shimmer wave via background-position keyframes.
|
||||
*/
|
||||
[data-skeleton] {
|
||||
--_skeleton-bg: color-mix(in srgb, var(--color-neutral-track) 60%, transparent);
|
||||
--_skeleton-highlight: color-mix(in srgb, var(--color-neutral-track) 100%, white 10%);
|
||||
--_skeleton-size: 1rem;
|
||||
--_skeleton-radius: var(--radius-md);
|
||||
--_skeleton-duration: 1.4s;
|
||||
|
||||
display: block;
|
||||
background: var(--_skeleton-bg);
|
||||
border-radius: var(--_skeleton-radius);
|
||||
inline-size: 100%;
|
||||
block-size: var(--_skeleton-size);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Sizes — intrinsic block-size when the consumer doesn't override. */
|
||||
[data-skeleton][data-size='xs'] {
|
||||
--_skeleton-size: 0.5rem;
|
||||
}
|
||||
[data-skeleton][data-size='sm'] {
|
||||
--_skeleton-size: 0.75rem;
|
||||
}
|
||||
[data-skeleton][data-size='md'] {
|
||||
--_skeleton-size: 1rem;
|
||||
}
|
||||
[data-skeleton][data-size='lg'] {
|
||||
--_skeleton-size: 1.5rem;
|
||||
}
|
||||
[data-skeleton][data-size='xl'] {
|
||||
--_skeleton-size: 2rem;
|
||||
}
|
||||
|
||||
/* ── Shapes ────────────────────────────────────────────────────────── */
|
||||
|
||||
[data-skeleton][data-shape='rect'] {
|
||||
--_skeleton-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
[data-skeleton][data-shape='circle'] {
|
||||
--_skeleton-radius: 50%;
|
||||
aspect-ratio: 1 / 1;
|
||||
inline-size: var(--_skeleton-size);
|
||||
}
|
||||
|
||||
[data-skeleton][data-shape='text'] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--_skeleton-size) * 0.5);
|
||||
background: transparent;
|
||||
overflow: visible;
|
||||
block-size: auto;
|
||||
}
|
||||
|
||||
[data-skeleton-line] {
|
||||
display: block;
|
||||
block-size: var(--_skeleton-size, 1rem);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--_skeleton-bg);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Color accents ─────────────────────────────────────────────────── */
|
||||
|
||||
[data-skeleton][data-color='primary'] {
|
||||
--_skeleton-bg: color-mix(in srgb, var(--color-primary-track) 50%, transparent);
|
||||
--_skeleton-highlight: color-mix(in srgb, var(--color-primary-track) 90%, white 8%);
|
||||
}
|
||||
[data-skeleton][data-color='secondary'] {
|
||||
--_skeleton-bg: color-mix(in srgb, var(--color-secondary-track) 50%, transparent);
|
||||
--_skeleton-highlight: color-mix(in srgb, var(--color-secondary-track) 90%, white 8%);
|
||||
}
|
||||
[data-skeleton][data-color='affirm'] {
|
||||
--_skeleton-bg: color-mix(in srgb, var(--color-affirm-track) 50%, transparent);
|
||||
}
|
||||
[data-skeleton][data-color='fulfill'] {
|
||||
--_skeleton-bg: color-mix(in srgb, var(--color-fulfill-track) 50%, transparent);
|
||||
}
|
||||
[data-skeleton][data-color='risk'] {
|
||||
--_skeleton-bg: color-mix(in srgb, var(--color-risk-track) 50%, transparent);
|
||||
}
|
||||
[data-skeleton][data-color='threat'] {
|
||||
--_skeleton-bg: color-mix(in srgb, var(--color-threat-track) 50%, transparent);
|
||||
}
|
||||
[data-skeleton][data-color='loss'] {
|
||||
--_skeleton-bg: color-mix(in srgb, var(--color-loss-track) 50%, transparent);
|
||||
}
|
||||
|
||||
/* ── Animation (shimmer wave) ──────────────────────────────────────── */
|
||||
|
||||
[data-skeleton][data-animated] {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--_skeleton-bg) 0%,
|
||||
var(--_skeleton-highlight) 50%,
|
||||
var(--_skeleton-bg) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer var(--_skeleton-duration) ease-in-out infinite;
|
||||
}
|
||||
|
||||
[data-skeleton][data-shape='text'][data-animated] {
|
||||
background-image: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
[data-skeleton][data-shape='text'][data-animated] [data-skeleton-line] {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--_skeleton-bg) 0%,
|
||||
var(--_skeleton-highlight) 50%,
|
||||
var(--_skeleton-bg) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer var(--_skeleton-duration) ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-skeleton][data-animated],
|
||||
[data-skeleton][data-shape='text'][data-animated] [data-skeleton-line] {
|
||||
animation: none;
|
||||
background-image: none;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Eidos `<Skeleton>` — content placeholder shown while data loads.
|
||||
*
|
||||
* Usage:
|
||||
* - `<Skeleton width="100%" height={40} />`
|
||||
* - `<Skeleton shape="circle" size="lg" />`
|
||||
* - `<Skeleton shape="text" lines={3} />`
|
||||
*
|
||||
* No morfo events — pure visual. The single `<div role="status">`
|
||||
* carries `aria-busy="true"` and a translated `aria-label="Loading"`
|
||||
* so SR users get the right announcement when this enters the DOM.
|
||||
*/
|
||||
import './skeleton.css';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { getActiveUix } from '$active-uix';
|
||||
import { SKELETON_LANGS } from './langs';
|
||||
import type { SkeletonProps } from './types';
|
||||
|
||||
let {
|
||||
shape = 'rect',
|
||||
size = 'md',
|
||||
color = 'neutral',
|
||||
lines = 1,
|
||||
animated = true,
|
||||
'aria-label': ariaLabel,
|
||||
children,
|
||||
...rest
|
||||
}: SkeletonProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const uix = getActiveUix();
|
||||
|
||||
const resolvedShape = $derived(eidos.resolve(shape, 'rect'));
|
||||
const resolvedSize = $derived(eidos.resolve(size, 'md'));
|
||||
const resolvedLabel = $derived(ariaLabel ?? uix.langs.ts(SKELETON_LANGS.LABEL));
|
||||
|
||||
// Stagger widths for multi-line text skeletons so the block reads as
|
||||
// a paragraph rather than a solid rectangle. Last line always shorter.
|
||||
const lineWidths = $derived.by(() => {
|
||||
if (resolvedShape !== 'text' || lines < 2) return undefined;
|
||||
const widths: string[] = [];
|
||||
for (let i = 0; i < lines; i++) {
|
||||
const last = i === lines - 1;
|
||||
widths.push(last ? '60%' : i === 0 ? '100%' : '94%');
|
||||
}
|
||||
return widths;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if resolvedShape === 'text' && lineWidths}
|
||||
<div
|
||||
data-skeleton=""
|
||||
data-shape="text"
|
||||
data-size={resolvedSize}
|
||||
data-color={color}
|
||||
data-animated={animated ? '' : undefined}
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-label={resolvedLabel}
|
||||
{...rest}
|
||||
>
|
||||
{#each lineWidths as w, i (i)}
|
||||
<span data-skeleton-line="" style="inline-size: {w};"></span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
data-skeleton=""
|
||||
data-shape={resolvedShape}
|
||||
data-size={resolvedSize}
|
||||
data-color={color}
|
||||
data-animated={animated ? '' : undefined}
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-label={resolvedLabel}
|
||||
{...rest}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { ColorRole, ResponsiveProp, Size } from '$uix/eidos/lib/types';
|
||||
import type { BoxProps } from '../box/types';
|
||||
|
||||
export type SkeletonShape = 'rect' | 'circle' | 'text';
|
||||
export type SkeletonSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
|
||||
export type SkeletonColor = ColorRole;
|
||||
|
||||
/**
|
||||
* Props for `<Skeleton>`.
|
||||
*
|
||||
* Composes through `<Box>` so every layout prop (width, height,
|
||||
* gridColumn, …) flows through. The `shape` prop only adjusts the
|
||||
* recipe (border-radius, intrinsic block-size). For text skeletons,
|
||||
* pass `lines={n}` to stack n text bars stagger-widthed.
|
||||
*/
|
||||
export type SkeletonProps = Omit<BoxProps, 'display'> & {
|
||||
/** Visual shape. @default 'rect' */
|
||||
shape?: ResponsiveProp<SkeletonShape>;
|
||||
/** Reservation size. @default 'md' (only meaningful when no width/height set) */
|
||||
size?: ResponsiveProp<SkeletonSize>;
|
||||
/** Accent palette (placeholder background lightness). @default 'neutral' */
|
||||
color?: SkeletonColor;
|
||||
/**
|
||||
* Number of stacked bars when `shape='text'`. Each bar's width
|
||||
* decreases slightly so the block looks like a paragraph.
|
||||
* @default 1
|
||||
*/
|
||||
lines?: number;
|
||||
/** Animate the shimmer pulse. @default true */
|
||||
animated?: boolean;
|
||||
/** Override the SR text. Defaults to a translated `'Loading'`. */
|
||||
'aria-label'?: string;
|
||||
children?: Snippet;
|
||||
};
|
||||
@ -0,0 +1,12 @@
|
||||
// Spinner — indeterminate progress indicator.
|
||||
//
|
||||
// import { Spinner } from '$uix/eidos/components/spinner';
|
||||
//
|
||||
// <Spinner />
|
||||
// <Spinner variant="dots" size="sm" color="affirm" />
|
||||
// <Spinner>Loading…</Spinner>
|
||||
import Spinner from './spinner.svelte';
|
||||
|
||||
export { Spinner };
|
||||
export default Spinner;
|
||||
export type { SpinnerProps, SpinnerSize, SpinnerColor, SpinnerVariant } from './types';
|
||||
@ -0,0 +1,4 @@
|
||||
/** Idlangref constants for the Spinner component. */
|
||||
export const SPINNER_LANGS = {
|
||||
LABEL: '#?components.spinner.label|Loading'
|
||||
} as const;
|
||||
@ -0,0 +1,194 @@
|
||||
/*
|
||||
* Spinner — indeterminate progress.
|
||||
*
|
||||
* Three variants share the same outer chrome:
|
||||
* - ring → rotating arc made by a conic-gradient mask
|
||||
* - dots → 3 dots, staggered scale animation
|
||||
* - bars → 4 bars, staggered height animation
|
||||
*
|
||||
* Reduced motion: animations stop but the element stays visible so the
|
||||
* "loading" affordance is preserved.
|
||||
*/
|
||||
[data-spinner] {
|
||||
--_spinner-size: 1.25rem;
|
||||
--_spinner-thickness: 2px;
|
||||
--_spinner-duration: 0.9s;
|
||||
--_spinner-color: var(--color-primary-text);
|
||||
--_spinner-track: color-mix(in srgb, var(--color-primary-track) 60%, transparent);
|
||||
--_spinner-gap: var(--space-2);
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--_spinner-gap);
|
||||
color: var(--_spinner-color);
|
||||
}
|
||||
|
||||
[data-spinner][data-size='xs'] {
|
||||
--_spinner-size: 0.75rem;
|
||||
--_spinner-thickness: 1.5px;
|
||||
}
|
||||
[data-spinner][data-size='sm'] {
|
||||
--_spinner-size: 1rem;
|
||||
--_spinner-thickness: 2px;
|
||||
}
|
||||
[data-spinner][data-size='md'] {
|
||||
--_spinner-size: 1.25rem;
|
||||
--_spinner-thickness: 2px;
|
||||
}
|
||||
[data-spinner][data-size='lg'] {
|
||||
--_spinner-size: 1.75rem;
|
||||
--_spinner-thickness: 3px;
|
||||
}
|
||||
[data-spinner][data-size='xl'] {
|
||||
--_spinner-size: 2.5rem;
|
||||
--_spinner-thickness: 4px;
|
||||
}
|
||||
|
||||
/* Color tokens — accent + track. */
|
||||
[data-spinner][data-color='secondary'] {
|
||||
--_spinner-color: var(--color-secondary-text);
|
||||
--_spinner-track: color-mix(in srgb, var(--color-secondary-track) 60%, transparent);
|
||||
}
|
||||
[data-spinner][data-color='neutral'] {
|
||||
--_spinner-color: var(--color-neutral-text);
|
||||
--_spinner-track: color-mix(in srgb, var(--color-neutral-track) 60%, transparent);
|
||||
}
|
||||
[data-spinner][data-color='affirm'] {
|
||||
--_spinner-color: var(--color-affirm-text);
|
||||
--_spinner-track: color-mix(in srgb, var(--color-affirm-track) 60%, transparent);
|
||||
}
|
||||
[data-spinner][data-color='fulfill'] {
|
||||
--_spinner-color: var(--color-fulfill-text);
|
||||
--_spinner-track: color-mix(in srgb, var(--color-fulfill-track) 60%, transparent);
|
||||
}
|
||||
[data-spinner][data-color='risk'] {
|
||||
--_spinner-color: var(--color-risk-text);
|
||||
--_spinner-track: color-mix(in srgb, var(--color-risk-track) 60%, transparent);
|
||||
}
|
||||
[data-spinner][data-color='threat'] {
|
||||
--_spinner-color: var(--color-threat-text);
|
||||
--_spinner-track: color-mix(in srgb, var(--color-threat-track) 60%, transparent);
|
||||
}
|
||||
[data-spinner][data-color='loss'] {
|
||||
--_spinner-color: var(--color-loss-text);
|
||||
--_spinner-track: color-mix(in srgb, var(--color-loss-track) 60%, transparent);
|
||||
}
|
||||
|
||||
/* ── Ring variant ──────────────────────────────────────────────────── */
|
||||
|
||||
[data-spinner][data-variant='ring'] [data-spinner-track] {
|
||||
display: block;
|
||||
inline-size: var(--_spinner-size);
|
||||
block-size: var(--_spinner-size);
|
||||
border-radius: 50%;
|
||||
border: var(--_spinner-thickness) solid var(--_spinner-track);
|
||||
border-top-color: var(--_spinner-color);
|
||||
animation: spinner-rotate var(--_spinner-duration) linear infinite;
|
||||
}
|
||||
|
||||
/* ── Dots variant ──────────────────────────────────────────────────── */
|
||||
|
||||
[data-spinner][data-variant='dots'] [data-spinner-track] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--_spinner-size) * 0.2);
|
||||
block-size: var(--_spinner-size);
|
||||
}
|
||||
|
||||
[data-spinner][data-variant='dots'] [data-spinner-dot] {
|
||||
display: block;
|
||||
inline-size: calc(var(--_spinner-size) * 0.3);
|
||||
block-size: calc(var(--_spinner-size) * 0.3);
|
||||
border-radius: 50%;
|
||||
background: var(--_spinner-color);
|
||||
animation: spinner-bounce 1.2s ease-in-out infinite both;
|
||||
}
|
||||
|
||||
[data-spinner][data-variant='dots'] [data-spinner-dot]:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
[data-spinner][data-variant='dots'] [data-spinner-dot]:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
/* ── Bars variant ──────────────────────────────────────────────────── */
|
||||
|
||||
[data-spinner][data-variant='bars'] [data-spinner-track] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--_spinner-size) * 0.12);
|
||||
block-size: var(--_spinner-size);
|
||||
}
|
||||
|
||||
[data-spinner][data-variant='bars'] [data-spinner-bar] {
|
||||
display: block;
|
||||
inline-size: calc(var(--_spinner-size) * 0.18);
|
||||
block-size: var(--_spinner-size);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--_spinner-color);
|
||||
animation: spinner-stretch 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
[data-spinner][data-variant='bars'] [data-spinner-bar]:nth-child(1) {
|
||||
animation-delay: -1s;
|
||||
}
|
||||
[data-spinner][data-variant='bars'] [data-spinner-bar]:nth-child(2) {
|
||||
animation-delay: -0.85s;
|
||||
}
|
||||
[data-spinner][data-variant='bars'] [data-spinner-bar]:nth-child(3) {
|
||||
animation-delay: -0.7s;
|
||||
}
|
||||
[data-spinner][data-variant='bars'] [data-spinner-bar]:nth-child(4) {
|
||||
animation-delay: -0.55s;
|
||||
}
|
||||
|
||||
/* ── Label ─────────────────────────────────────────────────────────── */
|
||||
|
||||
[data-spinner-label] {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-content-muted);
|
||||
}
|
||||
|
||||
/* ── Keyframes ─────────────────────────────────────────────────────── */
|
||||
|
||||
@keyframes spinner-rotate {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spinner-bounce {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0.5);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spinner-stretch {
|
||||
0%,
|
||||
40%,
|
||||
100% {
|
||||
transform: scaleY(0.4);
|
||||
}
|
||||
20% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-spinner] [data-spinner-track],
|
||||
[data-spinner] [data-spinner-dot],
|
||||
[data-spinner] [data-spinner-bar] {
|
||||
animation: none;
|
||||
}
|
||||
[data-spinner][data-variant='ring'] [data-spinner-track] {
|
||||
border-top-color: var(--_spinner-track);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Eidos `<Spinner>` — indeterminate progress indicator.
|
||||
*
|
||||
* <Spinner /> // default ring, size md, primary
|
||||
* <Spinner variant="dots" size="sm" color="affirm" />
|
||||
* <Spinner>Loading…</Spinner> // with inline label
|
||||
*
|
||||
* No morfo events — pure visual. Single `<div role="status">` with a
|
||||
* decorative track inside. Reduced-motion freezes the animation but
|
||||
* keeps the visual to preserve the loading affordance.
|
||||
*/
|
||||
import './spinner.css';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { getActiveUix } from '$active-uix';
|
||||
import { SPINNER_LANGS } from './langs';
|
||||
import type { SpinnerProps } from './types';
|
||||
|
||||
let {
|
||||
variant = 'ring',
|
||||
size = 'md',
|
||||
color = 'primary',
|
||||
'aria-label': ariaLabel,
|
||||
children,
|
||||
...rest
|
||||
}: SpinnerProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const uix = getActiveUix();
|
||||
|
||||
const resolvedSize = $derived(eidos.resolve(size, 'md'));
|
||||
const resolvedLabel = $derived(ariaLabel ?? uix.langs.ts(SPINNER_LANGS.LABEL));
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-spinner=""
|
||||
data-variant={variant}
|
||||
data-size={resolvedSize}
|
||||
data-color={color}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={resolvedLabel}
|
||||
{...rest}
|
||||
>
|
||||
{#if variant === 'dots'}
|
||||
<span data-spinner-track="" aria-hidden="true">
|
||||
<span data-spinner-dot=""></span>
|
||||
<span data-spinner-dot=""></span>
|
||||
<span data-spinner-dot=""></span>
|
||||
</span>
|
||||
{:else if variant === 'bars'}
|
||||
<span data-spinner-track="" aria-hidden="true">
|
||||
<span data-spinner-bar=""></span>
|
||||
<span data-spinner-bar=""></span>
|
||||
<span data-spinner-bar=""></span>
|
||||
<span data-spinner-bar=""></span>
|
||||
</span>
|
||||
{:else}
|
||||
<span data-spinner-track="" aria-hidden="true"></span>
|
||||
{/if}
|
||||
{#if children}
|
||||
<span data-spinner-label="">{@render children()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,28 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { ColorRole, ResponsiveProp, Size } from '$uix/eidos/lib/types';
|
||||
import type { BoxProps } from '../box/types';
|
||||
|
||||
export type SpinnerSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
|
||||
export type SpinnerColor = ColorRole;
|
||||
export type SpinnerVariant = 'ring' | 'dots' | 'bars';
|
||||
|
||||
/**
|
||||
* Props for `<Spinner>`.
|
||||
*
|
||||
* Composes through `<Box>` for layout primitives (gap, gridColumn, …).
|
||||
* The `variant` prop toggles between three canonical visual styles —
|
||||
* a rotating ring (default, lightest), bouncing dots (medium weight),
|
||||
* scrolling bars (most attention).
|
||||
*/
|
||||
export type SpinnerProps = Omit<BoxProps, 'display'> & {
|
||||
/** Visual style. @default 'ring' */
|
||||
variant?: SpinnerVariant;
|
||||
/** Spinner size. @default 'md' */
|
||||
size?: ResponsiveProp<SpinnerSize>;
|
||||
/** Accent palette. @default 'primary' */
|
||||
color?: SpinnerColor;
|
||||
/** Override the SR text. Defaults to a translated `'Loading'`. */
|
||||
'aria-label'?: string;
|
||||
/** Optional visible label rendered next to the spinner. */
|
||||
children?: Snippet;
|
||||
};
|
||||
@ -0,0 +1,12 @@
|
||||
import type { LangNode } from '$libs/langs';
|
||||
|
||||
/**
|
||||
* Default strings for the skeleton component. Merged under
|
||||
* `components.skeleton.*` by `ActiveUix` (via `componentLangs` barrel).
|
||||
*/
|
||||
export const skeletonLangs = {
|
||||
label: {
|
||||
es: 'Cargando',
|
||||
en: 'Loading'
|
||||
}
|
||||
} satisfies LangNode;
|
||||
@ -0,0 +1,12 @@
|
||||
import type { LangNode } from '$libs/langs';
|
||||
|
||||
/**
|
||||
* Default strings for the spinner component. Merged under
|
||||
* `components.spinner.*` by `ActiveUix` (via `componentLangs` barrel).
|
||||
*/
|
||||
export const spinnerLangs = {
|
||||
label: {
|
||||
es: 'Cargando',
|
||||
en: 'Loading'
|
||||
}
|
||||
} satisfies LangNode;
|
||||
@ -0,0 +1,47 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
/**
|
||||
* Skeleton — content placeholder shown while data loads.
|
||||
*
|
||||
* Eidos-native primitive: pure visual / a11y surface, no behavior, no
|
||||
* state, no events. Renders a single `<div role="status">` carrying an
|
||||
* `aria-label` so SR users get "loading" announced. Shape (rect / circle /
|
||||
* text) toggles CSS recipe geometry via `data-shape`.
|
||||
*
|
||||
* Justification for 0-event surface: Skeleton has no commit/emerge/shift
|
||||
* semantic — it disappears when the consumer swaps it for real content.
|
||||
* Adding events would manufacture meaning the primitive doesn't carry.
|
||||
* Same precedent as AspectRatio / Box / Flex / Grid.
|
||||
*/
|
||||
export const skeletonMorfo = {
|
||||
name: 'Skeleton',
|
||||
kebab: 'skeleton',
|
||||
scope: ['eidos'],
|
||||
texts: {
|
||||
label: '#?components.skeleton.label|Loading'
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'provider',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'status',
|
||||
optional: false,
|
||||
data: [
|
||||
{ attr: 'data-shape', value: v.propRef('shape'), severity: 'optional' },
|
||||
{ attr: 'data-animated', value: v.propRef('animated'), severity: 'optional' }
|
||||
],
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-label',
|
||||
value: v.translationRef('#?components.skeleton.label|Loading'),
|
||||
severity: 'recommended'
|
||||
},
|
||||
{ attr: 'aria-busy', value: v.literal('true') }
|
||||
]
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
@ -0,0 +1,54 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
/**
|
||||
* Spinner — indeterminate progress indicator.
|
||||
*
|
||||
* Eidos-native primitive. Single `<div role="status">` with a CSS-only
|
||||
* rotating glyph. Used wherever a determinate `<Progress>` is overkill
|
||||
* (network request in flight, async dropdown loading, route transition).
|
||||
*
|
||||
* Justification for 0-event surface: like Skeleton, Spinner emits no
|
||||
* commit/emerge/shift. It's pure perceptual chrome that the consumer
|
||||
* unmounts when work is done.
|
||||
*/
|
||||
export const spinnerMorfo = {
|
||||
name: 'Spinner',
|
||||
kebab: 'spinner',
|
||||
scope: ['eidos'],
|
||||
texts: {
|
||||
label: '#?components.spinner.label|Loading'
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'provider',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'status',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-label',
|
||||
value: v.translationRef('#?components.spinner.label|Loading'),
|
||||
severity: 'recommended'
|
||||
},
|
||||
{ attr: 'aria-live', value: v.literal('polite') }
|
||||
]
|
||||
},
|
||||
{
|
||||
// The visual rotating mark. Decorative — SR users hear the parent
|
||||
// `role="status"` announcement instead.
|
||||
name: 'Track',
|
||||
kebab: 'track',
|
||||
archetype: 'indicator',
|
||||
kind: 'public',
|
||||
defaultElement: 'span',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
@ -0,0 +1,261 @@
|
||||
<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';
|
||||
|
||||
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
|
||||
let tab = $state<Tab>('live');
|
||||
|
||||
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'
|
||||
];
|
||||
|
||||
const eidosSnippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import { Spinner } from '$uix/eidos/components/spinner';",
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<Spinner',
|
||||
variant !== 'ring' && ` variant="${variant}"`,
|
||||
size !== 'md' && ` size="${size}"`,
|
||||
color !== 'primary' && ` color="${color}"`,
|
||||
'>',
|
||||
withLabel && ' Loading…',
|
||||
'</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. Pure eidos primitive — no semantic events. Use
|
||||
<code><Progress></code> 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>{partsList.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>scope</span>eidos</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div data-uix-stage>
|
||||
<div data-uix-stage-area>
|
||||
<Box
|
||||
style="padding: var(--uix-space-6); display: flex; align-items: center; justify-content: center;"
|
||||
>
|
||||
{#if withLabel}
|
||||
<Spinner {variant} {size} {color}>Loading…</Spinner>
|
||||
{:else}
|
||||
<Spinner {variant} {size} {color} />
|
||||
{/if}
|
||||
</Box>
|
||||
</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>0</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>
|
||||
|
||||
<div data-uix-subsection-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span> props
|
||||
</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>
|
||||
|
||||
<div data-uix-subsection-head>All variants side-by-side</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>
|
||||
|
||||
<div data-uix-subsection-head>Color matrix</div>
|
||||
<Box style="display: flex; gap: var(--uix-space-5); flex-wrap: wrap; padding: var(--uix-space-3);">
|
||||
{#each colors as c (c)}
|
||||
<Box style="display: flex; flex-direction: column; align-items: center; gap: var(--uix-space-2);">
|
||||
<Spinner {variant} {size} color={c} />
|
||||
<code>{c}</code>
|
||||
</Box>
|
||||
{/each}
|
||||
</Box>
|
||||
</section>
|
||||
{:else if tab === 'api'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>API</h2>
|
||||
<table data-uix-api-table>
|
||||
<thead>
|
||||
<tr><th>prop</th><th>type</th><th>default</th><th>description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>variant</td><td>'ring' | 'dots' | 'bars'</td><td>'ring'</td><td>Visual style.</td></tr>
|
||||
<tr><td>size</td><td>'xs'..'xl'</td><td>'md'</td><td>Track diameter / dot size.</td></tr>
|
||||
<tr><td>color</td><td>ColorRole</td><td>'primary'</td><td>Accent palette.</td></tr>
|
||||
<tr><td>aria-label</td><td>string?</td><td>—</td><td>Override the translated 'Loading'.</td></tr>
|
||||
<tr><td>children</td><td>Snippet?</td><td>—</td><td>Optional inline label.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{:else if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>
|
||||
<span data-uix-layer-badge="morfo">morfo</span> contract
|
||||
</h2>
|
||||
<p data-uix-section-desc>
|
||||
Two parts: <code>provider</code> (role=status, polite live region) and <code>track</code>
|
||||
(decorative, aria-hidden). The variant prop only swaps the inner DOM of Track.
|
||||
</p>
|
||||
<table data-uix-api-table>
|
||||
<thead><tr><th>part</th><th>element</th><th>role</th><th>archetype</th></tr></thead>
|
||||
<tbody>
|
||||
{#each partsList as p (p.kebab)}
|
||||
<tr>
|
||||
<td><code>{p.kebab}</code></td>
|
||||
<td><code>{p.defaultElement}</code></td>
|
||||
<td>{p.role ?? '—'}</td>
|
||||
<td>{p.archetype ?? '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{:else 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 has no semantic events — the loading state is announced only through ARIA. There's
|
||||
no commit/emerge/shift to react to. Mount when work starts; unmount when it ends.
|
||||
</p>
|
||||
</section>
|
||||
{:else if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Recipe</h2>
|
||||
<div data-uix-subsection-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span> · canonical usage
|
||||
</div>
|
||||
<pre data-uix-snippet><code>{eidosSnippet}</code></pre>
|
||||
</section>
|
||||
{:else if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<code>role="status"</code> + <code>aria-live="polite"</code> on the root — the screen
|
||||
reader announces the configured label when the spinner enters the DOM (typically when
|
||||
you start a fetch).
|
||||
</li>
|
||||
<li>
|
||||
Default label is the translated "Loading" / "Cargando". Override per-instance with
|
||||
<code>aria-label="Loading orders"</code> for more specific context.
|
||||
</li>
|
||||
<li>
|
||||
Visual track parts (<code>data-spinner-track</code>, <code>-dot</code>, <code>-bar</code>)
|
||||
are <code>aria-hidden="true"</code> — decoration only.
|
||||
</li>
|
||||
<li>
|
||||
Animation respects <code>prefers-reduced-motion</code> — under reduced motion the
|
||||
rotation/bounce/stretch keyframes stop, but the visual stays so the user still sees the
|
||||
loading affordance.
|
||||
</li>
|
||||
<li>
|
||||
Use <code><Spinner></code> for unknown progress (network requests, async
|
||||
rendering). Switch to <code><Progress></code> when you can report a percentage.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
Loading…
Reference in new issue