@classic-homes/theme-svelte 1.25.1 → 1.26.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 (69) hide show
  1. package/dist/lib/components/AlertDialog.svelte +14 -6
  2. package/dist/lib/components/AlertDialog.svelte.d.ts +8 -2
  3. package/dist/lib/components/Badge.svelte +6 -2
  4. package/dist/lib/components/CodeField.svelte +24 -8
  5. package/dist/lib/components/CodeField.svelte.d.ts +8 -0
  6. package/dist/lib/components/Combobox.svelte +25 -111
  7. package/dist/lib/components/DataPanel.svelte +31 -24
  8. package/dist/lib/components/DataPanelHeader.svelte +66 -26
  9. package/dist/lib/components/DataTable.svelte +303 -138
  10. package/dist/lib/components/DataTable.svelte.d.ts +37 -13
  11. package/dist/lib/components/DateTimePicker.svelte +159 -34
  12. package/dist/lib/components/Dialog.svelte +14 -6
  13. package/dist/lib/components/Dialog.svelte.d.ts +8 -2
  14. package/dist/lib/components/FileUpload.svelte +23 -6
  15. package/dist/lib/components/FileUpload.svelte.d.ts +7 -2
  16. package/dist/lib/components/MultiSelect.svelte +28 -155
  17. package/dist/lib/components/NumberInput.svelte +28 -110
  18. package/dist/lib/components/OTPInput.svelte +73 -85
  19. package/dist/lib/components/Select.svelte +24 -29
  20. package/dist/lib/components/Signature.svelte +80 -48
  21. package/dist/lib/components/Signature.svelte.d.ts +6 -1
  22. package/dist/lib/components/SkipLink.svelte +7 -2
  23. package/dist/lib/components/SkipLink.svelte.d.ts +2 -0
  24. package/dist/lib/components/Slider.svelte +4 -4
  25. package/dist/lib/components/Toast.svelte +11 -1
  26. package/dist/lib/components/ToastContainer.svelte +38 -24
  27. package/dist/lib/components/Tooltip.svelte +19 -6
  28. package/dist/lib/components/Tooltip.svelte.d.ts +8 -2
  29. package/dist/lib/components/data-table/DataTableColumnFilter.svelte +3 -1
  30. package/dist/lib/components/data-table/DataTableEditableCell.svelte +25 -3
  31. package/dist/lib/components/data-table/DataTableFacetedFilter.svelte +50 -40
  32. package/dist/lib/components/data-table/DataTableFacetedFilter.svelte.d.ts +0 -10
  33. package/dist/lib/components/data-table/DataTableVirtual.svelte +109 -57
  34. package/dist/lib/components/data-table/DataTableVirtual.svelte.d.ts +2 -0
  35. package/dist/lib/components/data-table/createDataTable.svelte.d.ts +26 -4
  36. package/dist/lib/components/data-table/createDataTable.svelte.js +97 -40
  37. package/dist/lib/components/data-table/index.d.ts +1 -1
  38. package/dist/lib/components/data-table/types.d.ts +14 -4
  39. package/dist/lib/components/data-table/types.js +5 -1
  40. package/dist/lib/components/internal/TriggerChild.svelte +60 -0
  41. package/dist/lib/components/internal/TriggerChild.svelte.d.ts +32 -0
  42. package/dist/lib/components/layout/Sidebar.svelte +33 -3
  43. package/dist/lib/components/layout/Sidebar.svelte.d.ts +1 -1
  44. package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte +6 -1
  45. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +6 -1
  46. package/dist/lib/components/layout/sidebar/SidebarSection.svelte +6 -1
  47. package/dist/lib/components/media/Audio.svelte +16 -0
  48. package/dist/lib/components/media/Lightbox.svelte +29 -3
  49. package/dist/lib/components/media/MediaCarousel.svelte +19 -0
  50. package/dist/lib/components/media/MediaEmbed.svelte +32 -1
  51. package/dist/lib/components/media/Video.svelte +16 -0
  52. package/dist/lib/components/media/internal/MediaControls.svelte +9 -1
  53. package/dist/lib/components/notifications/NotificationBadge.svelte +1 -1
  54. package/dist/lib/components/notifications/NotificationItem.svelte +19 -28
  55. package/dist/lib/composables/useEscapeKey.svelte.d.ts +4 -0
  56. package/dist/lib/composables/useEscapeKey.svelte.js +15 -0
  57. package/dist/lib/composables/useHlsSource.svelte.js +31 -4
  58. package/dist/lib/composables/useMediaPlayer.svelte.js +38 -9
  59. package/dist/lib/composables/useMediaZoom.svelte.js +32 -0
  60. package/dist/lib/composables/usePersistedForm.svelte.d.ts +6 -2
  61. package/dist/lib/composables/usePersistedForm.svelte.js +7 -7
  62. package/dist/lib/composables/useReducedMotion.svelte.js +6 -1
  63. package/dist/lib/composables/useRovingTabindex.svelte.js +12 -0
  64. package/dist/lib/index.d.ts +1 -1
  65. package/dist/lib/stores/toast.svelte.d.ts +13 -0
  66. package/dist/lib/stores/toast.svelte.js +36 -5
  67. package/dist/lib/utils/media.d.ts +7 -0
  68. package/dist/lib/utils/media.js +18 -3
  69. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { cn } from '../utils.js';
3
- import { generateId, computeDescribedBy } from '../utils/form.js';
3
+ import { computeDescribedBy } from '../utils/form.js';
4
4
  import Label from './Label.svelte';
5
5
 
6
6
  interface Props {
@@ -57,9 +57,9 @@
57
57
  class: className,
58
58
  }: Props = $props();
59
59
 
60
- // Generate unique IDs for accessibility
61
- const randomSuffix = generateId('number-input').split('-').pop();
62
- const componentId = $derived(id || `number-input-${randomSuffix}`);
60
+ // Generate unique IDs for accessibility ($props.id() is SSR-stable)
61
+ const uid = $props.id();
62
+ const componentId = $derived(id || `number-input-${uid}`);
63
63
  const hintId = $derived(`${componentId}-hint`);
64
64
  const errorId = $derived(`${componentId}-error`);
65
65
 
@@ -148,115 +148,21 @@
148
148
  );
149
149
 
150
150
  const displayValue = $derived(value !== null ? String(value) : '');
151
-
152
- // Determine if we need a container wrapper
153
- const hasWrapper = $derived(!!label || !!hint || !!error);
154
151
  </script>
155
152
 
156
- {#if hasWrapper}
157
- <div class="space-y-2">
158
- {#if label}
159
- <Label for={componentId} {disabled}>
160
- {label}
161
- {#if required}
162
- <span class="text-destructive ml-0.5" aria-hidden="true">*</span>
163
- <span class="sr-only">(required)</span>
164
- {/if}
165
- </Label>
166
- {/if}
167
-
168
- <div class={cn('relative flex items-center', className)}>
169
- {#if showStepper}
170
- <button
171
- type="button"
172
- onclick={decrement}
173
- disabled={disabled || !canDecrement}
174
- class={cn(
175
- 'flex h-10 w-10 items-center justify-center rounded-l-md border border-r-0 border-input bg-background text-foreground text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
176
- error && 'border-destructive'
177
- )}
178
- aria-label="Decrease value"
179
- >
180
- <svg
181
- xmlns="http://www.w3.org/2000/svg"
182
- width="16"
183
- height="16"
184
- viewBox="0 0 24 24"
185
- fill="none"
186
- stroke="currentColor"
187
- stroke-width="2"
188
- stroke-linecap="round"
189
- stroke-linejoin="round"
190
- >
191
- <path d="M5 12h14" />
192
- </svg>
193
- </button>
194
- {/if}
195
-
196
- <input
197
- type="text"
198
- inputmode="decimal"
199
- value={displayValue}
200
- {placeholder}
201
- {disabled}
202
- {readonly}
203
- {required}
204
- {name}
205
- id={componentId}
206
- class={cn(
207
- 'flex h-10 w-full bg-background px-3 py-2 text-base sm:text-sm text-center ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
208
- showStepper ? 'border-y border-input' : 'border border-input rounded-md',
209
- error && 'border-destructive focus-visible:ring-destructive'
210
- )}
211
- oninput={handleInput}
212
- onblur={handleBlur}
213
- onkeydown={handleKeyDown}
214
- aria-invalid={error ? 'true' : undefined}
215
- aria-describedby={describedBy}
216
- />
217
-
218
- {#if showStepper}
219
- <button
220
- type="button"
221
- onclick={increment}
222
- disabled={disabled || !canIncrement}
223
- class={cn(
224
- 'flex h-10 w-10 items-center justify-center rounded-r-md border border-l-0 border-input bg-background text-foreground text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
225
- error && 'border-destructive'
226
- )}
227
- aria-label="Increase value"
228
- >
229
- <svg
230
- xmlns="http://www.w3.org/2000/svg"
231
- width="16"
232
- height="16"
233
- viewBox="0 0 24 24"
234
- fill="none"
235
- stroke="currentColor"
236
- stroke-width="2"
237
- stroke-linecap="round"
238
- stroke-linejoin="round"
239
- >
240
- <path d="M5 12h14" />
241
- <path d="M12 5v14" />
242
- </svg>
243
- </button>
153
+ <!-- One structure whether or not there is a label, hint or error, so the
154
+ focused control is never remounted when an error appears. -->
155
+ <div class="space-y-2">
156
+ {#if label}
157
+ <Label for={componentId} {disabled}>
158
+ {label}
159
+ {#if required}
160
+ <span class="text-destructive ml-0.5" aria-hidden="true">*</span>
161
+ <span class="sr-only">(required)</span>
244
162
  {/if}
245
- </div>
246
-
247
- {#if hint && !error}
248
- <p id={hintId} class="text-sm text-muted-foreground">
249
- {hint}
250
- </p>
251
- {/if}
163
+ </Label>
164
+ {/if}
252
165
 
253
- {#if error}
254
- <p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
255
- {error}
256
- </p>
257
- {/if}
258
- </div>
259
- {:else}
260
166
  <div class={cn('relative flex items-center', className)}>
261
167
  {#if showStepper}
262
168
  <button
@@ -335,4 +241,16 @@
335
241
  </button>
336
242
  {/if}
337
243
  </div>
338
- {/if}
244
+
245
+ {#if hint && !error}
246
+ <p id={hintId} class="text-sm text-muted-foreground">
247
+ {hint}
248
+ </p>
249
+ {/if}
250
+
251
+ {#if error}
252
+ <p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
253
+ {error}
254
+ </p>
255
+ {/if}
256
+ </div>
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { cn } from '../utils.js';
3
- import { generateId, computeDescribedBy } from '../utils/form.js';
3
+ import { untrack } from 'svelte';
4
+ import { computeDescribedBy } from '../utils/form.js';
4
5
  import Label from './Label.svelte';
5
6
  import LiveRegion from './LiveRegion.svelte';
6
7
 
@@ -49,9 +50,9 @@
49
50
  class: className,
50
51
  }: Props = $props();
51
52
 
52
- // Generate unique IDs for accessibility
53
- const randomSuffix = generateId('otp').split('-').pop();
54
- const componentId = $derived(id || `otp-${randomSuffix}`);
53
+ // Generate unique IDs for accessibility ($props.id() is SSR-stable)
54
+ const uid = $props.id();
55
+ const componentId = $derived(id || `otp-${uid}`);
55
56
  const hintId = $derived(`${componentId}-hint`);
56
57
  const errorId = $derived(`${componentId}-error`);
57
58
 
@@ -60,14 +61,32 @@
60
61
 
61
62
  let inputs: HTMLInputElement[] = $state([]);
62
63
 
63
- // Split value into individual digits, ensuring we always have `length` elements
64
- const digits = $derived.by(() => {
65
- const arr = value.split('').slice(0, length);
66
- // Pad array to always have `length` elements
67
- while (arr.length < length) {
68
- arr.push('');
69
- }
64
+ // One entry per cell. The cells, not `value`, are the source of truth for
65
+ // what is shown, so clearing a middle cell leaves a gap instead of shifting
66
+ // the later digits left, and a digit typed into a later cell stays there.
67
+ // `value` is the filled cells joined in order.
68
+ let digits = $state<string[]>(untrack(() => toCells(value, length)));
69
+
70
+ function toCells(str: string, len: number): string[] {
71
+ const arr = str.split('').slice(0, len);
72
+ while (arr.length < len) arr.push('');
70
73
  return arr;
74
+ }
75
+
76
+ function compact(cells: string[]): string {
77
+ return cells.join('').slice(0, length);
78
+ }
79
+
80
+ // Re-seed the cells when `value` or `length` changes from outside (a reset,
81
+ // a programmatic fill). Our own writes keep compact(digits) === value, so
82
+ // they leave the cells, gaps included, alone.
83
+ $effect(() => {
84
+ const next = value;
85
+ const len = length;
86
+ untrack(() => {
87
+ if (digits.length === len && compact(digits) === next) return;
88
+ digits = toCells(next, len);
89
+ });
71
90
  });
72
91
 
73
92
  // Pattern for input validation
@@ -88,11 +107,13 @@
88
107
  let announcement = $state('');
89
108
 
90
109
  function updateValue(newDigits: string[]) {
91
- // Filter out empty strings and join to get actual value
92
- const newValue = newDigits
93
- .filter((d) => d !== '')
94
- .join('')
95
- .slice(0, length);
110
+ digits = newDigits;
111
+ const newValue = compact(newDigits);
112
+ // The DOM `value` of a cell isn't reset when its digit doesn't change
113
+ // (e.g. a rejected key), so write every cell back explicitly.
114
+ newDigits.forEach((d, i) => {
115
+ if (inputs[i] && inputs[i].value !== d) inputs[i].value = d;
116
+ });
96
117
  value = newValue;
97
118
  onValueChange?.(newValue);
98
119
 
@@ -110,7 +131,15 @@
110
131
  const target = e.target as HTMLInputElement;
111
132
  const inputValue = target.value;
112
133
 
113
- if (!inputValue) return;
134
+ if (!inputValue) {
135
+ // The browser cleared the cell (e.g. cut, or IME); mirror it
136
+ if (digits[index]) {
137
+ const newDigits = [...digits];
138
+ newDigits[index] = '';
139
+ updateValue(newDigits);
140
+ }
141
+ return;
142
+ }
114
143
 
115
144
  // Handle paste of multiple characters
116
145
  if (inputValue.length > 1) {
@@ -206,78 +235,25 @@
206
235
  const target = e.target as HTMLInputElement;
207
236
  target.select();
208
237
  }
209
-
210
- // Determine if we need a container wrapper
211
- const hasWrapper = $derived(!!label || !!hint || !!error);
212
238
  </script>
213
239
 
214
- {#if hasWrapper}
215
- <div class="space-y-2">
216
- {#if label}
217
- <Label for={`${componentId}-0`} {disabled}>
218
- {label}
219
- {#if required}
220
- <span class="text-destructive ml-0.5" aria-hidden="true">*</span>
221
- <span class="sr-only">(required)</span>
222
- {/if}
223
- </Label>
224
- {/if}
225
-
226
- <div
227
- class={cn('flex gap-1.5 sm:gap-2', className)}
228
- role="group"
229
- aria-label={label || 'One-time password input'}
230
- aria-describedby={describedBy}
231
- >
232
- {#each { length } as _, index}
233
- <input
234
- bind:this={inputs[index]}
235
- type="text"
236
- inputmode={inputMode}
237
- maxlength="1"
238
- {pattern}
239
- {disabled}
240
- value={digits[index] || ''}
241
- id={`${componentId}-${index}`}
242
- oninput={(e) => handleInput(index, e)}
243
- onkeydown={(e) => handleKeyDown(index, e)}
244
- onpaste={handlePaste}
245
- onfocus={handleFocus}
246
- class={cn(
247
- 'h-10 w-10 text-base sm:h-12 sm:w-12 sm:text-lg text-center font-semibold rounded-md border border-input bg-background text-foreground ring-offset-background transition-colors',
248
- 'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
249
- 'disabled:cursor-not-allowed disabled:opacity-50',
250
- error && 'border-destructive focus:ring-destructive'
251
- )}
252
- aria-label={`Digit ${index + 1} of ${length}`}
253
- aria-invalid={error ? 'true' : undefined}
254
- autocomplete="one-time-code"
255
- />
256
- {/each}
257
- </div>
258
-
259
- <!-- Hidden input for form submission -->
260
- {#if name}
261
- <input type="hidden" {name} {value} />
262
- {/if}
263
-
264
- {#if hint && !error}
265
- <p id={hintId} class="text-sm text-muted-foreground">
266
- {hint}
267
- </p>
268
- {/if}
269
-
270
- {#if error}
271
- <p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
272
- {error}
273
- </p>
274
- {/if}
275
- </div>
276
- {:else}
240
+ <!-- One structure regardless of label/hint/error, so a cell holding focus is
241
+ never remounted when an error appears. -->
242
+ <div class="space-y-2">
243
+ {#if label}
244
+ <Label for={`${componentId}-0`} {disabled}>
245
+ {label}
246
+ {#if required}
247
+ <span class="text-destructive ml-0.5" aria-hidden="true">*</span>
248
+ <span class="sr-only">(required)</span>
249
+ {/if}
250
+ </Label>
251
+ {/if}
252
+
277
253
  <div
278
254
  class={cn('flex gap-1.5 sm:gap-2', className)}
279
255
  role="group"
280
- aria-label="One-time password input"
256
+ aria-label={label || 'One-time password input'}
281
257
  aria-describedby={describedBy}
282
258
  >
283
259
  {#each { length } as _, index}
@@ -311,7 +287,19 @@
311
287
  {#if name}
312
288
  <input type="hidden" {name} {value} />
313
289
  {/if}
314
- {/if}
290
+
291
+ {#if hint && !error}
292
+ <p id={hintId} class="text-sm text-muted-foreground">
293
+ {hint}
294
+ </p>
295
+ {/if}
296
+
297
+ {#if error}
298
+ <p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
299
+ {error}
300
+ </p>
301
+ {/if}
302
+ </div>
315
303
 
316
304
  <!-- Live region for screen reader announcements -->
317
305
  <LiveRegion message={announcement} politeness="polite" />
@@ -137,9 +137,6 @@
137
137
  onValueChange?.(newValue);
138
138
  }
139
139
 
140
- // Determine if we need a container wrapper
141
- const hasWrapper = $derived(!!label || !!hint || !!error);
142
-
143
140
  // Common trigger classes
144
141
  const triggerClasses = $derived(
145
142
  cn(
@@ -270,32 +267,30 @@
270
267
  </SelectPrimitive.Root>
271
268
  {/snippet}
272
269
 
273
- {#if hasWrapper}
274
- <div class="space-y-2">
275
- {#if label}
276
- <Label for={componentId} {disabled}>
277
- {label}
278
- {#if required}
279
- <span class="text-destructive ml-0.5" aria-hidden="true">*</span>
280
- <span class="sr-only">(required)</span>
281
- {/if}
282
- </Label>
283
- {/if}
270
+ <!-- One structure whether or not there is a label, hint or error, so the
271
+ focused control is never remounted when an error appears. -->
272
+ <div class="space-y-2">
273
+ {#if label}
274
+ <Label for={componentId} {disabled}>
275
+ {label}
276
+ {#if required}
277
+ <span class="text-destructive ml-0.5" aria-hidden="true">*</span>
278
+ <span class="sr-only">(required)</span>
279
+ {/if}
280
+ </Label>
281
+ {/if}
284
282
 
285
- {@render selectRoot(fallbackAriaLabel)}
283
+ {@render selectRoot(fallbackAriaLabel)}
286
284
 
287
- {#if hint && !error}
288
- <p id={hintId} class="text-sm text-muted-foreground">
289
- {hint}
290
- </p>
291
- {/if}
285
+ {#if hint && !error}
286
+ <p id={hintId} class="text-sm text-muted-foreground">
287
+ {hint}
288
+ </p>
289
+ {/if}
292
290
 
293
- {#if error}
294
- <p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
295
- {error}
296
- </p>
297
- {/if}
298
- </div>
299
- {:else}
300
- {@render selectRoot(fallbackAriaLabel)}
301
- {/if}
291
+ {#if error}
292
+ <p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
293
+ {error}
294
+ </p>
295
+ {/if}
296
+ </div>
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { untrack } from 'svelte';
3
+ import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
3
4
  import { cn } from '../utils.js';
4
5
  import { tv, type VariantProps } from 'tailwind-variants';
5
6
  import type {
@@ -28,7 +29,7 @@
28
29
  labelText: 'text-sm font-medium leading-none',
29
30
  modeToggle: 'inline-flex items-center rounded-lg bg-muted p-1',
30
31
  modeButton:
31
- 'inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium',
32
+ 'inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
32
33
  modeButtonActive: 'bg-background text-foreground shadow-sm',
33
34
  modeButtonInactive: 'text-muted-foreground hover:text-foreground',
34
35
  // A white pad, like the exported image, so the default black ink stays
@@ -46,7 +47,8 @@
46
47
  'h-2 w-20 cursor-pointer appearance-none rounded-full bg-muted accent-primary',
47
48
  typeInput: 'w-full',
48
49
  fontPicker: 'flex flex-wrap gap-2',
49
- fontButton: 'rounded-lg border-2 px-3 py-2 text-lg hover:border-primary',
50
+ fontButton:
51
+ 'rounded-lg border-2 px-3 py-2 text-lg hover:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
50
52
  fontButtonSelected: 'border-primary bg-primary/5',
51
53
  fontButtonUnselected: 'border-input',
52
54
  consent: 'flex items-start gap-2',
@@ -119,7 +121,12 @@
119
121
  minStrokeLength?: number;
120
122
  /** Minimum characters for typed signature */
121
123
  minTypedLength?: number;
122
- /** Custom validation function */
124
+ /**
125
+ * Extra validation, run on each new signature value once the built-in
126
+ * checks (strokes / typed length) pass. Return `{ valid: false, error }`
127
+ * to reject it: the error shows under the pad like the built-in ones and
128
+ * onComplete does not fire. onValueChange still receives the value.
129
+ */
123
130
  validate?: (data: SignatureData) => SignatureValidationResult;
124
131
 
125
132
  /** Whether to show consent checkbox */
@@ -443,17 +450,26 @@
443
450
  }
444
451
 
445
452
  // Load Google Font
453
+ // Families already requested, so a font is fetched once however many times
454
+ // loadFont runs before its first load resolves (plain Set: not reactive)
455
+ const fontsRequested = new Set<string>();
456
+
446
457
  async function loadFont(font: SignatureFont): Promise<void> {
447
- if (fontsLoaded.has(font.family)) return;
458
+ if (fontsLoaded.has(font.family) || fontsRequested.has(font.family)) return;
459
+ fontsRequested.add(font.family);
448
460
  if (!font.googleFont) {
449
461
  fontsLoaded = new Set([...fontsLoaded, font.family]);
450
462
  return;
451
463
  }
452
464
 
453
- const link = document.createElement('link');
454
- link.href = `https://fonts.googleapis.com/css2?family=${font.family.replace(/\s+/g, '+')}&display=swap`;
455
- link.rel = 'stylesheet';
456
- document.head.appendChild(link);
465
+ const href = `https://fonts.googleapis.com/css2?family=${font.family.replace(/\s+/g, '+')}&display=swap`;
466
+ // Another Signature on the page may have added it already
467
+ if (![...document.head.querySelectorAll('link')].some((l) => l.href === href)) {
468
+ const link = document.createElement('link');
469
+ link.href = href;
470
+ link.rel = 'stylesheet';
471
+ document.head.appendChild(link);
472
+ }
457
473
 
458
474
  // Wait for font to load
459
475
  await document.fonts.load(`48px "${font.family}"`);
@@ -725,10 +741,22 @@
725
741
  return;
726
742
  }
727
743
 
744
+ // Built-in checks first (an initial typedName never went through an
745
+ // input handler), then the consumer's check on the generated value
746
+ runValidation();
728
747
  const data = await generateSignatureData();
729
748
  value = data;
730
749
  onValueChange?.(data);
731
750
 
751
+ if (customValidate && validationResult.valid) {
752
+ const result = customValidate(data);
753
+ if (!result.valid) {
754
+ validationResult = { valid: false, error: result.error };
755
+ completeFired = false;
756
+ return;
757
+ }
758
+ }
759
+
732
760
  // Only fire onComplete once per valid signature
733
761
  if (isValid && hasSignature && !completeFired) {
734
762
  completeFired = true;
@@ -804,8 +832,11 @@
804
832
 
805
833
  // Effects
806
834
  $effect(() => {
807
- // Load all fonts on mount
808
- fonts.forEach((font) => loadFont(font));
835
+ // Load all fonts on mount (and when the list changes). untrack: loadFont
836
+ // reads and writes fontsLoaded, which would otherwise re-run this effect
837
+ // once per loaded font and request every font again.
838
+ const list = fonts;
839
+ untrack(() => list.forEach((font) => loadFont(font)));
809
840
  });
810
841
 
811
842
  $effect(() => {
@@ -880,34 +911,26 @@
880
911
  {/if}
881
912
 
882
913
  {#if showModeToggle}
883
- <div class={styles.modeToggle()} role="radiogroup" aria-label="Signature input method">
884
- <button
885
- type="button"
886
- role="radio"
887
- aria-checked={mode === 'draw'}
888
- class={cn(
889
- styles.modeButton(),
890
- mode === 'draw' ? styles.modeButtonActive() : styles.modeButtonInactive()
891
- )}
892
- onclick={() => setMode('draw')}
893
- {disabled}
894
- >
895
- Draw
896
- </button>
897
- <button
898
- type="button"
899
- role="radio"
900
- aria-checked={mode === 'type'}
901
- class={cn(
902
- styles.modeButton(),
903
- mode === 'type' ? styles.modeButtonActive() : styles.modeButtonInactive()
904
- )}
905
- onclick={() => setMode('type')}
906
- {disabled}
907
- >
908
- Type
909
- </button>
910
- </div>
914
+ <RadioGroupPrimitive.Root
915
+ value={mode}
916
+ onValueChange={(v) => setMode(v as 'draw' | 'type')}
917
+ {disabled}
918
+ orientation="horizontal"
919
+ aria-label="Signature input method"
920
+ class={styles.modeToggle()}
921
+ >
922
+ {#each [{ value: 'draw', label: 'Draw' }, { value: 'type', label: 'Type' }] as option (option.value)}
923
+ <RadioGroupPrimitive.Item
924
+ value={option.value}
925
+ class={cn(
926
+ styles.modeButton(),
927
+ mode === option.value ? styles.modeButtonActive() : styles.modeButtonInactive()
928
+ )}
929
+ >
930
+ {option.label}
931
+ </RadioGroupPrimitive.Item>
932
+ {/each}
933
+ </RadioGroupPrimitive.Root>
911
934
  {/if}
912
935
  </div>
913
936
 
@@ -1009,12 +1032,23 @@
1009
1032
  aria-invalid={error ? 'true' : undefined}
1010
1033
  />
1011
1034
 
1012
- <div class={styles.fontPicker()} role="listbox" aria-label="Select signature font">
1013
- {#each fonts as font}
1014
- <button
1015
- type="button"
1016
- role="option"
1017
- aria-selected={selectedFont === font.family}
1035
+ <!-- Single choice: a radio group (arrow keys move and select). Each
1036
+ option is named by its font, not the preview text it shows. -->
1037
+ <RadioGroupPrimitive.Root
1038
+ value={selectedFont}
1039
+ onValueChange={(family) => {
1040
+ const font = fonts.find((f) => f.family === family);
1041
+ if (font && family !== selectedFont) selectFont(font);
1042
+ }}
1043
+ {disabled}
1044
+ orientation="horizontal"
1045
+ aria-label="Signature font"
1046
+ class={styles.fontPicker()}
1047
+ >
1048
+ {#each fonts as font (font.family)}
1049
+ <RadioGroupPrimitive.Item
1050
+ value={font.family}
1051
+ aria-label={font.label}
1018
1052
  class={cn(
1019
1053
  styles.fontButton(),
1020
1054
  selectedFont === font.family
@@ -1022,13 +1056,11 @@
1022
1056
  : styles.fontButtonUnselected()
1023
1057
  )}
1024
1058
  style="font-family: '{font.family}', cursive;"
1025
- onclick={() => selectFont(font)}
1026
- {disabled}
1027
1059
  >
1028
1060
  {typedName || 'Preview'}
1029
- </button>
1061
+ </RadioGroupPrimitive.Item>
1030
1062
  {/each}
1031
- </div>
1063
+ </RadioGroupPrimitive.Root>
1032
1064
  </div>
1033
1065
  {/if}
1034
1066
 
@@ -40,7 +40,12 @@ interface Props {
40
40
  minStrokeLength?: number;
41
41
  /** Minimum characters for typed signature */
42
42
  minTypedLength?: number;
43
- /** Custom validation function */
43
+ /**
44
+ * Extra validation, run on each new signature value once the built-in
45
+ * checks (strokes / typed length) pass. Return `{ valid: false, error }`
46
+ * to reject it: the error shows under the pad like the built-in ones and
47
+ * onComplete does not fire. onValueChange still receives the value.
48
+ */
44
49
  validate?: (data: SignatureData) => SignatureValidationResult;
45
50
  /** Whether to show consent checkbox */
46
51
  showConsent?: boolean;