@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
@@ -18,8 +18,7 @@
18
18
  * Create the table with `enablePagination: false` so `table.rows` holds every
19
19
  * row rather than one page.
20
20
  */
21
- import type { DataTableInstance, DataTableRow } from './createDataTable.svelte.js';
22
- import type { DataTableColumn } from './types.js';
21
+ import type { DataTableInstance } from './createDataTable.svelte.js';
23
22
  import Checkbox from '../Checkbox.svelte';
24
23
  import { cn } from '../../utils.js';
25
24
  import { getAlignClass, getColumnValue, getColumnWidthStyle, getAriaSortValue } from './utils.js';
@@ -37,6 +36,8 @@
37
36
  onRowClick?: (row: T) => void;
38
37
  /** Whether row selection is enabled */
39
38
  enableRowSelection?: boolean;
39
+ /** Show the select-all checkbox in the header (default: true) */
40
+ enableSelectAll?: boolean;
40
41
  /** Additional CSS classes */
41
42
  class?: string;
42
43
  }
@@ -48,16 +49,32 @@
48
49
  height = '500px',
49
50
  onRowClick,
50
51
  enableRowSelection = false,
52
+ enableSelectAll = true,
51
53
  class: className,
52
54
  }: Props = $props();
53
55
 
54
56
  let containerRef: HTMLDivElement | null = $state(null);
55
57
  let scrollTop = $state(0);
58
+ let measuredHeight = $state(0);
59
+
60
+ // The rendered height of the scroll body. `height` can be any CSS length
61
+ // (80vh, 30rem, calc(...)), so measure it rather than parse it; the parse
62
+ // is only a fallback before layout (or where there is none, e.g. SSR).
63
+ $effect(() => {
64
+ const node = containerRef;
65
+ if (!node) return;
66
+ measuredHeight = node.clientHeight;
67
+ if (typeof ResizeObserver === 'undefined') return;
68
+ const observer = new ResizeObserver(() => {
69
+ measuredHeight = node.clientHeight;
70
+ });
71
+ observer.observe(node);
72
+ return () => observer.disconnect();
73
+ });
56
74
 
57
- // Parse container height to number for calculations
58
75
  const containerHeightPx = $derived.by(() => {
59
- if (!height) return 500;
60
- const num = parseInt(height, 10);
76
+ if (measuredHeight > 0) return measuredHeight;
77
+ const num = parseInt(height ?? '', 10);
61
78
  return isNaN(num) ? 500 : num;
62
79
  });
63
80
 
@@ -104,63 +121,96 @@
104
121
  if (!sortable) return;
105
122
  table.toggleSort(columnId);
106
123
  }
124
+
125
+ function handleHeaderKeydown(e: KeyboardEvent, columnId: string, sortable: boolean | undefined) {
126
+ if (!sortable || (e.key !== 'Enter' && e.key !== ' ')) return;
127
+ // Space would otherwise scroll the page
128
+ e.preventDefault();
129
+ handleHeaderClick(columnId, sortable);
130
+ }
131
+
132
+ // Ignore keys aimed at the row's own controls (the checkbox handles its
133
+ // own Enter/Space).
134
+ function handleRowKeydown(e: KeyboardEvent, row: T) {
135
+ if (e.target !== e.currentTarget) return;
136
+ if (e.key !== 'Enter' && e.key !== ' ') return;
137
+ e.preventDefault();
138
+ handleRowClick(row);
139
+ }
107
140
  </script>
108
141
 
109
- <div class={cn('rounded-md border', className)}>
142
+ <!-- Only a window of rows is in the DOM, so aria-rowcount/aria-rowindex
143
+ give assistive tech the real size and position (header is row 1). -->
144
+ <div
145
+ class={cn('rounded-md border', className)}
146
+ role="table"
147
+ aria-rowcount={allRows.length + 1}
148
+ aria-colcount={table.columns.length + (enableRowSelection ? 1 : 0)}
149
+ >
110
150
  <!-- Sticky Header -->
111
- <div class="flex items-center border-b bg-muted/50" style:height="{rowHeight}px" role="row">
112
- {#if enableRowSelection}
113
- <div class="flex-shrink-0 px-4" style="width: 48px">
114
- <Checkbox
115
- checked={table.isAllSelected}
116
- indeterminate={table.isSomeSelected}
117
- onCheckedChange={() => table.toggleAllRows()}
118
- aria-label="Select all rows"
119
- />
120
- </div>
121
- {/if}
122
- {#each table.columns as column (column.id)}
123
- {@const isSorted = table.sortColumn === column.id}
124
- <div
125
- class={cn(
126
- 'flex-shrink-0 px-4 font-medium text-muted-foreground',
127
- getAlignClass(column.align),
128
- column.sortable && 'cursor-pointer select-none hover:text-foreground'
129
- )}
130
- style={column.width ? `width: ${column.width}` : 'flex: 1'}
131
- onclick={() => handleHeaderClick(column.id, column.sortable)}
132
- onkeydown={(e) =>
133
- (e.key === 'Enter' || e.key === ' ') && handleHeaderClick(column.id, column.sortable)}
134
- role="columnheader"
135
- tabindex={column.sortable ? 0 : -1}
136
- aria-sort={getAriaSortValue(isSorted, table.sortDirection)}
137
- >
138
- <span class="flex items-center gap-1">
139
- {column.header}
140
- {#if isSorted}
141
- <svg
142
- class={cn('h-4 w-4', table.sortDirection === 'desc' && 'rotate-180')}
143
- fill="none"
144
- viewBox="0 0 24 24"
145
- stroke="currentColor"
146
- aria-hidden="true"
147
- >
148
- <path
149
- stroke-linecap="round"
150
- stroke-linejoin="round"
151
- stroke-width="2"
152
- d="M5 15l7-7 7 7"
153
- />
154
- </svg>
151
+ <div role="rowgroup">
152
+ <div
153
+ class="flex items-center border-b bg-muted/50"
154
+ style:height="{rowHeight}px"
155
+ role="row"
156
+ aria-rowindex={1}
157
+ >
158
+ {#if enableRowSelection}
159
+ <div class="flex-shrink-0 px-4" style="width: 48px" role="columnheader">
160
+ {#if enableSelectAll}
161
+ <Checkbox
162
+ checked={table.isAllSelected}
163
+ indeterminate={table.isSomeSelected}
164
+ onCheckedChange={() => table.toggleAllRows()}
165
+ aria-label="Select all rows"
166
+ />
167
+ {:else}
168
+ <span class="sr-only">Select</span>
155
169
  {/if}
156
- </span>
157
- </div>
158
- {/each}
170
+ </div>
171
+ {/if}
172
+ {#each table.columns as column (column.id)}
173
+ {@const isSorted = table.sortColumn === column.id}
174
+ <div
175
+ class={cn(
176
+ 'flex-shrink-0 px-4 font-medium text-muted-foreground',
177
+ getAlignClass(column.align),
178
+ column.sortable && 'cursor-pointer select-none hover:text-foreground'
179
+ )}
180
+ style={column.width ? `width: ${column.width}` : 'flex: 1'}
181
+ onclick={() => handleHeaderClick(column.id, column.sortable)}
182
+ onkeydown={(e) => handleHeaderKeydown(e, column.id, column.sortable)}
183
+ role="columnheader"
184
+ tabindex={column.sortable ? 0 : undefined}
185
+ aria-sort={getAriaSortValue(isSorted, table.sortDirection)}
186
+ >
187
+ <span class="flex items-center gap-1">
188
+ {column.header}
189
+ {#if isSorted}
190
+ <svg
191
+ class={cn('h-4 w-4', table.sortDirection === 'desc' && 'rotate-180')}
192
+ fill="none"
193
+ viewBox="0 0 24 24"
194
+ stroke="currentColor"
195
+ aria-hidden="true"
196
+ >
197
+ <path
198
+ stroke-linecap="round"
199
+ stroke-linejoin="round"
200
+ stroke-width="2"
201
+ d="M5 15l7-7 7 7"
202
+ />
203
+ </svg>
204
+ {/if}
205
+ </span>
206
+ </div>
207
+ {/each}
208
+ </div>
159
209
  </div>
160
210
 
161
211
  <!-- Virtualized Body -->
162
212
  <div bind:this={containerRef} class="overflow-auto" style:height onscroll={handleScroll}>
163
- <div style:height="{totalHeight}px" style:position="relative">
213
+ <div style:height="{totalHeight}px" style:position="relative" role="rowgroup">
164
214
  {#each visibleRows as { row, index, offset } (row.id)}
165
215
  <div
166
216
  class={cn(
@@ -171,12 +221,14 @@
171
221
  style:height="{rowHeight}px"
172
222
  style:transform="translateY({offset}px)"
173
223
  onclick={() => handleRowClick(row.original)}
174
- onkeydown={(e) => e.key === 'Enter' && handleRowClick(row.original)}
224
+ onkeydown={onRowClick ? (e) => handleRowKeydown(e, row.original) : undefined}
175
225
  role="row"
176
- tabindex={onRowClick ? 0 : -1}
226
+ aria-rowindex={index + 2}
227
+ aria-selected={enableRowSelection ? row.isSelected : undefined}
228
+ tabindex={onRowClick ? 0 : undefined}
177
229
  >
178
230
  {#if enableRowSelection}
179
- <div class="flex-shrink-0 px-4" style="width: 48px">
231
+ <div class="flex-shrink-0 px-4" style="width: 48px" role="cell">
180
232
  <Checkbox
181
233
  checked={row.isSelected}
182
234
  onCheckedChange={() => table.toggleRowSelection(row.id)}
@@ -32,6 +32,8 @@ declare function $$render<T extends object>(): {
32
32
  onRowClick?: (row: T) => void;
33
33
  /** Whether row selection is enabled */
34
34
  enableRowSelection?: boolean;
35
+ /** Show the select-all checkbox in the header (default: true) */
36
+ enableSelectAll?: boolean;
35
37
  /** Additional CSS classes */
36
38
  class?: string;
37
39
  };
@@ -23,7 +23,7 @@
23
23
  * - Standard Svelte patterns for debugging
24
24
  * - Excellent performance characteristics
25
25
  */
26
- import type { DataTableColumn } from './types.js';
26
+ import type { ColumnFilterValue, ColumnFiltersState, DataTableColumn } from './types.js';
27
27
  /**
28
28
  * Options for creating a native data table instance.
29
29
  */
@@ -48,6 +48,13 @@ export interface DataTableOptions<T> {
48
48
  initialHiddenColumns?: string[];
49
49
  /** Initially selected rows (by row ID) */
50
50
  initialSelectedRows?: string[];
51
+ /** Initial column filters */
52
+ initialColumnFilters?: ColumnFiltersState;
53
+ /**
54
+ * Allow more than one selected row (default true). When false, selecting a
55
+ * row replaces the previous selection.
56
+ */
57
+ enableMultiRowSelection?: boolean;
51
58
  /** Use server-side sorting (skip client-side sort) */
52
59
  manualSorting?: boolean;
53
60
  /** Use server-side filtering (skip client-side filter) */
@@ -66,7 +73,7 @@ export interface DataTableOptions<T> {
66
73
  /** Callback when global filter changes */
67
74
  onGlobalFilterChange?: (filter: string) => void;
68
75
  /** Callback when column filters change */
69
- onColumnFiltersChange?: (filters: Map<string, string>) => void;
76
+ onColumnFiltersChange?: (filters: ColumnFiltersState) => void;
70
77
  /** Callback when pagination changes */
71
78
  onPaginationChange?: (state: {
72
79
  pageIndex: number;
@@ -77,6 +84,20 @@ export interface DataTableOptions<T> {
77
84
  /** Callback when column visibility changes */
78
85
  onColumnVisibilityChange?: (hiddenColumns: Set<string>) => void;
79
86
  }
87
+ /**
88
+ * State that {@link DataTableInstance.syncState} can set directly, e.g. to
89
+ * mirror a parent's controlled props.
90
+ */
91
+ export interface DataTableSyncState {
92
+ sortColumn?: string | null;
93
+ sortDirection?: 'asc' | 'desc';
94
+ globalFilter?: string;
95
+ columnFilters?: ColumnFiltersState;
96
+ pageIndex?: number;
97
+ pageSize?: number;
98
+ selectedRows?: Set<string>;
99
+ hiddenColumns?: Set<string>;
100
+ }
80
101
  /**
81
102
  * Row object with computed properties.
82
103
  */
@@ -127,10 +148,11 @@ export declare function createDataTable<T extends object>(optionsFn: () => DataT
127
148
  setSortColumn: (columnId: string | null) => void;
128
149
  setSortDirection: (direction: "asc" | "desc") => void;
129
150
  readonly globalFilter: string;
130
- readonly columnFilters: Map<string, string>;
151
+ readonly columnFilters: ColumnFiltersState;
131
152
  setGlobalFilter: (value: string) => void;
132
- setColumnFilter: (columnId: string, value: string) => void;
153
+ setColumnFilter: (columnId: string, value: ColumnFilterValue) => void;
133
154
  clearColumnFilters: () => void;
155
+ syncState: (state: DataTableSyncState) => void;
134
156
  readonly pageIndex: number;
135
157
  readonly pageSize: number;
136
158
  readonly totalRows: number;
@@ -82,6 +82,8 @@ export function createDataTable(optionsFn) {
82
82
  hiddenColumns = new Set(options.initialHiddenColumns);
83
83
  if (options.initialSelectedRows)
84
84
  selectedRows = new Set(options.initialSelectedRows);
85
+ if (options.initialColumnFilters)
86
+ columnFilters = new Map(options.initialColumnFilters);
85
87
  initialized = true;
86
88
  }
87
89
  });
@@ -93,30 +95,27 @@ export function createDataTable(optionsFn) {
93
95
  if (options.manualFiltering)
94
96
  return data;
95
97
  let result = data;
96
- // Global filter
97
- if (globalFilter.trim()) {
98
- const search = globalFilter.toLowerCase();
99
- result = result.filter((row) => visibleColumns.some((col) => {
100
- const value = getColumnValue(row, col);
101
- return String(value ?? '')
102
- .toLowerCase()
103
- .includes(search);
104
- }));
98
+ // Global filter: matches what the cell shows as well as its raw value
99
+ const globalSearch = globalFilter.trim().toLowerCase();
100
+ if (globalSearch) {
101
+ result = result.filter((row) => visibleColumns.some((col) => cellMatchesText(row, col, globalSearch)));
105
102
  }
106
103
  // Column filters
107
104
  for (const [columnId, filterValue] of columnFilters) {
108
- if (!filterValue.trim())
105
+ if (isEmptyFilter(filterValue))
109
106
  continue;
110
107
  const column = columns.find((c) => c.id === columnId);
111
108
  if (!column)
112
109
  continue;
113
- const search = filterValue.toLowerCase();
114
- result = result.filter((row) => {
115
- const value = getColumnValue(row, column);
116
- return String(value ?? '')
117
- .toLowerCase()
118
- .includes(search);
119
- });
110
+ if (Array.isArray(filterValue)) {
111
+ // Faceted filter: exact membership, so 'active' never matches 'inactive'
112
+ const accepted = new Set(filterValue);
113
+ result = result.filter((row) => accepted.has(String(getColumnValue(row, column) ?? '')));
114
+ }
115
+ else {
116
+ const search = filterValue.trim().toLowerCase();
117
+ result = result.filter((row) => cellMatchesText(row, column, search));
118
+ }
120
119
  }
121
120
  return result;
122
121
  });
@@ -149,7 +148,8 @@ export function createDataTable(optionsFn) {
149
148
  cmp = aVal.getTime() - bVal.getTime();
150
149
  }
151
150
  else {
152
- cmp = String(aVal).localeCompare(String(bVal));
151
+ // numeric: "9" sorts before "10", not after "100"
152
+ cmp = String(aVal).localeCompare(String(bVal), undefined, { numeric: true });
153
153
  }
154
154
  return sortDirection === 'asc' ? cmp : -cmp;
155
155
  });
@@ -191,6 +191,38 @@ export function createDataTable(optionsFn) {
191
191
  const isAllSelected = $derived(rows.length > 0 && rows.every((row) => selectedRows.has(row.id)));
192
192
  const isSomeSelected = $derived(rows.some((row) => selectedRows.has(row.id)) && !isAllSelected);
193
193
  // === ACTIONS ===
194
+ /**
195
+ * Set state directly without firing any `on*Change` callback. Used to
196
+ * mirror controlled props into the table; user-driven changes should use
197
+ * the setters below.
198
+ */
199
+ function syncState(state) {
200
+ if (state.sortColumn !== undefined)
201
+ sortColumn = state.sortColumn;
202
+ if (state.sortDirection !== undefined)
203
+ sortDirection = state.sortDirection;
204
+ if (state.globalFilter !== undefined)
205
+ globalFilter = state.globalFilter;
206
+ if (state.columnFilters !== undefined)
207
+ columnFilters = new Map(state.columnFilters);
208
+ if (state.pageIndex !== undefined)
209
+ pageIndex = state.pageIndex;
210
+ if (state.pageSize !== undefined)
211
+ pageSize = state.pageSize;
212
+ if (state.selectedRows !== undefined)
213
+ selectedRows = new Set(state.selectedRows);
214
+ if (state.hiddenColumns !== undefined)
215
+ hiddenColumns = new Set(state.hiddenColumns);
216
+ }
217
+ /** Filtering moves back to the first page; tell a controlled pager so. */
218
+ function resetPageForFilter() {
219
+ if (options.manualPagination)
220
+ return;
221
+ const moved = pageIndex !== 0;
222
+ pageIndex = 0;
223
+ if (moved)
224
+ options.onPaginationChange?.({ pageIndex: 0, pageSize });
225
+ }
194
226
  function setSort(columnId) {
195
227
  if (columnId === sortColumn) {
196
228
  // Toggle direction or clear
@@ -224,23 +256,23 @@ export function createDataTable(optionsFn) {
224
256
  }
225
257
  function setGlobalFilter(value) {
226
258
  globalFilter = value;
227
- if (!options.manualPagination) {
228
- pageIndex = 0; // Reset to first page
229
- }
259
+ resetPageForFilter();
230
260
  options.onGlobalFilterChange?.(value);
231
261
  }
262
+ /**
263
+ * Set one column's filter. A string is a text filter; an array is a set of
264
+ * accepted values. An empty string or array clears the filter.
265
+ */
232
266
  function setColumnFilter(columnId, value) {
233
267
  const newFilters = new Map(columnFilters);
234
- if (value.trim()) {
235
- newFilters.set(columnId, value);
268
+ if (isEmptyFilter(value)) {
269
+ newFilters.delete(columnId);
236
270
  }
237
271
  else {
238
- newFilters.delete(columnId);
272
+ newFilters.set(columnId, Array.isArray(value) ? [...value] : value);
239
273
  }
240
274
  columnFilters = newFilters;
241
- if (!options.manualPagination) {
242
- pageIndex = 0;
243
- }
275
+ resetPageForFilter();
244
276
  options.onColumnFiltersChange?.(columnFilters);
245
277
  }
246
278
  function clearColumnFilters() {
@@ -279,9 +311,10 @@ export function createDataTable(optionsFn) {
279
311
  pageIndex = Math.max(0, totalPages - 1);
280
312
  options.onPaginationChange?.({ pageIndex: Math.max(0, totalPages - 1), pageSize });
281
313
  }
314
+ const multiSelect = $derived(options.enableMultiRowSelection !== false);
282
315
  function toggleRowSelection(rowId) {
283
- const newSelection = new Set(selectedRows);
284
- if (newSelection.has(rowId)) {
316
+ const newSelection = multiSelect ? new Set(selectedRows) : new Set();
317
+ if (selectedRows.has(rowId)) {
285
318
  newSelection.delete(rowId);
286
319
  }
287
320
  else {
@@ -291,7 +324,7 @@ export function createDataTable(optionsFn) {
291
324
  options.onRowSelectionChange?.(selectedRows);
292
325
  }
293
326
  function setRowSelection(rowId, selected) {
294
- const newSelection = new Set(selectedRows);
327
+ const newSelection = selected && !multiSelect ? new Set() : new Set(selectedRows);
295
328
  if (selected) {
296
329
  newSelection.add(rowId);
297
330
  }
@@ -302,6 +335,9 @@ export function createDataTable(optionsFn) {
302
335
  options.onRowSelectionChange?.(selectedRows);
303
336
  }
304
337
  function toggleAllRows() {
338
+ // Selecting every row makes no sense when only one may be selected
339
+ if (!multiSelect && !isAllSelected)
340
+ return;
305
341
  if (isAllSelected) {
306
342
  // Deselect all visible rows
307
343
  const newSelection = new Set(selectedRows);
@@ -317,6 +353,8 @@ export function createDataTable(optionsFn) {
317
353
  options.onRowSelectionChange?.(selectedRows);
318
354
  }
319
355
  function selectAllRows() {
356
+ if (!multiSelect)
357
+ return;
320
358
  const newSelection = new Set(selectedRows);
321
359
  rows.forEach((row) => newSelection.add(row.id));
322
360
  selectedRows = newSelection;
@@ -357,16 +395,7 @@ export function createDataTable(optionsFn) {
357
395
  return getColumnValue(row, column);
358
396
  }
359
397
  function getFormattedCellValue(row, column) {
360
- const value = getColumnValue(row, column);
361
- if (column.format) {
362
- try {
363
- return column.format(value, row);
364
- }
365
- catch {
366
- return String(value ?? '');
367
- }
368
- }
369
- return String(value ?? '');
398
+ return formatValue(row, column);
370
399
  }
371
400
  // === RETURN API ===
372
401
  return {
@@ -404,6 +433,8 @@ export function createDataTable(optionsFn) {
404
433
  setGlobalFilter,
405
434
  setColumnFilter,
406
435
  clearColumnFilters,
436
+ // Controlled-state mirroring
437
+ syncState,
407
438
  // Pagination
408
439
  get pageIndex() {
409
440
  return clampedPageIndex;
@@ -470,3 +501,29 @@ function getColumnValue(row, column) {
470
501
  }
471
502
  return row[column.accessor];
472
503
  }
504
+ function formatValue(row, column) {
505
+ const value = getColumnValue(row, column);
506
+ if (column.format) {
507
+ try {
508
+ return column.format(value, row);
509
+ }
510
+ catch {
511
+ return String(value ?? '');
512
+ }
513
+ }
514
+ return String(value ?? '');
515
+ }
516
+ /** Case-insensitive substring match against the raw and the formatted value. */
517
+ function cellMatchesText(row, column, search) {
518
+ if (String(getColumnValue(row, column) ?? '')
519
+ .toLowerCase()
520
+ .includes(search)) {
521
+ return true;
522
+ }
523
+ return !!column.format && formatValue(row, column).toLowerCase().includes(search);
524
+ }
525
+ function isEmptyFilter(value) {
526
+ if (value === undefined)
527
+ return true;
528
+ return Array.isArray(value) ? value.length === 0 : !value.trim();
529
+ }
@@ -14,7 +14,7 @@
14
14
  * - `utils`: Shared utilities for formatting, alignment, etc.
15
15
  */
16
16
  export { createDataTable, type DataTableOptions, type DataTableRow, type DataTableInstance, } from './createDataTable.svelte.js';
17
- export { type DataTableColumn, type DataTableFilterFn, type DataTableFilterOption, type ColumnMeta, type BaseColumnMeta, type DataColumnMeta, type FilterableColumnMeta, type SelectionColumnMeta, type RowSelectionState, type ColumnVisibilityState, type ColumnFiltersState, type PaginationState, type LegacyRowSelectionState, type LegacyVisibilityState, type LegacyColumnFiltersState, legacyToSetSelection, setToLegacySelection, legacyToSetVisibility, setToLegacyVisibility, legacyToMapFilters, mapToLegacyFilters, type DataTableProps, type DataTableCoreProps, type DataTableSortingProps, type DataTableSelectionProps, type DataTableVisibilityProps, type DataTableFilteringProps, type DataTablePaginationProps, type DataTableVirtualizationProps, type DataTableEditingProps, type DataTableInteractionProps, type InferRowType, type FormatResult, } from './types.js';
17
+ export { type DataTableColumn, type DataTableFilterFn, type DataTableFilterOption, type ColumnMeta, type BaseColumnMeta, type DataColumnMeta, type FilterableColumnMeta, type SelectionColumnMeta, type RowSelectionState, type ColumnVisibilityState, type ColumnFiltersState, type ColumnFilterValue, type PaginationState, type LegacyRowSelectionState, type LegacyVisibilityState, type LegacyColumnFiltersState, legacyToSetSelection, setToLegacySelection, legacyToSetVisibility, setToLegacyVisibility, legacyToMapFilters, mapToLegacyFilters, type DataTableProps, type DataTableCoreProps, type DataTableSortingProps, type DataTableSelectionProps, type DataTableVisibilityProps, type DataTableFilteringProps, type DataTablePaginationProps, type DataTableVirtualizationProps, type DataTableEditingProps, type DataTableInteractionProps, type InferRowType, type FormatResult, } from './types.js';
18
18
  export { getAlignClass, getColumnAlign, formatCellValue, formatCellValueSafe, getColumnValue, getColumnWidthStyle, getAriaSortValue, defaultGetRowId, debounce, } from './utils.js';
19
19
  export { default as DataTableToolbar } from './DataTableToolbar.svelte';
20
20
  export { default as DataTableSearch } from './DataTableSearch.svelte';
@@ -93,10 +93,19 @@ export declare function legacyToSetVisibility(legacy: LegacyVisibilityState, all
93
93
  * Convert Set-based visibility state to legacy format.
94
94
  */
95
95
  export declare function setToLegacyVisibility(hiddenSet: ColumnVisibilityState, allColumnIds: string[]): LegacyVisibilityState;
96
+ /**
97
+ * A single column filter value.
98
+ *
99
+ * - `string`: a text filter, matched as a case-insensitive substring of the
100
+ * cell's raw or formatted value.
101
+ * - `string[]`: a set of accepted values (faceted filter), matched exactly
102
+ * against the cell's raw value. An empty array filters nothing.
103
+ */
104
+ export type ColumnFilterValue = string | string[];
96
105
  /**
97
106
  * Column filters state is a Map from column ID to filter value.
98
107
  */
99
- export type ColumnFiltersState = Map<string, string>;
108
+ export type ColumnFiltersState = Map<string, ColumnFilterValue>;
100
109
  /**
101
110
  * Legacy column filter format (array of {id, value} objects).
102
111
  */
@@ -152,8 +161,9 @@ export interface DataTableSortingProps {
152
161
  /**
153
162
  * Callback when sort changes.
154
163
  * When provided, enables server-side sorting (manual mode).
164
+ * Called with `column: null` when the sort is cleared.
155
165
  */
156
- onSort?: (column: string, direction: 'asc' | 'desc') => void;
166
+ onSort?: (column: string | null, direction: 'asc' | 'desc') => void;
157
167
  }
158
168
  /**
159
169
  * Props for row selection functionality.
@@ -236,9 +246,9 @@ export interface DataTableFilteringProps {
236
246
  /** Callback when global filter changes */
237
247
  onGlobalFilterChange?: (filter: string) => void;
238
248
  /** Controlled column filters */
239
- columnFilters?: Map<string, string>;
249
+ columnFilters?: ColumnFiltersState;
240
250
  /** Callback when column filters change */
241
- onColumnFiltersChange?: (filters: Map<string, string>) => void;
251
+ onColumnFiltersChange?: (filters: ColumnFiltersState) => void;
242
252
  /**
243
253
  * Use server-side filtering (manual mode).
244
254
  *
@@ -51,7 +51,11 @@ export function setToLegacyVisibility(hiddenSet, allColumnIds) {
51
51
  export function legacyToMapFilters(legacy) {
52
52
  const map = new Map();
53
53
  for (const { id, value } of legacy) {
54
- if (value !== undefined && value !== null && value !== '') {
54
+ if (Array.isArray(value)) {
55
+ if (value.length > 0)
56
+ map.set(id, value.map(String));
57
+ }
58
+ else if (value !== undefined && value !== null && value !== '') {
55
59
  map.set(id, String(value));
56
60
  }
57
61
  }
@@ -0,0 +1,60 @@
1
+ <!--
2
+ @component
3
+ Renders a consumer's trigger snippet inside a bits-ui `child` snippet.
4
+
5
+ The snippet receives the trigger props (`{ props }`) and should spread them
6
+ on its own focusable element (`<Button {...props}>`), so that element is the
7
+ real trigger: it carries aria-expanded/aria-describedby and gets focus back
8
+ when a dialog closes.
9
+
10
+ Snippets written before the props were passed ignore them. Those are
11
+ detected after mount (no element inside carries the trigger id) and the
12
+ props move to a wrapping span, the old behaviour. `onfallback` tells the
13
+ parent so it can change the trigger id, which makes bits-ui look the
14
+ trigger node up again.
15
+ -->
16
+ <script lang="ts" module>
17
+ /** What a trigger snippet receives: spread `props` on the focusable element */
18
+ export type TriggerSnippetProps = { props: Record<string, unknown> };
19
+ </script>
20
+
21
+ <script lang="ts">
22
+ import type { Snippet } from 'svelte';
23
+ import { cn } from '../../utils.js';
24
+
25
+ interface Props {
26
+ /** The props bits-ui hands to its `child` snippet */
27
+ props: Record<string, unknown>;
28
+ /** The consumer's trigger snippet */
29
+ trigger: Snippet<[TriggerSnippetProps]>;
30
+ /** Class for the fallback wrapper span */
31
+ fallbackClass?: string;
32
+ /** Called once when the snippet ignored the props */
33
+ onfallback?: () => void;
34
+ }
35
+
36
+ let { props, trigger, fallbackClass, onfallback }: Props = $props();
37
+
38
+ let fallback = $state(false);
39
+ let probe = $state<HTMLElement | null>(null);
40
+
41
+ $effect(() => {
42
+ if (fallback || !probe) return;
43
+ const id = props.id;
44
+ const spread = [...probe.querySelectorAll('[id]')].some((el) => el.id === id);
45
+ if (!spread) {
46
+ fallback = true;
47
+ onfallback?.();
48
+ }
49
+ });
50
+ </script>
51
+
52
+ {#if fallback}
53
+ <span {...props} class={cn(fallbackClass, props.class as string | undefined)}>
54
+ {@render trigger({ props: {} })}
55
+ </span>
56
+ {:else}
57
+ <span bind:this={probe} class="contents">
58
+ {@render trigger({ props })}
59
+ </span>
60
+ {/if}