@dorsk/tsumikit 0.1.1 → 0.2.1

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.
Files changed (51) hide show
  1. package/README.md +4 -2
  2. package/dist/clipboard.d.ts +1 -0
  3. package/dist/clipboard.js +30 -0
  4. package/dist/components/atoms/Badge.svelte +65 -3
  5. package/dist/components/atoms/Badge.svelte.d.ts +4 -0
  6. package/dist/components/atoms/Button.svelte +80 -8
  7. package/dist/components/atoms/Button.svelte.d.ts +4 -0
  8. package/dist/components/atoms/Checkbox.svelte +7 -1
  9. package/dist/components/atoms/Checkbox.svelte.d.ts +2 -0
  10. package/dist/components/atoms/Dot.svelte +67 -0
  11. package/dist/components/atoms/Dot.svelte.d.ts +12 -0
  12. package/dist/components/atoms/Input.svelte +28 -2
  13. package/dist/components/atoms/Input.svelte.d.ts +5 -1
  14. package/dist/components/atoms/Select.svelte +9 -2
  15. package/dist/components/atoms/Select.svelte.d.ts +2 -0
  16. package/dist/components/atoms/Slider.svelte +5 -4
  17. package/dist/components/atoms/Switch.svelte +6 -1
  18. package/dist/components/atoms/Switch.svelte.d.ts +1 -0
  19. package/dist/components/atoms/Text.svelte +7 -0
  20. package/dist/components/atoms/Textarea.svelte +26 -1
  21. package/dist/components/atoms/Textarea.svelte.d.ts +4 -0
  22. package/dist/components/layouts/AppShell.svelte +15 -8
  23. package/dist/components/layouts/AutoGrid.svelte +32 -2
  24. package/dist/components/layouts/AutoGrid.svelte.d.ts +1 -0
  25. package/dist/components/molecules/Accordion.svelte +6 -3
  26. package/dist/components/molecules/CopyButton.svelte +2 -26
  27. package/dist/components/molecules/FileButton.svelte +45 -3
  28. package/dist/components/molecules/IconButton.svelte +23 -3
  29. package/dist/components/molecules/IconButton.svelte.d.ts +2 -0
  30. package/dist/components/molecules/Modal.svelte +15 -4
  31. package/dist/components/molecules/Modal.svelte.d.ts +3 -0
  32. package/dist/components/molecules/OptionButton.svelte +28 -30
  33. package/dist/components/molecules/Popover.svelte +46 -25
  34. package/dist/components/molecules/Popover.svelte.d.ts +7 -2
  35. package/dist/components/molecules/SelectButton.svelte +20 -16
  36. package/dist/components/molecules/Tabs.svelte +26 -7
  37. package/dist/components/molecules/Tabs.svelte.d.ts +2 -0
  38. package/dist/components/molecules/Toggle.svelte +30 -15
  39. package/dist/components/molecules/Tooltip.svelte +41 -28
  40. package/dist/components/molecules/Tooltip.svelte.d.ts +1 -1
  41. package/dist/components/organisms/DataTable.svelte +85 -4
  42. package/dist/components/organisms/DataTable.svelte.d.ts +6 -0
  43. package/dist/floating.d.ts +10 -0
  44. package/dist/floating.js +56 -0
  45. package/dist/index.d.ts +2 -1
  46. package/dist/index.js +2 -1
  47. package/dist/styles/app.css +14 -234
  48. package/dist/styles/variables.css +1 -1
  49. package/package.json +2 -1
  50. package/dist/components/atoms/Chip.svelte +0 -53
  51. package/dist/components/atoms/Chip.svelte.d.ts +0 -11
package/README.md CHANGED
@@ -69,7 +69,7 @@ import { Button, Field, Input, Modal, ThemePicker } from '@dorsk/tsumikit';
69
69
  ## Components
70
70
 
71
71
  **Atoms:** Text, Heading, Button, Input, Textarea, Select, Switch, Checkbox,
72
- Slider, Progress, Card, Badge, Chip, Link, Icon (open registry — pass a
72
+ Slider, Progress, Card, Badge, Dot, Link, Icon (open registry — pass a
73
73
  `children` snippet for any custom SVG).
74
74
 
75
75
  **Molecules:** Field, IconButton, SelectButton, Toggle, OptionButton, Modal,
@@ -95,6 +95,8 @@ Use the `.cq-*` utilities (`.cq-hide`, `.cq-stack`, `.cq-truncate`,
95
95
  you drag the sidebar down to that icon rail (width persisted).
96
96
 
97
97
  **Stores:** `theme`, `toasts`, `fontScale` (opt-in). **Actions:** `autoresize`.
98
+ **Helpers:** `copyToClipboard(text)` — async Clipboard API with an
99
+ insecure-context fallback; returns whether it succeeded.
98
100
 
99
101
  ## Sizing & zoom
100
102
 
@@ -121,7 +123,7 @@ them in JS — less code, better a11y, fewer edge cases:
121
123
  - **`color-scheme`** per theme so native widgets/scrollbars match; **`@media
122
124
  (forced-colors)`** (Windows High Contrast) and **`prefers-contrast`** support;
123
125
  **`prefers-reduced-motion`** disables animation globally.
124
- - **Intrinsic responsive layout**: `.auto-grid` (auto-fit + `minmax`) and `.cq`
126
+ - **Intrinsic responsive layout**: `<AutoGrid>` (auto-fit + `minmax`) and `.cq`
125
127
  (container queries) adapt to available space, not just viewport breakpoints.
126
128
 
127
129
  ## Syntax highlighting (CodeBlock)
@@ -0,0 +1 @@
1
+ export declare function copyToClipboard(value: string): Promise<boolean>;
@@ -0,0 +1,30 @@
1
+ // Copy `value` to the clipboard. Prefers the async Clipboard API and falls back
2
+ // to a hidden-textarea + execCommand for insecure contexts / older browsers.
3
+ // Returns whether the copy succeeded; never throws. UI concerns (toasts, a
4
+ // transient "copied" state) belong to the caller.
5
+ export async function copyToClipboard(value) {
6
+ try {
7
+ if (navigator.clipboard?.writeText) {
8
+ await navigator.clipboard.writeText(value);
9
+ return true;
10
+ }
11
+ }
12
+ catch {
13
+ /* fall through to legacy path */
14
+ }
15
+ // Fallback for insecure contexts / older browsers.
16
+ try {
17
+ const ta = document.createElement('textarea');
18
+ ta.value = value;
19
+ ta.style.position = 'fixed';
20
+ ta.style.opacity = '0';
21
+ document.body.appendChild(ta);
22
+ ta.select();
23
+ const ok = document.execCommand('copy');
24
+ document.body.removeChild(ta);
25
+ return ok;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
@@ -1,8 +1,11 @@
1
1
  <script lang="ts">
2
- // Base badge/pill primitive. Owns the shape + tone palette from theme tokens.
2
+ // Inline label primitive the project's single pill. Covers three jobs via
3
+ // props rather than separate components:
4
+ // • state → `tone` (neutral/ok/warn/danger/info) semantic palette
5
+ // • info → `mono` for paths/ids/code-ish metadata
6
+ // • tag → `removable` renders a dismiss button + fires `onremove`
3
7
  // Polymorphic via `as` so it can be a static <span> or an interactive
4
- // <button> (e.g. SubagentBadge). Specialized badges compose this and add only
5
- // their specifics (per-machine hue, toggle state).
8
+ // <button>. `size="sm"` is the compact form for counts/dense rows.
6
9
  import type { Snippet } from 'svelte';
7
10
 
8
11
  type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
@@ -10,12 +13,20 @@
10
13
  let {
11
14
  tone = 'neutral',
12
15
  as = 'span',
16
+ size = 'md',
17
+ mono = false,
18
+ removable = false,
19
+ onremove,
13
20
  class: klass = '',
14
21
  children,
15
22
  ...rest
16
23
  }: {
17
24
  tone?: Tone;
18
25
  as?: 'span' | 'button';
26
+ size?: 'sm' | 'md';
27
+ mono?: boolean;
28
+ removable?: boolean;
29
+ onremove?: (e: MouseEvent) => void;
19
30
  class?: string;
20
31
  children?: Snippet;
21
32
  [key: string]: unknown;
@@ -29,9 +40,22 @@
29
40
  class:badge-warn={tone === 'warn'}
30
41
  class:badge-danger={tone === 'danger'}
31
42
  class:badge-info={tone === 'info'}
43
+ class:badge-sm={size === 'sm'}
44
+ class:mono
45
+ class:interactive={as === 'button'}
32
46
  {...rest}
33
47
  >
34
48
  {@render children?.()}
49
+ {#if removable}
50
+ <button
51
+ type="button"
52
+ class="remove"
53
+ aria-label="Remove"
54
+ onclick={(e) => onremove?.(e)}
55
+ >
56
+ ×
57
+ </button>
58
+ {/if}
35
59
  </svelte:element>
36
60
 
37
61
  <style>
@@ -48,6 +72,12 @@
48
72
  color: var(--text-muted);
49
73
  border: 1px solid var(--border);
50
74
  white-space: nowrap;
75
+ max-width: 100%;
76
+ }
77
+ .badge-sm {
78
+ padding: 0 0.4rem;
79
+ font-size: 0.6875rem;
80
+ gap: 0.15rem;
51
81
  }
52
82
  .badge-ok {
53
83
  color: var(--ok);
@@ -69,4 +99,36 @@
69
99
  border-color: color-mix(in srgb, var(--info) 40%, transparent);
70
100
  background: color-mix(in srgb, var(--info) 12%, transparent);
71
101
  }
102
+ .mono {
103
+ font-family: var(--font-mono);
104
+ font-weight: var(--fw-normal);
105
+ }
106
+ .interactive {
107
+ cursor: pointer;
108
+ transition:
109
+ border-color 0.12s var(--ease),
110
+ color 0.12s var(--ease);
111
+ }
112
+ .interactive:hover {
113
+ border-color: var(--border-strong);
114
+ color: var(--text);
115
+ }
116
+ .remove {
117
+ display: inline-flex;
118
+ align-items: center;
119
+ justify-content: center;
120
+ margin: 0 -0.15rem 0 0;
121
+ padding: 0 0.1rem;
122
+ border: 0;
123
+ background: none;
124
+ color: inherit;
125
+ font: inherit;
126
+ line-height: 1;
127
+ cursor: pointer;
128
+ opacity: 0.6;
129
+ transition: opacity 0.12s var(--ease);
130
+ }
131
+ .remove:hover {
132
+ opacity: 1;
133
+ }
72
134
  </style>
@@ -3,6 +3,10 @@ type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
3
3
  type $$ComponentProps = {
4
4
  tone?: Tone;
5
5
  as?: 'span' | 'button';
6
+ size?: 'sm' | 'md';
7
+ mono?: boolean;
8
+ removable?: boolean;
9
+ onremove?: (e: MouseEvent) => void;
6
10
  class?: string;
7
11
  children?: Snippet;
8
12
  [key: string]: unknown;
@@ -7,6 +7,15 @@
7
7
  size?: 'sm' | 'md';
8
8
  control?: boolean;
9
9
  block?: boolean;
10
+ // Square 2.25rem icon-only tap target (IconButton). `iconInline` is the
11
+ // borderless, compact variant (chip-remove ✕, inline edit ✎); pair with
12
+ // `hoverDanger` to tint it red on hover (delete affordances).
13
+ icon?: boolean;
14
+ iconInline?: boolean;
15
+ hoverDanger?: boolean;
16
+ // Async/busy state: shows a spinner, blocks clicks, sets aria-busy. Stays
17
+ // disabled-equivalent while true (so a double-submit can't fire).
18
+ loading?: boolean;
10
19
  class?: string;
11
20
  children?: Snippet;
12
21
  };
@@ -16,6 +25,10 @@
16
25
  size = 'md',
17
26
  control = false,
18
27
  block = false,
28
+ icon = false,
29
+ iconInline = false,
30
+ hoverDanger = false,
31
+ loading = false,
19
32
  type = 'button',
20
33
  disabled = false,
21
34
  title,
@@ -29,7 +42,8 @@
29
42
  <button
30
43
  {...rest}
31
44
  {type}
32
- {disabled}
45
+ disabled={disabled || loading}
46
+ aria-busy={loading || undefined}
33
47
  {title}
34
48
  class="btn {klass}"
35
49
  class:btn-primary={variant === 'primary'}
@@ -38,16 +52,20 @@
38
52
  class:btn-sm={size === 'sm'}
39
53
  class:btn-control={control}
40
54
  class:btn-block={block}
55
+ class:btn-icon={icon}
56
+ class:btn-icon-inline={iconInline}
57
+ class:hover-danger={hoverDanger}
58
+ class:loading
41
59
  onclick={onclick}
42
60
  >
61
+ {#if loading}<span class="btn-spinner" aria-hidden="true"></span>{/if}
43
62
  {@render children?.()}
44
63
  </button>
45
64
 
46
65
  <style>
47
- /* The canonical control: owns its variants/sizes from theme tokens. Svelte 5
48
- scopes via :where() (zero added specificity), so these match the same way
49
- the global .btn rules did feature-component overrides (e.g. .dhead
50
- .tapbtn) keep winning by specificity. Icons size themselves (Icon.svelte),
66
+ /* The canonical control: owns its variants/sizes/modifiers from theme tokens.
67
+ Svelte 5 scopes via :where() (zero added specificity), so feature-component
68
+ overrides keep winning by specificity. Icons size themselves (Icon.svelte),
51
69
  so no svg sizing rule lives here. */
52
70
  .btn {
53
71
  display: inline-flex;
@@ -110,8 +128,9 @@
110
128
  .btn-block {
111
129
  width: 100%;
112
130
  }
113
- /* Uniform-height control (CCT-250 item 1): icon buttons, inputs and action
114
- buttons that share a toolbar/composer row line up exactly. */
131
+
132
+ /* Uniform-height control: icon buttons, inputs and action buttons that share a
133
+ toolbar/composer row line up exactly. */
115
134
  .btn-control {
116
135
  display: inline-flex;
117
136
  align-items: center;
@@ -143,7 +162,7 @@
143
162
  }
144
163
  /* .btn-control follows .btn-primary in source order and would otherwise paint
145
164
  the primary action with the neutral --surface; restore the accent fill when
146
- both are present (CCT-345). */
165
+ both are present. */
147
166
  .btn-control.btn-primary {
148
167
  background: var(--accent);
149
168
  border-color: var(--accent);
@@ -153,4 +172,57 @@
153
172
  border-color: var(--accent);
154
173
  filter: brightness(1.08);
155
174
  }
175
+
176
+ /* Icon-only buttons (IconButton). Square box = consistent 2.25rem tap target. */
177
+ .btn-icon {
178
+ min-height: 2.25rem;
179
+ min-width: 2.25rem;
180
+ padding: var(--sp-2);
181
+ }
182
+ .btn-icon-inline {
183
+ min-height: 0;
184
+ min-width: 0;
185
+ padding: 0 var(--sp-1);
186
+ border: none;
187
+ background: none;
188
+ color: var(--text-muted);
189
+ }
190
+ .btn-icon-inline:hover:not(:disabled) {
191
+ border: none;
192
+ background: none;
193
+ color: var(--text);
194
+ }
195
+ .btn-icon-inline.hover-danger:hover:not(:disabled) {
196
+ color: var(--danger);
197
+ }
198
+
199
+ /* Two-state (toggle) buttons — e.g. an IconButton with `pressed`. Reacts to
200
+ the native aria-pressed that flows through, so no extra class. Tint defaults
201
+ to the accent; override per-instance with `style="--btn-on: var(--warn)"`. */
202
+ .btn[aria-pressed='true'] {
203
+ color: var(--btn-on, var(--accent));
204
+ }
205
+
206
+ /* Loading: keep full opacity (it's busy, not disabled-looking) + a wait cursor,
207
+ and show a spinner in the current text color. */
208
+ .btn.loading {
209
+ cursor: wait;
210
+ }
211
+ .btn.loading:disabled {
212
+ opacity: 1;
213
+ }
214
+ .btn-spinner {
215
+ width: 0.9em;
216
+ height: 0.9em;
217
+ flex: none;
218
+ border: 2px solid currentColor;
219
+ border-top-color: transparent;
220
+ border-radius: 50%;
221
+ animation: btn-spin 0.6s linear infinite;
222
+ }
223
+ @keyframes btn-spin {
224
+ to {
225
+ transform: rotate(360deg);
226
+ }
227
+ }
156
228
  </style>
@@ -5,6 +5,10 @@ type ButtonProps = HTMLButtonAttributes & {
5
5
  size?: 'sm' | 'md';
6
6
  control?: boolean;
7
7
  block?: boolean;
8
+ icon?: boolean;
9
+ iconInline?: boolean;
10
+ hoverDanger?: boolean;
11
+ loading?: boolean;
8
12
  class?: string;
9
13
  children?: Snippet;
10
14
  };
@@ -8,6 +8,7 @@
8
8
  let {
9
9
  checked = $bindable(false),
10
10
  indeterminate = false,
11
+ invalid = false,
11
12
  label,
12
13
  class: klass = '',
13
14
  el = $bindable(null),
@@ -15,6 +16,8 @@
15
16
  }: HTMLInputAttributes & {
16
17
  checked?: boolean;
17
18
  indeterminate?: boolean;
19
+ /** Error state: danger box border + aria-invalid. */
20
+ invalid?: boolean;
18
21
  label: string;
19
22
  el?: HTMLInputElement | null;
20
23
  } = $props();
@@ -26,7 +29,7 @@
26
29
  </script>
27
30
 
28
31
  <label class="checkbox {klass}">
29
- <input bind:this={el} type="checkbox" bind:checked {...rest} />
32
+ <input bind:this={el} type="checkbox" bind:checked {...rest} aria-invalid={invalid || undefined} />
30
33
  <span class="box" aria-hidden="true"></span>
31
34
  <span class="label-text">{label}</span>
32
35
  </label>
@@ -90,6 +93,9 @@
90
93
  outline: 2px solid var(--accent);
91
94
  outline-offset: 2px;
92
95
  }
96
+ input[aria-invalid='true']:not(:checked):not(:indeterminate) + .box {
97
+ border-color: var(--danger);
98
+ }
93
99
  input:disabled ~ * {
94
100
  opacity: 0.45;
95
101
  }
@@ -2,6 +2,8 @@ import type { HTMLInputAttributes } from 'svelte/elements';
2
2
  type $$ComponentProps = HTMLInputAttributes & {
3
3
  checked?: boolean;
4
4
  indeterminate?: boolean;
5
+ /** Error state: danger box border + aria-invalid. */
6
+ invalid?: boolean;
5
7
  label: string;
6
8
  el?: HTMLInputElement | null;
7
9
  };
@@ -0,0 +1,67 @@
1
+ <script lang="ts">
2
+ // Status dot — a small coloured disc, optionally followed by a label. Owns its
3
+ // own styling (it does not rely on any global `.dot` rules). Colour comes from
4
+ // either:
5
+ // • status → one of the semantic presets (active/stale/dead/hibernated),
6
+ // each mapped to a token.
7
+ // • color → any CSS colour (or var()) for a one-off; overrides `status`.
8
+ // `glow` adds a soft halo in the dot's own colour. With a `label` the whole
9
+ // thing renders as an inline-flex row (dot + caption); without one it's a bare
10
+ // inline dot, so it can sit inline next to other text.
11
+ import Text from './Text.svelte';
12
+
13
+ type Status = 'active' | 'stale' | 'dead' | 'hibernated';
14
+
15
+ const STATUS_COLOR: Record<Status, string> = {
16
+ active: 'var(--dot-active)',
17
+ stale: 'var(--dot-stale)',
18
+ dead: 'var(--dot-dead)',
19
+ hibernated: 'var(--dot-hibernated)',
20
+ };
21
+
22
+ let {
23
+ status = 'active',
24
+ color,
25
+ label,
26
+ glow = false,
27
+ class: klass = '',
28
+ ...rest
29
+ }: {
30
+ status?: Status;
31
+ color?: string;
32
+ label?: string;
33
+ glow?: boolean;
34
+ class?: string;
35
+ [key: string]: unknown;
36
+ } = $props();
37
+
38
+ const resolved = $derived(color ?? STATUS_COLOR[status]);
39
+ </script>
40
+
41
+ {#if label}
42
+ <span class="dot-row {klass}" {...rest}>
43
+ <span class="dot" class:glow style="--dot-color:{resolved}"></span>
44
+ <Text variant="caption">{label}</Text>
45
+ </span>
46
+ {:else}
47
+ <span class="dot {klass}" class:glow style="--dot-color:{resolved}" {...rest}></span>
48
+ {/if}
49
+
50
+ <style>
51
+ .dot-row {
52
+ display: inline-flex;
53
+ align-items: center;
54
+ gap: var(--sp-1);
55
+ }
56
+ .dot {
57
+ width: 0.55rem;
58
+ height: 0.55rem;
59
+ border-radius: var(--r-pill);
60
+ flex: none;
61
+ display: inline-block;
62
+ background: var(--dot-color);
63
+ }
64
+ .glow {
65
+ box-shadow: 0 0 6px var(--dot-color);
66
+ }
67
+ </style>
@@ -0,0 +1,12 @@
1
+ type Status = 'active' | 'stale' | 'dead' | 'hibernated';
2
+ type $$ComponentProps = {
3
+ status?: Status;
4
+ color?: string;
5
+ label?: string;
6
+ glow?: boolean;
7
+ class?: string;
8
+ [key: string]: unknown;
9
+ };
10
+ declare const Dot: import("svelte").Component<$$ComponentProps, {}, "">;
11
+ type Dot = ReturnType<typeof Dot>;
12
+ export default Dot;
@@ -4,8 +4,14 @@
4
4
  // switches to the monospace family (paths, tokens, env values).
5
5
  import type { HTMLInputAttributes } from 'svelte/elements';
6
6
 
7
- type Props = HTMLInputAttributes & {
7
+ // `size` shadows the native char-width attribute (unused in token-sized
8
+ // layouts) to expose a height preset instead.
9
+ type Props = Omit<HTMLInputAttributes, 'size'> & {
8
10
  mono?: boolean;
11
+ size?: 'sm' | 'md';
12
+ /** Error state: danger border + aria-invalid (also styles if a consumer
13
+ * sets aria-invalid directly). */
14
+ invalid?: boolean;
9
15
  class?: string;
10
16
  value?: HTMLInputAttributes['value'];
11
17
  el?: HTMLInputElement | null;
@@ -13,6 +19,8 @@
13
19
 
14
20
  let {
15
21
  mono = false,
22
+ size = 'md',
23
+ invalid = false,
16
24
  class: klass = '',
17
25
  value = $bindable(),
18
26
  el = $bindable(null),
@@ -20,7 +28,15 @@
20
28
  }: Props = $props();
21
29
  </script>
22
30
 
23
- <input bind:this={el} class="input {klass}" class:mono bind:value {...rest} />
31
+ <input
32
+ bind:this={el}
33
+ class="input {klass}"
34
+ class:mono
35
+ class:input-sm={size === 'sm'}
36
+ bind:value
37
+ {...rest}
38
+ aria-invalid={invalid || undefined}
39
+ />
24
40
 
25
41
  <style>
26
42
  .input {
@@ -36,6 +52,16 @@
36
52
  outline: none;
37
53
  border-color: var(--accent);
38
54
  }
55
+ .input-sm {
56
+ padding: var(--sp-2);
57
+ font-size: var(--fs-sm);
58
+ }
59
+ .input[aria-invalid='true'] {
60
+ border-color: var(--danger);
61
+ }
62
+ .input[aria-invalid='true']:focus {
63
+ border-color: var(--danger);
64
+ }
39
65
  .mono {
40
66
  font-family: var(--font-mono);
41
67
  }
@@ -1,6 +1,10 @@
1
1
  import type { HTMLInputAttributes } from 'svelte/elements';
2
- type Props = HTMLInputAttributes & {
2
+ type Props = Omit<HTMLInputAttributes, 'size'> & {
3
3
  mono?: boolean;
4
+ size?: 'sm' | 'md';
5
+ /** Error state: danger border + aria-invalid (also styles if a consumer
6
+ * sets aria-invalid directly). */
7
+ invalid?: boolean;
4
8
  class?: string;
5
9
  value?: HTMLInputAttributes['value'];
6
10
  el?: HTMLInputElement | null;
@@ -17,6 +17,8 @@
17
17
 
18
18
  type Props = HTMLSelectAttributes & {
19
19
  variant?: 'default' | 'ghost';
20
+ /** Error state: danger border + aria-invalid. */
21
+ invalid?: boolean;
20
22
  class?: string;
21
23
  value?: HTMLSelectAttributes['value'];
22
24
  children?: Snippet;
@@ -24,6 +26,7 @@
24
26
 
25
27
  let {
26
28
  variant = 'default',
29
+ invalid = false,
27
30
  class: klass = '',
28
31
  value = $bindable(),
29
32
  children,
@@ -32,12 +35,12 @@
32
35
  </script>
33
36
 
34
37
  {#if variant === 'ghost'}
35
- <select class="select ghost {klass}" bind:value {...rest}>
38
+ <select class="select ghost {klass}" bind:value {...rest} aria-invalid={invalid || undefined}>
36
39
  {@render children?.()}
37
40
  </select>
38
41
  {:else}
39
42
  <div class="select-wrap {klass}">
40
- <select class="select" bind:value {...rest}>
43
+ <select class="select" bind:value {...rest} aria-invalid={invalid || undefined}>
41
44
  {@render children?.()}
42
45
  </select>
43
46
  <span class="select-chevron" aria-hidden="true">
@@ -71,6 +74,10 @@
71
74
  outline: none;
72
75
  border-color: var(--accent);
73
76
  }
77
+ .select[aria-invalid='true'],
78
+ .select[aria-invalid='true']:focus {
79
+ border-color: var(--danger);
80
+ }
74
81
  .select-chevron {
75
82
  position: absolute;
76
83
  top: 50%;
@@ -2,6 +2,8 @@ import type { Snippet } from 'svelte';
2
2
  import type { HTMLSelectAttributes } from 'svelte/elements';
3
3
  type Props = HTMLSelectAttributes & {
4
4
  variant?: 'default' | 'ghost';
5
+ /** Error state: danger border + aria-invalid. */
6
+ invalid?: boolean;
5
7
  class?: string;
6
8
  value?: HTMLSelectAttributes['value'];
7
9
  children?: Snippet;
@@ -4,6 +4,7 @@
4
4
  // engines from tokens. The track shows a filled portion up to the current
5
5
  // value, and an optional `output` displays the value with a correct
6
6
  // for-association. `bind:value` and all native attrs/events pass through.
7
+ // Recolor per-instance via `--slider-accent` (defaults to the theme accent).
7
8
  import type { HTMLInputAttributes } from 'svelte/elements';
8
9
 
9
10
  let {
@@ -73,7 +74,7 @@
73
74
  border-radius: var(--r-pill);
74
75
  background: linear-gradient(
75
76
  to right,
76
- var(--accent) var(--pct),
77
+ var(--slider-accent, var(--accent)) var(--pct),
77
78
  var(--bg-elevated-2) var(--pct)
78
79
  );
79
80
  }
@@ -85,7 +86,7 @@
85
86
  input[type='range']::-moz-range-progress {
86
87
  height: 0.35rem;
87
88
  border-radius: var(--r-pill);
88
- background: var(--accent);
89
+ background: var(--slider-accent, var(--accent));
89
90
  }
90
91
  /* Thumb */
91
92
  input[type='range']::-webkit-slider-thumb {
@@ -95,7 +96,7 @@
95
96
  width: 1.2rem;
96
97
  height: 1.2rem;
97
98
  border-radius: 50%;
98
- background: var(--accent);
99
+ background: var(--slider-accent, var(--accent));
99
100
  border: 2px solid var(--bg);
100
101
  box-shadow: var(--shadow-sm);
101
102
  transition: transform 0.1s var(--ease);
@@ -104,7 +105,7 @@
104
105
  width: 1.2rem;
105
106
  height: 1.2rem;
106
107
  border-radius: 50%;
107
- background: var(--accent);
108
+ background: var(--slider-accent, var(--accent));
108
109
  border: 2px solid var(--bg);
109
110
  box-shadow: var(--shadow-sm);
110
111
  }
@@ -6,10 +6,11 @@
6
6
 
7
7
  let {
8
8
  checked = false,
9
+ invalid = false,
9
10
  label,
10
11
  class: klass = '',
11
12
  ...rest
12
- }: HTMLButtonAttributes & { checked?: boolean; label: string } = $props();
13
+ }: HTMLButtonAttributes & { checked?: boolean; invalid?: boolean; label: string } = $props();
13
14
  </script>
14
15
 
15
16
  <button
@@ -19,6 +20,7 @@
19
20
  class:on={checked}
20
21
  role="switch"
21
22
  aria-checked={checked}
23
+ aria-invalid={invalid || undefined}
22
24
  aria-label={label}
23
25
  >
24
26
  <span class="knob"></span>
@@ -61,4 +63,7 @@
61
63
  opacity: 0.45;
62
64
  cursor: not-allowed;
63
65
  }
66
+ .switch[aria-invalid='true']:not(.on) {
67
+ border-color: var(--danger);
68
+ }
64
69
  </style>
@@ -1,6 +1,7 @@
1
1
  import type { HTMLButtonAttributes } from 'svelte/elements';
2
2
  type $$ComponentProps = HTMLButtonAttributes & {
3
3
  checked?: boolean;
4
+ invalid?: boolean;
4
5
  label: string;
5
6
  };
6
7
  declare const Switch: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -118,7 +118,14 @@
118
118
  .fs-2xl {
119
119
  font-size: var(--fs-2xl);
120
120
  }
121
+ /* `text-overflow: ellipsis` is a no-op on an inline box, so a truncated bare
122
+ <Text> (default as="span") would overrun its container instead of clipping.
123
+ inline-block gives it a block formatting context so the ellipsis applies,
124
+ while max-width keeps it from overflowing the parent; on block elements
125
+ (as="p"/"div") these are harmless. */
121
126
  .truncate {
127
+ display: inline-block;
128
+ max-width: 100%;
122
129
  overflow: hidden;
123
130
  text-overflow: ellipsis;
124
131
  white-space: nowrap;