@dorsk/tsumikit 0.22.0 → 0.24.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.
Files changed (36) hide show
  1. package/README.md +151 -8
  2. package/dist/components/atoms/Button.svelte +121 -12
  3. package/dist/components/atoms/Button.svelte.d.ts +4 -0
  4. package/dist/components/atoms/Scrim.svelte +81 -0
  5. package/dist/components/atoms/Scrim.svelte.d.ts +14 -0
  6. package/dist/components/layouts/Cluster.svelte +33 -0
  7. package/dist/components/layouts/Cluster.svelte.d.ts +1 -0
  8. package/dist/components/layouts/ResizablePanel.svelte +238 -120
  9. package/dist/components/layouts/ResizablePanel.svelte.d.ts +24 -8
  10. package/dist/components/layouts/resizable-panel-frame.d.ts +70 -0
  11. package/dist/components/layouts/resizable-panel-frame.js +161 -0
  12. package/dist/components/molecules/CopyButton.svelte +11 -2
  13. package/dist/components/molecules/CopyButton.svelte.d.ts +4 -0
  14. package/dist/components/molecules/FileButton.svelte +32 -3
  15. package/dist/components/molecules/FileButton.svelte.d.ts +4 -0
  16. package/dist/components/molecules/IconButton.svelte +34 -6
  17. package/dist/components/molecules/IconButton.svelte.d.ts +3 -0
  18. package/dist/components/molecules/Popover.svelte +32 -2
  19. package/dist/components/molecules/Popover.svelte.d.ts +5 -0
  20. package/dist/components/molecules/SelectButton.svelte +6 -1
  21. package/dist/components/molecules/SelectButton.svelte.d.ts +2 -0
  22. package/dist/components/molecules/ThemePicker.svelte +11 -13
  23. package/dist/components/organisms/DataTable.svelte +210 -10
  24. package/dist/components/organisms/DataTable.svelte.d.ts +27 -1
  25. package/dist/index.d.ts +3 -1
  26. package/dist/index.js +3 -1
  27. package/dist/stores/theme.svelte.d.ts +21 -5
  28. package/dist/stores/theme.svelte.js +48 -22
  29. package/dist/styles/app.css +7 -297
  30. package/dist/styles/reset.css +97 -0
  31. package/dist/styles/syntax.css +94 -0
  32. package/dist/styles/themes.css +729 -0
  33. package/dist/styles/tokens.css +207 -0
  34. package/dist/styles/utilities.css +111 -0
  35. package/dist/styles/variables.css +5 -915
  36. package/package.json +7 -2
@@ -61,3 +61,164 @@ export function createFrameBatcher(requestFrame, cancelFrame, apply) {
61
61
  },
62
62
  };
63
63
  }
64
+
65
+ /**
66
+ * Resolve a width prop that is either a pixel number or a CSS length.
67
+ * Plain `px` strings parse directly; anything else is handed to `measure`,
68
+ * which lays the length out and returns its pixel size (or `undefined` when
69
+ * there is no DOM to measure in).
70
+ *
71
+ * @param {number | string} value
72
+ * @param {(css: string) => number | undefined} measure
73
+ * @returns {number | undefined}
74
+ */
75
+ export function resolveLength(value, measure) {
76
+ if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
77
+ const px = /^\s*(-?\d*\.?\d+)px\s*$/.exec(value);
78
+ if (px) return Number(px[1]);
79
+ const measured = measure(value);
80
+ return measured !== undefined && Number.isFinite(measured) ? measured : undefined;
81
+ }
82
+
83
+ /**
84
+ * @typedef {object} ResizeHandleParams
85
+ * @property {'left' | 'right'} side Edge the resized box sits on; dragging away from it grows the box.
86
+ * @property {(width: number) => void} onwidth Called once per animation frame while dragging and on every keyboard step.
87
+ * @property {(width: number) => void} [oncommit] Called with the settled width on pointer release and after each keyboard step.
88
+ * @property {() => void} [onreset] Double-click on the handle.
89
+ * @property {(active: boolean) => void} [onactive] Drag start/end, for a `resizing` class.
90
+ * @property {() => number} [measure] Current width in px; defaults to the handle's parent box.
91
+ * @property {number} [min]
92
+ * @property {number} [max]
93
+ * @property {number} [step] Pixels per arrow key press (default 16).
94
+ */
95
+
96
+ /**
97
+ * Svelte action turning any element into a pointer + keyboard width grip.
98
+ * One pointer-capture / rAF-coalesced implementation shared by
99
+ * ResizablePanel and consumer-built grips.
100
+ *
101
+ * Usage: <div role="separator" tabindex="0" use:resizeHandle={{ side, min, max, onwidth }}></div>
102
+ *
103
+ * @param {HTMLElement} node
104
+ * @param {ResizeHandleParams} params
105
+ */
106
+ export function resizeHandle(node, params) {
107
+ let current = params;
108
+ let active = false;
109
+ let startX = 0;
110
+ let startWidth = 0;
111
+
112
+ const frames = createFrameBatcher(
113
+ (callback) => requestAnimationFrame(callback),
114
+ (handle) => cancelAnimationFrame(handle),
115
+ /** @param {number} width */
116
+ (width) => current.onwidth(width),
117
+ );
118
+
119
+ /** @param {number} width */
120
+ function clamp(width) {
121
+ const min = current.min ?? 1;
122
+ const max = current.max ?? Number.POSITIVE_INFINITY;
123
+ return Math.round(Math.max(min, Math.min(width, Math.max(min, max))));
124
+ }
125
+
126
+ function direction() {
127
+ return current.side === 'right' ? -1 : 1;
128
+ }
129
+
130
+ function measure() {
131
+ if (current.measure) return current.measure();
132
+ return node.parentElement?.getBoundingClientRect().width ?? 0;
133
+ }
134
+
135
+ /** @param {number} clientX */
136
+ function widthAt(clientX) {
137
+ return clamp(startWidth + (clientX - startX) * direction());
138
+ }
139
+
140
+ /** @param {PointerEvent} event */
141
+ function down(event) {
142
+ if (event.button !== 0) return;
143
+ active = true;
144
+ startX = event.clientX;
145
+ startWidth = measure();
146
+ node.setPointerCapture(event.pointerId);
147
+ event.preventDefault();
148
+ current.onactive?.(true);
149
+ }
150
+
151
+ /** @param {PointerEvent} event */
152
+ function move(event) {
153
+ if (!active) return;
154
+ frames.schedule(widthAt(event.clientX));
155
+ }
156
+
157
+ /** @param {PointerEvent} event */
158
+ function up(event) {
159
+ if (!active) return;
160
+ active = false;
161
+ const width = widthAt(event.clientX);
162
+ frames.flush(width);
163
+ try {
164
+ node.releasePointerCapture(event.pointerId);
165
+ } catch {
166
+ // Pointer capture may already have been released by the browser.
167
+ }
168
+ current.onactive?.(false);
169
+ current.oncommit?.(width);
170
+ }
171
+
172
+ /** @param {KeyboardEvent} event */
173
+ function keydown(event) {
174
+ const step = current.step ?? 16;
175
+ /** @type {number | undefined} */
176
+ let next;
177
+ switch (event.key) {
178
+ case 'ArrowLeft':
179
+ next = measure() - step * direction();
180
+ break;
181
+ case 'ArrowRight':
182
+ next = measure() + step * direction();
183
+ break;
184
+ case 'Home':
185
+ next = current.min;
186
+ break;
187
+ case 'End':
188
+ next = current.max;
189
+ break;
190
+ }
191
+ if (next === undefined) return;
192
+ event.preventDefault();
193
+ const width = clamp(next);
194
+ current.onwidth(width);
195
+ current.oncommit?.(width);
196
+ }
197
+
198
+ function reset() {
199
+ current.onreset?.();
200
+ }
201
+
202
+ node.addEventListener('pointerdown', down);
203
+ node.addEventListener('pointermove', move);
204
+ node.addEventListener('pointerup', up);
205
+ node.addEventListener('pointercancel', up);
206
+ node.addEventListener('keydown', keydown);
207
+ node.addEventListener('dblclick', reset);
208
+
209
+ return {
210
+ /** @param {ResizeHandleParams} next */
211
+ update(next) {
212
+ current = next;
213
+ },
214
+ destroy() {
215
+ node.removeEventListener('pointerdown', down);
216
+ node.removeEventListener('pointermove', move);
217
+ node.removeEventListener('pointerup', up);
218
+ node.removeEventListener('pointercancel', up);
219
+ node.removeEventListener('keydown', keydown);
220
+ node.removeEventListener('dblclick', reset);
221
+ frames.discard();
222
+ },
223
+ };
224
+ }
@@ -13,7 +13,9 @@
13
13
  label = 'Copy',
14
14
  copiedLabel = 'Copied',
15
15
  variant = 'ghost',
16
- showLabel = true,
16
+ showLabel,
17
+ box,
18
+ hitArea = 'auto',
17
19
  resetMs = 1500,
18
20
  class: klass = ''
19
21
  }: {
@@ -22,11 +24,16 @@
22
24
  label?: string;
23
25
  copiedLabel?: string;
24
26
  variant?: 'default' | 'primary' | 'ghost' | 'danger';
27
+ /** Defaults to true, or false when `box` makes it icon-only. */
25
28
  showLabel?: boolean;
29
+ /** Shared square box scale (`--box-xs/sm/md/lg`): a square icon-only copy control. */
30
+ box?: 'xs' | 'sm' | 'md' | 'lg';
31
+ hitArea?: 'auto' | 'compact';
26
32
  resetMs?: number;
27
33
  class?: string;
28
34
  } = $props();
29
35
 
36
+ const labelShown = $derived(showLabel ?? box === undefined);
30
37
  let copied = $state(false);
31
38
  let status = $state('');
32
39
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -46,12 +53,14 @@
46
53
  <Button
47
54
  data-tsu="CopyButton"
48
55
  {variant}
56
+ {box}
57
+ {hitArea}
49
58
  class={klass}
50
59
  onclick={copy}
51
60
  aria-label={copied ? copiedLabel : label}
52
61
  title={label}
53
62
  >
54
63
  <Icon name={copied ? 'check' : 'copy'} />
55
- {#if showLabel}<span>{copied ? copiedLabel : label}</span>{/if}
64
+ {#if labelShown}<span>{copied ? copiedLabel : label}</span>{/if}
56
65
  </Button>
57
66
  <span class="sr-only" role="status" aria-live="polite">{status}</span>
@@ -4,7 +4,11 @@ type $$ComponentProps = {
4
4
  label?: string;
5
5
  copiedLabel?: string;
6
6
  variant?: 'default' | 'primary' | 'ghost' | 'danger';
7
+ /** Defaults to true, or false when `box` makes it icon-only. */
7
8
  showLabel?: boolean;
9
+ /** Shared square box scale (`--box-xs/sm/md/lg`): a square icon-only copy control. */
10
+ box?: 'xs' | 'sm' | 'md' | 'lg';
11
+ hitArea?: 'auto' | 'compact';
8
12
  resetMs?: number;
9
13
  class?: string;
10
14
  };
@@ -16,6 +16,8 @@
16
16
  disabled = false,
17
17
  variant = 'default',
18
18
  size = 'md',
19
+ box,
20
+ hitArea = 'auto',
19
21
  class: klass = ''
20
22
  }: {
21
23
  onfiles: (files: File[]) => void;
@@ -36,9 +38,15 @@
36
38
  /** Match the Button atom's sizes so a FileButton lines up with buttons in
37
39
  * the same row. */
38
40
  size?: 'sm' | 'md' | 'lg';
41
+ /** Shared square box scale (`--box-xs/sm/md/lg`); implies `iconOnly`. */
42
+ box?: 'xs' | 'sm' | 'md' | 'lg';
43
+ /** Icon-only buttons grow a 44px hit slab on coarse pointers; `compact` opts out. */
44
+ hitArea?: 'auto' | 'compact';
39
45
  class?: string;
40
46
  } = $props();
41
47
 
48
+ const onlyIcon = $derived(iconOnly || box !== undefined);
49
+
42
50
  function onchange(e: Event) {
43
51
  const input = e.currentTarget as HTMLInputElement;
44
52
  const files = Array.from(input.files ?? []);
@@ -54,9 +62,12 @@
54
62
  class:ghost={variant === 'ghost'}
55
63
  class:sm={size === 'sm'}
56
64
  class:lg={size === 'lg'}
57
- class:icon-only={iconOnly}
65
+ class:icon-only={onlyIcon}
66
+ class:box={box !== undefined}
67
+ class:hit-compact={hitArea === 'compact'}
68
+ style:--file-box={box ? `var(--box-${box})` : undefined}
58
69
  class:disabled
59
- aria-label={iconOnly ? label : undefined}
70
+ aria-label={onlyIcon ? label : undefined}
60
71
  >
61
72
  <input
62
73
  class="sr-only"
@@ -71,7 +82,7 @@
71
82
  {:else if emoji}
72
83
  <span class="emoji" aria-hidden="true">{emoji}</span>
73
84
  {/if}
74
- <span class:sr-only={iconOnly}>{label}</span>
85
+ <span class:sr-only={onlyIcon}>{label}</span>
75
86
  </label>
76
87
 
77
88
  <style>
@@ -120,6 +131,24 @@
120
131
  padding: var(--sp-2);
121
132
  aspect-ratio: 1;
122
133
  }
134
+ .file-btn.box {
135
+ width: var(--file-box);
136
+ min-width: var(--file-box);
137
+ height: var(--file-box);
138
+ min-height: var(--file-box);
139
+ padding: 0;
140
+ flex: none;
141
+ }
142
+ @media (pointer: coarse) {
143
+ .file-btn.icon-only:not(.hit-compact) {
144
+ position: relative;
145
+ }
146
+ .file-btn.icon-only:not(.hit-compact)::after {
147
+ content: '';
148
+ position: absolute;
149
+ inset: min(0px, calc((100% - var(--touch-target)) / 2));
150
+ }
151
+ }
123
152
  /* Emoji glyph is bumped above the text size — at 1em a paperclip is hard to
124
153
  read, so render it a touch larger for legibility. */
125
154
  .emoji {
@@ -18,6 +18,10 @@ type $$ComponentProps = {
18
18
  /** Match the Button atom's sizes so a FileButton lines up with buttons in
19
19
  * the same row. */
20
20
  size?: 'sm' | 'md' | 'lg';
21
+ /** Shared square box scale (`--box-xs/sm/md/lg`); implies `iconOnly`. */
22
+ box?: 'xs' | 'sm' | 'md' | 'lg';
23
+ /** Icon-only buttons grow a 44px hit slab on coarse pointers; `compact` opts out. */
24
+ hitArea?: 'auto' | 'compact';
21
25
  class?: string;
22
26
  };
23
27
  declare const FileButton: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -18,9 +18,18 @@
18
18
  variant?: 'default' | 'primary' | 'ghost' | 'danger';
19
19
  // Semantic state tint (forwarded to Button). Pairs well with `chip`.
20
20
  tone?: 'none' | 'accent' | 'info' | 'warn' | 'danger';
21
- // Larger 2.5rem outlined square icon-chip (header/toolbar actions).
21
+ // Outlined `--box-lg` square icon-chip (header/toolbar actions).
22
22
  chip?: boolean;
23
+ // Shared square box scale (`--box-xs/sm/md/lg`); default `md` is the classic
24
+ // 2.25rem box. Forwarded to Button.
25
+ box?: 'xs' | 'sm' | 'md' | 'lg';
26
+ // SVG glyph px. Emoji text glyphs render at ×1.35 of it.
23
27
  size?: number;
28
+ // Exact glyph size for SVG *and* text glyphs (px number or any CSS length),
29
+ // no emoji multiplier. Wins over `size`.
30
+ glyphSize?: number | string;
31
+ // Opt out of the 44px coarse-pointer hit slab in dense rows.
32
+ hitArea?: 'auto' | 'compact';
24
33
  // Borderless, compact icon affordance (chip-remove ✕, inline edit ✎) —
25
34
  // no square box; just a muted glyph that brightens on hover. Pair with
26
35
  // `hoverDanger` to tint it red on hover (delete affordances).
@@ -46,7 +55,10 @@
46
55
  variant = 'ghost',
47
56
  tone = 'none',
48
57
  chip = false,
58
+ box,
49
59
  size = 18,
60
+ glyphSize,
61
+ hitArea = 'auto',
50
62
  inline = false,
51
63
  hoverDanger = false,
52
64
  pressed,
@@ -55,6 +67,11 @@
55
67
  class: klass = '',
56
68
  ...rest
57
69
  }: IconButtonProps = $props();
70
+
71
+ const glyphCss = $derived(
72
+ glyphSize === undefined ? undefined : typeof glyphSize === 'number' ? `${glyphSize}px` : glyphSize
73
+ );
74
+ const emojiCss = $derived(glyphCss ?? `${size * 1.35}px`);
58
75
  </script>
59
76
 
60
77
  <!-- Composition: the icon-only button is a Button (canonical control styling)
@@ -65,6 +82,8 @@
65
82
  {variant}
66
83
  {tone}
67
84
  {chip}
85
+ {box}
86
+ {hitArea}
68
87
  {disabled}
69
88
  {title}
70
89
  {onclick}
@@ -76,19 +95,28 @@
76
95
  aria-label={label}
77
96
  >
78
97
  {#if children}
79
- <Icon {size}>{@render children()}</Icon>
98
+ {#if glyphCss}
99
+ <span class="glyph" style="font-size: {glyphCss}"><Icon>{@render children()}</Icon></span>
100
+ {:else}
101
+ <Icon {size}>{@render children()}</Icon>
102
+ {/if}
80
103
  {:else if emoji}
81
- <span class="emoji" style="font-size: {size * 1.35}px" aria-hidden="true">{emoji}</span>
104
+ <span class="emoji" style="font-size: {emojiCss}" aria-hidden="true">{emoji}</span>
82
105
  {:else if icon}
83
- <Icon name={icon} {size} />
106
+ {#if glyphCss}
107
+ <span class="glyph" style="font-size: {glyphCss}"><Icon name={icon} /></span>
108
+ {:else}
109
+ <Icon name={icon} {size} />
110
+ {/if}
84
111
  {/if}
85
112
  </Button>
86
113
 
87
114
  <style>
88
115
  /* Off-registry glyph (emoji) rendered as text rather than an SVG. Sized off the
89
116
  `size` prop (×1.35, since an emoji reads small next to an SVG glyph of the
90
- same px) and centered so it shares the button's tap target. */
91
- .emoji {
117
+ same px) unless `glyphSize` is exact; centered so it shares the tap target. */
118
+ .emoji,
119
+ .glyph {
92
120
  display: inline-flex;
93
121
  align-items: center;
94
122
  justify-content: center;
@@ -14,7 +14,10 @@ type IconButtonProps = HTMLButtonAttributes & {
14
14
  variant?: 'default' | 'primary' | 'ghost' | 'danger';
15
15
  tone?: 'none' | 'accent' | 'info' | 'warn' | 'danger';
16
16
  chip?: boolean;
17
+ box?: 'xs' | 'sm' | 'md' | 'lg';
17
18
  size?: number;
19
+ glyphSize?: number | string;
20
+ hitArea?: 'auto' | 'compact';
18
21
  inline?: boolean;
19
22
  hoverDanger?: boolean;
20
23
  pressed?: boolean;
@@ -27,8 +27,10 @@
27
27
  variant,
28
28
  tone = 'none',
29
29
  size,
30
+ box,
30
31
  control = false,
31
32
  block = false,
33
+ hitArea = 'auto',
32
34
  disabled = false,
33
35
  onopen,
34
36
  onclose
@@ -52,9 +54,14 @@
52
54
  variant?: TriggerVariant;
53
55
  tone?: TriggerTone;
54
56
  size?: TriggerSize;
57
+ /** Shared square box scale (`--box-xs/sm/md/lg`) for an icon-only trigger;
58
+ * the default trigger is the `md` 2.25rem box. */
59
+ box?: 'xs' | 'sm' | 'md' | 'lg';
55
60
  /** Use the shared `--control-height` toolbar/composer contract. */
56
61
  control?: boolean;
57
62
  block?: boolean;
63
+ /** Icon-only triggers grow a 44px hit slab on coarse pointers; `compact` opts out. */
64
+ hitArea?: 'auto' | 'compact';
58
65
  disabled?: boolean;
59
66
  onopen?: () => void;
60
67
  onclose?: () => void;
@@ -109,6 +116,9 @@
109
116
  class:trigger-lg={size === 'lg'}
110
117
  class:trigger-control={control}
111
118
  class:trigger-block={block}
119
+ class:trigger-box={box !== undefined}
120
+ class:hit-compact={hitArea === 'compact'}
121
+ style:--pop-box={box ? `var(--box-${box})` : undefined}
112
122
  class:trigger-tone-accent={tone === 'accent'}
113
123
  class:trigger-tone-success={tone === 'success'}
114
124
  class:trigger-tone-info={tone === 'info'}
@@ -143,8 +153,8 @@
143
153
  display: inline-flex;
144
154
  align-items: center;
145
155
  justify-content: center;
146
- min-height: 2.25rem;
147
- min-width: 2.25rem;
156
+ min-height: var(--box-md);
157
+ min-width: var(--box-md);
148
158
  padding: var(--sp-2);
149
159
  border: 1px solid transparent;
150
160
  border-radius: var(--r-md);
@@ -221,6 +231,26 @@
221
231
  .pop-trigger.trigger-block {
222
232
  width: 100%;
223
233
  }
234
+ .pop-trigger.trigger-box {
235
+ width: var(--pop-box);
236
+ min-width: var(--pop-box);
237
+ height: var(--pop-box);
238
+ min-height: var(--pop-box);
239
+ padding: 0;
240
+ flex: none;
241
+ }
242
+ /* Coarse pointers: icon-only triggers extend their hit area to --touch-target
243
+ via an invisible slab; layout does not move. */
244
+ @media (pointer: coarse) {
245
+ .pop-trigger:not(.canonical, .bare, .hit-compact) {
246
+ position: relative;
247
+ }
248
+ .pop-trigger:not(.canonical, .bare, .hit-compact)::after {
249
+ content: '';
250
+ position: absolute;
251
+ inset: min(0px, calc((100% - var(--touch-target)) / 2));
252
+ }
253
+ }
224
254
  .pop-trigger.trigger-tone-accent {
225
255
  --pop-trigger-tone: var(--accent);
226
256
  }
@@ -23,9 +23,14 @@ type $$ComponentProps = {
23
23
  variant?: TriggerVariant;
24
24
  tone?: TriggerTone;
25
25
  size?: TriggerSize;
26
+ /** Shared square box scale (`--box-xs/sm/md/lg`) for an icon-only trigger;
27
+ * the default trigger is the `md` 2.25rem box. */
28
+ box?: 'xs' | 'sm' | 'md' | 'lg';
26
29
  /** Use the shared `--control-height` toolbar/composer contract. */
27
30
  control?: boolean;
28
31
  block?: boolean;
32
+ /** Icon-only triggers grow a 44px hit slab on coarse pointers; `compact` opts out. */
33
+ hitArea?: 'auto' | 'compact';
29
34
  disabled?: boolean;
30
35
  onopen?: () => void;
31
36
  onclose?: () => void;
@@ -17,6 +17,8 @@
17
17
  options,
18
18
  groups,
19
19
  onchange,
20
+ box,
21
+ hitArea = 'auto',
20
22
  class: klass = '',
21
23
  ...rest
22
24
  }: {
@@ -31,6 +33,9 @@
31
33
  // the theme picker to split light/dark; falls back to `options` otherwise.
32
34
  groups?: { label: string; options: Option[] }[];
33
35
  onchange: (value: string) => void;
36
+ // Shared square box scale (`--box-xs/sm/md/lg`); default is the 2.25rem icon box.
37
+ box?: 'xs' | 'sm' | 'md' | 'lg';
38
+ hitArea?: 'auto' | 'compact';
34
39
  class?: string;
35
40
  [key: string]: unknown;
36
41
  } = $props();
@@ -39,7 +44,7 @@
39
44
  <!-- The wrapper (owned here, so styled scoped) is the positioning context that
40
45
  clips the transparent overlaid <select>; the icon Button shows through. -->
41
46
  <span class="select-button {klass}" data-tsu="SelectButton" {...rest}>
42
- <Button variant="ghost" icon {title} aria-label={label}>
47
+ <Button variant="ghost" icon {box} {hitArea} {title} aria-label={label}>
43
48
  <span aria-hidden="true">{glyph}</span>
44
49
  <Select
45
50
  variant="ghost"
@@ -13,6 +13,8 @@ type $$ComponentProps = {
13
13
  options: Option[];
14
14
  }[];
15
15
  onchange: (value: string) => void;
16
+ box?: 'xs' | 'sm' | 'md' | 'lg';
17
+ hitArea?: 'auto' | 'compact';
16
18
  class?: string;
17
19
  [key: string]: unknown;
18
20
  };
@@ -1,23 +1,21 @@
1
1
  <script lang="ts">
2
2
  // Theme switcher: a compact icon-button hosting a native <select> over the
3
- // full theme registry, wired to the global theme store. The native control
4
- // gives correct mobile/keyboard behaviour and outside-click handling for free.
5
- // The button glyph reflects the active theme's icon.
3
+ // full theme registry (built-ins + theme.register()), wired to the global
4
+ // theme store. The native control gives correct mobile/keyboard behaviour and
5
+ // outside-click handling for free. The button glyph reflects the active theme.
6
6
  import SelectButton from './SelectButton.svelte';
7
- import { theme, THEMES } from '../../stores/theme.svelte';
7
+ import { type ThemeDef, theme } from '../../stores/theme.svelte';
8
8
 
9
9
  let { class: klass = '' }: { class?: string } = $props();
10
10
 
11
- // Split the registry into light/dark sections (TSU-1) so the long list is
12
- // scannable. `<optgroup>` keeps native popup/keyboard behaviour for free.
13
- const toOption = (t: (typeof THEMES)[number]) => ({
11
+ const toOption = (t: ThemeDef) => ({
14
12
  value: t.id,
15
- label: `${t.icon} ${t.label}`
13
+ label: `${t.icon ?? theme.fallbackIcon} ${t.label}`
16
14
  });
17
- const groups = [
18
- { label: '— light', options: THEMES.filter((t) => t.mode === 'light').map(toOption) },
19
- { label: '— dark', options: THEMES.filter((t) => t.mode === 'dark').map(toOption) }
20
- ];
15
+ const groups = $derived([
16
+ { label: '— light', options: theme.all.filter((t) => t.mode === 'light').map(toOption) },
17
+ { label: '— dark', options: theme.all.filter((t) => t.mode === 'dark').map(toOption) }
18
+ ]);
21
19
  </script>
22
20
 
23
21
  <SelectButton
@@ -28,5 +26,5 @@
28
26
  title={`Theme: ${theme.label}`}
29
27
  value={theme.current}
30
28
  {groups}
31
- onchange={(v) => theme.set(v as (typeof THEMES)[number]['id'])}
29
+ onchange={(v) => theme.set(v)}
32
30
  />