@spaethtech/svelte-ui 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -152,8 +152,10 @@ Mount `<Toaster />` once at the app root, then push from anywhere with `toast.su
152
152
  All from `@spaethtech/svelte-ui` (see the shipped `docs/components.md` + `docs/usage.md` for props +
153
153
  examples):
154
154
 
155
- - **Form:** `Button` `ButtonDropdown` `Input` `Select` `List` `TextArea` `Checkbox` `Toggle` `Radio`
156
- `Rating` · **`FieldGroup`** (fieldset wrapper for radio/checkbox/toggle sets) · **`ButtonGroup`**
155
+ - **Form:** `Button` `ButtonDropdown` `Input` `Select` `Slider` `List` `TextArea` `Checkbox` `Toggle`
156
+ `Radio` `Rating` · **`Slider`** (draggable number/range; `range` dual-thumb, `min`/`max`/`step`,
157
+ `showValue`/`format`; FieldChrome + `variant`/`size`; keyboard + `role="slider"`) · **`FieldGroup`**
158
+ (fieldset wrapper for radio/checkbox/toggle sets) · **`ButtonGroup`**
157
159
  (joined row/`col` of `<Button>`s as one unit — an action toolbar or a **controlled** segmented
158
160
  selector; `items` of `ButtonGroupItem` {`value`,`text`,`icon`,`variant`,`disabled`,`menu`,`onclick`}
159
161
  or a presentational default slot; `select` `'none'|'single'|'multi'` with `bind:value`/`bind:values`;
@@ -169,7 +171,9 @@ examples):
169
171
  - **Overlays:** `Dialog` (pure modal shell — BYO content; `width`/`height` take any CSS value;
170
172
  `onShow(ctx)`/`onHide(ctx)` control focus — set `ctx.autoFocus` to a selector/element/`false`,
171
173
  `ctx.restoreFocus` likewise) · `ConfirmDialog` (title + message + Confirm/Cancel, built on Dialog)
172
- · `Popup` `Menu` · `tooltip` (a Svelte `use:` action) · `anchored` (positioning engine)
174
+ · **`Drawer`** (edge-anchored sliding panel / "sheet"; `side` left/right/top/bottom, `size` CSS
175
+ length, `bind:open`, `dismissible`/`backdrop`; same modal mechanics as Dialog — a pure shell, put a
176
+ Card inside) · `Popup` `Menu` · `tooltip` (a Svelte `use:` action) · `anchored` (positioning engine)
173
177
  - **Navigation:** `TabStrip` (tabs; `size`/`variant`, `gap`/`pad` bordered spacer cells, `height` for a
174
178
  fixed-height bar with bottom-aligned tabs, `activeSurface` to flow the active tab into the content
175
179
  below) · **`SideBarMenu`** (data-driven vertical nav: `items`/`bottomItems` of `SideBarMenuItem`
@@ -178,7 +182,12 @@ examples):
178
182
  `interactMode` `'hover'|'click'` popouts; `currentPath` active-match (per-item `activeMatch` for
179
183
  query-keyed/exact rules); `bind:open`/`bind:api`/`bind:activeMode`; `publishWidthVar` → CSS width var;
180
184
  popouts expose `data-sbm-popout`/`data-open`)
181
- - **Feedback:** `Alert` `Banner` `Badge` `Toaster` + `toast`
185
+ - **Feedback:** `Alert` `Banner` `Badge` `Toaster` + `toast` · **`Spinner`** (indeterminate loading
186
+ ring; `variant`/`size`, omit `variant` to inherit `currentColor`. `Button` has a `loading` prop that
187
+ renders one in place of its icon and blocks interaction.) · **`Progress`** (horizontal bar;
188
+ determinate `value`/`max` or `indeterminate` sweep; `variant`/`size` = 4/8/12px; `label`/`showValue`.)
189
+ · **`Skeleton`** (neutral loading placeholder; `shape` text/rect/circle, `lines`, `width`/`height`,
190
+ `animate` shimmer. No `variant` — it's absent-content, not a themed surface. Compose to sketch a card.)
182
191
  - **Layout:** `Card` `CardHeader` `CardBody` `CardFooter` · **`Grid`** (auto-placed equal-cell grid;
183
192
  `columns` + `gap`)
184
193
  - **Disclosure:** **`Disclosure`** (one expand/collapse section; `title`/`header`, `bind:open`, panel
@@ -5,6 +5,7 @@
5
5
  import { responsiveClasses, type Responsive } from "../types/responsive.js";
6
6
  import type { HTMLButtonAttributes, HTMLAnchorAttributes } from "svelte/elements";
7
7
  import { tooltip } from "../positioning/tooltip.js";
8
+ import Spinner from "./Spinner/Spinner.svelte";
8
9
  import { DEV } from "esm-env";
9
10
 
10
11
  // Only lib-specific props live here; standard element attributes (class, title, aria-*, onclick,
@@ -15,6 +16,8 @@
15
16
  text?: string;
16
17
  variant?: Responsive<Variant>;
17
18
  size?: Responsive<Size>;
19
+ /** Show a `Spinner` (in place of the icon) and block interaction — for async actions. */
20
+ loading?: boolean;
18
21
  /** Border colour matches the fill (invisible ring, box unchanged) — the default. Set
19
22
  * `borderless={false}` for the standard contrasting tint-border; on the `ghost` variant that's
20
23
  * the "outline button" (transparent fill + a 30% text-tint edge), no separate variant needed. */
@@ -44,6 +47,7 @@
44
47
  text,
45
48
  variant = "secondary",
46
49
  size = "md",
50
+ loading = false,
47
51
  borderless = true,
48
52
  children,
49
53
  disabled = false,
@@ -65,7 +69,7 @@
65
69
  "Button: Props (text/icon) and children provided. Using props, ignoring children.",
66
70
  );
67
71
  }
68
- if (!text && !icon && !children) {
72
+ if (!text && !icon && !children && !loading) {
69
73
  console.warn("Button: No content provided (text, icon, or children). Button will be empty.");
70
74
  }
71
75
  }
@@ -179,40 +183,53 @@
179
183
  <span class="inline-flex {iconSizeClass}">{@render icon?.()}</span>
180
184
  {/snippet}
181
185
 
186
+ <!-- Shared content — `loading` swaps a Spinner in for the icon (Spinner inherits the button's text
187
+ colour via currentColor); the label stays visible. Squared/icon-only shows just the spinner. -->
188
+ {#snippet content()}
189
+ <div class="{text || children ? '-translate-y-px' : ''} flex items-center gap-2">
190
+ {#if loading}
191
+ <Spinner {size} />
192
+ {/if}
193
+ {#if hasProps}
194
+ {#if isIconOnly}
195
+ {#if !loading}{@render iconSlot()}{/if}
196
+ {:else}
197
+ {#if icon && !loading}
198
+ {@render iconSlot()}
199
+ {/if}
200
+ {#if text}
201
+ <span>{text}</span>
202
+ {/if}
203
+ {/if}
204
+ {:else if children}
205
+ {@render children()}
206
+ {/if}
207
+ </div>
208
+ {/snippet}
209
+
182
210
  {#if href}
183
211
  <a
184
212
  bind:this={element}
185
213
  {href}
186
214
  use:tooltip={{ text: title ?? undefined }}
187
- {onclick}
215
+ onclick={loading ? undefined : onclick}
188
216
  {onmouseenter}
189
217
  {onmouseleave}
190
- class={buttonClasses}
218
+ aria-busy={loading || undefined}
219
+ aria-disabled={loading || undefined}
220
+ tabindex={loading ? -1 : undefined}
221
+ class="{buttonClasses} {loading ? 'opacity-50 pointer-events-none' : ''}"
191
222
  {...restProps as HTMLAnchorAttributes}
192
223
  >
193
- <div class="{text || children ? '-translate-y-px' : ''} flex items-center gap-2">
194
- {#if hasProps}
195
- {#if isIconOnly}
196
- {@render iconSlot()}
197
- {:else}
198
- {#if icon}
199
- {@render iconSlot()}
200
- {/if}
201
- {#if text}
202
- <span>{text}</span>
203
- {/if}
204
- {/if}
205
- {:else if children}
206
- {@render children()}
207
- {/if}
208
- </div>
224
+ {@render content()}
209
225
  </a>
210
226
  {:else}
211
227
  <button
212
228
  bind:this={element}
213
229
  {type}
214
- {disabled}
215
- aria-disabled={disabled}
230
+ disabled={disabled || loading}
231
+ aria-disabled={disabled || loading}
232
+ aria-busy={loading || undefined}
216
233
  use:tooltip={{ text: title ?? undefined }}
217
234
  {onclick}
218
235
  {onmouseenter}
@@ -220,21 +237,6 @@
220
237
  class={buttonClasses}
221
238
  {...restProps as HTMLButtonAttributes}
222
239
  >
223
- <div class="{text || children ? '-translate-y-px' : ''} flex items-center gap-2">
224
- {#if hasProps}
225
- {#if isIconOnly}
226
- {@render iconSlot()}
227
- {:else}
228
- {#if icon}
229
- {@render iconSlot()}
230
- {/if}
231
- {#if text}
232
- <span>{text}</span>
233
- {/if}
234
- {/if}
235
- {:else if children}
236
- {@render children()}
237
- {/if}
238
- </div>
240
+ {@render content()}
239
241
  </button>
240
242
  {/if}
@@ -9,6 +9,8 @@ interface BaseProps {
9
9
  text?: string;
10
10
  variant?: Responsive<Variant>;
11
11
  size?: Responsive<Size>;
12
+ /** Show a `Spinner` (in place of the icon) and block interaction — for async actions. */
13
+ loading?: boolean;
12
14
  /** Border colour matches the fill (invisible ring, box unchanged) — the default. Set
13
15
  * `borderless={false}` for the standard contrasting tint-border; on the `ghost` variant that's
14
16
  * the "outline button" (transparent fill + a 30% text-tint edge), no separate variant needed. */
@@ -0,0 +1,184 @@
1
+ <!--
2
+ /**
3
+ * Drawer (Sheet) — an edge-anchored overlay panel that slides in from a screen edge. Shares Dialog's
4
+ * modal mechanics (portal to <body>, scroll-lock, dismissable backdrop, focus trap, role="dialog" +
5
+ * aria-modal) but pins to `side` and slides. A pure shell — put content (a Card) inside via children.
6
+ * See Drawer.spec.md.
7
+ */
8
+ -->
9
+ <script lang="ts">
10
+ import type { Snippet } from "svelte";
11
+ import { cubicOut } from "svelte/easing";
12
+ import type { TransitionConfig } from "svelte/transition";
13
+ import { fade } from "svelte/transition";
14
+
15
+ interface DrawerShowContext {
16
+ panel: HTMLElement;
17
+ autoFocus: string | HTMLElement | false;
18
+ }
19
+ interface DrawerHideContext {
20
+ restoreFocus: HTMLElement | null | false;
21
+ }
22
+
23
+ let {
24
+ open = $bindable(false),
25
+ side = "right",
26
+ size = "20rem",
27
+ dismissible = true,
28
+ backdrop = true,
29
+ onClose,
30
+ onShow,
31
+ onHide,
32
+ ariaLabel,
33
+ class: cls = "",
34
+ children,
35
+ }: {
36
+ open?: boolean;
37
+ side?: "left" | "right" | "top" | "bottom";
38
+ size?: string;
39
+ dismissible?: boolean;
40
+ backdrop?: boolean;
41
+ onClose?: () => void;
42
+ onShow?: (ctx: DrawerShowContext) => void;
43
+ onHide?: (ctx: DrawerHideContext) => void;
44
+ ariaLabel?: string;
45
+ class?: string;
46
+ children?: Snippet;
47
+ } = $props();
48
+
49
+ const FOCUSABLE =
50
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
51
+
52
+ let panelEl: HTMLElement | undefined = $state();
53
+ let previouslyFocused: HTMLElement | null = null;
54
+
55
+ function portal(node: HTMLElement) {
56
+ document.body.appendChild(node);
57
+ return { destroy: () => node.remove() };
58
+ }
59
+
60
+ // Slide from the anchored edge. Percentage transform → fully clears the edge at any `size`.
61
+ function slide(_node: Element): TransitionConfig {
62
+ const axis = side === "left" || side === "right" ? "X" : "Y";
63
+ const sign = side === "left" || side === "top" ? -1 : 1;
64
+ return {
65
+ duration: 220,
66
+ easing: cubicOut,
67
+ css: (t) => `transform: translate${axis}(${(1 - t) * 100 * sign}%)`,
68
+ };
69
+ }
70
+
71
+ // Lock background scroll while open; compensate for the scrollbar width.
72
+ $effect(() => {
73
+ if (!open) return;
74
+ const sbw = window.innerWidth - document.documentElement.clientWidth;
75
+ const prevOverflow = document.body.style.overflow;
76
+ const prevPad = document.body.style.paddingRight;
77
+ document.body.style.overflow = "hidden";
78
+ if (sbw > 0) document.body.style.paddingRight = `${sbw}px`;
79
+ return () => {
80
+ document.body.style.overflow = prevOverflow;
81
+ document.body.style.paddingRight = prevPad;
82
+ };
83
+ });
84
+
85
+ // Focus management: remember → focus first focusable (or onShow target) → restore on close.
86
+ $effect(() => {
87
+ if (!open) return;
88
+ previouslyFocused = document.activeElement as HTMLElement | null;
89
+ queueMicrotask(() => {
90
+ if (!panelEl) return;
91
+ const ctx: DrawerShowContext = { panel: panelEl, autoFocus: FOCUSABLE };
92
+ onShow?.(ctx);
93
+ let target: HTMLElement | null;
94
+ if (ctx.autoFocus === false) target = panelEl;
95
+ else if (ctx.autoFocus instanceof HTMLElement) target = ctx.autoFocus;
96
+ else target = panelEl.querySelector<HTMLElement>(ctx.autoFocus) ?? panelEl;
97
+ target?.focus();
98
+ });
99
+ return () => {
100
+ const ctx: DrawerHideContext = { restoreFocus: previouslyFocused };
101
+ onHide?.(ctx);
102
+ if (ctx.restoreFocus) ctx.restoreFocus.focus?.();
103
+ };
104
+ });
105
+
106
+ const isX = $derived(side === "left" || side === "right");
107
+ const sideClass = $derived(
108
+ {
109
+ left: "inset-y-0 left-0 h-full border-r",
110
+ right: "inset-y-0 right-0 h-full border-l",
111
+ top: "inset-x-0 top-0 w-full border-b",
112
+ bottom: "inset-x-0 bottom-0 w-full border-t",
113
+ }[side],
114
+ );
115
+ const panelStyle = $derived(
116
+ isX
117
+ ? `width:${size};max-width:calc(100vw - 1rem)`
118
+ : `height:${size};max-height:calc(100vh - 1rem)`,
119
+ );
120
+
121
+ function close() {
122
+ if (!dismissible) return;
123
+ onClose?.();
124
+ open = false;
125
+ }
126
+ function onBackdrop(event: MouseEvent) {
127
+ if (event.target === event.currentTarget) close();
128
+ }
129
+ function onKeydown(event: KeyboardEvent) {
130
+ if (!open) return;
131
+ if (event.key === "Escape") {
132
+ close();
133
+ return;
134
+ }
135
+ if (event.key === "Tab" && panelEl) {
136
+ const items = [...panelEl.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
137
+ (el) => el.offsetParent !== null,
138
+ );
139
+ if (items.length === 0) {
140
+ event.preventDefault();
141
+ panelEl.focus();
142
+ return;
143
+ }
144
+ const first = items[0];
145
+ const last = items[items.length - 1];
146
+ const active = document.activeElement;
147
+ if (event.shiftKey && (active === first || active === panelEl)) {
148
+ event.preventDefault();
149
+ last.focus();
150
+ } else if (!event.shiftKey && active === last) {
151
+ event.preventDefault();
152
+ first.focus();
153
+ }
154
+ }
155
+ }
156
+ </script>
157
+
158
+ <svelte:window on:keydown={onKeydown} />
159
+
160
+ {#if open}
161
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
162
+ <div use:portal class="fixed inset-0 z-[9999]" role="presentation">
163
+ {#if backdrop}
164
+ <div
165
+ class="absolute inset-0 bg-black/50"
166
+ onclick={onBackdrop}
167
+ role="presentation"
168
+ transition:fade={{ duration: 200 }}
169
+ ></div>
170
+ {/if}
171
+ <div
172
+ bind:this={panelEl}
173
+ class="fixed {sideClass} overflow-auto z-[10000] shadow-xl [background-color:var(--ui-color-background)] [border-color:var(--ui-border-color)] {cls}"
174
+ style={panelStyle}
175
+ role="dialog"
176
+ aria-modal="true"
177
+ aria-label={ariaLabel}
178
+ tabindex="-1"
179
+ transition:slide
180
+ >
181
+ {@render children?.()}
182
+ </div>
183
+ </div>
184
+ {/if}
@@ -0,0 +1,24 @@
1
+ import type { Snippet } from "svelte";
2
+ interface DrawerShowContext {
3
+ panel: HTMLElement;
4
+ autoFocus: string | HTMLElement | false;
5
+ }
6
+ interface DrawerHideContext {
7
+ restoreFocus: HTMLElement | null | false;
8
+ }
9
+ type $$ComponentProps = {
10
+ open?: boolean;
11
+ side?: "left" | "right" | "top" | "bottom";
12
+ size?: string;
13
+ dismissible?: boolean;
14
+ backdrop?: boolean;
15
+ onClose?: () => void;
16
+ onShow?: (ctx: DrawerShowContext) => void;
17
+ onHide?: (ctx: DrawerHideContext) => void;
18
+ ariaLabel?: string;
19
+ class?: string;
20
+ children?: Snippet;
21
+ };
22
+ declare const Drawer: import("svelte").Component<$$ComponentProps, {}, "open">;
23
+ type Drawer = ReturnType<typeof Drawer>;
24
+ export default Drawer;
@@ -0,0 +1 @@
1
+ export { default as Drawer } from "./Drawer.svelte";
@@ -0,0 +1 @@
1
+ export { default as Drawer } from "./Drawer.svelte";
@@ -0,0 +1,97 @@
1
+ <!--
2
+ /**
3
+ * Progress — a horizontal progress bar; the determinate counterpart to Spinner. Determinate when a
4
+ * `value` is given (fill width = value/max), indeterminate (a sweeping segment) otherwise. Honours the
5
+ * shared `variant`/`size` axes and is drawn from `--ui-*` tokens. See `Progress.spec.md`.
6
+ */
7
+ -->
8
+ <script lang="ts">
9
+ import type { Variant } from "../../types/variants.js";
10
+ import { variantToken } from "../../types/variants.js";
11
+ import type { Size } from "../../types/sizes.js";
12
+ import { responsiveClasses, type Responsive } from "../../types/responsive.js";
13
+
14
+ let {
15
+ value,
16
+ max = 100,
17
+ indeterminate = value == null,
18
+ variant = "primary",
19
+ size = "md",
20
+ label,
21
+ showValue = false,
22
+ class: cls = "",
23
+ }: {
24
+ value?: number;
25
+ max?: number;
26
+ indeterminate?: boolean;
27
+ variant?: Variant;
28
+ size?: Responsive<Size>;
29
+ label?: string;
30
+ showValue?: boolean;
31
+ class?: string;
32
+ } = $props();
33
+
34
+ const token = $derived(variantToken[variant]);
35
+ const pct = $derived(
36
+ value == null || max <= 0 ? 0 : Math.max(0, Math.min(100, (value / max) * 100)),
37
+ );
38
+
39
+ // Bar thickness on the shared size axis (4 / 8 / 12px).
40
+ const heightClass: Record<Size, string> = { sm: "h-1", md: "h-2", lg: "h-3" };
41
+ const textClass: Record<Size, string> = { sm: "text-xs", md: "text-sm", lg: "text-sm" };
42
+ </script>
43
+
44
+ <div class="w-full {cls}">
45
+ {#if label || (showValue && !indeterminate)}
46
+ <div
47
+ class="mb-1 flex items-center justify-between {responsiveClasses(
48
+ size,
49
+ textClass,
50
+ )} [color:color-mix(in_srgb,var(--ui-color-text)_70%,transparent)]"
51
+ >
52
+ {#if label}<span>{label}</span>{:else}<span></span>{/if}
53
+ {#if showValue && !indeterminate}<span class="tabular-nums">{Math.round(pct)}%</span>{/if}
54
+ </div>
55
+ {/if}
56
+
57
+ <div
58
+ role="progressbar"
59
+ aria-label={label}
60
+ aria-valuemin={indeterminate ? undefined : 0}
61
+ aria-valuemax={indeterminate ? undefined : max}
62
+ aria-valuenow={indeterminate ? undefined : value}
63
+ class="relative w-full overflow-hidden rounded-full {responsiveClasses(size, heightClass)}"
64
+ style="background-color: color-mix(in srgb, var({token}) 16%, transparent);"
65
+ >
66
+ {#if indeterminate}
67
+ <div
68
+ class="ui-progress-indet absolute top-0 bottom-0 w-2/5 rounded-full"
69
+ style="background-color: var({token});"
70
+ ></div>
71
+ {:else}
72
+ <div
73
+ class="h-full rounded-full transition-[width] duration-300 ease-out"
74
+ style="width: {pct}%; background-color: var({token});"
75
+ ></div>
76
+ {/if}
77
+ </div>
78
+ </div>
79
+
80
+ <style>
81
+ @keyframes ui-progress-indet {
82
+ from {
83
+ left: -40%;
84
+ }
85
+ to {
86
+ left: 100%;
87
+ }
88
+ }
89
+ .ui-progress-indet {
90
+ animation: ui-progress-indet 1.4s ease-in-out infinite;
91
+ }
92
+ @media (prefers-reduced-motion: reduce) {
93
+ .ui-progress-indet {
94
+ animation-duration: 3s;
95
+ }
96
+ }
97
+ </style>
@@ -0,0 +1,16 @@
1
+ import type { Variant } from "../../types/variants.js";
2
+ import type { Size } from "../../types/sizes.js";
3
+ import { type Responsive } from "../../types/responsive.js";
4
+ type $$ComponentProps = {
5
+ value?: number;
6
+ max?: number;
7
+ indeterminate?: boolean;
8
+ variant?: Variant;
9
+ size?: Responsive<Size>;
10
+ label?: string;
11
+ showValue?: boolean;
12
+ class?: string;
13
+ };
14
+ declare const Progress: import("svelte").Component<$$ComponentProps, {}, "">;
15
+ type Progress = ReturnType<typeof Progress>;
16
+ export default Progress;
@@ -0,0 +1 @@
1
+ export { default as Progress } from "./Progress.svelte";
@@ -0,0 +1 @@
1
+ export { default as Progress } from "./Progress.svelte";
@@ -0,0 +1,87 @@
1
+ <!--
2
+ /**
3
+ * Skeleton — a colour-neutral content placeholder shown while real content loads (text lines, an
4
+ * image rectangle, an avatar circle). Decorative (`aria-hidden`); the loading state is announced by
5
+ * the surrounding region. CSS-only shimmer. See `Skeleton.spec.md`.
6
+ */
7
+ -->
8
+ <script lang="ts">
9
+ let {
10
+ shape = "text",
11
+ width,
12
+ height,
13
+ lines = 1,
14
+ radius,
15
+ animate = true,
16
+ class: cls = "",
17
+ }: {
18
+ shape?: "text" | "rect" | "circle";
19
+ width?: string | number;
20
+ height?: string | number;
21
+ lines?: number;
22
+ radius?: string;
23
+ animate?: boolean;
24
+ class?: string;
25
+ } = $props();
26
+
27
+ const css = (v: string | number | undefined): string | undefined =>
28
+ typeof v === "number" ? `${v}px` : v;
29
+
30
+ const h = $derived(
31
+ css(height) ?? (shape === "text" ? "0.75em" : shape === "circle" ? "2.5rem" : "1rem"),
32
+ );
33
+ const w = $derived(css(width) ?? (shape === "circle" ? h : "100%"));
34
+ const rad = $derived(
35
+ radius ??
36
+ (shape === "circle" ? "9999px" : shape === "text" ? "0.25rem" : "var(--ui-border-radius)"),
37
+ );
38
+ const blockClass = $derived(`block ${animate ? "ui-skeleton" : "ui-skeleton-base"}`);
39
+ </script>
40
+
41
+ {#if shape === "text" && lines > 1}
42
+ <div class="flex flex-col gap-2 {cls}" aria-hidden="true">
43
+ {#each Array(lines) as _, i (i)}
44
+ <span
45
+ class={blockClass}
46
+ style="width: {i === lines - 1 ? '70%' : (css(width) ?? '100%')}; height: {h}; border-radius: {rad};"
47
+ ></span>
48
+ {/each}
49
+ </div>
50
+ {:else}
51
+ <span
52
+ class="{blockClass} {cls}"
53
+ style="width: {w}; height: {h}; border-radius: {rad};"
54
+ aria-hidden="true"
55
+ ></span>
56
+ {/if}
57
+
58
+ <style>
59
+ .ui-skeleton,
60
+ .ui-skeleton-base {
61
+ background-color: color-mix(in srgb, var(--ui-color-text) 10%, transparent);
62
+ }
63
+ .ui-skeleton {
64
+ background-image: linear-gradient(
65
+ 90deg,
66
+ transparent 0%,
67
+ color-mix(in srgb, var(--ui-color-text) 9%, transparent) 50%,
68
+ transparent 100%
69
+ );
70
+ background-size: 200% 100%;
71
+ background-repeat: no-repeat;
72
+ animation: ui-skeleton-shimmer 1.4s ease-in-out infinite;
73
+ }
74
+ @keyframes ui-skeleton-shimmer {
75
+ from {
76
+ background-position: -150% 0;
77
+ }
78
+ to {
79
+ background-position: 150% 0;
80
+ }
81
+ }
82
+ @media (prefers-reduced-motion: reduce) {
83
+ .ui-skeleton {
84
+ animation: none;
85
+ }
86
+ }
87
+ </style>
@@ -0,0 +1,12 @@
1
+ type $$ComponentProps = {
2
+ shape?: "text" | "rect" | "circle";
3
+ width?: string | number;
4
+ height?: string | number;
5
+ lines?: number;
6
+ radius?: string;
7
+ animate?: boolean;
8
+ class?: string;
9
+ };
10
+ declare const Skeleton: import("svelte").Component<$$ComponentProps, {}, "">;
11
+ type Skeleton = ReturnType<typeof Skeleton>;
12
+ export default Skeleton;
@@ -0,0 +1 @@
1
+ export { default as Skeleton } from "./Skeleton.svelte";
@@ -0,0 +1 @@
1
+ export { default as Skeleton } from "./Skeleton.svelte";
@@ -0,0 +1,219 @@
1
+ <!--
2
+ /**
3
+ * Slider — a draggable range input (single value or a [min,max] pair). A form field: wears
4
+ * FieldChrome and the shared variant/size axes, drawn from --ui-* tokens. Custom track+thumb (not a
5
+ * native range input) so it themes consistently and supports a dual-thumb range. See Slider.spec.md.
6
+ */
7
+ -->
8
+ <script lang="ts">
9
+ import FieldChrome, { nextFieldId } from "../FieldChrome.svelte";
10
+ import type { Snippet } from "svelte";
11
+ import type { Variant } from "../../types/variants.js";
12
+ import { variantToken } from "../../types/variants.js";
13
+ import type { Size } from "../../types/sizes.js";
14
+ import { responsiveClasses, type Responsive } from "../../types/responsive.js";
15
+
16
+ let {
17
+ value = $bindable(0),
18
+ min = 0,
19
+ max = 100,
20
+ step = 1,
21
+ range = false,
22
+ variant = "primary",
23
+ size = "md",
24
+ disabled = false,
25
+ showValue = false,
26
+ format = (n: number) => String(n),
27
+ label = null,
28
+ aside,
29
+ error = null,
30
+ description = null,
31
+ required = false,
32
+ id,
33
+ class: cls = "",
34
+ }: {
35
+ value?: number | [number, number];
36
+ min?: number;
37
+ max?: number;
38
+ step?: number;
39
+ range?: boolean;
40
+ variant?: Variant;
41
+ size?: Responsive<Size>;
42
+ disabled?: boolean;
43
+ showValue?: boolean;
44
+ format?: (n: number) => string;
45
+ label?: string | null;
46
+ aside?: Snippet;
47
+ error?: string | Snippet | null;
48
+ description?: string | Snippet | null;
49
+ required?: boolean;
50
+ id?: string;
51
+ class?: string;
52
+ } = $props();
53
+
54
+ const controlId = id ?? nextFieldId();
55
+ const isRange = $derived(range || Array.isArray(value));
56
+ // Normalised thumbs. In range mode a non-tuple value defaults to [min, max].
57
+ const thumbs = $derived<number[]>(
58
+ isRange
59
+ ? Array.isArray(value)
60
+ ? value
61
+ : [min, max]
62
+ : [typeof value === "number" ? value : min],
63
+ );
64
+
65
+ const token = $derived(variantToken[variant]);
66
+
67
+ const clampSnap = (n: number): number => {
68
+ const s = Math.round((n - min) / step) * step + min;
69
+ return Math.max(min, Math.min(max, s));
70
+ };
71
+ const pctOf = (n: number): number => (max <= min ? 0 : ((n - min) / (max - min)) * 100);
72
+
73
+ function setThumb(i: number, raw: number) {
74
+ let v = clampSnap(raw);
75
+ if (isRange) {
76
+ const arr = [thumbs[0], thumbs[1]] as [number, number];
77
+ if (i === 0) v = Math.min(v, arr[1]);
78
+ else v = Math.max(v, arr[0]);
79
+ arr[i] = v;
80
+ value = arr;
81
+ } else {
82
+ value = v;
83
+ }
84
+ }
85
+
86
+ let trackEl: HTMLDivElement | undefined = $state();
87
+ let dragging: number | null = $state(null);
88
+
89
+ const valueFromClientX = (clientX: number): number => {
90
+ if (!trackEl) return min;
91
+ const rect = trackEl.getBoundingClientRect();
92
+ const ratio = rect.width <= 0 ? 0 : (clientX - rect.left) / rect.width;
93
+ return min + ratio * (max - min);
94
+ };
95
+ const nearestThumb = (v: number): number => {
96
+ if (!isRange) return 0;
97
+ return Math.abs(v - thumbs[0]) <= Math.abs(v - thumbs[1]) ? 0 : 1;
98
+ };
99
+
100
+ function onTrackPointerDown(e: PointerEvent) {
101
+ if (disabled) return;
102
+ const v = valueFromClientX(e.clientX);
103
+ const i = nearestThumb(v);
104
+ dragging = i;
105
+ setThumb(i, v);
106
+ window.addEventListener("pointermove", onPointerMove);
107
+ window.addEventListener("pointerup", onPointerUp);
108
+ }
109
+ function onPointerMove(e: PointerEvent) {
110
+ if (dragging === null) return;
111
+ setThumb(dragging, valueFromClientX(e.clientX));
112
+ }
113
+ function onPointerUp() {
114
+ dragging = null;
115
+ window.removeEventListener("pointermove", onPointerMove);
116
+ window.removeEventListener("pointerup", onPointerUp);
117
+ }
118
+ // Safety: drop listeners if we unmount mid-drag.
119
+ $effect(() => () => {
120
+ window.removeEventListener("pointermove", onPointerMove);
121
+ window.removeEventListener("pointerup", onPointerUp);
122
+ });
123
+
124
+ function onThumbKeydown(e: KeyboardEvent, i: number) {
125
+ if (disabled) return;
126
+ const big = step * 10;
127
+ let delta = 0;
128
+ switch (e.key) {
129
+ case "ArrowRight":
130
+ case "ArrowUp":
131
+ delta = step;
132
+ break;
133
+ case "ArrowLeft":
134
+ case "ArrowDown":
135
+ delta = -step;
136
+ break;
137
+ case "PageUp":
138
+ delta = big;
139
+ break;
140
+ case "PageDown":
141
+ delta = -big;
142
+ break;
143
+ case "Home":
144
+ e.preventDefault();
145
+ setThumb(i, min);
146
+ return;
147
+ case "End":
148
+ e.preventDefault();
149
+ setThumb(i, max);
150
+ return;
151
+ default:
152
+ return;
153
+ }
154
+ e.preventDefault();
155
+ setThumb(i, thumbs[i] + delta);
156
+ }
157
+
158
+ const trackH: Record<Size, string> = { sm: "h-1", md: "h-1.5", lg: "h-2" };
159
+ const thumbSz: Record<Size, string> = { sm: "w-3 h-3", md: "w-4 h-4", lg: "w-5 h-5" };
160
+ const fillLeft = $derived(isRange ? pctOf(thumbs[0]) : 0);
161
+ const fillRight = $derived(pctOf(thumbs[isRange ? 1 : 0]));
162
+
163
+ const thumbLabel = (i: number): string | undefined =>
164
+ isRange ? `${label ?? "Value"} ${i === 0 ? "minimum" : "maximum"}` : (label ?? undefined);
165
+ </script>
166
+
167
+ <FieldChrome {label} {aside} {error} {description} {required} {controlId} {size} class={cls}>
168
+ {#snippet control({ describedBy })}
169
+ <div class="flex items-center gap-3">
170
+ <!-- The track is the click-to-position surface; the thumbs below carry role="slider". -->
171
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
172
+ <div
173
+ bind:this={trackEl}
174
+ onpointerdown={onTrackPointerDown}
175
+ class="relative flex-1 select-none rounded-full {disabled
176
+ ? 'opacity-50 pointer-events-none'
177
+ : 'cursor-pointer'} {responsiveClasses(size, trackH)}"
178
+ style="background-color: color-mix(in srgb, var({token}) 16%, transparent);"
179
+ >
180
+ <!-- filled range -->
181
+ <div
182
+ class="absolute top-0 bottom-0 rounded-full"
183
+ style="left: {fillLeft}%; right: {100 - fillRight}%; background-color: var({token});"
184
+ ></div>
185
+ <!-- thumbs -->
186
+ {#each thumbs as t, i (i)}
187
+ <div
188
+ id={i === 0 ? controlId : undefined}
189
+ role="slider"
190
+ tabindex={disabled ? -1 : 0}
191
+ aria-valuemin={isRange && i === 1 ? thumbs[0] : min}
192
+ aria-valuemax={isRange && i === 0 ? thumbs[1] : max}
193
+ aria-valuenow={t}
194
+ aria-valuetext={format(t)}
195
+ aria-label={thumbLabel(i)}
196
+ aria-orientation="horizontal"
197
+ aria-disabled={disabled || undefined}
198
+ aria-describedby={describedBy}
199
+ onkeydown={(e) => onThumbKeydown(e, i)}
200
+ class="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 shadow {disabled
201
+ ? ''
202
+ : 'cursor-grab active:cursor-grabbing'} focus-visible:[outline:2px_solid_color-mix(in_srgb,var(--ui-color-text)_70%,transparent)] focus-visible:[outline-offset:2px] {responsiveClasses(
203
+ size,
204
+ thumbSz,
205
+ )}"
206
+ style="left: {pctOf(t)}%; background-color: var({token}); border-color: var(--ui-color-background);"
207
+ ></div>
208
+ {/each}
209
+ </div>
210
+ {#if showValue}
211
+ <span
212
+ class="tabular-nums text-sm [color:var(--ui-color-text)] min-w-[3ch] shrink-0 text-right"
213
+ >
214
+ {isRange ? `${format(thumbs[0])}–${format(thumbs[1])}` : format(thumbs[0])}
215
+ </span>
216
+ {/if}
217
+ </div>
218
+ {/snippet}
219
+ </FieldChrome>
@@ -0,0 +1,26 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { Variant } from "../../types/variants.js";
3
+ import type { Size } from "../../types/sizes.js";
4
+ import { type Responsive } from "../../types/responsive.js";
5
+ type $$ComponentProps = {
6
+ value?: number | [number, number];
7
+ min?: number;
8
+ max?: number;
9
+ step?: number;
10
+ range?: boolean;
11
+ variant?: Variant;
12
+ size?: Responsive<Size>;
13
+ disabled?: boolean;
14
+ showValue?: boolean;
15
+ format?: (n: number) => string;
16
+ label?: string | null;
17
+ aside?: Snippet;
18
+ error?: string | Snippet | null;
19
+ description?: string | Snippet | null;
20
+ required?: boolean;
21
+ id?: string;
22
+ class?: string;
23
+ };
24
+ declare const Slider: import("svelte").Component<$$ComponentProps, {}, "value">;
25
+ type Slider = ReturnType<typeof Slider>;
26
+ export default Slider;
@@ -0,0 +1 @@
1
+ export { default as Slider } from "./Slider.svelte";
@@ -0,0 +1 @@
1
+ export { default as Slider } from "./Slider.svelte";
@@ -0,0 +1,56 @@
1
+ <!--
2
+ /**
3
+ * Spinner — an indeterminate loading ring. Honours the shared `variant`/`size` axes and is drawn
4
+ * from `--ui-*` tokens via `currentColor`, so it is theme-correct automatically and inherits the
5
+ * ambient text colour when `variant` is omitted (e.g. white inside a filled Button).
6
+ *
7
+ * For a known 0–100% progress, use `Progress` instead. See `Spinner.spec.md`.
8
+ */
9
+ -->
10
+ <script lang="ts">
11
+ import type { Variant } from "../../types/variants.js";
12
+ import { variantToken } from "../../types/variants.js";
13
+ import type { Size } from "../../types/sizes.js";
14
+ import { responsiveClasses, type Responsive } from "../../types/responsive.js";
15
+
16
+ let {
17
+ variant,
18
+ size = "md",
19
+ label = "Loading",
20
+ class: cls = "",
21
+ }: {
22
+ /** Colour accent. Omit to inherit `currentColor` (e.g. inside a filled Button). */
23
+ variant?: Variant;
24
+ size?: Responsive<Size>;
25
+ /** Accessible label (visually hidden). */
26
+ label?: string;
27
+ class?: string;
28
+ } = $props();
29
+
30
+ // 16 / 20 / 24px rings on the shared size axis.
31
+ const sizeClass: Record<Size, string> = {
32
+ sm: "w-4 h-4 border-2",
33
+ md: "w-5 h-5 border-2",
34
+ lg: "w-6 h-6 border-[3px]",
35
+ };
36
+
37
+ // Track = faint currentColor; head (top edge) = full currentColor. If `variant` is set, colour
38
+ // `currentColor` from its token; otherwise inherit the surrounding text colour.
39
+ const style = $derived(
40
+ (variant ? `color: var(${variantToken[variant]});` : "") +
41
+ "border-color: color-mix(in srgb, currentColor 25%, transparent);" +
42
+ "border-top-color: currentColor;",
43
+ );
44
+ </script>
45
+
46
+ <span
47
+ role="status"
48
+ aria-live="polite"
49
+ class="inline-block shrink-0 animate-spin rounded-full border-solid align-[-0.125em] {responsiveClasses(
50
+ size,
51
+ sizeClass,
52
+ )} {cls}"
53
+ {style}
54
+ >
55
+ <span class="sr-only">{label}</span>
56
+ </span>
@@ -0,0 +1,14 @@
1
+ import type { Variant } from "../../types/variants.js";
2
+ import type { Size } from "../../types/sizes.js";
3
+ import { type Responsive } from "../../types/responsive.js";
4
+ type $$ComponentProps = {
5
+ /** Colour accent. Omit to inherit `currentColor` (e.g. inside a filled Button). */
6
+ variant?: Variant;
7
+ size?: Responsive<Size>;
8
+ /** Accessible label (visually hidden). */
9
+ label?: string;
10
+ class?: string;
11
+ };
12
+ declare const Spinner: import("svelte").Component<$$ComponentProps, {}, "">;
13
+ type Spinner = ReturnType<typeof Spinner>;
14
+ export default Spinner;
@@ -0,0 +1 @@
1
+ export { default as Spinner } from "./Spinner.svelte";
@@ -0,0 +1 @@
1
+ export { default as Spinner } from "./Spinner.svelte";
package/dist/index.d.ts CHANGED
@@ -10,13 +10,18 @@ export { default as CardFooter } from "./components/CardFooter.svelte";
10
10
  export { default as Toaster } from "./components/Toast/Toaster.svelte";
11
11
  export { toast, getToasts, dismissToast, type ToastVariant, type ToastItem, } from "./components/Toast/toast.svelte.js";
12
12
  export { default as Dialog } from "./components/Dialog.svelte";
13
+ export { Drawer } from "./components/Drawer/index.js";
13
14
  export { default as ConfirmDialog } from "./components/ConfirmDialog.svelte";
14
15
  export { default as Input } from "./components/Input.svelte";
15
16
  export { default as NotesEditor } from "./components/NotesEditor.svelte";
16
17
  export { default as Rating } from "./components/Rating.svelte";
17
18
  export { default as Select } from "./components/Select.svelte";
19
+ export { Slider } from "./components/Slider/index.js";
18
20
  export { default as List } from "./components/List.svelte";
19
21
  export { default as TextArea } from "./components/TextArea.svelte";
22
+ export { Spinner } from "./components/Spinner/index.js";
23
+ export { Progress } from "./components/Progress/index.js";
24
+ export { Skeleton } from "./components/Skeleton/index.js";
20
25
  export { default as PasswordInput } from "./components/PasswordInput.svelte";
21
26
  export { default as EmailInput } from "./components/EmailInput.svelte";
22
27
  export { default as SearchInput } from "./components/SearchInput.svelte";
package/dist/index.js CHANGED
@@ -12,13 +12,18 @@ export { default as CardFooter } from "./components/CardFooter.svelte";
12
12
  export { default as Toaster } from "./components/Toast/Toaster.svelte";
13
13
  export { toast, getToasts, dismissToast, } from "./components/Toast/toast.svelte.js";
14
14
  export { default as Dialog } from "./components/Dialog.svelte";
15
+ export { Drawer } from "./components/Drawer/index.js";
15
16
  export { default as ConfirmDialog } from "./components/ConfirmDialog.svelte";
16
17
  export { default as Input } from "./components/Input.svelte";
17
18
  export { default as NotesEditor } from "./components/NotesEditor.svelte";
18
19
  export { default as Rating } from "./components/Rating.svelte";
19
20
  export { default as Select } from "./components/Select.svelte";
21
+ export { Slider } from "./components/Slider/index.js";
20
22
  export { default as List } from "./components/List.svelte";
21
23
  export { default as TextArea } from "./components/TextArea.svelte";
24
+ export { Spinner } from "./components/Spinner/index.js";
25
+ export { Progress } from "./components/Progress/index.js";
26
+ export { Skeleton } from "./components/Skeleton/index.js";
22
27
  // Specialized Input Components
23
28
  export { default as PasswordInput } from "./components/PasswordInput.svelte";
24
29
  export { default as EmailInput } from "./components/EmailInput.svelte";
@@ -49,10 +49,11 @@ Flexible button/anchor component.
49
49
 
50
50
  - **Location**: `src/lib/components/Button.svelte`
51
51
  - **Axes**: `variant`, `size`
52
- - **Props**: `text`, `icon` (snippet), `href` (renders an `<a>`), `disabled`, `onclick`, `title`,
53
- plus native button/anchor attributes
52
+ - **Props**: `text`, `icon` (snippet), `href` (renders an `<a>`), `disabled`, `loading`, `onclick`,
53
+ `title`, plus native button/anchor attributes
54
54
  - **Features**: icon-only sizing, themed tooltip driven from `title` (via `use:tooltip`),
55
- TypeScript discriminated union for button-vs-anchor
55
+ TypeScript discriminated union for button-vs-anchor; `loading` swaps a `Spinner` in for the icon
56
+ (inheriting the button's text colour) and blocks interaction (`aria-busy`)
56
57
  - **Accessibility**: ARIA attributes, focus-visible outlines
57
58
 
58
59
  ### Input
@@ -78,6 +79,21 @@ Custom dropdown built on `Input` + `List`.
78
79
  `maxResults`, `headers`, `searchTerm`)
79
80
  - **Modes**: static `options` or search-as-you-type against `url`
80
81
 
82
+ ### Slider
83
+
84
+ Draggable range input — a single value or a `[min, max]` pair.
85
+
86
+ - **Location**: `src/lib/components/Slider/Slider.svelte`
87
+ - **Axes**: `variant` (fill + thumb; track is a soft tint), `size` (track thickness + thumb size)
88
+ - **Props**: `value` (bindable — number, or `[lo, hi]` in range mode), `min`/`max`/`step`, `range`,
89
+ `disabled`, `showValue`, `format`, plus the FieldChrome vocabulary (`label`, `aside`, `error`,
90
+ `description`, `required`, `id`), `class`
91
+ - **Interaction**: drag, click-to-position, keyboard (arrows / PageUp-Dn / Home / End); range thumbs
92
+ can't cross
93
+ - **Accessibility**: each thumb `role="slider"` with `aria-valuemin/max/now`, `aria-valuetext`
94
+ (`format`), `aria-label`; `aria-describedby` wired from FieldChrome
95
+ - **Theming**: fill/thumb = variant token; track = that token @16%; thumb ring = `--ui-color-background`
96
+
81
97
  ### List
82
98
 
83
99
  Standalone selectable list (Select's dropdown body).
@@ -306,6 +322,20 @@ dialog (operators, casts, dynamic `now()` dates, and a worked examples table). T
306
322
  + Escape dismiss, **focus trap** (initial focus in, Tab wraps, focus restored on close),
307
323
  `role="dialog"` + `aria-modal`.
308
324
 
325
+ ### Drawer
326
+
327
+ **Edge-anchored overlay panel** (a "sheet") — Dialog's mechanics, but slides in from a screen edge.
328
+ Pure shell; put a `Card` (or anything) inside.
329
+
330
+ - **Location**: `src/lib/components/Drawer/Drawer.svelte`
331
+ - **Props**: `open` (bindable), `side` (`left`/`right`/`top`/`bottom`, default `right`), `size` (any CSS
332
+ length — width for left/right, height for top/bottom; a surface dimension, **not** the sm/md/lg axis;
333
+ capped to viewport − 1rem), `dismissible` (default `true`), `backdrop` (default `true`), `onClose`,
334
+ `onShow`/`onHide` (same focus-knob contract as `Dialog`), `ariaLabel`, `class`, `children`
335
+ - **Features**: portals to `<body>`, scroll-lock, focus trap + restore, Escape / backdrop dismiss,
336
+ CSS slide transition (percentage-based so it clears the edge at any `size`), `role="dialog"` +
337
+ `aria-modal`
338
+
309
339
  ### ConfirmDialog
310
340
 
311
341
  The common title + message + Confirm/Cancel modal, composed from a `Card` on top of the `Dialog`
@@ -360,6 +390,42 @@ Shared placement engine — a Svelte action plus `computePlacement` / `resolveBo
360
390
 
361
391
  ## Display / Theme
362
392
 
393
+ ### Spinner
394
+
395
+ Indeterminate loading ring — the primitive `Button`'s `loading` state and the loading family build on.
396
+
397
+ - **Location**: `src/lib/components/Spinner/Spinner.svelte`
398
+ - **Axes**: `variant` (optional), `size` (`sm`/`md`/`lg` = 16/20/24px)
399
+ - **Props**: `variant` (omit to inherit `currentColor` — e.g. white inside a filled Button), `size`,
400
+ `label` (visually-hidden, default `"Loading"`), `class`
401
+ - **Accessibility**: `role="status"` + `aria-live="polite"` + visually-hidden `label`
402
+ - **Theming**: track = `currentColor` @25%, head = `currentColor`; `variant` sets `color` to the
403
+ semantic token. No per-component tokens. For a known 0–100%, use `Progress`.
404
+
405
+ ### Progress
406
+
407
+ Horizontal progress bar — the determinate counterpart to `Spinner`.
408
+
409
+ - **Location**: `src/lib/components/Progress/Progress.svelte`
410
+ - **Axes**: `variant` (fill; track is a soft tint of it), `size` (`sm`/`md`/`lg` = 4/8/12px thick)
411
+ - **Props**: `value` (0…`max`; omit → indeterminate), `max` (default 100), `indeterminate`, `variant`,
412
+ `size`, `label` (caption + accessible name), `showValue` (percentage), `class`
413
+ - **Accessibility**: `role="progressbar"` with `aria-valuemin/max/now` (now omitted when indeterminate)
414
+ - **Theming**: fill = variant token, track = that token @16%. Indeterminate sweep is CSS-only (no
415
+ timer). No per-component tokens.
416
+
417
+ ### Skeleton
418
+
419
+ Colour-neutral content placeholder for loading states.
420
+
421
+ - **Location**: `src/lib/components/Skeleton/Skeleton.svelte`
422
+ - **Props**: `shape` (`text`/`rect`/`circle`), `width`/`height` (number → px), `lines` (text),
423
+ `radius`, `animate` (shimmer, default on), `class`. **No `variant`/`size`** — it reads as absent
424
+ content, sized by `width`/`height`.
425
+ - **Accessibility**: all nodes `aria-hidden`; announce loading on the surrounding region (`aria-busy`)
426
+ - **Theming**: neutral text-tint base + CSS shimmer; corners use `--ui-border-radius`. Compose several
427
+ to sketch a card/list row.
428
+
363
429
  ### Badge
364
430
 
365
431
  Status indicator / label.
package/docs/usage.md CHANGED
@@ -218,6 +218,103 @@ A `Disclosure` is one expand/collapse section (`bind:open`); an `Accordion` coor
218
218
  <Button text="Delete" variant="danger">
219
219
  {#snippet icon()}<IconDelete />{/snippet}
220
220
  </Button>
221
+
222
+ <!-- Loading — swaps a Spinner in for the icon (inheriting text colour) and blocks interaction -->
223
+ <Button text="Saving…" variant="primary" loading />
224
+ ```
225
+
226
+ ### Spinner
227
+
228
+ An indeterminate loading ring on the shared `variant`/`size` axes. Omit `variant` to inherit the
229
+ ambient text colour (e.g. white inside a filled Button). For a known 0–100%, use `Progress`.
230
+
231
+ ```svelte
232
+ <script>
233
+ import { Spinner } from "@spaethtech/svelte-ui";
234
+ </script>
235
+
236
+ <!-- Inherits the ambient text colour -->
237
+ <Spinner />
238
+
239
+ <!-- Coloured by a semantic variant -->
240
+ <Spinner variant="primary" />
241
+
242
+ <!-- size="md" is the default -->
243
+ <Spinner size="sm" variant="success" />
244
+ <Spinner size="lg" variant="danger" />
245
+
246
+ <!-- On a coloured surface, omit variant so it adopts the text colour -->
247
+ <div class="[background-color:var(--ui-color-primary)] text-white p-4">
248
+ <Spinner label="Loading" />
249
+ </div>
250
+ ```
251
+
252
+ ### Progress
253
+
254
+ A horizontal progress bar. Determinate with a `value`; indeterminate (a sweeping segment) without one.
255
+
256
+ ```svelte
257
+ <script>
258
+ import { Progress } from "@spaethtech/svelte-ui";
259
+ let value = $state(40);
260
+ </script>
261
+
262
+ <!-- Determinate: proportional fill, optional caption + percentage -->
263
+ <Progress {value} label="Uploading" showValue />
264
+
265
+ <!-- Indeterminate: omit value (or set indeterminate) for a sweeping bar -->
266
+ <Progress indeterminate label="Working…" />
267
+
268
+ <!-- variant colours the fill; size sets thickness (sm/md/lg = 4/8/12px) -->
269
+ <Progress value={65} variant="success" size="lg" />
270
+ ```
271
+
272
+ ### Skeleton
273
+
274
+ A neutral placeholder shown while content loads. Compose primitives to sketch the real layout.
275
+
276
+ ```svelte
277
+ <script>
278
+ import { Skeleton, Card } from "@spaethtech/svelte-ui";
279
+ </script>
280
+
281
+ <Skeleton shape="text" width="12rem" />
282
+ <Skeleton shape="rect" height={120} />
283
+ <Skeleton shape="circle" height={48} />
284
+
285
+ <!-- Multiple lines (last line shortened) -->
286
+ <Skeleton shape="text" lines={4} />
287
+
288
+ <!-- Composed: a loading card -->
289
+ <Card class="p-4 max-w-sm">
290
+ <div class="flex items-center gap-3">
291
+ <Skeleton shape="circle" height={40} />
292
+ <div class="flex-1"><Skeleton shape="text" width="60%" /></div>
293
+ </div>
294
+ <div class="mt-4"><Skeleton shape="rect" height={140} /></div>
295
+ </Card>
296
+ ```
297
+
298
+ ### Slider
299
+
300
+ A draggable range input — a single value or a `[min, max]` pair. A form field (wears FieldChrome).
301
+
302
+ ```svelte
303
+ <script>
304
+ import { Slider } from "@spaethtech/svelte-ui";
305
+ let volume = $state(60);
306
+ let price = $state([200, 750]);
307
+ </script>
308
+
309
+ <!-- Single value -->
310
+ <Slider bind:value={volume} label="Volume" showValue />
311
+
312
+ <!-- Range (dual thumb — thumbs can't cross); format the displayed/announced value -->
313
+ <Slider bind:value={price} range min={0} max={1000} step={10}
314
+ label="Price" showValue format={(n) => "$" + n} />
315
+
316
+ <!-- variant colours the fill + thumb; size scales the control -->
317
+ <Slider value={65} variant="success" size="lg" label="Level" />
221
318
  ```
222
319
 
223
320
  ### ButtonGroup
@@ -478,6 +575,35 @@ ignore it):
478
575
  </Dialog>
479
576
  ```
480
577
 
578
+ ### Drawer (sheet)
579
+
580
+ An edge-anchored panel that slides in — same modal mechanics as `Dialog`, pinned to a `side`. Put a
581
+ `Card` inside.
582
+
583
+ ```svelte
584
+ <script>
585
+ import { Drawer, Card, CardHeader, CardBody, CardFooter, Button, Input } from "@spaethtech/svelte-ui";
586
+ let open = $state(false);
587
+ </script>
588
+
589
+ <Button text="Settings" onclick={() => (open = true)} />
590
+
591
+ <Drawer bind:open side="right" size="24rem" ariaLabel="Settings">
592
+ <Card class="h-full flex flex-col">
593
+ <CardHeader><h3 class="font-medium">Settings</h3></CardHeader>
594
+ <CardBody>
595
+ <Input label="Display name" placeholder="Jane Doe" />
596
+ </CardBody>
597
+ <CardFooter separator={false}>
598
+ <div class="flex justify-end gap-2">
599
+ <Button text="Cancel" variant="ghost" onclick={() => (open = false)} />
600
+ <Button text="Save" variant="primary" onclick={() => (open = false)} />
601
+ </div>
602
+ </CardFooter>
603
+ </Card>
604
+ </Drawer>
605
+ ```
606
+
481
607
  ### Badge
482
608
 
483
609
  ```svelte
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"