@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
@@ -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;
@@ -1,6 +1,6 @@
1
1
  import type { HTMLAttributes } from 'svelte/elements';
2
2
  declare const Skeleton: import("svelte").Component<HTMLAttributes<HTMLDivElement> & {
3
- variant?: "default" | "button" | "text" | "title" | "avatar" | "card" | undefined;
3
+ variant?: "text" | "default" | "button" | "title" | "avatar" | "card" | undefined;
4
4
  /** Animation style for the skeleton */
5
5
  animation?: "pulse" | "shimmer" | "none";
6
6
  class?: string;
@@ -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>
@@ -79,23 +79,21 @@
79
79
  updateScrollShadows();
80
80
  });
81
81
 
82
- // Create a reactive object for context
83
- // Initialize with empty values - the effect will set them immediately
84
- const tabsState = $state({ activeTab: '', tabsId: '' });
85
-
86
- // Keep context state in sync with props (runs immediately and on changes)
87
- $effect(() => {
88
- tabsState.activeTab = value;
89
- tabsState.tabsId = id;
82
+ // Context for TabPanel children. Getters read the props directly, so the
83
+ // active panel is right on the first render and during SSR (an $effect
84
+ // would only fill it in after hydration).
85
+ setContext('tabs', {
86
+ get activeTab() {
87
+ return value;
88
+ },
89
+ get tabsId() {
90
+ return id;
91
+ },
90
92
  });
91
93
 
92
- // Set up context for TabPanel children (done once at component init)
93
- setContext('tabs', tabsState);
94
-
95
94
  function selectTab(tabId: string) {
96
95
  if (tabs.find((t) => t.id === tabId && t.disabled)) return;
97
96
  value = tabId;
98
- tabsState.activeTab = tabId;
99
97
  onValueChange?.(tabId);
100
98
  }
101
99
 
@@ -6,17 +6,17 @@
6
6
 
7
7
  type ToastType = 'success' | 'error' | 'warning' | 'info';
8
8
 
9
+ // Status tokens (light/dark aware) over an opaque card surface: the tint is a
10
+ // ::before layer so the toast never shows the page through it, and text stays
11
+ // foreground-on-card for contrast in both modes.
9
12
  const toastVariants = tv({
10
- base: 'group pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden rounded-lg border p-4 shadow-lg transition-all',
13
+ base: "group pointer-events-auto relative isolate flex w-full items-start gap-3 overflow-hidden rounded-lg border bg-card p-4 text-card-foreground shadow-lg transition-all before:absolute before:inset-0 before:-z-10 before:content-['']",
11
14
  variants: {
12
15
  type: {
13
- success:
14
- 'bg-green-50 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800',
15
- error:
16
- 'bg-red-50 text-red-800 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800',
17
- warning:
18
- 'bg-yellow-50 text-yellow-800 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-400 dark:border-yellow-800',
19
- info: 'bg-blue-50 text-blue-800 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800',
16
+ success: 'border-success/40 before:bg-success/10',
17
+ error: 'border-destructive/40 before:bg-destructive/10',
18
+ warning: 'border-warning/40 before:bg-warning/10',
19
+ info: 'border-info/40 before:bg-info/10',
20
20
  },
21
21
  },
22
22
  defaultVariants: {
@@ -36,10 +36,10 @@
36
36
  };
37
37
 
38
38
  const iconColors: Record<ToastType, string> = {
39
- success: 'text-green-500 dark:text-green-400',
40
- error: 'text-red-500 dark:text-red-400',
41
- warning: 'text-yellow-500 dark:text-yellow-400',
42
- info: 'text-blue-500 dark:text-blue-400',
39
+ success: 'text-success',
40
+ error: 'text-destructive',
41
+ warning: 'text-warning',
42
+ info: 'text-info',
43
43
  };
44
44
 
45
45
  interface Props {
@@ -7,7 +7,6 @@
7
7
  */
8
8
  import type { DataTableInstance } from './createDataTable.svelte.js';
9
9
  import DropdownMenu from '../DropdownMenu.svelte';
10
- import Button from '../Button.svelte';
11
10
  import { cn } from '../../utils.js';
12
11
 
13
12
  interface Props {
@@ -19,13 +18,13 @@
19
18
 
20
19
  let { table, class: className }: Props = $props();
21
20
 
22
- // Build menu items with checkmarks for visible columns
21
+ // One checkbox item per column, checked while the column is visible
23
22
  const menuItems = $derived.by(() => {
24
23
  return table.allColumns.map((column) => {
25
- const isVisible = !table.hiddenColumns.has(column.id);
26
24
  return {
27
25
  type: 'item' as const,
28
- label: `${isVisible ? '✓ ' : ' '}${column.header}`,
26
+ label: column.header,
27
+ checked: !table.hiddenColumns.has(column.id),
29
28
  onSelect: () => {
30
29
  table.toggleColumnVisibility(column.id);
31
30
  },
@@ -35,32 +34,31 @@
35
34
  </script>
36
35
 
37
36
  <div class={cn('relative', className)}>
37
+ <!-- DropdownMenu renders the trigger <button>; style it via triggerVariant
38
+ rather than nesting a Button inside it. -->
38
39
  <DropdownMenu
39
40
  items={[{ type: 'label', label: 'Toggle columns' }, { type: 'separator' }, ...menuItems]}
41
+ triggerVariant="outline"
42
+ triggerSize="sm"
43
+ triggerClass="ml-auto"
40
44
  >
41
45
  {#snippet trigger()}
42
- <Button variant="outline" size="sm" class="ml-auto" aria-label="Toggle column visibility">
43
- <svg
44
- class="mr-2 h-4 w-4"
45
- fill="none"
46
- viewBox="0 0 24 24"
47
- stroke="currentColor"
48
- stroke-width="2"
49
- aria-hidden="true"
50
- >
51
- <path
52
- stroke-linecap="round"
53
- stroke-linejoin="round"
54
- d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
55
- />
56
- <path
57
- stroke-linecap="round"
58
- stroke-linejoin="round"
59
- d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
60
- />
61
- </svg>
62
- Columns
63
- </Button>
46
+ <svg
47
+ class="mr-2 h-4 w-4"
48
+ fill="none"
49
+ viewBox="0 0 24 24"
50
+ stroke="currentColor"
51
+ stroke-width="2"
52
+ aria-hidden="true"
53
+ >
54
+ <path
55
+ stroke-linecap="round"
56
+ stroke-linejoin="round"
57
+ d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
58
+ />
59
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
60
+ </svg>
61
+ Columns
64
62
  {/snippet}
65
63
  </DropdownMenu>
66
64
  </div>
@@ -6,6 +6,7 @@
6
6
  * Supports text, number, and select inputs.
7
7
  * Works with the native Svelte 5 DataTable implementation.
8
8
  */
9
+ import { tick } from 'svelte';
9
10
  import type { DataTableColumn } from './types.js';
10
11
  import type { DataTableRow } from './createDataTable.svelte.js';
11
12
  import { cn } from '../../utils.js';
@@ -29,6 +30,7 @@
29
30
 
30
31
  let isEditing = $state(false);
31
32
  let editValue = $state<string>('');
33
+ let buttonEl = $state<HTMLButtonElement | null>(null);
32
34
 
33
35
  // Get the current display value
34
36
  const displayValue = $derived.by(() => {
@@ -70,13 +72,21 @@
70
72
  editValue = '';
71
73
  }
72
74
 
75
+ // After a keyboard save/cancel the input is gone; return focus to the cell.
76
+ async function refocusCell() {
77
+ await tick();
78
+ buttonEl?.focus();
79
+ }
80
+
73
81
  function handleKeydown(e: KeyboardEvent) {
74
82
  if (e.key === 'Enter') {
75
83
  e.preventDefault();
76
84
  saveEdit();
85
+ refocusCell();
77
86
  } else if (e.key === 'Escape') {
78
87
  e.preventDefault();
79
88
  cancelEdit();
89
+ refocusCell();
80
90
  } else if (e.key === 'Tab') {
81
91
  saveEdit();
82
92
  }
@@ -91,29 +101,32 @@
91
101
  }
92
102
  </script>
93
103
 
94
- {#if isEditable}
104
+ {#if isEditable && isEditing}
105
+ <!-- The input sits beside, not inside, the button: an input in a button is
106
+ invalid, and its Enter would bubble to the button and reopen the editor. -->
107
+ <div class={cn('relative w-full', className)}>
108
+ <input
109
+ use:autofocus
110
+ type="text"
111
+ value={editValue}
112
+ oninput={(e: Event) => (editValue = (e.target as HTMLInputElement).value)}
113
+ onkeydown={handleKeydown}
114
+ onblur={handleBlur}
115
+ class="h-7 w-full rounded border border-input bg-background px-2 text-base sm:text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
116
+ />
117
+ </div>
118
+ {:else if isEditable}
95
119
  <button
120
+ bind:this={buttonEl}
96
121
  type="button"
97
122
  class={cn('relative w-full text-left hover:bg-muted-hover', className)}
98
123
  ondblclick={handleDoubleClick}
99
- onkeydown={(e: KeyboardEvent) => !isEditing && e.key === 'Enter' && startEditing()}
124
+ onkeydown={(e: KeyboardEvent) => e.key === 'Enter' && startEditing()}
100
125
  >
101
- {#if isEditing}
102
- <input
103
- use:autofocus
104
- type="text"
105
- value={editValue}
106
- oninput={(e: Event) => (editValue = (e.target as HTMLInputElement).value)}
107
- onkeydown={handleKeydown}
108
- onblur={handleBlur}
109
- class="h-7 w-full rounded border border-input bg-background px-2 text-base sm:text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
110
- />
111
- {:else}
112
- <span class="border-b border-dashed border-transparent hover:border-primary">
113
- {displayValue}
114
- </span>
115
- <span class="sr-only">(double-click to edit)</span>
116
- {/if}
126
+ <span class="border-b border-dashed border-transparent hover:border-primary">
127
+ {displayValue}
128
+ </span>
129
+ <span class="sr-only">(double-click to edit)</span>
117
130
  </button>
118
131
  {:else}
119
132
  <span class={cn('relative', className)}>
@@ -1,10 +1,3 @@
1
- /**
2
- * DataTableEditableCell - Inline editable cell component
3
- *
4
- * Allows users to edit cell values in place.
5
- * Supports text, number, and select inputs.
6
- * Works with the native Svelte 5 DataTable implementation.
7
- */
8
1
  import type { DataTableColumn } from './types.js';
9
2
  import type { DataTableRow } from './createDataTable.svelte.js';
10
3
  declare function $$render<T extends object>(): {
@@ -28,6 +28,9 @@
28
28
  class: className,
29
29
  }: Props = $props();
30
30
 
31
+ const uid = $props.id();
32
+ const pageSizeLabelId = `datatable-page-size-${uid}`;
33
+
31
34
  function handlePageSizeChange(value: string) {
32
35
  table.setPageSize(Number(value));
33
36
  }
@@ -45,8 +48,9 @@
45
48
  <div class="flex flex-wrap items-center gap-x-4 gap-y-2 sm:gap-x-6 lg:gap-x-8">
46
49
  <!-- Page size selector -->
47
50
  <div class="flex items-center gap-2">
48
- <span class="text-sm text-muted-foreground">Rows per page</span>
51
+ <span id={pageSizeLabelId} class="text-sm text-muted-foreground">Rows per page</span>
49
52
  <Select
53
+ aria-labelledby={pageSizeLabelId}
50
54
  value={String(table.pageSize)}
51
55
  options={pageSizeOptions.map((size) => ({
52
56
  value: String(size),
@@ -14,6 +14,9 @@
14
14
  * ```svelte
15
15
  * <DataTableVirtual {table} height="600px" rowHeight={48} />
16
16
  * ```
17
+ *
18
+ * Create the table with `enablePagination: false` so `table.rows` holds every
19
+ * row rather than one page.
17
20
  */
18
21
  import type { DataTableInstance, DataTableRow } from './createDataTable.svelte.js';
19
22
  import type { DataTableColumn } from './types.js';
@@ -96,12 +99,6 @@
96
99
  }
97
100
  }
98
101
 
99
- // Handle row selection
100
- function handleSelectRow(rowId: string, e: MouseEvent) {
101
- e.stopPropagation();
102
- table.toggleRowSelection(rowId);
103
- }
104
-
105
102
  // Handle header click for sorting
106
103
  function handleHeaderClick(columnId: string, sortable: boolean | undefined) {
107
104
  if (!sortable) return;
@@ -184,7 +181,7 @@
184
181
  checked={row.isSelected}
185
182
  onCheckedChange={() => table.toggleRowSelection(row.id)}
186
183
  aria-label={`Select row ${index + 1}`}
187
- onclick={(e: MouseEvent) => handleSelectRow(row.id, e)}
184
+ onclick={(e: MouseEvent) => e.stopPropagation()}
188
185
  />
189
186
  </div>
190
187
  {/if}
@@ -13,6 +13,9 @@
13
13
  * ```svelte
14
14
  * <DataTableVirtual {table} height="600px" rowHeight={48} />
15
15
  * ```
16
+ *
17
+ * Create the table with `enablePagination: false` so `table.rows` holds every
18
+ * row rather than one page.
16
19
  */
17
20
  import type { DataTableInstance } from './createDataTable.svelte.js';
18
21
  declare function $$render<T extends object>(): {
@@ -32,7 +32,7 @@ export interface DataTableOptions<T> {
32
32
  data: T[];
33
33
  /** Column definitions */
34
34
  columns: DataTableColumn<T>[];
35
- /** Get unique row ID (defaults to index) */
35
+ /** Get unique row ID (defaults to the row's index in `data`) */
36
36
  getRowId?: (row: T, index: number) => string;
37
37
  /** Initial sort column ID */
38
38
  initialSortColumn?: string;
@@ -54,6 +54,11 @@ export interface DataTableOptions<T> {
54
54
  manualFiltering?: boolean;
55
55
  /** Use server-side pagination (skip client-side pagination) */
56
56
  manualPagination?: boolean;
57
+ /**
58
+ * Slice rows into pages (default true). Set false to get every row in
59
+ * `rows`, e.g. for a DataTable without a pager or for DataTableVirtual.
60
+ */
61
+ enablePagination?: boolean;
57
62
  /** Total page count (required for manual pagination) */
58
63
  pageCount?: number;
59
64
  /** Callback when sort changes */
@@ -162,16 +162,26 @@ export function createDataTable(optionsFn) {
162
162
  : Math.ceil(totalRows / pageSize) || 1);
163
163
  // Clamp pageIndex when data changes
164
164
  const clampedPageIndex = $derived(Math.min(Math.max(0, pageIndex), Math.max(0, totalPages - 1)));
165
- const paginatedData = $derived(options.manualPagination
165
+ const paginatedData = $derived(options.manualPagination || options.enablePagination === false
166
166
  ? sortedData // In manual mode, data is already paginated from server
167
167
  : sortedData.slice(clampedPageIndex * pageSize, (clampedPageIndex + 1) * pageSize));
168
168
  // === ROW HELPERS ===
169
- const rows = $derived(paginatedData.map((row, index) => {
170
- const globalIndex = options.manualPagination ? index : clampedPageIndex * pageSize + index;
171
- const id = getRowId(row, globalIndex);
169
+ // Index of each row in the source data, so row ids (and with them the
170
+ // selection) follow the row rather than its position after sort/filter/page.
171
+ const sourceIndex = $derived.by(() => {
172
+ const map = new Map();
173
+ data.forEach((row, i) => {
174
+ if (!map.has(row))
175
+ map.set(row, i);
176
+ });
177
+ return map;
178
+ });
179
+ const rows = $derived(paginatedData.map((row) => {
180
+ const index = sourceIndex.get(row) ?? -1;
181
+ const id = getRowId(row, index);
172
182
  return {
173
183
  id,
174
- index: globalIndex,
184
+ index,
175
185
  original: row,
176
186
  isSelected: selectedRows.has(id),
177
187
  };
@@ -248,14 +258,17 @@ export function createDataTable(optionsFn) {
248
258
  }
249
259
  function nextPage() {
250
260
  if (clampedPageIndex < totalPages - 1) {
251
- pageIndex = clampedPageIndex + 1;
252
- options.onPaginationChange?.({ pageIndex: clampedPageIndex + 1, pageSize });
261
+ // Read before assigning: clampedPageIndex recomputes from pageIndex.
262
+ const next = clampedPageIndex + 1;
263
+ pageIndex = next;
264
+ options.onPaginationChange?.({ pageIndex: next, pageSize });
253
265
  }
254
266
  }
255
267
  function previousPage() {
256
268
  if (clampedPageIndex > 0) {
257
- pageIndex = clampedPageIndex - 1;
258
- options.onPaginationChange?.({ pageIndex: clampedPageIndex - 1, pageSize });
269
+ const previous = clampedPageIndex - 1;
270
+ pageIndex = previous;
271
+ options.onPaginationChange?.({ pageIndex: previous, pageSize });
259
272
  }
260
273
  }
261
274
  function firstPage() {
@@ -64,7 +64,7 @@
64
64
  <!-- Skip Link for Accessibility -->
65
65
  <a
66
66
  href="#{skipToId}"
67
- class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
67
+ class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-80 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
68
68
  >
69
69
  {skipToText}
70
70
  </a>