@classic-homes/theme-svelte 1.25.0 → 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 +74 -86
  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,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { Popover, Calendar } from 'bits-ui';
2
+ import { Popover } from 'bits-ui';
3
3
  import { tick } from 'svelte';
4
4
  import { cn } from '../utils.js';
5
5
  import { parseDate, toISOString, parseDateInput } from '../utils/date.js';
@@ -194,6 +194,99 @@
194
194
  return days;
195
195
  });
196
196
 
197
+ // The days split into weeks, the rows of the day grid
198
+ const calendarWeeks = $derived.by(() => {
199
+ const weeks: (number | null)[][] = [];
200
+ for (let i = 0; i < calendarDays.length; i += 7) {
201
+ const week = calendarDays.slice(i, i + 7);
202
+ while (week.length < 7) week.push(null);
203
+ weeks.push(week);
204
+ }
205
+ return weeks;
206
+ });
207
+
208
+ const daysInViewMonth = $derived(new Date(viewYear, viewMonth + 1, 0).getDate());
209
+
210
+ // Roving focus within the day grid: the one day with tabindex=0. Falls back
211
+ // to the selected day, then today, then the 1st when unset or out of range.
212
+ let focusedDay = $state<number | null>(null);
213
+ const today = new Date();
214
+ const activeDay = $derived.by(() => {
215
+ if (focusedDay !== null && focusedDay <= daysInViewMonth) return focusedDay;
216
+ if (value && value.getMonth() === viewMonth && value.getFullYear() === viewYear) {
217
+ return value.getDate();
218
+ }
219
+ if (today.getMonth() === viewMonth && today.getFullYear() === viewYear) {
220
+ return today.getDate();
221
+ }
222
+ return 1;
223
+ });
224
+
225
+ let dayGrid = $state<HTMLElement | null>(null);
226
+
227
+ function dayLabel(day: number): string {
228
+ return new Date(viewYear, viewMonth, day).toLocaleDateString('en-US', {
229
+ weekday: 'long',
230
+ month: 'long',
231
+ day: 'numeric',
232
+ year: 'numeric',
233
+ });
234
+ }
235
+
236
+ function isToday(day: number): boolean {
237
+ return (
238
+ today.getDate() === day && today.getMonth() === viewMonth && today.getFullYear() === viewYear
239
+ );
240
+ }
241
+
242
+ /** Move grid focus to a date (any month), switching the view to it */
243
+ async function focusDate(date: Date) {
244
+ viewMonth = date.getMonth();
245
+ viewYear = date.getFullYear();
246
+ focusedDay = date.getDate();
247
+ await tick();
248
+ dayGrid?.querySelector<HTMLElement>(`[data-day="${focusedDay}"]`)?.focus();
249
+ }
250
+
251
+ // Grid keyboard model (APG date picker dialog): arrows move a day / a week,
252
+ // Home/End to the week's start/end, PageUp/PageDown a month (Shift: a year).
253
+ function handleDayKeyDown(e: KeyboardEvent, day: number) {
254
+ const current = new Date(viewYear, viewMonth, day);
255
+ const next = new Date(current);
256
+ switch (e.key) {
257
+ case 'ArrowLeft':
258
+ next.setDate(day - 1);
259
+ break;
260
+ case 'ArrowRight':
261
+ next.setDate(day + 1);
262
+ break;
263
+ case 'ArrowUp':
264
+ next.setDate(day - 7);
265
+ break;
266
+ case 'ArrowDown':
267
+ next.setDate(day + 7);
268
+ break;
269
+ case 'Home':
270
+ next.setDate(day - current.getDay());
271
+ break;
272
+ case 'End':
273
+ next.setDate(day + (6 - current.getDay()));
274
+ break;
275
+ case 'PageUp':
276
+ case 'PageDown': {
277
+ const step = (e.key === 'PageUp' ? -1 : 1) * (e.shiftKey ? 12 : 1);
278
+ const target = new Date(viewYear, viewMonth + step, 1);
279
+ const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
280
+ next.setFullYear(target.getFullYear(), target.getMonth(), Math.min(day, lastDay));
281
+ break;
282
+ }
283
+ default:
284
+ return;
285
+ }
286
+ e.preventDefault();
287
+ focusDate(next);
288
+ }
289
+
197
290
  // Generate time options
198
291
  const hourOptions = $derived(
199
292
  timeFormat === '12h'
@@ -285,6 +378,7 @@
285
378
  }
286
379
 
287
380
  function prevMonth() {
381
+ focusedDay = null;
288
382
  if (viewMonth === 0) {
289
383
  viewMonth = 11;
290
384
  viewYear--;
@@ -294,6 +388,7 @@
294
388
  }
295
389
 
296
390
  function nextMonth() {
391
+ focusedDay = null;
297
392
  if (viewMonth === 11) {
298
393
  viewMonth = 0;
299
394
  viewYear++;
@@ -414,6 +509,15 @@
414
509
  ];
415
510
 
416
511
  const dayNames = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
512
+ const fullDayNames = [
513
+ 'Sunday',
514
+ 'Monday',
515
+ 'Tuesday',
516
+ 'Wednesday',
517
+ 'Thursday',
518
+ 'Friday',
519
+ 'Saturday',
520
+ ];
417
521
  </script>
418
522
 
419
523
  {#snippet pickerContent()}
@@ -499,7 +603,7 @@
499
603
  <path d="m15 18-6-6 6-6" />
500
604
  </svg>
501
605
  </button>
502
- <span class="text-sm font-medium">
606
+ <span id="{componentId}-month" class="text-sm font-medium" aria-live="polite">
503
607
  {monthNames[viewMonth]}
504
608
  {viewYear}
505
609
  </span>
@@ -525,39 +629,60 @@
525
629
  </button>
526
630
  </div>
527
631
 
528
- <!-- Day names -->
529
- <div class="grid grid-cols-7 gap-1 mb-1">
530
- {#each dayNames as dayName}
531
- <div
532
- class="h-8 w-8 flex items-center justify-center text-xs text-muted-foreground font-medium"
533
- >
534
- {dayName}
535
- </div>
536
- {/each}
537
- </div>
538
-
539
- <!-- Calendar days -->
540
- <div class="grid grid-cols-7 gap-1">
541
- {#each calendarDays as day}
542
- {#if day === null}
543
- <div class="h-8 w-8"></div>
544
- {:else}
545
- <button
546
- type="button"
547
- onclick={() => selectDate(day)}
548
- disabled={isDateDisabled(day)}
549
- class={cn(
550
- 'h-8 w-8 inline-flex items-center justify-center rounded-md text-sm bg-transparent',
551
- 'hover:bg-accent hover:text-accent-foreground',
552
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
553
- 'disabled:pointer-events-none disabled:opacity-50',
554
- isDateSelected(day) &&
555
- 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
556
- )}
632
+ <!-- Day grid: a grid of weeks with one roving tab stop; arrow keys move
633
+ between days (see handleDayKeyDown). -->
634
+ <div
635
+ bind:this={dayGrid}
636
+ role="grid"
637
+ aria-labelledby="{componentId}-month"
638
+ class="flex flex-col gap-1"
639
+ >
640
+ <div role="row" class="grid grid-cols-7 gap-1 mb-1">
641
+ {#each dayNames as dayName, i}
642
+ <div
643
+ role="columnheader"
644
+ aria-label={fullDayNames[i]}
645
+ class="h-8 w-8 flex items-center justify-center text-xs text-muted-foreground font-medium"
557
646
  >
558
- {day}
559
- </button>
560
- {/if}
647
+ <span aria-hidden="true">{dayName}</span>
648
+ </div>
649
+ {/each}
650
+ </div>
651
+
652
+ {#each calendarWeeks as week}
653
+ <div role="row" class="grid grid-cols-7 gap-1">
654
+ {#each week as day}
655
+ {#if day === null}
656
+ <div role="gridcell" class="h-8 w-8"></div>
657
+ {:else}
658
+ {@const selected = isDateSelected(day)}
659
+ {@const unavailable = isDateDisabled(day)}
660
+ <div role="gridcell" aria-selected={selected}>
661
+ <button
662
+ type="button"
663
+ data-day={day}
664
+ tabindex={day === activeDay ? 0 : -1}
665
+ aria-label={dayLabel(day)}
666
+ aria-disabled={unavailable ? 'true' : undefined}
667
+ aria-current={isToday(day) ? 'date' : undefined}
668
+ onclick={() => selectDate(day)}
669
+ onkeydown={(e) => handleDayKeyDown(e, day)}
670
+ onfocus={() => (focusedDay = day)}
671
+ class={cn(
672
+ 'h-8 w-8 inline-flex items-center justify-center rounded-md text-sm bg-transparent',
673
+ 'hover:bg-accent hover:text-accent-foreground',
674
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
675
+ 'aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:hover:bg-transparent aria-disabled:hover:text-current',
676
+ selected &&
677
+ 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
678
+ )}
679
+ >
680
+ {day}
681
+ </button>
682
+ </div>
683
+ {/if}
684
+ {/each}
685
+ </div>
561
686
  {/each}
562
687
  </div>
563
688
 
@@ -2,6 +2,7 @@
2
2
  import { Dialog as DialogPrimitive } from 'bits-ui';
3
3
  import { cn } from '../utils.js';
4
4
  import type { Snippet } from 'svelte';
5
+ import TriggerChild, { type TriggerSnippetProps } from './internal/TriggerChild.svelte';
5
6
 
6
7
  interface Props {
7
8
  /** Whether the dialog is open */
@@ -18,8 +19,13 @@
18
19
  size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
19
20
  /** Content inside the dialog */
20
21
  children: Snippet;
21
- /** Optional trigger element */
22
- trigger?: Snippet;
22
+ /**
23
+ * Optional trigger element. It receives `{ props }`: spread them on your
24
+ * own button (`{#snippet trigger({ props })}<Button {...props}>Open</Button>{/snippet}`)
25
+ * so that button is the trigger and gets focus back on close. A snippet
26
+ * that ignores them still works, wrapped in a span trigger as before.
27
+ */
28
+ trigger?: Snippet<[TriggerSnippetProps]>;
23
29
  /** Optional footer element */
24
30
  footer?: Snippet;
25
31
  /** Optional custom header content (replaces title/description) */
@@ -62,6 +68,10 @@
62
68
  onCloseAutoFocus,
63
69
  }: Props = $props();
64
70
 
71
+ const uid = $props.id();
72
+ // Changes once if the trigger snippet ignores its props (see TriggerChild)
73
+ let triggerId = $state(`${uid}-trigger`);
74
+
65
75
  function handleOpenChange(newOpen: boolean) {
66
76
  open = newOpen;
67
77
  onOpenChange?.(newOpen);
@@ -75,11 +85,9 @@
75
85
 
76
86
  <DialogPrimitive.Root bind:open onOpenChange={handleOpenChange}>
77
87
  {#if trigger}
78
- <DialogPrimitive.Trigger>
88
+ <DialogPrimitive.Trigger id={triggerId}>
79
89
  {#snippet child({ props })}
80
- <span {...props}>
81
- {@render trigger()}
82
- </span>
90
+ <TriggerChild {props} {trigger} onfallback={() => (triggerId = `${uid}-trigger-wrap`)} />
83
91
  {/snippet}
84
92
  </DialogPrimitive.Trigger>
85
93
  {/if}
@@ -1,4 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import { type TriggerSnippetProps } from './internal/TriggerChild.svelte';
2
3
  interface Props {
3
4
  /** Whether the dialog is open */
4
5
  open?: boolean;
@@ -14,8 +15,13 @@ interface Props {
14
15
  size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
15
16
  /** Content inside the dialog */
16
17
  children: Snippet;
17
- /** Optional trigger element */
18
- trigger?: Snippet;
18
+ /**
19
+ * Optional trigger element. It receives `{ props }`: spread them on your
20
+ * own button (`{#snippet trigger({ props })}<Button {...props}>Open</Button>{/snippet}`)
21
+ * so that button is the trigger and gets focus back on close. A snippet
22
+ * that ignores them still works, wrapped in a span trigger as before.
23
+ */
24
+ trigger?: Snippet<[TriggerSnippetProps]>;
19
25
  /** Optional footer element */
20
26
  footer?: Snippet;
21
27
  /** Optional custom header content (replaces title/description) */
@@ -37,8 +37,11 @@
37
37
  error?: string;
38
38
  /** Callback when files are selected */
39
39
  onFilesChange?: (files: FileMetadata[]) => void;
40
- /** Callback to handle file upload (optional - if not provided, files are stored locally) */
41
- onUpload?: (file: File) => Promise<FileMetadata>;
40
+ /**
41
+ * Callback to handle file upload (optional - if not provided, files are stored locally).
42
+ * `signal` aborts when the file is removed mid-upload; pass it to `fetch`.
43
+ */
44
+ onUpload?: (file: File, options: { signal: AbortSignal }) => Promise<FileMetadata>;
42
45
  /** Label text */
43
46
  label?: string;
44
47
  /** Hint text shown below the drop zone */
@@ -92,6 +95,13 @@
92
95
  /** Map of file IDs to their AbortControllers for cancellation */
93
96
  const uploadControllers = new Map<string, AbortController>();
94
97
 
98
+ /**
99
+ * Slots held by batches still reading previews. `files` only grows once a
100
+ * preview is read, so without this two quick drops could both pass the
101
+ * `maxFiles` check.
102
+ */
103
+ let reservedSlots = 0;
104
+
95
105
  const displayError = $derived(externalError || internalError);
96
106
 
97
107
  /**
@@ -274,9 +284,12 @@
274
284
 
275
285
  // Generate preview for images
276
286
  if (file.type.startsWith('image/')) {
277
- metadata.url = await new Promise<string>((resolve) => {
287
+ // An unreadable file falls back to the icon instead of hanging the batch
288
+ metadata.url = await new Promise<string | undefined>((resolve) => {
278
289
  const reader = new FileReader();
279
290
  reader.onload = () => resolve(reader.result as string);
291
+ reader.onerror = () => resolve(undefined);
292
+ reader.onabort = () => resolve(undefined);
280
293
  reader.readAsDataURL(file);
281
294
  });
282
295
  } else if (file.type.startsWith('video/')) {
@@ -310,23 +323,27 @@
310
323
  internalError = undefined;
311
324
  const fileArray = Array.from(selectedFiles);
312
325
 
313
- // Check max files limit
314
- if (files.length + fileArray.length > maxFiles) {
326
+ // Check max files limit, counting batches that are still being read
327
+ if (files.length + reservedSlots + fileArray.length > maxFiles) {
315
328
  internalError = `Maximum ${maxFiles} files allowed`;
316
329
  return;
317
330
  }
318
331
 
319
332
  // Track upload promises for waiting on completion
320
333
  const uploadPromises: Promise<void>[] = [];
334
+ reservedSlots += fileArray.length;
321
335
 
322
336
  for (const file of fileArray) {
323
337
  const validationError = validateFile(file);
324
338
  if (validationError) {
325
339
  internalError = validationError;
340
+ reservedSlots--;
326
341
  continue;
327
342
  }
328
343
 
329
344
  const metadata = await createFileMetadata(file);
345
+ // The slot passes from the reservation to `files`
346
+ reservedSlots--;
330
347
 
331
348
  // Add file to list immediately with uploading state
332
349
  files = [...files, metadata];
@@ -338,7 +355,7 @@
338
355
 
339
356
  const uploadPromise = (async () => {
340
357
  try {
341
- const result = await onUpload(file);
358
+ const result = await onUpload(file, { signal: controller.signal });
342
359
 
343
360
  // Check if upload was cancelled (file removed during upload)
344
361
  if (controller.signal.aborted) return;
@@ -30,8 +30,13 @@ interface Props {
30
30
  error?: string;
31
31
  /** Callback when files are selected */
32
32
  onFilesChange?: (files: FileMetadata[]) => void;
33
- /** Callback to handle file upload (optional - if not provided, files are stored locally) */
34
- onUpload?: (file: File) => Promise<FileMetadata>;
33
+ /**
34
+ * Callback to handle file upload (optional - if not provided, files are stored locally).
35
+ * `signal` aborts when the file is removed mid-upload; pass it to `fetch`.
36
+ */
37
+ onUpload?: (file: File, options: {
38
+ signal: AbortSignal;
39
+ }) => Promise<FileMetadata>;
35
40
  /** Label text */
36
41
  label?: string;
37
42
  /** Hint text shown below the drop zone */
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { Combobox as ComboboxPrimitive } from 'bits-ui';
3
3
  import { cn } from '../utils.js';
4
- import { generateId, computeDescribedBy } from '../utils/form.js';
4
+ import { computeDescribedBy } from '../utils/form.js';
5
5
  import Spinner from './Spinner.svelte';
6
6
  import Label from './Label.svelte';
7
7
  import LiveRegion from './LiveRegion.svelte';
@@ -69,9 +69,9 @@
69
69
  class: className,
70
70
  }: Props = $props();
71
71
 
72
- // Generate unique IDs for accessibility
73
- const randomSuffix = generateId('multiselect').split('-').pop();
74
- const componentId = $derived(id || `multiselect-${randomSuffix}`);
72
+ // Generate unique IDs for accessibility ($props.id() is SSR-stable)
73
+ const uid = $props.id();
74
+ const componentId = $derived(id || `multiselect-${uid}`);
75
75
  const hintId = $derived(`${componentId}-hint`);
76
76
  const errorId = $derived(`${componentId}-error`);
77
77
 
@@ -137,9 +137,6 @@
137
137
  };
138
138
  });
139
139
 
140
- // Determine if we need a container wrapper
141
- const hasWrapper = $derived(!!label || !!hint || !!error);
142
-
143
140
  // Generate announcement message for screen readers
144
141
  const resultsAnnouncement = $derived.by(() => {
145
142
  if (loading) return 'Loading options...';
@@ -158,155 +155,19 @@
158
155
  });
159
156
  </script>
160
157
 
161
- {#if hasWrapper}
162
- <div class="space-y-2">
163
- {#if label}
164
- <Label for={componentId} {disabled}>
165
- {label}
166
- {#if required}
167
- <span class="text-destructive ml-0.5" aria-hidden="true">*</span>
168
- <span class="sr-only">(required)</span>
169
- {/if}
170
- </Label>
171
- {/if}
172
-
173
- <div class={cn('w-full', className)}>
174
- <!-- Selected tags -->
175
- {#if selectedLabels.length > 0}
176
- <div class="flex flex-wrap gap-1 mb-2">
177
- {#each value as selectedValue}
178
- {@const labelText = options.find((o) => o.value === selectedValue)?.label}
179
- {#if labelText}
180
- <span
181
- class="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium text-foreground"
182
- >
183
- {labelText}
184
- <button
185
- type="button"
186
- onclick={() => removeValue(selectedValue)}
187
- class="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
188
- {disabled}
189
- aria-label={`Remove ${labelText}`}
190
- >
191
- <svg
192
- xmlns="http://www.w3.org/2000/svg"
193
- width="14"
194
- height="14"
195
- viewBox="0 0 24 24"
196
- fill="none"
197
- stroke="currentColor"
198
- stroke-width="2"
199
- stroke-linecap="round"
200
- stroke-linejoin="round"
201
- >
202
- <path d="M18 6 6 18" />
203
- <path d="m6 6 12 12" />
204
- </svg>
205
- </button>
206
- </span>
207
- {/if}
208
- {/each}
209
- </div>
158
+ <!-- One structure whether or not there is a label, hint or error, so the
159
+ focused control is never remounted when an error appears. -->
160
+ <div class="space-y-2">
161
+ {#if label}
162
+ <Label for={componentId} {disabled}>
163
+ {label}
164
+ {#if required}
165
+ <span class="text-destructive ml-0.5" aria-hidden="true">*</span>
166
+ <span class="sr-only">(required)</span>
210
167
  {/if}
168
+ </Label>
169
+ {/if}
211
170
 
212
- <ComboboxPrimitive.Root
213
- type="multiple"
214
- {disabled}
215
- {required}
216
- {name}
217
- bind:open
218
- onOpenChange={handleOpenChange}
219
- onValueChange={handleValueChange}
220
- {value}
221
- >
222
- <div class="relative">
223
- <ComboboxPrimitive.Input
224
- id={componentId}
225
- placeholder={value.length > 0 ? `${value.length} selected` : placeholder}
226
- oninput={handleSearchInput}
227
- onfocus={() => (open = true)}
228
- onclick={() => (open = true)}
229
- class={cn(
230
- 'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background text-foreground px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
231
- error && 'border-destructive focus:ring-destructive'
232
- )}
233
- aria-invalid={error ? 'true' : undefined}
234
- aria-describedby={describedBy}
235
- />
236
- </div>
237
-
238
- <!-- Portal to <body>: escapes transformed/overflow-hidden ancestors
239
- (e.g. Dialog panels) that would clip the dropdown. -->
240
- <ComboboxPrimitive.Portal>
241
- <ComboboxPrimitive.Content
242
- class="isolate relative z-50 min-w-[var(--bits-floating-anchor-width)] overflow-hidden rounded-md border border-border bg-popover 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"
243
- sideOffset={4}
244
- collisionPadding={8}
245
- avoidCollisions={true}
246
- >
247
- <div class="p-1 max-h-72 overflow-y-auto">
248
- {#if loading}
249
- <div class="flex items-center justify-center py-6">
250
- <Spinner size="sm" />
251
- <span class="ml-2 text-sm text-muted-foreground">Loading...</span>
252
- </div>
253
- {:else if filteredOptions.length === 0}
254
- <div class="py-6 text-center text-sm text-muted-foreground">
255
- {emptyMessage}
256
- </div>
257
- {:else}
258
- {#each filteredOptions as option}
259
- {@const isSelected = value.includes(option.value)}
260
- {@const isDisabledByMax = maxReached && !isSelected}
261
- <ComboboxPrimitive.Item
262
- value={option.value}
263
- label={option.label}
264
- disabled={option.disabled || isDisabledByMax}
265
- class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
266
- >
267
- <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
268
- {#if isSelected}
269
- <svg
270
- xmlns="http://www.w3.org/2000/svg"
271
- width="16"
272
- height="16"
273
- viewBox="0 0 24 24"
274
- fill="none"
275
- stroke="currentColor"
276
- stroke-width="2"
277
- stroke-linecap="round"
278
- stroke-linejoin="round"
279
- class="h-4 w-4"
280
- >
281
- <polyline points="20 6 9 17 4 12" />
282
- </svg>
283
- {:else}
284
- <span class="h-3.5 w-3.5 rounded-sm border border-primary"></span>
285
- {/if}
286
- </span>
287
- {option.label}
288
- </ComboboxPrimitive.Item>
289
- {/each}
290
- {/if}
291
- </div>
292
- </ComboboxPrimitive.Content>
293
- </ComboboxPrimitive.Portal>
294
- </ComboboxPrimitive.Root>
295
- </div>
296
-
297
- {#if hint && !error}
298
- <p id={hintId} class="text-sm text-muted-foreground">
299
- {hint}
300
- </p>
301
- {/if}
302
-
303
- {#if error}
304
- <p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
305
- {error}
306
- </p>
307
- {/if}
308
- </div>
309
- {:else}
310
171
  <div class={cn('w-full', className)}>
311
172
  <!-- Selected tags -->
312
173
  {#if selectedLabels.length > 0}
@@ -430,7 +291,19 @@
430
291
  </ComboboxPrimitive.Portal>
431
292
  </ComboboxPrimitive.Root>
432
293
  </div>
433
- {/if}
294
+
295
+ {#if hint && !error}
296
+ <p id={hintId} class="text-sm text-muted-foreground">
297
+ {hint}
298
+ </p>
299
+ {/if}
300
+
301
+ {#if error}
302
+ <p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
303
+ {error}
304
+ </p>
305
+ {/if}
306
+ </div>
434
307
 
435
308
  <!-- Live region for screen reader announcements -->
436
309
  <LiveRegion message={resultsAnnouncement} politeness="polite" />