@classic-homes/theme-svelte 1.22.0 → 1.22.2

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 (110) hide show
  1. package/dist/lib/components/Alert.svelte +8 -10
  2. package/dist/lib/components/Combobox.svelte +2 -2
  3. package/dist/lib/components/DataPanel.svelte +19 -5
  4. package/dist/lib/components/DataTable.svelte +15 -6
  5. package/dist/lib/components/DataTable.svelte.d.ts +1 -1
  6. package/dist/lib/components/DateTimePicker.svelte +31 -34
  7. package/dist/lib/components/DropdownMenu.svelte +52 -30
  8. package/dist/lib/components/DropdownMenu.svelte.d.ts +5 -0
  9. package/dist/lib/components/FileUpload.svelte +5 -19
  10. package/dist/lib/components/HeaderSearch.svelte +18 -10
  11. package/dist/lib/components/MultiSelect.svelte +2 -2
  12. package/dist/lib/components/NumberInput.svelte +15 -2
  13. package/dist/lib/components/Select.svelte +19 -5
  14. package/dist/lib/components/Select.svelte.d.ts +4 -0
  15. package/dist/lib/components/Signature.svelte +15 -4
  16. package/dist/lib/components/Signature.svelte.d.ts +1 -1
  17. package/dist/lib/components/Skeleton.svelte.d.ts +1 -1
  18. package/dist/lib/components/Slider.svelte +8 -6
  19. package/dist/lib/components/Tabs.svelte +10 -12
  20. package/dist/lib/components/Toast.svelte +12 -12
  21. package/dist/lib/components/data-table/DataTableColumnVisibility.svelte +24 -26
  22. package/dist/lib/components/data-table/DataTableEditableCell.svelte +31 -18
  23. package/dist/lib/components/data-table/DataTableEditableCell.svelte.d.ts +0 -7
  24. package/dist/lib/components/data-table/DataTablePagination.svelte +5 -1
  25. package/dist/lib/components/data-table/DataTableVirtual.svelte +4 -7
  26. package/dist/lib/components/data-table/DataTableVirtual.svelte.d.ts +3 -0
  27. package/dist/lib/components/data-table/createDataTable.svelte.d.ts +6 -1
  28. package/dist/lib/components/data-table/createDataTable.svelte.js +22 -9
  29. package/dist/lib/components/layout/AppShell.svelte +1 -1
  30. package/dist/lib/components/layout/DashboardLayout.svelte +15 -6
  31. package/dist/lib/components/layout/Footer.svelte +6 -6
  32. package/dist/lib/components/layout/FormPageLayout.svelte +4 -3
  33. package/dist/lib/components/layout/Sidebar.svelte +53 -21
  34. package/dist/lib/components/media/Image.svelte.d.ts +1 -1
  35. package/dist/lib/components/media/Lightbox.svelte +13 -9
  36. package/dist/lib/components/media/MediaCarousel.svelte +5 -0
  37. package/dist/lib/components/media/MediaGrid.svelte +4 -1
  38. package/dist/lib/components/notifications/NotificationDialog.svelte +8 -5
  39. package/dist/lib/components/notifications/NotificationItem.svelte +16 -6
  40. package/dist/lib/components/studio/InsertPalette.svelte +4 -1
  41. package/dist/lib/components/studio/StudioCanvas.svelte +23 -2
  42. package/dist/lib/components/studio/StudioCanvas.svelte.d.ts +5 -1
  43. package/dist/lib/composables/useAsync.svelte.d.ts +1 -1
  44. package/dist/lib/composables/useAsync.svelte.js +33 -14
  45. package/dist/lib/composables/useFocusTrap.svelte.js +9 -2
  46. package/dist/lib/composables/useForm.svelte.js +19 -7
  47. package/dist/lib/composables/usePersistedForm.svelte.js +37 -10
  48. package/dist/lib/notices/sanitize.js +10 -3
  49. package/dist/lib/notices/store.d.ts +3 -3
  50. package/dist/lib/notices/store.js +14 -2
  51. package/dist/lib/notices/types.d.ts +8 -8
  52. package/dist/lib/notices/types.js +5 -1
  53. package/dist/lib/stores/sidebar.svelte.d.ts +12 -0
  54. package/dist/lib/stores/sidebar.svelte.js +76 -26
  55. package/dist/lib/stores/toast.svelte.js +12 -1
  56. package/dist/lib/utils/date.d.ts +3 -1
  57. package/dist/lib/utils/date.js +54 -34
  58. package/dist/lib/utils/form.d.ts +2 -1
  59. package/dist/lib/utils/form.js +3 -2
  60. package/dist/lib/utils/media.js +4 -3
  61. package/dist/lib/utils/url.d.ts +10 -0
  62. package/dist/lib/utils/url.js +21 -0
  63. package/dist/lib/utils.js +14 -1
  64. package/package.json +5 -4
  65. package/dist/__tests__/FileUpload.test.js +0 -45
  66. package/dist/__tests__/a11y/Alert.a11y.test.js +0 -84
  67. package/dist/__tests__/a11y/AlertDialog.a11y.test.js +0 -126
  68. package/dist/__tests__/a11y/Button.a11y.test.js +0 -66
  69. package/dist/__tests__/a11y/Card.a11y.test.js +0 -77
  70. package/dist/__tests__/a11y/Combobox.a11y.test.js +0 -209
  71. package/dist/__tests__/a11y/Dialog.a11y.test.js +0 -103
  72. package/dist/__tests__/a11y/DropdownMenu.a11y.test.js +0 -132
  73. package/dist/__tests__/a11y/FileUpload.a11y.test.js +0 -158
  74. package/dist/__tests__/a11y/FilterToolbar.a11y.test.js +0 -62
  75. package/dist/__tests__/a11y/Icon.a11y.test.js +0 -119
  76. package/dist/__tests__/a11y/Input.a11y.test.js +0 -96
  77. package/dist/__tests__/a11y/LiveRegion.a11y.test.js +0 -102
  78. package/dist/__tests__/a11y/Media.a11y.test.js +0 -145
  79. package/dist/__tests__/a11y/PublicLayout.a11y.test.js +0 -139
  80. package/dist/__tests__/a11y/SegmentedControl.a11y.test.js +0 -50
  81. package/dist/__tests__/a11y/Switch.a11y.test.js +0 -95
  82. package/dist/__tests__/a11y/Tabs.a11y.test.js +0 -116
  83. package/dist/__tests__/a11y/Textarea.a11y.test.js +0 -111
  84. package/dist/__tests__/a11y/Toolbar.a11y.test.js +0 -31
  85. package/dist/__tests__/a11y/ViewToggle.a11y.test.js +0 -40
  86. package/dist/__tests__/action-header.test.js +0 -150
  87. package/dist/__tests__/alert-dialog-dismiss.test.js +0 -54
  88. package/dist/__tests__/code-field.test.js +0 -65
  89. package/dist/__tests__/control-text-color.test.js +0 -29
  90. package/dist/__tests__/data-panel-card-gestures.test.js +0 -138
  91. package/dist/__tests__/data-panel-viewport.test.js +0 -45
  92. package/dist/__tests__/dropdown-trigger.test.js +0 -39
  93. package/dist/__tests__/header-search.test.js +0 -90
  94. package/dist/__tests__/media-components.test.js +0 -206
  95. package/dist/__tests__/media-hls.test.js +0 -273
  96. package/dist/__tests__/media-matterport.test.js +0 -296
  97. package/dist/__tests__/media-players.test.js +0 -244
  98. package/dist/__tests__/media-scrubber.test.js +0 -44
  99. package/dist/__tests__/media-utils.test.js +0 -427
  100. package/dist/__tests__/nav-access.test.js +0 -145
  101. package/dist/__tests__/page-header.test.js +0 -36
  102. package/dist/__tests__/public-layout-drawer.test.js +0 -116
  103. package/dist/__tests__/setup.js +0 -56
  104. package/dist/__tests__/sidebar-collapse.test.js +0 -58
  105. package/dist/__tests__/sidebar-docs-parity.test.js +0 -134
  106. package/dist/__tests__/sidebar-expansion.test.js +0 -59
  107. package/dist/__tests__/sidebar-items.test.js +0 -59
  108. package/dist/__tests__/sidebar-mobile.test.js +0 -86
  109. package/dist/__tests__/studio-canvas-item.svelte.test.js +0 -98
  110. package/dist/__tests__/studio.test.js +0 -240
@@ -7,17 +7,15 @@
7
7
  base: 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
8
8
  variants: {
9
9
  variant: {
10
- default:
11
- 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-900 dark:text-gray-100',
10
+ // Status tokens follow the theme into dark mode. Text stays foreground:
11
+ // the status hues are tuned for fills and icons, not body-text contrast.
12
+ default: 'bg-muted/50 border-border text-foreground',
12
13
  destructive:
13
- 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 [&>svg]:text-red-700 dark:[&>svg]:text-red-400',
14
- error:
15
- 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 [&>svg]:text-red-700 dark:[&>svg]:text-red-400',
16
- success:
17
- 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 text-green-700 dark:text-green-400 [&>svg]:text-green-700 dark:[&>svg]:text-green-400',
18
- warning:
19
- 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800 text-yellow-700 dark:text-yellow-400 [&>svg]:text-yellow-700 dark:[&>svg]:text-yellow-400',
20
- info: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-400 [&>svg]:text-blue-700 dark:[&>svg]:text-blue-400',
14
+ 'bg-destructive/10 border-destructive/40 text-foreground [&>svg]:text-destructive',
15
+ error: 'bg-destructive/10 border-destructive/40 text-foreground [&>svg]:text-destructive',
16
+ success: 'bg-success/10 border-success/40 text-foreground [&>svg]:text-success',
17
+ warning: 'bg-warning/10 border-warning/40 text-foreground [&>svg]:text-warning',
18
+ info: 'bg-info/10 border-info/40 text-foreground [&>svg]:text-info',
21
19
  },
22
20
  },
23
21
  defaultVariants: {
@@ -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" />
@@ -169,6 +169,10 @@
169
169
  class: className,
170
170
  }: Props = $props();
171
171
 
172
+ // Unique per instance: two panels on a page must not share a title id.
173
+ const uid = $props.id();
174
+ const titleId = `data-panel-${uid}-title`;
175
+
172
176
  // Resolve constraints and card config (use $derived to react to prop changes)
173
177
  const resolvedConstraints = $derived({ ...DEFAULT_CONSTRAINTS, ...constraints });
174
178
  const resolvedCardConfig = $derived({ ...DEFAULT_CARD_CONFIG, ...cardConfig });
@@ -440,6 +444,15 @@
440
444
  handleExpandedChange(!isExpanded);
441
445
  }
442
446
 
447
+ // Keyboard activation for the card's role="button" header areas. Space must
448
+ // not also scroll the page, and a held key must not toggle repeatedly.
449
+ function handleCardToggleKeyDown(e: KeyboardEvent) {
450
+ if (e.key !== 'Enter' && e.key !== ' ') return;
451
+ e.preventDefault();
452
+ if (e.repeat) return;
453
+ handleToggleExpand();
454
+ }
455
+
443
456
  // Header collapse button. A detached full panel collapses by re-pinning to its
444
457
  // last edge (edge always holds the previous pin, or the default) and showing the
445
458
  // collapsed tab. Pinned/card just toggle expanded.
@@ -1357,7 +1370,8 @@
1357
1370
  .join(';')}
1358
1371
  role={mode === 'detached' || variant === 'card' ? 'dialog' : 'complementary'}
1359
1372
  aria-modal={isDialog ? 'true' : undefined}
1360
- aria-labelledby={title ? 'data-panel-title' : undefined}
1373
+ aria-labelledby={title && !header ? titleId : undefined}
1374
+ aria-label={title && header ? title : undefined}
1361
1375
  aria-expanded={variant === 'card' ? isExpanded : undefined}
1362
1376
  tabindex="-1"
1363
1377
  onpointerdown={variant === 'full' &&
@@ -1388,7 +1402,7 @@
1388
1402
  {#if variant === 'card' && resolvedCardConfig.showDragHandle}
1389
1403
  <div
1390
1404
  class="absolute top-1.5 left-1/2 -translate-x-1/2 w-10 h-1.5 rounded-full bg-muted-foreground/30 cursor-pointer touch-none"
1391
- onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && handleToggleExpand()}
1405
+ onkeydown={handleCardToggleKeyDown}
1392
1406
  onpointerdown={handleCardHeaderPointerDown}
1393
1407
  role="button"
1394
1408
  tabindex="0"
@@ -1399,7 +1413,7 @@
1399
1413
  {#if variant === 'card'}
1400
1414
  <div
1401
1415
  class="flex-1 min-w-0"
1402
- onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && handleToggleExpand()}
1416
+ onkeydown={handleCardToggleKeyDown}
1403
1417
  role="button"
1404
1418
  tabindex="0"
1405
1419
  >
@@ -1410,7 +1424,7 @@
1410
1424
  <p class="text-sm text-muted-foreground truncate">{subtitle}</p>
1411
1425
  {/if}
1412
1426
  {#if title}
1413
- <h3 id="data-panel-title" class="text-lg font-semibold truncate">{title}</h3>
1427
+ <h3 id={titleId} class="text-lg font-semibold truncate">{title}</h3>
1414
1428
  {/if}
1415
1429
  {/if}
1416
1430
  </div>
@@ -1423,7 +1437,7 @@
1423
1437
  <p class="text-sm text-muted-foreground truncate">{subtitle}</p>
1424
1438
  {/if}
1425
1439
  {#if title}
1426
- <h3 id="data-panel-title" class="text-lg font-semibold truncate">{title}</h3>
1440
+ <h3 id={titleId} class="text-lg font-semibold truncate">{title}</h3>
1427
1441
  {/if}
1428
1442
  {/if}
1429
1443
  </div>
@@ -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 -->
@@ -134,14 +134,15 @@
134
134
  document.getElementById(optionId(selectedIndex))?.scrollIntoView?.({ block: 'nearest' });
135
135
  });
136
136
 
137
- // Keyboard shortcut handler
137
+ // Keyboard shortcut handler. Not registered while disabled, so the browser's
138
+ // own Ctrl+K stays usable and no hidden open is queued for later.
138
139
  $effect(() => {
139
- if (!enableShortcut || typeof window === 'undefined') return;
140
+ if (!enabled || !enableShortcut || typeof window === 'undefined') return;
140
141
 
141
142
  const handleKeydown = (e: KeyboardEvent) => {
142
143
  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === shortcutKey) {
143
144
  e.preventDefault();
144
- open = true;
145
+ setOpen(true);
145
146
  }
146
147
  };
147
148
 
@@ -193,17 +194,24 @@
193
194
 
194
195
  function handleSelect(item: SearchResultItem) {
195
196
  onSelect?.(item);
196
- open = false;
197
- query = '';
197
+ setOpen(false);
198
198
  }
199
199
 
200
- function handleOpenChange(isOpen: boolean) {
200
+ /**
201
+ * Every open/close goes through here. bits-ui only reports its own
202
+ * (Escape, outside click) changes, so the triggers, shortcut and selection
203
+ * must notify `onOpenChange` themselves.
204
+ */
205
+ function setOpen(isOpen: boolean) {
206
+ if (open === isOpen) return;
201
207
  open = isOpen;
202
- onOpenChange?.(isOpen);
203
208
  if (!isOpen) {
204
209
  query = '';
205
210
  selectedIndex = 0;
211
+ if (debounceTimer) clearTimeout(debounceTimer);
212
+ debounceTimer = null;
206
213
  }
214
+ onOpenChange?.(isOpen);
207
215
  }
208
216
  </script>
209
217
 
@@ -213,7 +221,7 @@
213
221
  <!-- Icon-only variant -->
214
222
  <button
215
223
  type="button"
216
- onclick={() => (open = true)}
224
+ onclick={() => setOpen(true)}
217
225
  class={cn(
218
226
  'inline-flex items-center justify-center rounded-md border border-input bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
219
227
  size === 'sm' ? 'h-8 w-8' : 'h-9 w-9',
@@ -235,7 +243,7 @@
235
243
  <!-- Default full search box variant -->
236
244
  <button
237
245
  type="button"
238
- onclick={() => (open = true)}
246
+ onclick={() => setOpen(true)}
239
247
  class={cn(
240
248
  'flex items-center gap-2 rounded-md border border-input bg-background px-3 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
241
249
  size === 'sm' ? 'h-8' : 'h-9',
@@ -263,7 +271,7 @@
263
271
  {/if}
264
272
 
265
273
  <!-- Search Dialog -->
266
- <DialogPrimitive.Root bind:open onOpenChange={handleOpenChange}>
274
+ <DialogPrimitive.Root {open} onOpenChange={setOpen}>
267
275
  <DialogPrimitive.Portal>
268
276
  <DialogPrimitive.Overlay
269
277
  class="fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
@@ -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
  }