@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.
- package/.claude/skills/svelte-ui/SKILL.md +13 -4
- package/dist/components/Button.svelte +39 -37
- package/dist/components/Button.svelte.d.ts +2 -0
- package/dist/components/Drawer/Drawer.svelte +184 -0
- package/dist/components/Drawer/Drawer.svelte.d.ts +24 -0
- package/dist/components/Drawer/index.d.ts +1 -0
- package/dist/components/Drawer/index.js +1 -0
- package/dist/components/Progress/Progress.svelte +97 -0
- package/dist/components/Progress/Progress.svelte.d.ts +16 -0
- package/dist/components/Progress/index.d.ts +1 -0
- package/dist/components/Progress/index.js +1 -0
- package/dist/components/Skeleton/Skeleton.svelte +87 -0
- package/dist/components/Skeleton/Skeleton.svelte.d.ts +12 -0
- package/dist/components/Skeleton/index.d.ts +1 -0
- package/dist/components/Skeleton/index.js +1 -0
- package/dist/components/Slider/Slider.svelte +219 -0
- package/dist/components/Slider/Slider.svelte.d.ts +26 -0
- package/dist/components/Slider/index.d.ts +1 -0
- package/dist/components/Slider/index.js +1 -0
- package/dist/components/Spinner/Spinner.svelte +56 -0
- package/dist/components/Spinner/Spinner.svelte.d.ts +14 -0
- package/dist/components/Spinner/index.d.ts +1 -0
- package/dist/components/Spinner/index.js +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/docs/components.md +69 -3
- package/docs/usage.md +126 -0
- package/package.json +1 -1
|
@@ -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`
|
|
156
|
-
`Rating` · **`
|
|
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
|
-
·
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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";
|
package/docs/components.md
CHANGED
|
@@ -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`, `
|
|
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
|