@classic-homes/theme-svelte 1.22.1 → 1.22.3

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 (39) hide show
  1. package/dist/lib/components/Combobox.svelte +2 -2
  2. package/dist/lib/components/DataTable.svelte +15 -6
  3. package/dist/lib/components/DataTable.svelte.d.ts +1 -1
  4. package/dist/lib/components/DateTimePicker.svelte +31 -34
  5. package/dist/lib/components/DropdownMenu.svelte +52 -30
  6. package/dist/lib/components/DropdownMenu.svelte.d.ts +5 -0
  7. package/dist/lib/components/FileUpload.svelte +5 -19
  8. package/dist/lib/components/MultiSelect.svelte +2 -2
  9. package/dist/lib/components/NumberInput.svelte +15 -2
  10. package/dist/lib/components/Select.svelte +19 -5
  11. package/dist/lib/components/Select.svelte.d.ts +4 -0
  12. package/dist/lib/components/Signature.svelte +15 -4
  13. package/dist/lib/components/Signature.svelte.d.ts +1 -1
  14. package/dist/lib/components/Slider.svelte +8 -6
  15. package/dist/lib/components/Tabs.svelte +10 -12
  16. package/dist/lib/components/Toast.svelte +12 -12
  17. package/dist/lib/components/data-table/DataTableColumnVisibility.svelte +24 -26
  18. package/dist/lib/components/data-table/DataTableEditableCell.svelte +31 -18
  19. package/dist/lib/components/data-table/DataTableEditableCell.svelte.d.ts +0 -7
  20. package/dist/lib/components/data-table/DataTablePagination.svelte +5 -1
  21. package/dist/lib/components/data-table/DataTableVirtual.svelte +4 -7
  22. package/dist/lib/components/data-table/DataTableVirtual.svelte.d.ts +3 -0
  23. package/dist/lib/components/data-table/createDataTable.svelte.d.ts +6 -1
  24. package/dist/lib/components/data-table/createDataTable.svelte.js +22 -9
  25. package/dist/lib/components/layout/Sidebar.svelte +28 -0
  26. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +66 -50
  27. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte.d.ts +2 -0
  28. package/dist/lib/composables/useAsync.svelte.d.ts +1 -1
  29. package/dist/lib/composables/useAsync.svelte.js +33 -14
  30. package/dist/lib/composables/useForm.svelte.js +19 -7
  31. package/dist/lib/composables/usePersistedForm.svelte.js +37 -10
  32. package/dist/lib/stores/sidebar.svelte.d.ts +6 -0
  33. package/dist/lib/stores/sidebar.svelte.js +15 -0
  34. package/dist/lib/stores/toast.svelte.js +12 -1
  35. package/dist/lib/utils/date.d.ts +3 -1
  36. package/dist/lib/utils/date.js +54 -34
  37. package/dist/lib/utils/form.d.ts +2 -1
  38. package/dist/lib/utils/form.js +3 -2
  39. package/package.json +1 -1
@@ -186,7 +186,7 @@
186
186
  collisionPadding={8}
187
187
  avoidCollisions={true}
188
188
  >
189
- <div class="p-1 max-h-[300px] overflow-y-auto">
189
+ <div class="p-1 max-h-72 overflow-y-auto">
190
190
  {#if loading}
191
191
  <div class="flex items-center justify-center py-6">
192
192
  <Spinner size="sm" />
@@ -282,7 +282,7 @@
282
282
  collisionPadding={8}
283
283
  avoidCollisions={true}
284
284
  >
285
- <div class="p-1 max-h-[300px] overflow-y-auto">
285
+ <div class="p-1 max-h-72 overflow-y-auto">
286
286
  {#if loading}
287
287
  <div class="flex items-center justify-center py-6">
288
288
  <Spinner size="sm" />
@@ -85,7 +85,7 @@
85
85
  rowSelection?: Set<string>;
86
86
  /** Callback when selection changes */
87
87
  onRowSelectionChange?: (selection: Set<string>) => void;
88
- /** Function to get unique row ID (defaults to index) */
88
+ /** Function to get unique row ID (defaults to the row's index in `data`) */
89
89
  getRowId?: (row: T, index: number) => string;
90
90
 
91
91
  // === Visibility Props ===
@@ -239,7 +239,7 @@
239
239
  const table = createDataTable<T>(() => ({
240
240
  data,
241
241
  columns,
242
- getRowId: getRowId ?? ((row, index) => String(index)),
242
+ getRowId,
243
243
  initialSortColumn: sortColumn,
244
244
  initialSortDirection: sortDirection,
245
245
  initialPageSize: pageSize,
@@ -248,6 +248,8 @@
248
248
  manualSorting: !!onSort,
249
249
  manualFiltering,
250
250
  manualPagination,
251
+ // Without the pager every row must render, not just the first page.
252
+ enablePagination,
251
253
  pageCount,
252
254
  onSortChange: (col, dir) => {
253
255
  sortColumn = col ?? undefined;
@@ -324,9 +326,14 @@
324
326
  table.toggleAllRows();
325
327
  }
326
328
 
327
- function handleSelectRow(rowId: string, e: MouseEvent) {
328
- e.stopPropagation();
329
- table.toggleRowSelection(rowId);
329
+ // Keyboard activation for clickable rows; ignore keys aimed at the
330
+ // row's own controls (the selection checkbox handles its own Space).
331
+ function handleRowKeydown(e: KeyboardEvent, row: T) {
332
+ if (e.target !== e.currentTarget) return;
333
+ if (e.key === 'Enter' || e.key === ' ') {
334
+ e.preventDefault();
335
+ handleRowClick(row);
336
+ }
330
337
  }
331
338
 
332
339
  // Literal class strings so Tailwind's content scan generates them.
@@ -442,7 +449,9 @@
442
449
  onRowClick && 'cursor-pointer hover:bg-muted-hover',
443
450
  row.isSelected && 'bg-primary/5'
444
451
  )}
452
+ tabindex={onRowClick ? 0 : undefined}
445
453
  onclick={() => handleRowClick(row.original)}
454
+ onkeydown={onRowClick ? (e) => handleRowKeydown(e, row.original) : undefined}
446
455
  >
447
456
  {#if enableRowSelection}
448
457
  <td class="px-4 py-3">
@@ -451,7 +460,7 @@
451
460
  checked={row.isSelected}
452
461
  onCheckedChange={() => table.toggleRowSelection(row.id)}
453
462
  aria-label={`Select row ${index + 1}`}
454
- onclick={(e: MouseEvent) => handleSelectRow(row.id, e)}
463
+ onclick={(e: MouseEvent) => e.stopPropagation()}
455
464
  />
456
465
  {/if}
457
466
  </td>
@@ -66,7 +66,7 @@ declare function $$render<T extends object>(): {
66
66
  rowSelection?: Set<string>;
67
67
  /** Callback when selection changes */
68
68
  onRowSelectionChange?: (selection: Set<string>) => void;
69
- /** Function to get unique row ID (defaults to index) */
69
+ /** Function to get unique row ID (defaults to the row's index in `data`) */
70
70
  getRowId?: (row: T, index: number) => string;
71
71
  /** Enable column visibility toggling */
72
72
  enableColumnVisibility?: boolean;
@@ -75,8 +75,9 @@
75
75
  const hintId = $derived(`${componentId}-hint`);
76
76
  const errorId = $derived(`${componentId}-error`);
77
77
 
78
- // Sync stringValue -> value when stringValue changes externally
79
- let lastStringValue = $state<string | null>(stringValue);
78
+ // Sync stringValue -> value when stringValue changes externally. Starts at
79
+ // null so an initial stringValue is adopted before value -> stringValue runs.
80
+ let lastStringValue = $state<string | null>(null);
80
81
  $effect(() => {
81
82
  if (stringValue !== lastStringValue) {
82
83
  lastStringValue = stringValue;
@@ -117,7 +118,8 @@
117
118
  // Compute aria-describedby based on hint and error
118
119
  const describedBy = $derived.by(() => {
119
120
  const ids: string[] = [];
120
- if (hint) ids.push(hintId);
121
+ // The hint is not rendered while an error shows
122
+ if (hint && !displayError) ids.push(hintId);
121
123
  if (displayError) ids.push(errorId);
122
124
  return ids.length > 0 ? ids.join(' ') : undefined;
123
125
  });
@@ -412,9 +414,6 @@
412
414
  ];
413
415
 
414
416
  const dayNames = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
415
-
416
- // Determine if we need a container wrapper
417
- const hasWrapper = $derived(!!label || !!hint || !!error || !!parseError);
418
417
  </script>
419
418
 
420
419
  {#snippet pickerContent()}
@@ -633,32 +632,30 @@
633
632
  {/if}
634
633
  {/snippet}
635
634
 
636
- {#if hasWrapper}
637
- <div class="space-y-2">
638
- {#if label}
639
- <Label for={componentId} {disabled}>
640
- {label}
641
- {#if required}
642
- <span class="text-destructive ml-0.5" aria-hidden="true">*</span>
643
- <span class="sr-only">(required)</span>
644
- {/if}
645
- </Label>
646
- {/if}
647
-
648
- {@render pickerContent()}
649
-
650
- {#if hint && !displayError}
651
- <p id={hintId} class="text-sm text-muted-foreground">
652
- {hint}
653
- </p>
654
- {/if}
655
-
656
- {#if displayError}
657
- <p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
658
- {displayError}
659
- </p>
660
- {/if}
661
- </div>
662
- {:else}
635
+ <!-- Always wrapped: toggling the wrapper on a parse error would re-mount the
636
+ input and drop focus. -->
637
+ <div class="space-y-2">
638
+ {#if label}
639
+ <Label for={componentId} {disabled}>
640
+ {label}
641
+ {#if required}
642
+ <span class="text-destructive ml-0.5" aria-hidden="true">*</span>
643
+ <span class="sr-only">(required)</span>
644
+ {/if}
645
+ </Label>
646
+ {/if}
647
+
663
648
  {@render pickerContent()}
664
- {/if}
649
+
650
+ {#if hint && !displayError}
651
+ <p id={hintId} class="text-sm text-muted-foreground">
652
+ {hint}
653
+ </p>
654
+ {/if}
655
+
656
+ {#if displayError}
657
+ <p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
658
+ {displayError}
659
+ </p>
660
+ {/if}
661
+ </div>
@@ -17,6 +17,11 @@
17
17
  icon?: IconName;
18
18
  /** Render the item as a link to this URL (selection still fires `onSelect`) */
19
19
  href?: string;
20
+ /**
21
+ * Make this a checkbox item (`menuitemcheckbox`) showing this state.
22
+ * Selecting it fires `onSelect` and keeps the menu open.
23
+ */
24
+ checked?: boolean;
20
25
  }
21
26
 
22
27
  export interface DropdownMenuGroup {
@@ -102,6 +107,19 @@
102
107
  <div role="presentation" class="px-2 py-1.5 text-sm font-semibold">
103
108
  {item.label}
104
109
  </div>
110
+ {:else if item.checked !== undefined}
111
+ <DropdownMenuPrimitive.CheckboxItem
112
+ checked={item.checked}
113
+ disabled={item.disabled}
114
+ closeOnSelect={false}
115
+ onSelect={() => item.onSelect?.()}
116
+ class="relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none bg-popover data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
117
+ >
118
+ {#if item.checked}
119
+ <Icon name="check" size={16} class="absolute left-2 shrink-0" aria-hidden="true" />
120
+ {/if}
121
+ {@render itemContent(item)}
122
+ </DropdownMenuPrimitive.CheckboxItem>
105
123
  {:else}
106
124
  {@const itemClass = cn(
107
125
  'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none bg-popover data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default',
@@ -151,34 +169,38 @@
151
169
  {/snippet}
152
170
  </DropdownMenuPrimitive.Trigger>
153
171
 
154
- <DropdownMenuPrimitive.Content
155
- {side}
156
- {align}
157
- sideOffset={4}
158
- collisionPadding={8}
159
- avoidCollisions={true}
160
- class={cn(
161
- 'isolate z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
162
- className
163
- )}
164
- >
165
- <div class="max-h-[300px] overflow-y-auto overflow-x-hidden">
166
- {#each items as item}
167
- {#if isGroup(item)}
168
- <DropdownMenuPrimitive.Group>
169
- {#if item.label}
170
- <DropdownMenuPrimitive.GroupHeading class="px-2 py-1.5 text-sm font-semibold">
171
- {item.label}
172
- </DropdownMenuPrimitive.GroupHeading>
173
- {/if}
174
- {#each item.items as subItem}
175
- {@render entry(subItem)}
176
- {/each}
177
- </DropdownMenuPrimitive.Group>
178
- {:else}
179
- {@render entry(item)}
180
- {/if}
181
- {/each}
182
- </div>
183
- </DropdownMenuPrimitive.Content>
172
+ <!-- Portal so the menu is not clipped by an overflow/transformed ancestor
173
+ (a Dialog, or the DataTable's scroll wrapper). -->
174
+ <DropdownMenuPrimitive.Portal>
175
+ <DropdownMenuPrimitive.Content
176
+ {side}
177
+ {align}
178
+ sideOffset={4}
179
+ collisionPadding={8}
180
+ avoidCollisions={true}
181
+ class={cn(
182
+ 'isolate z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
183
+ className
184
+ )}
185
+ >
186
+ <div class="max-h-72 overflow-y-auto overflow-x-hidden">
187
+ {#each items as item}
188
+ {#if isGroup(item)}
189
+ <DropdownMenuPrimitive.Group>
190
+ {#if item.label}
191
+ <DropdownMenuPrimitive.GroupHeading class="px-2 py-1.5 text-sm font-semibold">
192
+ {item.label}
193
+ </DropdownMenuPrimitive.GroupHeading>
194
+ {/if}
195
+ {#each item.items as subItem}
196
+ {@render entry(subItem)}
197
+ {/each}
198
+ </DropdownMenuPrimitive.Group>
199
+ {:else}
200
+ {@render entry(item)}
201
+ {/if}
202
+ {/each}
203
+ </div>
204
+ </DropdownMenuPrimitive.Content>
205
+ </DropdownMenuPrimitive.Portal>
184
206
  </DropdownMenuPrimitive.Root>
@@ -13,6 +13,11 @@ export interface DropdownMenuItem {
13
13
  icon?: IconName;
14
14
  /** Render the item as a link to this URL (selection still fires `onSelect`) */
15
15
  href?: string;
16
+ /**
17
+ * Make this a checkbox item (`menuitemcheckbox`) showing this state.
18
+ * Selecting it fires `onSelect` and keeps the menu open.
19
+ */
20
+ checked?: boolean;
16
21
  }
17
22
  export interface DropdownMenuGroup {
18
23
  label?: string;
@@ -86,7 +86,6 @@
86
86
 
87
87
  let isDragging = $state(false);
88
88
  let internalError = $state<string | undefined>(undefined);
89
- let fileInput: HTMLInputElement;
90
89
  let lightboxOpen = $state(false);
91
90
  let lightboxIndex = $state(0);
92
91
 
@@ -420,13 +419,6 @@
420
419
  }
421
420
  }
422
421
 
423
- // Handle click to browse
424
- function handleClick() {
425
- if (!disabled) {
426
- fileInput?.click();
427
- }
428
- }
429
-
430
422
  // Handle file input change
431
423
  function handleInputChange(e: Event) {
432
424
  const input = e.target as HTMLInputElement;
@@ -492,20 +484,15 @@
492
484
 
493
485
  const usePreviewGrid = $derived(previewLayout === 'grid' && previewItems.length > 0);
494
486
 
487
+ // The focusable file input carries the description, not the drop-zone label
488
+ const describedBy = $derived(displayError ? `${id}-error` : hint ? `${id}-hint` : undefined);
489
+
495
490
  function openPreview(fileId: string) {
496
491
  const previewIndex = previewItems.findIndex((item) => item.id === fileId);
497
492
  if (previewIndex === -1) return;
498
493
  lightboxIndex = previewIndex;
499
494
  lightboxOpen = true;
500
495
  }
501
-
502
- // Handle keyboard activation
503
- function handleKeyDown(e: KeyboardEvent) {
504
- if (e.key === 'Enter' || e.key === ' ') {
505
- e.preventDefault();
506
- handleClick();
507
- }
508
- }
509
496
  </script>
510
497
 
511
498
  <div class={cn('space-y-2', className)}>
@@ -518,7 +505,6 @@
518
505
  <!-- Drop Zone - using label to wrap the hidden input for better accessibility -->
519
506
  <label
520
507
  for={id}
521
- aria-describedby={displayError ? `${id}-error` : undefined}
522
508
  class={cn(
523
509
  'relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6',
524
510
  'cursor-pointer focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
@@ -560,12 +546,12 @@
560
546
 
561
547
  <!-- Hidden file input -->
562
548
  <input
563
- bind:this={fileInput}
564
549
  type="file"
565
550
  {id}
566
551
  multiple={maxFiles > 1}
567
552
  accept={acceptedTypes.join(',')}
568
553
  {disabled}
554
+ aria-describedby={describedBy}
569
555
  class="sr-only"
570
556
  onchange={handleInputChange}
571
557
  />
@@ -573,7 +559,7 @@
573
559
 
574
560
  <!-- Hint text -->
575
561
  {#if hint && !displayError}
576
- <p class="text-sm text-muted-foreground">{hint}</p>
562
+ <p id={`${id}-hint`} class="text-sm text-muted-foreground">{hint}</p>
577
563
  {/if}
578
564
 
579
565
  <!-- Error message -->
@@ -244,7 +244,7 @@
244
244
  collisionPadding={8}
245
245
  avoidCollisions={true}
246
246
  >
247
- <div class="p-1 max-h-[300px] overflow-y-auto">
247
+ <div class="p-1 max-h-72 overflow-y-auto">
248
248
  {#if loading}
249
249
  <div class="flex items-center justify-center py-6">
250
250
  <Spinner size="sm" />
@@ -381,7 +381,7 @@
381
381
  collisionPadding={8}
382
382
  avoidCollisions={true}
383
383
  >
384
- <div class="p-1 max-h-[300px] overflow-y-auto">
384
+ <div class="p-1 max-h-72 overflow-y-auto">
385
385
  {#if loading}
386
386
  <div class="flex items-center justify-center py-6">
387
387
  <Spinner size="sm" />
@@ -101,10 +101,23 @@
101
101
  }
102
102
  }
103
103
 
104
+ // Decimal places of a number, e.g. 0.25 -> 2, 1e-7 -> 7
105
+ function decimalPlaces(n: number): number {
106
+ const [mantissa, exponent] = String(n).split('e');
107
+ const fraction = mantissa.split('.')[1]?.length ?? 0;
108
+ return Math.max(0, fraction - (exponent ? parseInt(exponent) : 0));
109
+ }
110
+
111
+ // Add in floating point, then round away artifacts (0.2 + 0.1 -> 0.3)
112
+ function addStep(current: number, delta: number): number {
113
+ const places = Math.min(20, Math.max(decimalPlaces(current), decimalPlaces(delta)));
114
+ return Number((current + delta).toFixed(places));
115
+ }
116
+
104
117
  function increment() {
105
118
  if (disabled || readonly) return;
106
119
  const currentValue = value ?? min ?? 0;
107
- const newValue = clampValue(currentValue + step);
120
+ const newValue = clampValue(addStep(currentValue, step));
108
121
  value = newValue;
109
122
  onValueChange?.(newValue);
110
123
  }
@@ -112,7 +125,7 @@
112
125
  function decrement() {
113
126
  if (disabled || readonly) return;
114
127
  const currentValue = value ?? max ?? 0;
115
- const newValue = clampValue(currentValue - step);
128
+ const newValue = clampValue(addStep(currentValue, -step));
116
129
  value = newValue;
117
130
  onValueChange?.(newValue);
118
131
  }
@@ -41,6 +41,10 @@
41
41
  error?: string;
42
42
  /** Hint text displayed below the select */
43
43
  hint?: string;
44
+ /** Accessible name for the trigger, when there is no visible label */
45
+ 'aria-label'?: string;
46
+ /** ID of an element that labels the trigger */
47
+ 'aria-labelledby'?: string;
44
48
  }
45
49
 
46
50
  let {
@@ -56,6 +60,8 @@
56
60
  id,
57
61
  error,
58
62
  hint,
63
+ 'aria-label': ariaLabelProp,
64
+ 'aria-labelledby': ariaLabelledby,
59
65
  }: Props = $props();
60
66
 
61
67
  // Generate unique IDs for accessibility using sequential counter
@@ -68,11 +74,16 @@
68
74
  // Compute aria-describedby based on hint and error
69
75
  const describedBy = $derived.by(() => {
70
76
  const ids: string[] = [];
71
- if (hint) ids.push(hintId);
77
+ // The hint is not rendered while an error shows
78
+ if (hint && !error) ids.push(hintId);
72
79
  if (error) ids.push(errorId);
73
80
  return ids.length > 0 ? ids.join(' ') : undefined;
74
81
  });
75
82
 
83
+ const fallbackAriaLabel = $derived(
84
+ ariaLabelProp ?? (!label && !id && !ariaLabelledby ? placeholder : undefined)
85
+ );
86
+
76
87
  // Validate props in development
77
88
  $effect(() => {
78
89
  validateNonEmptyArray(options, 'options', 'Select');
@@ -81,7 +92,7 @@
81
92
  // Accessibility: warn if no label is associated in dev mode
82
93
  if (DEV) {
83
94
  $effect(() => {
84
- if (!label && !id) {
95
+ if (!label && !id && !ariaLabelProp && !ariaLabelledby) {
85
96
  console.warn(
86
97
  '[Select] Missing accessible label. Provide a label prop or an id with a matching <label for="...">.'
87
98
  );
@@ -209,7 +220,7 @@
209
220
  collisionPadding={8}
210
221
  avoidCollisions={true}
211
222
  >
212
- <div class="p-1 max-h-[300px] overflow-y-auto">
223
+ <div class="p-1 max-h-72 overflow-y-auto">
213
224
  {#each options as item}
214
225
  {#if isGroup(item)}
215
226
  <SelectPrimitive.Group>
@@ -229,6 +240,8 @@
229
240
  </SelectPrimitive.Portal>
230
241
  {/snippet}
231
242
 
243
+ <!-- The placeholder is only a last-resort name: an aria-label would override
244
+ an external <label for={id}> or aria-labelledby. -->
232
245
  {#snippet selectRoot(ariaLabel: string | undefined)}
233
246
  <SelectPrimitive.Root
234
247
  type="single"
@@ -243,6 +256,7 @@
243
256
  id={componentId}
244
257
  class={triggerClasses}
245
258
  aria-label={ariaLabel}
259
+ aria-labelledby={ariaLabelledby}
246
260
  aria-invalid={error ? 'true' : undefined}
247
261
  aria-describedby={describedBy}
248
262
  >
@@ -268,7 +282,7 @@
268
282
  </Label>
269
283
  {/if}
270
284
 
271
- {@render selectRoot(!label ? placeholder : undefined)}
285
+ {@render selectRoot(fallbackAriaLabel)}
272
286
 
273
287
  {#if hint && !error}
274
288
  <p id={hintId} class="text-sm text-muted-foreground">
@@ -283,5 +297,5 @@
283
297
  {/if}
284
298
  </div>
285
299
  {:else}
286
- {@render selectRoot(placeholder)}
300
+ {@render selectRoot(fallbackAriaLabel)}
287
301
  {/if}
@@ -32,6 +32,10 @@ interface Props {
32
32
  error?: string;
33
33
  /** Hint text displayed below the select */
34
34
  hint?: string;
35
+ /** Accessible name for the trigger, when there is no visible label */
36
+ 'aria-label'?: string;
37
+ /** ID of an element that labels the trigger */
38
+ 'aria-labelledby'?: string;
35
39
  }
36
40
  declare const Select: import("svelte").Component<Props, {}, "value">;
37
41
  type Select = ReturnType<typeof Select>;
@@ -31,7 +31,9 @@
31
31
  'inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium',
32
32
  modeButtonActive: 'bg-background text-foreground shadow-sm',
33
33
  modeButtonInactive: 'text-muted-foreground hover:text-foreground',
34
- canvasContainer: 'relative w-full overflow-hidden rounded-lg border bg-background',
34
+ // A white pad, like the exported image, so the default black ink stays
35
+ // visible in dark mode.
36
+ canvasContainer: 'relative w-full overflow-hidden rounded-lg border bg-white',
35
37
  canvas: 'block w-full cursor-crosshair touch-none',
36
38
  toolbar: 'flex flex-wrap items-center gap-2',
37
39
  toolButton:
@@ -88,7 +90,7 @@
88
90
  /** Default typed name for type mode */
89
91
  typedName?: string;
90
92
 
91
- /** Stroke color (default: currentColor) */
93
+ /** Stroke color (default: #000000, drawn on a white pad to match the export) */
92
94
  strokeColor?: string;
93
95
  /** Stroke width in pixels */
94
96
  strokeWidth?: number;
@@ -222,8 +224,11 @@
222
224
  );
223
225
  let fontsLoaded = $state<Set<string>>(new Set());
224
226
 
225
- // Consent state (untrack to intentionally capture initial value only)
227
+ // Consent state, kept in step with the bindable prop both ways
226
228
  let consented = $state(untrack(() => initialConsented));
229
+ $effect(() => {
230
+ consented = initialConsented;
231
+ });
227
232
 
228
233
  // Validation state
229
234
  let validationResult = $state<SignatureValidationResult>({ valid: false });
@@ -661,7 +666,10 @@
661
666
  function handlePointerUp(e: PointerEvent): void {
662
667
  if (!isDrawing || !currentStroke || disabled) return;
663
668
 
664
- canvas.releasePointerCapture(e.pointerId);
669
+ // After pointercancel the capture is already gone and release would throw
670
+ if (canvas.hasPointerCapture?.(e.pointerId)) {
671
+ canvas.releasePointerCapture(e.pointerId);
672
+ }
665
673
  isDrawing = false;
666
674
  currentStroke.endTime = Date.now();
667
675
 
@@ -732,6 +740,8 @@
732
740
  function handleConsentChange(e: Event): void {
733
741
  const target = e.target as HTMLInputElement;
734
742
  consented = target.checked;
743
+ // Write back so bind:consented reflects the checkbox
744
+ initialConsented = consented;
735
745
  onConsentChange?.(consented);
736
746
  updateValue();
737
747
  }
@@ -915,6 +925,7 @@
915
925
  onpointerdown={handlePointerDown}
916
926
  onpointermove={handlePointerMove}
917
927
  onpointerup={handlePointerUp}
928
+ onpointercancel={handlePointerUp}
918
929
  onpointerleave={handlePointerLeave}
919
930
  onkeydown={handleKeyDown}
920
931
  ></canvas>
@@ -14,7 +14,7 @@ interface Props {
14
14
  showModeToggle?: boolean;
15
15
  /** Default typed name for type mode */
16
16
  typedName?: string;
17
- /** Stroke color (default: currentColor) */
17
+ /** Stroke color (default: #000000, drawn on a white pad to match the export) */
18
18
  strokeColor?: string;
19
19
  /** Stroke width in pixels */
20
20
  strokeWidth?: number;
@@ -7,23 +7,25 @@
7
7
 
8
8
  const sliderVariants = tv({
9
9
  slots: {
10
- root: 'relative flex w-full touch-none select-none items-center',
11
- range: 'absolute h-full bg-primary rounded-full',
10
+ root: 'relative flex w-full touch-none select-none items-center data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-40 data-[orientation=vertical]:flex-col',
11
+ // Vertical: bits-ui positions the range by top/bottom, so drop h-full
12
+ range:
13
+ 'absolute h-full bg-primary rounded-full data-[orientation=vertical]:h-auto data-[orientation=vertical]:w-full',
12
14
  thumb:
13
15
  "block rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 relative after:absolute after:left-1/2 after:top-1/2 after:h-11 after:w-11 after:-translate-x-1/2 after:-translate-y-1/2 after:rounded-full after:content-['']",
14
16
  },
15
17
  variants: {
16
18
  size: {
17
19
  sm: {
18
- root: 'h-1.5',
20
+ root: 'h-1.5 data-[orientation=vertical]:w-1.5',
19
21
  thumb: 'h-4 w-4',
20
22
  },
21
23
  md: {
22
- root: 'h-2',
24
+ root: 'h-2 data-[orientation=vertical]:w-2',
23
25
  thumb: 'h-5 w-5',
24
26
  },
25
27
  lg: {
26
- root: 'h-3',
28
+ root: 'h-3 data-[orientation=vertical]:w-3',
27
29
  thumb: 'h-6 w-6',
28
30
  },
29
31
  },
@@ -140,7 +142,6 @@
140
142
  onValueChange={handleValueChange}
141
143
  onValueCommit={handleValueCommit}
142
144
  class={cn(styles.root(), 'rounded-full bg-muted')}
143
- aria-describedby={describedBy}
144
145
  >
145
146
  <SliderPrimitive.Range class={styles.range()} />
146
147
  {#each value as thumbValue, i}
@@ -152,6 +153,7 @@
152
153
  aria-valuemin={min}
153
154
  aria-valuemax={max}
154
155
  aria-valuetext={formatValue(thumbValue)}
156
+ aria-describedby={describedBy}
155
157
  />
156
158
  {/each}
157
159
  </SliderPrimitive.Root>