@happyvertical/smrt-ui 0.42.3 → 0.42.5

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 (52) hide show
  1. package/README.md +275 -0
  2. package/dist/components/data/DataTable.svelte +1444 -172
  3. package/dist/components/data/DataTable.svelte.d.ts +1 -1
  4. package/dist/components/data/DataTable.svelte.d.ts.map +1 -1
  5. package/dist/components/data/DataTableController.d.ts +235 -0
  6. package/dist/components/data/DataTableController.d.ts.map +1 -0
  7. package/dist/components/data/DataTableController.js +792 -0
  8. package/dist/components/data/DataTableIdentity.d.ts +21 -0
  9. package/dist/components/data/DataTableIdentity.d.ts.map +1 -0
  10. package/dist/components/data/DataTableIdentity.js +28 -0
  11. package/dist/components/data/DataTableLayout.d.ts +37 -0
  12. package/dist/components/data/DataTableLayout.d.ts.map +1 -0
  13. package/dist/components/data/DataTableLayout.js +135 -0
  14. package/dist/components/data/DataTablePerformance.d.ts +31 -0
  15. package/dist/components/data/DataTablePerformance.d.ts.map +1 -0
  16. package/dist/components/data/DataTablePerformance.js +46 -0
  17. package/dist/components/data/DataTableVirtualization.d.ts +71 -0
  18. package/dist/components/data/DataTableVirtualization.d.ts.map +1 -0
  19. package/dist/components/data/DataTableVirtualization.js +100 -0
  20. package/dist/components/data/__benchmarks__/DataTable.bench.d.ts +2 -0
  21. package/dist/components/data/__benchmarks__/DataTable.bench.d.ts.map +1 -0
  22. package/dist/components/data/__benchmarks__/DataTable.bench.js +53 -0
  23. package/dist/components/data/__fixtures__/DataTableConformanceFixture.d.ts +27 -0
  24. package/dist/components/data/__fixtures__/DataTableConformanceFixture.d.ts.map +1 -0
  25. package/dist/components/data/__fixtures__/DataTableConformanceFixture.js +141 -0
  26. package/dist/components/data/__fixtures__/DataTablePerformanceFixture.d.ts +10 -0
  27. package/dist/components/data/__fixtures__/DataTablePerformanceFixture.d.ts.map +1 -0
  28. package/dist/components/data/__fixtures__/DataTablePerformanceFixture.js +23 -0
  29. package/dist/components/data/__tests__/DataTable.test.js +644 -21
  30. package/dist/components/data/__tests__/DataTableConformance.test.js +212 -0
  31. package/dist/components/data/__tests__/DataTableController.test.js +336 -0
  32. package/dist/components/data/__tests__/DataTableIdentity.test.js +29 -0
  33. package/dist/components/data/__tests__/DataTableLayout.test.js +195 -0
  34. package/dist/components/data/__tests__/DataTablePerformance.test.js +21 -0
  35. package/dist/components/data/__tests__/DataTableVirtualization.test.js +80 -0
  36. package/dist/components/data/__tests__/DataTableVirtualizationComponent.test.js +255 -0
  37. package/dist/components/data/__tests__/data-surface.test.js +675 -0
  38. package/dist/components/data/data-surface.d.ts +246 -0
  39. package/dist/components/data/data-surface.d.ts.map +1 -0
  40. package/dist/components/data/data-surface.js +1102 -0
  41. package/dist/components/data/index.d.ts +5 -0
  42. package/dist/components/data/index.d.ts.map +1 -1
  43. package/dist/components/data/index.js +5 -0
  44. package/dist/components/data/types.d.ts +110 -2
  45. package/dist/components/data/types.d.ts.map +1 -1
  46. package/dist/i18n/strings.d.ts +26 -0
  47. package/dist/i18n/strings.d.ts.map +1 -1
  48. package/dist/i18n/strings.js +28 -2
  49. package/dist/svelte/playground/DataTablePreview.svelte +400 -10
  50. package/dist/svelte/playground/DataTablePreview.svelte.d.ts.map +1 -1
  51. package/dist/svelte/playground.js +2 -2
  52. package/package.json +3 -2
@@ -11,15 +11,38 @@
11
11
  * - Material 3 styling with theme token support
12
12
  */
13
13
 
14
- import type { Snippet } from 'svelte';
14
+ import { type Snippet, tick } from 'svelte';
15
15
  import { M } from '../../i18n/strings.js';
16
16
  import Trans from '../../i18n/Trans.svelte';
17
17
  import { useI18n } from '../../i18n/use-i18n.js';
18
18
  import Pagination from '../ui/Pagination.svelte';
19
+ import {
20
+ compareDataTableRowIds,
21
+ createDataTableController,
22
+ type DataTableCommand,
23
+ type DataTableController,
24
+ type DataTableFilter,
25
+ type DataTableModes,
26
+ type DataTableRowId,
27
+ type DataTableSnapshot,
28
+ type DataTableViewState,
29
+ type DataTableViewStateInput,
30
+ dataTableRowIdKey,
31
+ } from './DataTableController.js';
32
+ import { resolveDataTableRows } from './DataTableIdentity.js';
33
+ import {
34
+ type DataTableResolvedColumn,
35
+ resolveDataTableLayout,
36
+ } from './DataTableLayout.js';
37
+ import {
38
+ maximumDataTableVirtualScrollTop,
39
+ resolveDataTableVirtualWindow,
40
+ scrollTopForDataTableRow,
41
+ } from './DataTableVirtualization.js';
19
42
  import type {
20
43
  DataTableColumn,
21
44
  DataTableProps,
22
- SortDirection,
45
+ DataTableStructuralRow,
23
46
  SortState,
24
47
  } from './types.js';
25
48
  import { defaultSort, getNestedValue } from './types.js';
@@ -37,10 +60,12 @@ let {
37
60
  data = [],
38
61
  columns = [],
39
62
  rowKey,
63
+ agentAddressable = false,
40
64
  selectable = false,
41
65
  selected = $bindable(new Set<string | number>()),
42
66
  onSelectionChange,
43
67
  onRowClick,
68
+ rowLabel,
44
69
  sortable = false,
45
70
  sort = $bindable({ columnId: null, direction: null }),
46
71
  onSortChange,
@@ -49,6 +74,7 @@ let {
49
74
  page = $bindable(1),
50
75
  pageSize,
51
76
  manualPagination = false,
77
+ virtualization,
52
78
  totalRows,
53
79
  onPageChange,
54
80
  expanded = $bindable(new Set<string | number>()),
@@ -57,8 +83,14 @@ let {
57
83
  expandedContent,
58
84
  toolbar,
59
85
  footer,
86
+ structuralRows = [],
60
87
  visibleColumnIds,
61
88
  loading = false,
89
+ refreshing = false,
90
+ stale = false,
91
+ partialResults = false,
92
+ error = null,
93
+ onRetry,
62
94
  empty,
63
95
  rowClass,
64
96
  size = 'md',
@@ -68,85 +100,283 @@ let {
68
100
  caption,
69
101
  dense = false,
70
102
  cell,
103
+ controller,
104
+ state: controlledState,
105
+ initialState,
106
+ onStateChange,
107
+ modes,
71
108
  }: ExtendedProps<T> = $props();
72
109
 
73
- // Get row key value
74
- function getRowKey(row: T, index: number): string | number {
75
- if (!rowKey) return index;
76
- if (typeof rowKey === 'function') return rowKey(row);
77
- return row[rowKey] as string | number;
110
+ const dataTableId = $props.id();
111
+
112
+ function legacyModes(): DataTableModes {
113
+ return {
114
+ filtering: modes?.filtering ?? 'local',
115
+ sorting: modes?.sorting ?? (manualSorting ? 'manual' : 'local'),
116
+ pagination: modes?.pagination ?? (manualPagination ? 'manual' : 'local'),
117
+ };
78
118
  }
79
119
 
80
- function getDisplayRowKey(row: T, index: number): string | number {
81
- return getRowKey(row, displayIndexOffset + index);
120
+ function legacyViewState(): Partial<DataTableViewState> {
121
+ const next: Partial<DataTableViewState> = {
122
+ sorting:
123
+ sort.columnId && sort.direction
124
+ ? [{ columnId: sort.columnId, direction: sort.direction }]
125
+ : [],
126
+ page,
127
+ pageSize: pageSize ?? null,
128
+ selectedRowIds: [...selected],
129
+ expandedRowIds: [...expanded],
130
+ };
131
+ if (visibleColumnIds) {
132
+ next.columnVisibility = columns.map((column) => ({
133
+ columnId: column.id,
134
+ visible: visibleColumnIds.has(column.id),
135
+ }));
136
+ }
137
+ return next;
82
138
  }
83
139
 
84
- // Handle sort click
85
- function handleSort(column: DataTableColumn<T>) {
86
- if (!sortable || !column.sortable) return;
140
+ function mergedLegacyState(): Omit<DataTableViewState, 'selection'> {
141
+ const { selection: _selection, ...current } = localController.getState();
142
+ return { ...current, ...legacyViewState() };
143
+ }
87
144
 
88
- const newSort: SortState = {
89
- columnId: column.id,
90
- direction:
91
- sort.columnId === column.id
92
- ? sort.direction === 'asc'
93
- ? 'desc'
94
- : sort.direction === 'desc'
95
- ? null
96
- : 'asc'
97
- : 'asc',
98
- };
145
+ const localController = createDataTableController({
146
+ modes: legacyModes(),
147
+ onStateChange: (next, command) => onStateChange?.(next, command),
148
+ });
149
+
150
+ let controllerSnapshot = $state<DataTableSnapshot>(localController.snapshot());
151
+ let measuredColumnWidths = $state<Record<string, number>>({});
152
+ let publishedLegacySignature: string | undefined;
153
+ let localControllerInitialized = false;
154
+ let hasHorizontalOverflow = $state(false);
155
+ let canScrollLeft = $state(false);
156
+ let canScrollRight = $state(false);
157
+
158
+ function activeController(): DataTableController {
159
+ return controller ?? localController;
160
+ }
161
+
162
+ function legacySignature(): string {
163
+ const legacy = legacyViewState();
164
+ return JSON.stringify({
165
+ sorting: legacy.sorting,
166
+ page: legacy.page,
167
+ pageSize: legacy.pageSize,
168
+ columnVisibility: legacy.columnVisibility,
169
+ selectedRowIds: [...(legacy.selectedRowIds ?? [])].sort(),
170
+ expandedRowIds: [...(legacy.expandedRowIds ?? [])].sort(),
171
+ });
172
+ }
173
+
174
+ function legacySort(next: DataTableViewState): SortState {
175
+ const rule = next.sorting[0];
176
+ return rule
177
+ ? { columnId: rule.columnId, direction: rule.direction }
178
+ : { columnId: null, direction: null };
179
+ }
180
+
181
+ function publishLegacyState(
182
+ next: DataTableViewState,
183
+ command: DataTableCommand,
184
+ previous: DataTableViewState,
185
+ ) {
186
+ const nextSort = legacySort(next);
187
+ const previousSort = legacySort(previous);
188
+ sort = nextSort;
189
+ page = next.page;
190
+ selected = new Set(next.selectedRowIds);
191
+ expanded = new Set(next.expandedRowIds);
192
+ publishedLegacySignature = legacySignature();
193
+
194
+ if (
195
+ nextSort.columnId !== previousSort.columnId ||
196
+ nextSort.direction !== previousSort.direction
197
+ ) {
198
+ onSortChange?.(nextSort);
199
+ }
200
+ if (
201
+ JSON.stringify(next.selectedRowIds) !==
202
+ JSON.stringify(previous.selectedRowIds)
203
+ ) {
204
+ onSelectionChange?.(new Set(next.selectedRowIds));
205
+ }
206
+ if (
207
+ JSON.stringify(next.expandedRowIds) !==
208
+ JSON.stringify(previous.expandedRowIds)
209
+ ) {
210
+ onExpandedChange?.(new Set(next.expandedRowIds));
211
+ }
212
+ if (next.page !== previous.page) onPageChange?.(next.page);
213
+ void command;
214
+ }
215
+
216
+ $effect(() => {
217
+ if (controller) return;
218
+
219
+ localController.setControlled(controlledState !== undefined);
220
+ localController.setModes(legacyModes());
221
+ const columnIds = columns.map((column) => column.id);
222
+ const hiddenColumnIds = columns
223
+ .filter(
224
+ (column) =>
225
+ column.hidden ||
226
+ (visibleColumnIds !== undefined && !visibleColumnIds.has(column.id)),
227
+ )
228
+ .map((column) => column.id);
229
+
230
+ if (!localControllerInitialized) {
231
+ localControllerInitialized = true;
232
+ const nextState: DataTableViewStateInput = controlledState ?? {
233
+ ...mergedLegacyState(),
234
+ ...initialState,
235
+ };
236
+ localController.replaceState(nextState);
237
+ localController.setColumnIds(columnIds, hiddenColumnIds);
238
+ return;
239
+ }
240
+
241
+ localController.setColumnIds(columnIds, hiddenColumnIds);
242
+
243
+ if (controlledState !== undefined) {
244
+ localController.replaceState(controlledState);
245
+ return;
246
+ }
99
247
 
100
- if (newSort.direction === null) {
101
- newSort.columnId = null;
248
+ const signature = legacySignature();
249
+ if (signature === publishedLegacySignature) {
250
+ publishedLegacySignature = undefined;
251
+ return;
102
252
  }
253
+ localController.replaceState(mergedLegacyState());
254
+ });
255
+
256
+ $effect(() => {
257
+ const current = activeController();
258
+ current.setColumnIds(
259
+ columns.map((column) => column.id),
260
+ columns
261
+ .filter(
262
+ (column) =>
263
+ column.hidden ||
264
+ (visibleColumnIds !== undefined && !visibleColumnIds.has(column.id)),
265
+ )
266
+ .map((column) => column.id),
267
+ );
268
+ controllerSnapshot = current.snapshot();
269
+ return current.subscribe((transition) => {
270
+ controllerSnapshot = transition.next;
271
+ if (
272
+ current === localController &&
273
+ controlledState === undefined &&
274
+ transition.command
275
+ ) {
276
+ publishLegacyState(
277
+ transition.next.state,
278
+ transition.command,
279
+ transition.previous.state,
280
+ );
281
+ }
282
+ });
283
+ });
284
+
285
+ const tableState = $derived(controllerSnapshot.state);
286
+ const tableModes = $derived(controllerSnapshot.modes);
287
+ const constrainedLayoutState = $derived.by(() => ({
288
+ ...tableState,
289
+ columnWidths: tableState.columnWidths.map((entry) => {
290
+ const column = columns.find((candidate) => candidate.id === entry.columnId);
291
+ return column
292
+ ? { ...entry, width: clampedColumnWidth(column, entry.width) }
293
+ : entry;
294
+ }),
295
+ }));
296
+ const requiresStableRowIdentity = $derived(
297
+ selectable ||
298
+ Boolean(expandedContent) ||
299
+ agentAddressable ||
300
+ tableModes.filtering === 'manual' ||
301
+ tableModes.sorting === 'manual' ||
302
+ tableModes.pagination === 'manual' ||
303
+ Boolean(virtualization),
304
+ );
305
+ const sourceRows = $derived.by(() =>
306
+ resolveDataTableRows(data, rowKey, {
307
+ requireStableIdentity: requiresStableRowIdentity,
308
+ }),
309
+ );
103
310
 
104
- sort = newSort;
105
- onSortChange?.(newSort);
311
+ function dispatch(command: DataTableCommand) {
312
+ activeController().dispatch(command);
106
313
  }
107
314
 
108
- // Handle row selection
109
- function handleRowSelect(key: string | number, event: Event) {
110
- event.stopPropagation();
111
- const newSelected = new Set(selected);
315
+ function handleSort(column: DataTableColumn<T>, event: MouseEvent) {
316
+ if (!sortable || !column.sortable) return;
317
+ dispatch({
318
+ type: 'toggleSorting',
319
+ columnId: column.id,
320
+ multi: event.shiftKey,
321
+ });
322
+ }
112
323
 
113
- if (newSelected.has(key)) {
114
- newSelected.delete(key);
115
- } else {
116
- newSelected.add(key);
324
+ function usesAutomaticSortButton(column: DataTableColumn<T>): boolean {
325
+ return (
326
+ sortable && column.sortable === true && column.headerSortMode !== 'manual'
327
+ );
328
+ }
329
+
330
+ function sortActionLabel(column: DataTableColumn<T>): string {
331
+ const columnSort = tableState.sorting.find(
332
+ (sort) => sort.columnId === column.id,
333
+ );
334
+ if (!columnSort) {
335
+ return t(M['ui.data_table.sort_ascending'], { column: column.label });
117
336
  }
337
+ if (columnSort.direction === 'asc') {
338
+ return t(M['ui.data_table.sort_descending'], { column: column.label });
339
+ }
340
+ return t(M['ui.data_table.clear_sort'], { column: column.label });
341
+ }
118
342
 
119
- selected = newSelected;
120
- onSelectionChange?.(newSelected);
343
+ function handleRowSelect(key: DataTableRowId, event: Event) {
344
+ event.stopPropagation();
345
+ dispatch({ type: 'toggleRowSelection', rowId: key });
121
346
  }
122
347
 
123
- // Handle select all
124
348
  function handleSelectAll() {
349
+ if (tableState.selection.scope === 'allMatching') {
350
+ dispatch({
351
+ type: 'setSelection',
352
+ selection: { scope: 'explicit', rowIds: [] },
353
+ });
354
+ return;
355
+ }
356
+ const selectedIds = new Set(tableState.selectedRowIds);
357
+ const visibleIds = displayRows.map(({ rowId }) => rowId);
358
+ if (tableState.selection.scope === 'page') {
359
+ dispatch({
360
+ type: 'setPageSelection',
361
+ rowIds: allSelected ? [] : visibleIds,
362
+ });
363
+ return;
364
+ }
125
365
  if (allSelected) {
126
- const visibleKeys = new Set(
127
- displayData.map((row, i) => getDisplayRowKey(row, i)),
128
- );
129
- selected = new Set([...selected].filter((key) => !visibleKeys.has(key)));
366
+ for (const key of visibleIds) selectedIds.delete(key);
130
367
  } else {
131
- selected = new Set([
132
- ...selected,
133
- ...displayData.map((row, i) => getDisplayRowKey(row, i)),
134
- ]);
368
+ for (const key of visibleIds) selectedIds.add(key);
135
369
  }
136
- onSelectionChange?.(selected);
370
+ dispatch({ type: 'setSelectedRows', rowIds: [...selectedIds] });
137
371
  }
138
372
 
139
- function handleExpanded(key: string | number, event: Event) {
373
+ function handleExpanded(key: DataTableRowId, event: Event) {
140
374
  event.stopPropagation();
141
- const next = new Set(expanded);
142
- next.has(key) ? next.delete(key) : next.add(key);
143
- expanded = next;
144
- onExpandedChange?.(next);
375
+ dispatch({ type: 'toggleRowExpansion', rowId: key });
145
376
  }
146
377
 
147
378
  function handlePageChange(next: number) {
148
- page = Math.min(Math.max(1, next), totalPages);
149
- onPageChange?.(page);
379
+ dispatch({ type: 'setPage', page: next });
150
380
  }
151
381
 
152
382
  // Handle row click
@@ -154,6 +384,100 @@ function handleRowClick(row: T, index: number) {
154
384
  onRowClick?.(row, index);
155
385
  }
156
386
 
387
+ function getRowLabel(row: T, index: number): string {
388
+ return (
389
+ rowLabel?.(row, index) ??
390
+ t(M['ui.data_table.row_number'], {
391
+ number: virtualRowOffset + index + 1,
392
+ })
393
+ );
394
+ }
395
+
396
+ function expansionContentId(key: DataTableRowId): string {
397
+ return `${dataTableId}-expanded-${encodeURIComponent(dataTableRowIdKey(key))}`;
398
+ }
399
+
400
+ function isNestedInteractiveTarget(target: EventTarget | null): boolean {
401
+ return (
402
+ target instanceof Element &&
403
+ target.closest(
404
+ 'a, button, input, select, textarea, label, summary, [contenteditable="true"], [role="button"], [role="link"], [role="menuitem"], [role="checkbox"], [role="switch"]',
405
+ ) !== null
406
+ );
407
+ }
408
+
409
+ function handleRowActivation(event: MouseEvent, row: T, index: number) {
410
+ if (isNestedInteractiveTarget(event.target)) return;
411
+ handleRowClick(row, index);
412
+ }
413
+
414
+ function handleRowKeydown(event: KeyboardEvent, row: T, index: number) {
415
+ if (
416
+ !onRowClick ||
417
+ isNestedInteractiveTarget(event.target) ||
418
+ (event.key !== 'Enter' && event.key !== ' ')
419
+ ) {
420
+ return;
421
+ }
422
+ event.preventDefault();
423
+ handleRowClick(row, index);
424
+ }
425
+
426
+ function updateOverflowState() {
427
+ const container = tableContainer;
428
+ if (!container) return;
429
+
430
+ const maxScrollLeft = Math.max(
431
+ 0,
432
+ container.scrollWidth - container.clientWidth,
433
+ );
434
+ hasHorizontalOverflow = maxScrollLeft > 1;
435
+ canScrollLeft = hasHorizontalOverflow && container.scrollLeft > 1;
436
+ canScrollRight =
437
+ hasHorizontalOverflow && container.scrollLeft < maxScrollLeft - 1;
438
+ }
439
+
440
+ function handleOverflowKeydown(event: KeyboardEvent) {
441
+ const container = tableContainer;
442
+ if (!container || !hasHorizontalOverflow || event.target !== container) {
443
+ return;
444
+ }
445
+
446
+ const maxScrollLeft = Math.max(
447
+ 0,
448
+ container.scrollWidth - container.clientWidth,
449
+ );
450
+ const scrollStep = Math.max(Math.floor(container.clientWidth * 0.8), 40);
451
+ let nextScrollLeft: number | undefined;
452
+
453
+ switch (event.key) {
454
+ case 'ArrowLeft':
455
+ nextScrollLeft = container.scrollLeft - scrollStep;
456
+ break;
457
+ case 'ArrowRight':
458
+ nextScrollLeft = container.scrollLeft + scrollStep;
459
+ break;
460
+ case 'Home':
461
+ nextScrollLeft = 0;
462
+ break;
463
+ case 'End':
464
+ nextScrollLeft = maxScrollLeft;
465
+ break;
466
+ }
467
+
468
+ if (nextScrollLeft === undefined) return;
469
+
470
+ const clampedScrollLeft = Math.max(
471
+ 0,
472
+ Math.min(maxScrollLeft, nextScrollLeft),
473
+ );
474
+ if (clampedScrollLeft === container.scrollLeft) return;
475
+
476
+ event.preventDefault();
477
+ container.scrollLeft = clampedScrollLeft;
478
+ updateOverflowState();
479
+ }
480
+
157
481
  // Action to set indeterminate state (can't be set via HTML attribute)
158
482
  function setIndeterminate(node: HTMLInputElement, value: boolean) {
159
483
  node.indeterminate = value;
@@ -164,160 +488,774 @@ function setIndeterminate(node: HTMLInputElement, value: boolean) {
164
488
  };
165
489
  }
166
490
 
167
- // Get visible columns
168
- const visibleColumns = $derived(
169
- columns.filter(
170
- (col) => !col.hidden && (!visibleColumnIds || visibleColumnIds.has(col.id)),
171
- ),
491
+ const dataTableLayout = $derived(
492
+ resolveDataTableLayout(columns, constrainedLayoutState, measuredColumnWidths),
493
+ );
494
+ const visibleColumns = $derived(dataTableLayout.columns);
495
+ const dataBodyStructuralRows = $derived(
496
+ structuralRows.filter((row) => row.kind !== 'footer'),
172
497
  );
498
+ const footerStructuralRows = $derived(
499
+ structuralRows.filter((row) => row.kind === 'footer'),
500
+ );
501
+ const structuralRowCount = $derived(
502
+ dataBodyStructuralRows.length +
503
+ footerStructuralRows.length +
504
+ (footer ? 1 : 0),
505
+ );
506
+ const headerRowCount = $derived(dataTableLayout.headerRows.length);
173
507
 
174
- const filteredData = $derived(filterFn ? data.filter(filterFn) : data);
508
+ function sameFilterValue(value: unknown, expected: unknown): boolean {
509
+ return Object.is(value, expected);
510
+ }
175
511
 
176
- // Sort data
177
- const sortedData = $derived.by(() => {
178
- if (manualSorting || !sort.columnId || !sort.direction) return filteredData;
512
+ function textValue(value: unknown): string {
513
+ return String(value ?? '').toLowerCase();
514
+ }
179
515
 
180
- const column = columns.find((c) => c.id === sort.columnId);
181
- if (!column) return filteredData;
516
+ function compareFilterValues(left: unknown, right: unknown): number {
517
+ const leftValue = left instanceof Date ? left.getTime() : left;
518
+ const rightValue = right instanceof Date ? right.getTime() : right;
519
+ if (typeof leftValue === 'number' && typeof rightValue === 'number')
520
+ return leftValue - rightValue;
521
+ const a = String(leftValue ?? '');
522
+ const b = String(rightValue ?? '');
523
+ return a < b ? -1 : a > b ? 1 : 0;
524
+ }
182
525
 
183
- const accessor = column.accessor ?? column.id;
184
- const direction = sort.direction;
526
+ function matchesFilter(
527
+ row: T,
528
+ column: DataTableColumn<T>,
529
+ filter: DataTableFilter,
530
+ ): boolean {
531
+ if (column.filterable === false) return true;
532
+ const value = getCellValue(row, column);
533
+ if (column.filterFn) return column.filterFn(row, value, filter);
534
+ const expected = filter.value;
535
+ const valueText = textValue(value);
536
+ const expectedText = textValue(expected);
537
+ switch (filter.operator) {
538
+ case 'equals':
539
+ return sameFilterValue(value, expected);
540
+ case 'notEquals':
541
+ return !sameFilterValue(value, expected);
542
+ case 'contains':
543
+ return valueText.includes(expectedText);
544
+ case 'notContains':
545
+ return !valueText.includes(expectedText);
546
+ case 'startsWith':
547
+ return valueText.startsWith(expectedText);
548
+ case 'endsWith':
549
+ return valueText.endsWith(expectedText);
550
+ case 'in':
551
+ return (
552
+ Array.isArray(expected) &&
553
+ expected.some((entry) => sameFilterValue(value, entry))
554
+ );
555
+ case 'notIn':
556
+ return (
557
+ Array.isArray(expected) &&
558
+ !expected.some((entry) => sameFilterValue(value, entry))
559
+ );
560
+ case 'gt':
561
+ return compareFilterValues(value, expected) > 0;
562
+ case 'gte':
563
+ return compareFilterValues(value, expected) >= 0;
564
+ case 'lt':
565
+ return compareFilterValues(value, expected) < 0;
566
+ case 'lte':
567
+ return compareFilterValues(value, expected) <= 0;
568
+ case 'isNull':
569
+ return value == null;
570
+ case 'isNotNull':
571
+ return value != null;
572
+ }
573
+ }
185
574
 
186
- return [...filteredData].sort((a, b) => {
187
- if (column.sortFn) {
188
- return column.sortFn(a, b, direction);
575
+ const filteredRows = $derived.by(() => {
576
+ if (tableModes.filtering === 'manual') return sourceRows;
577
+ const search = tableState.search.toLowerCase();
578
+ return sourceRows.filter(({ row, sourceIndex }) => {
579
+ if (filterFn && !filterFn(row, sourceIndex)) return false;
580
+ if (
581
+ search &&
582
+ !columns.some(
583
+ (column) =>
584
+ column.searchable !== false &&
585
+ textValue(getCellValue(row, column)).includes(search),
586
+ )
587
+ ) {
588
+ return false;
189
589
  }
190
- return defaultSort(a, b, String(accessor), direction);
590
+ return tableState.filters.every((filter) => {
591
+ const column = columns.find(
592
+ (candidate) => candidate.id === filter.columnId,
593
+ );
594
+ return Boolean(column && matchesFilter(row, column, filter));
595
+ });
191
596
  });
192
597
  });
193
598
 
194
- const totalRowCount = $derived(totalRows ?? sortedData.length);
599
+ const sortedRows = $derived.by(() => {
600
+ if (tableModes.sorting === 'manual' || tableState.sorting.length === 0)
601
+ return filteredRows;
602
+ return filteredRows.slice().sort((left, right) => {
603
+ for (const rule of tableState.sorting) {
604
+ const column = columns.find(
605
+ (candidate) => candidate.id === rule.columnId,
606
+ );
607
+ if (!column) continue;
608
+ const result = column.sortFn
609
+ ? column.sortFn(left.row, right.row, rule.direction)
610
+ : defaultSort(
611
+ left.row,
612
+ right.row,
613
+ String(column.accessor ?? column.id),
614
+ rule.direction,
615
+ );
616
+ if (result !== 0) return result;
617
+ }
618
+ return rowKey
619
+ ? compareDataTableRowIds(left.rowId, right.rowId)
620
+ : left.sourceIndex - right.sourceIndex;
621
+ });
622
+ });
623
+
624
+ const totalRowCount = $derived(
625
+ tableModes.pagination === 'manual' ? totalRows : sortedRows.length,
626
+ );
195
627
  const totalPages = $derived(
196
- pageSize ? Math.max(1, Math.ceil(totalRowCount / pageSize)) : 1,
628
+ tableState.pageSize
629
+ ? totalRowCount === undefined
630
+ ? null
631
+ : Math.max(1, Math.ceil(totalRowCount / tableState.pageSize))
632
+ : 1,
633
+ );
634
+ const displayRows = $derived.by(() => {
635
+ if (!tableState.pageSize || tableModes.pagination === 'manual')
636
+ return sortedRows;
637
+ const start = (tableState.page - 1) * tableState.pageSize;
638
+ return sortedRows.slice(start, start + tableState.pageSize);
639
+ });
640
+
641
+ let tableContainer: HTMLDivElement | undefined = $state();
642
+ let tableCaption: HTMLTableCaptionElement | undefined = $state();
643
+ let tableHead: HTMLTableSectionElement | undefined = $state();
644
+ let tableBody: HTMLTableSectionElement | undefined = $state();
645
+ let tableStructuralBody: HTMLTableSectionElement | undefined = $state();
646
+ let tableFooter: HTMLTableSectionElement | undefined = $state();
647
+ let virtualScrollTop = $state(0);
648
+ let virtualStructuralHeight = $state(0);
649
+ let virtualCaptionHeight = $state(0);
650
+ let virtualFooterHeight = $state(0);
651
+
652
+ const virtualizedBody = $derived(Boolean(virtualization) && !expandedContent);
653
+
654
+ const virtualizationWindow = $derived.by(() =>
655
+ resolveDataTableVirtualWindow({
656
+ options: virtualization,
657
+ rowCount: displayRows.length,
658
+ scrollTop: virtualization?.scrollTop ?? virtualScrollTop,
659
+ hasVariableRowHeight: Boolean(expandedContent),
660
+ headerRowCount,
661
+ summaryRowCount: structuralRowCount,
662
+ }),
663
+ );
664
+ const renderedRows = $derived.by(() =>
665
+ displayRows.slice(
666
+ virtualizationWindow.startIndex,
667
+ virtualizationWindow.endIndex,
668
+ ),
669
+ );
670
+ const virtualRowCount = $derived(totalRowCount ?? displayRows.length);
671
+ const virtualRowOffset = $derived(
672
+ tableModes.pagination === 'manual' && tableState.pageSize
673
+ ? (tableState.page - 1) * tableState.pageSize
674
+ : 0,
197
675
  );
198
- const displayData = $derived.by(() => {
199
- if (!pageSize || manualPagination) return sortedData;
200
- const start = (page - 1) * pageSize;
201
- return sortedData.slice(start, start + pageSize);
676
+ const virtualContainerHeight = $derived(
677
+ virtualizedBody && virtualization
678
+ ? virtualization.viewportHeight + virtualStructuralHeight
679
+ : undefined,
680
+ );
681
+
682
+ function clampVirtualScrollTop(nextScrollTop: number): number {
683
+ if (!virtualization) return 0;
684
+ const maximum = Math.max(
685
+ 0,
686
+ maximumDataTableVirtualScrollTop(
687
+ displayRows.length,
688
+ virtualization,
689
+ virtualFooterHeight,
690
+ ),
691
+ );
692
+ return Math.min(Math.max(0, nextScrollTop), maximum);
693
+ }
694
+
695
+ function setVirtualScrollTop(nextScrollTop: number, notify = false) {
696
+ if (!virtualization || !tableContainer || !virtualizedBody) return;
697
+ const clampedScrollTop = clampVirtualScrollTop(nextScrollTop);
698
+ tableContainer.scrollTop = clampedScrollTop;
699
+ virtualScrollTop = clampedScrollTop;
700
+ if (notify) virtualization.onScrollTopChange?.(clampedScrollTop);
701
+ }
702
+
703
+ function handleTableScroll(event: Event) {
704
+ if (!virtualizationWindow.enabled) return;
705
+ const nextScrollTop = (event.currentTarget as HTMLDivElement).scrollTop;
706
+ const clampedScrollTop = clampVirtualScrollTop(nextScrollTop);
707
+ virtualScrollTop = clampedScrollTop;
708
+ virtualization?.onScrollTopChange?.(clampedScrollTop);
709
+ }
710
+
711
+ function handleVirtualizationKeydown(event: KeyboardEvent) {
712
+ if (!virtualizationWindow.enabled || !virtualization || !tableContainer)
713
+ return;
714
+ const pageStep = Math.max(
715
+ virtualization.rowHeight,
716
+ virtualization.viewportHeight - virtualization.rowHeight,
717
+ );
718
+ const currentScrollTop = tableContainer.scrollTop;
719
+ const nextScrollTop =
720
+ event.key === 'ArrowDown'
721
+ ? currentScrollTop + virtualization.rowHeight
722
+ : event.key === 'ArrowUp'
723
+ ? currentScrollTop - virtualization.rowHeight
724
+ : event.key === 'PageDown'
725
+ ? currentScrollTop + pageStep
726
+ : event.key === 'PageUp'
727
+ ? currentScrollTop - pageStep
728
+ : event.key === 'Home'
729
+ ? 0
730
+ : event.key === 'End'
731
+ ? Number.POSITIVE_INFINITY
732
+ : undefined;
733
+ if (nextScrollTop === undefined) return;
734
+ event.preventDefault();
735
+ setVirtualScrollTop(nextScrollTop, true);
736
+ }
737
+
738
+ function handleRowFocus(rowId: DataTableRowId) {
739
+ virtualization?.onFocusedRowIdChange?.(rowId);
740
+ }
741
+
742
+ $effect(() => {
743
+ if (!virtualizedBody || !tableContainer) return;
744
+ const controlledScrollTop = virtualization?.scrollTop;
745
+ if (controlledScrollTop !== undefined) {
746
+ setVirtualScrollTop(controlledScrollTop);
747
+ }
748
+ });
749
+
750
+ $effect(() => {
751
+ if (!virtualizedBody || !virtualization || !tableContainer) return;
752
+ const focusRowId = virtualization.focusedRowId;
753
+ const focusIndex =
754
+ focusRowId == null
755
+ ? -1
756
+ : displayRows.findIndex(({ rowId }) => rowId === focusRowId);
757
+ if (focusIndex < 0 || focusRowId == null) return;
758
+ const nextScrollTop = scrollTopForDataTableRow(
759
+ focusIndex,
760
+ displayRows.length,
761
+ virtualization,
762
+ tableContainer.scrollTop,
763
+ );
764
+ setVirtualScrollTop(nextScrollTop, virtualization.scrollTop !== undefined);
765
+ const focusContainer = tableContainer;
766
+ void tick().then(() => {
767
+ const focusedRow = Array.from(
768
+ focusContainer.querySelectorAll<HTMLTableRowElement>('tr[data-row-id]'),
769
+ ).find((row) => row.dataset.rowId === dataTableRowIdKey(focusRowId));
770
+ focusedRow?.focus({ preventScroll: true });
771
+ });
202
772
  });
203
- const displayIndexOffset = $derived(pageSize ? (page - 1) * pageSize : 0);
204
773
 
205
774
  $effect(() => {
206
- if (page > totalPages) handlePageChange(totalPages);
775
+ if (!virtualizedBody || !tableBody) {
776
+ virtualStructuralHeight = 0;
777
+ virtualCaptionHeight = 0;
778
+ virtualFooterHeight = 0;
779
+ return;
780
+ }
781
+
782
+ const body = tableBody;
783
+ const captionElement = tableCaption;
784
+ const head = tableHead;
785
+ const structuralBody = tableStructuralBody;
786
+ const footerElement = tableFooter;
787
+ const measureStructure = () => {
788
+ virtualStructuralHeight = body.offsetTop;
789
+ virtualCaptionHeight = captionElement?.offsetHeight ?? 0;
790
+ virtualFooterHeight =
791
+ (structuralBody?.offsetHeight ?? 0) + (footerElement?.offsetHeight ?? 0);
792
+ };
793
+ measureStructure();
794
+ if (typeof ResizeObserver === 'undefined') return;
795
+
796
+ const observer = new ResizeObserver(measureStructure);
797
+ if (captionElement) observer.observe(captionElement);
798
+ if (head) observer.observe(head);
799
+ if (structuralBody) observer.observe(structuralBody);
800
+ if (footerElement) observer.observe(footerElement);
801
+ return () => observer.disconnect();
802
+ });
803
+
804
+ $effect(() => {
805
+ const total = totalRows;
806
+ if (total === undefined) return;
807
+ if (tableModes.pagination !== 'manual') {
808
+ throw new TypeError(
809
+ 'DataTable totalRows is only valid when pagination mode is manual',
810
+ );
811
+ }
812
+ if (!Number.isFinite(total) || !Number.isInteger(total) || total < 0) {
813
+ throw new TypeError(
814
+ 'DataTable totalRows must be a non-negative integer when supplied',
815
+ );
816
+ }
207
817
  });
208
818
 
209
- // Selection state
819
+ $effect(() => {
820
+ void tableState.page;
821
+ void tableState.pageSize;
822
+ activeController().clampPage(totalRowCount);
823
+ });
824
+
825
+ const selectedIds = $derived(new Set(tableState.selectedRowIds));
826
+ const expandedIds = $derived(new Set(tableState.expandedRowIds));
827
+ const currentSort = $derived(legacySort(tableState));
828
+
210
829
  const allSelected = $derived(
211
- displayData.length > 0 &&
212
- displayData.every((row, i) => selected.has(getDisplayRowKey(row, i))),
830
+ tableState.selection.scope === 'allMatching' ||
831
+ (displayRows.length > 0 &&
832
+ displayRows.every(({ rowId }) => selectedIds.has(rowId))),
213
833
  );
214
834
  const someSelected = $derived(
215
- displayData.some((row, i) => selected.has(getDisplayRowKey(row, i))) &&
835
+ tableState.selection.scope !== 'allMatching' &&
836
+ displayRows.some(({ rowId }) => selectedIds.has(rowId)) &&
216
837
  !allSelected,
217
838
  );
218
839
 
840
+ const errorMessage = $derived.by(() => {
841
+ if (error == null) return undefined;
842
+ const message = typeof error === 'string' ? error : error.message;
843
+ return message.trim() || t(M['ui.data_table.load_error']);
844
+ });
845
+ const hasRenderedRows = $derived(displayRows.length > 0);
846
+ const isInitialLoading = $derived(
847
+ (loading || refreshing) && !hasRenderedRows && !errorMessage,
848
+ );
849
+ const isRefreshing = $derived(
850
+ (loading || refreshing) && hasRenderedRows && !errorMessage,
851
+ );
852
+ const isBusy = $derived((loading || refreshing) && !errorMessage);
853
+
219
854
  const columnCount = $derived(
220
855
  visibleColumns.length + (selectable ? 1 : 0) + (expandedContent ? 1 : 0),
221
856
  );
857
+ const overflowRegionLabel = $derived(
858
+ caption
859
+ ? t(M['ui.data_table.overflow_region'], { caption })
860
+ : t(M['ui.data_table.default_overflow_region']),
861
+ );
222
862
 
223
- // Get cell value
224
863
  function getCellValue(row: T, column: DataTableColumn<T>): unknown {
225
864
  const accessor = column.accessor ?? column.id;
226
865
  return getNestedValue(row, String(accessor));
227
866
  }
228
867
 
229
- // Size classes
868
+ function widthForColumn(
869
+ column: DataTableResolvedColumn<T>,
870
+ ): string | undefined {
871
+ return column.width === undefined ? column.column.width : `${column.width}px`;
872
+ }
873
+
874
+ function observeColumnWidth(node: HTMLElement, initialColumnId: string) {
875
+ let columnId = initialColumnId;
876
+ const recordWidth = () => {
877
+ const width = Math.ceil(node.getBoundingClientRect().width);
878
+ if (width <= 0 || measuredColumnWidths[columnId] === width) return;
879
+ measuredColumnWidths = { ...measuredColumnWidths, [columnId]: width };
880
+ };
881
+ recordWidth();
882
+ if (typeof ResizeObserver === 'undefined') return;
883
+ const observer = new ResizeObserver(recordWidth);
884
+ observer.observe(node);
885
+ return {
886
+ update(nextColumnId: string) {
887
+ columnId = nextColumnId;
888
+ recordWidth();
889
+ },
890
+ destroy() {
891
+ observer.disconnect();
892
+ },
893
+ };
894
+ }
895
+
896
+ function numberFromCssPixels(value: string | undefined): number | undefined {
897
+ if (!value) return undefined;
898
+ const match = /^(\d+(?:\.\d+)?)(px|rem)$/.exec(value.trim());
899
+ if (!match) return undefined;
900
+ const number = Number(match[1]);
901
+ if (match[2] === 'px') return number;
902
+ const rootFontSize =
903
+ typeof document === 'undefined'
904
+ ? undefined
905
+ : /^\d+(?:\.\d+)?px$/.exec(
906
+ getComputedStyle(document.documentElement).fontSize.trim(),
907
+ );
908
+ return number * (rootFontSize ? Number(rootFontSize[0].slice(0, -2)) : 16);
909
+ }
910
+
911
+ function minimumWidth(column: DataTableColumn<T>): number {
912
+ return numberFromCssPixels(column.minWidth) ?? 40;
913
+ }
914
+
915
+ function maximumWidth(column: DataTableColumn<T>): number | undefined {
916
+ return numberFromCssPixels(column.maxWidth);
917
+ }
918
+
919
+ function clampedColumnWidth(column: DataTableColumn<T>, width: number): number {
920
+ const maximum = maximumWidth(column);
921
+ return Math.min(
922
+ Math.max(minimumWidth(column), Math.round(width)),
923
+ maximum ?? Infinity,
924
+ );
925
+ }
926
+
927
+ function currentColumnWidth(
928
+ resolved: DataTableResolvedColumn<T>,
929
+ target?: EventTarget | null,
930
+ ): number {
931
+ const measuredWidth = measuredColumnWidths[resolved.column.id];
932
+ if (measuredWidth !== undefined && measuredWidth > 0) return measuredWidth;
933
+ if (resolved.width !== undefined) return resolved.width;
934
+ const staticWidth = numberFromCssPixels(resolved.column.width);
935
+ if (staticWidth !== undefined) return staticWidth;
936
+ const header = target instanceof Element ? target.closest('th') : undefined;
937
+ return header?.getBoundingClientRect().width ?? 160;
938
+ }
939
+
940
+ function setColumnWidth(column: DataTableColumn<T>, width: number) {
941
+ dispatch({
942
+ type: 'setColumnWidth',
943
+ columnId: column.id,
944
+ width: clampedColumnWidth(column, width),
945
+ });
946
+ }
947
+
948
+ function handleResizeKeydown(
949
+ event: KeyboardEvent,
950
+ resolved: DataTableResolvedColumn<T>,
951
+ ) {
952
+ const step = event.shiftKey ? 32 : 8;
953
+ const current = currentColumnWidth(resolved, event.currentTarget);
954
+ const maximum = maximumWidth(resolved.column);
955
+ const next =
956
+ event.key === 'ArrowLeft'
957
+ ? current - step
958
+ : event.key === 'ArrowRight'
959
+ ? current + step
960
+ : event.key === 'Home'
961
+ ? minimumWidth(resolved.column)
962
+ : event.key === 'End' && maximum !== undefined
963
+ ? maximum
964
+ : undefined;
965
+ if (next === undefined) return;
966
+ event.preventDefault();
967
+ setColumnWidth(resolved.column, next);
968
+ }
969
+
970
+ function handleResizePointerDown(
971
+ event: PointerEvent,
972
+ resolved: DataTableResolvedColumn<T>,
973
+ ) {
974
+ if (event.button !== 0) return;
975
+ event.preventDefault();
976
+ event.stopPropagation();
977
+ const handle = event.currentTarget as HTMLElement;
978
+ const startX = event.clientX;
979
+ const startWidth = currentColumnWidth(resolved, handle);
980
+ handle.setPointerCapture?.(event.pointerId);
981
+ const update = (nextEvent: PointerEvent) =>
982
+ setColumnWidth(resolved.column, startWidth + nextEvent.clientX - startX);
983
+ const finish = () => {
984
+ handle.removeEventListener('pointermove', update);
985
+ handle.removeEventListener('pointerup', finish);
986
+ handle.removeEventListener('pointercancel', finish);
987
+ };
988
+ handle.addEventListener('pointermove', update);
989
+ handle.addEventListener('pointerup', finish, { once: true });
990
+ handle.addEventListener('pointercancel', finish, { once: true });
991
+ }
992
+
993
+ function structuralLabelColumnId(
994
+ row: DataTableStructuralRow<T>,
995
+ ): string | undefined {
996
+ if (
997
+ row.labelColumnId &&
998
+ visibleColumns.some((column) => column.column.id === row.labelColumnId)
999
+ ) {
1000
+ return row.labelColumnId;
1001
+ }
1002
+ return visibleColumns[0]?.column.id;
1003
+ }
1004
+
1005
+ function structuralRowLabel(row: DataTableStructuralRow<T>): string {
1006
+ const kind =
1007
+ row.kind === 'summary'
1008
+ ? t(M['ui.data_table.summary'])
1009
+ : row.kind === 'subtotal'
1010
+ ? t(M['ui.data_table.subtotal'])
1011
+ : row.kind === 'aggregate'
1012
+ ? t(M['ui.data_table.aggregate'])
1013
+ : t(M['ui.data_table.footer_row']);
1014
+ return t(M['ui.data_table.structural_row'], { kind, label: row.label });
1015
+ }
1016
+
1017
+ function structuralCellValue(
1018
+ row: DataTableStructuralRow<T>,
1019
+ column: DataTableColumn<T>,
1020
+ ): unknown {
1021
+ return (
1022
+ row.values?.[column.id] ??
1023
+ (column.id === structuralLabelColumnId(row) ? row.label : '')
1024
+ );
1025
+ }
1026
+
230
1027
  const sizeClasses = {
231
1028
  sm: 'data-table--sm',
232
1029
  md: 'data-table--md',
233
1030
  lg: 'data-table--lg',
234
1031
  };
1032
+
1033
+ $effect(() => {
1034
+ const container = tableContainer;
1035
+ void columnCount;
1036
+ void displayRows.length;
1037
+ if (!container) return;
1038
+
1039
+ const resizeObserver =
1040
+ typeof ResizeObserver === 'undefined'
1041
+ ? undefined
1042
+ : new ResizeObserver(updateOverflowState);
1043
+ resizeObserver?.observe(container);
1044
+ const table = container.querySelector('table');
1045
+ if (table) resizeObserver?.observe(table);
1046
+ window.addEventListener('resize', updateOverflowState);
1047
+ updateOverflowState();
1048
+
1049
+ return () => {
1050
+ resizeObserver?.disconnect();
1051
+ window.removeEventListener('resize', updateOverflowState);
1052
+ };
1053
+ });
235
1054
  </script>
236
1055
 
237
1056
  {#if toolbar}<div class="data-table-toolbar">{@render toolbar()}</div>{/if}
238
- <div class="data-table-container" class:data-table-container--sticky={stickyHeader}>
1057
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex: the region provides horizontal or virtual keyboard scrolling. -->
1058
+ <div
1059
+ bind:this={tableContainer}
1060
+ class="data-table-container"
1061
+ class:data-table-container--sticky={stickyHeader || virtualizedBody}
1062
+ class:data-table-container--overflowing={hasHorizontalOverflow}
1063
+ class:data-table-container--virtualized={virtualizationWindow.enabled}
1064
+ style:height={virtualContainerHeight === undefined ? undefined : `${virtualContainerHeight}px`}
1065
+ tabindex={virtualizationWindow.enabled || hasHorizontalOverflow ? 0 : undefined}
1066
+ role={virtualizationWindow.enabled || hasHorizontalOverflow ? 'region' : undefined}
1067
+ aria-label={virtualizationWindow.enabled
1068
+ ? caption
1069
+ ? t(M['ui.data_table.virtual_region'], { caption })
1070
+ : t(M['ui.data_table.default_virtual_region'])
1071
+ : hasHorizontalOverflow
1072
+ ? overflowRegionLabel
1073
+ : undefined}
1074
+ onscroll={(event) => {
1075
+ updateOverflowState();
1076
+ handleTableScroll(event);
1077
+ }}
1078
+ onkeydown={(event) => {
1079
+ handleVirtualizationKeydown(event);
1080
+ if (!event.defaultPrevented) handleOverflowKeydown(event);
1081
+ }}
1082
+ >
239
1083
  <table
240
1084
  class="data-table {sizeClasses[size]}"
241
1085
  class:data-table--striped={striped}
1086
+ class:data-table--virtualized={virtualizationWindow.enabled}
242
1087
  class:data-table--hoverable={hoverable}
243
1088
  class:data-table--dense={dense}
244
- class:data-table--loading={loading}
1089
+ class:data-table--loading={isInitialLoading}
1090
+ aria-busy={isBusy}
1091
+ aria-rowcount={virtualizationWindow.enabled
1092
+ ? virtualRowCount + headerRowCount + structuralRowCount
1093
+ : undefined}
1094
+ style={`--data-table-caption-height: ${virtualCaptionHeight}px`}
245
1095
  >
246
1096
  {#if caption}
247
- <caption class="data-table__caption">{caption}</caption>
1097
+ <caption bind:this={tableCaption} class="data-table__caption">{caption}</caption>
248
1098
  {/if}
249
1099
 
250
- <thead class="data-table__head">
251
- <tr class="data-table__row data-table__row--header">
252
- {#if expandedContent}<th class="data-table__cell data-table__cell--expand" scope="col"><span class="sr-only">Expand</span></th>{/if}
253
- {#if selectable}
254
- <th class="data-table__cell data-table__cell--checkbox" scope="col">
255
- <input
256
- type="checkbox"
257
- checked={allSelected}
258
- use:setIndeterminate={someSelected}
259
- onchange={handleSelectAll}
260
- aria-label={t(M['ui.data_table.select_all'])}
261
- class="data-table__checkbox"
262
- />
263
- </th>
264
- {/if}
1100
+ <thead bind:this={tableHead} class="data-table__head">
1101
+ {#each dataTableLayout.headerRows as headerRow, headerIndex (headerIndex)}
1102
+ <tr class="data-table__row data-table__row--header">
1103
+ {#if headerIndex === 0 && expandedContent}
1104
+ <th class="data-table__cell data-table__cell--expand" scope="col" rowspan={headerRowCount}>
1105
+ <span class="sr-only"><Trans key={M['ui.data_table.expand']} /></span>
1106
+ </th>
1107
+ {/if}
1108
+ {#if headerIndex === 0 && selectable}
1109
+ <th class="data-table__cell data-table__cell--checkbox" scope="col" rowspan={headerRowCount}>
1110
+ <input
1111
+ type="checkbox"
1112
+ checked={allSelected}
1113
+ use:setIndeterminate={someSelected}
1114
+ onchange={handleSelectAll}
1115
+ aria-label={t(M['ui.data_table.select_current_page'])}
1116
+ class="data-table__checkbox"
1117
+ />
1118
+ </th>
1119
+ {/if}
265
1120
 
266
- {#each visibleColumns as column (column.id)}
267
- <th
268
- class="data-table__cell data-table__cell--header"
269
- class:data-table__cell--sortable={sortable && column.sortable}
270
- class:data-table__cell--sorted={sort.columnId === column.id}
271
- style:width={column.width}
272
- style:min-width={column.minWidth}
273
- style:max-width={column.maxWidth}
274
- style:text-align={column.align}
275
- scope="col"
276
- aria-sort={sort.columnId === column.id
277
- ? sort.direction === 'asc'
278
- ? 'ascending'
279
- : 'descending'
280
- : undefined}
281
- >
282
- {#if column.header}
283
- {@render column.header({ column })}
284
- {:else if sortable && column.sortable}
285
- <button
286
- type="button"
287
- class="data-table__sort-button"
288
- onclick={() => handleSort(column)}
1121
+ {#each headerRow as headerCell, cellIndex (`${headerIndex}:${cellIndex}`)}
1122
+ {#if headerCell.kind === 'group'}
1123
+ <th
1124
+ class="data-table__cell data-table__cell--header data-table__cell--header-group"
1125
+ class:data-table__cell--pinned-start={headerCell.pin === 'start'}
1126
+ class:data-table__cell--pinned-end={headerCell.pin === 'end'}
1127
+ scope="colgroup"
1128
+ colspan={headerCell.colspan}
1129
+ rowspan={headerCell.rowspan}
1130
+ style:left={headerCell.pin === 'start' ? headerCell.stickyOffset : undefined}
1131
+ style:right={headerCell.pin === 'end' ? headerCell.stickyOffset : undefined}
289
1132
  >
290
- <span>{column.label}</span>
291
- <span class="data-table__sort-icon" aria-hidden="true">
292
- {#if sort.columnId === column.id}
293
- {sort.direction === 'asc' ? '↑' : '↓'}
1133
+ {headerCell.label}
1134
+ </th>
1135
+ {:else}
1136
+ {@const resolvedColumn = headerCell.column}
1137
+ {@const column = resolvedColumn.column}
1138
+ <th
1139
+ class="data-table__cell data-table__cell--header"
1140
+ class:data-table__cell--sortable={usesAutomaticSortButton(column)}
1141
+ class:data-table__cell--sorted={currentSort.columnId === column.id}
1142
+ class:data-table__cell--pinned-start={resolvedColumn.pin === 'start'}
1143
+ class:data-table__cell--pinned-end={resolvedColumn.pin === 'end'}
1144
+ data-column-id={column.id}
1145
+ data-column-role={column.role}
1146
+ data-responsive-priority={column.responsive?.priority}
1147
+ data-keep-visible={column.responsive?.keepVisible ? 'true' : undefined}
1148
+ use:observeColumnWidth={column.id}
1149
+ style:width={widthForColumn(resolvedColumn)}
1150
+ style:min-width={column.minWidth}
1151
+ style:max-width={column.maxWidth}
1152
+ style:left={resolvedColumn.pin === 'start' ? resolvedColumn.stickyOffset : undefined}
1153
+ style:right={resolvedColumn.pin === 'end' ? resolvedColumn.stickyOffset : undefined}
1154
+ style:text-align={column.align}
1155
+ scope="col"
1156
+ rowspan={headerCell.rowspan}
1157
+ aria-label={column.resizable && !column.header
1158
+ ? column.label
1159
+ : undefined}
1160
+ aria-sort={currentSort.columnId === column.id
1161
+ ? currentSort.direction === 'asc'
1162
+ ? 'ascending'
1163
+ : 'descending'
1164
+ : undefined}
1165
+ >
1166
+ {#if usesAutomaticSortButton(column)}
1167
+ {#if column.header}
1168
+ <div class="data-table__header-content">
1169
+ {@render column.header({ column })}
1170
+ <button
1171
+ type="button"
1172
+ class="data-table__sort-button"
1173
+ onclick={(event) => handleSort(column, event)}
1174
+ aria-label={sortActionLabel(column)}
1175
+ >
1176
+ <span class="data-table__sort-icon" aria-hidden="true">
1177
+ {#if currentSort.columnId === column.id}
1178
+ {currentSort.direction === 'asc' ? '↑' : '↓'}
1179
+ {:else}
1180
+
1181
+ {/if}
1182
+ </span>
1183
+ </button>
1184
+ </div>
294
1185
  {:else}
295
-
1186
+ <button
1187
+ type="button"
1188
+ class="data-table__sort-button"
1189
+ onclick={(event) => handleSort(column, event)}
1190
+ aria-label={sortActionLabel(column)}
1191
+ >
1192
+ <span>{column.label}</span>
1193
+ <span class="data-table__sort-icon" aria-hidden="true">
1194
+ {#if currentSort.columnId === column.id}
1195
+ {currentSort.direction === 'asc' ? '↑' : '↓'}
1196
+ {:else}
1197
+
1198
+ {/if}
1199
+ </span>
1200
+ </button>
296
1201
  {/if}
297
- </span>
298
- </button>
299
- {:else}
300
- {column.label}
1202
+ {:else if column.header}
1203
+ {@render column.header({ column })}
1204
+ {:else}
1205
+ {column.label}
1206
+ {/if}
1207
+ {#if column.resizable}
1208
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions: a focusable ARIA separator is the resize control. -->
1209
+ <span
1210
+ class="data-table__resize-handle"
1211
+ role="separator"
1212
+ aria-orientation="vertical"
1213
+ aria-label={t(M['ui.data_table.resize_column'], { column: column.label })}
1214
+ aria-valuemin={minimumWidth(column)}
1215
+ aria-valuemax={maximumWidth(column)}
1216
+ aria-valuenow={Math.round(currentColumnWidth(resolvedColumn))}
1217
+ tabindex="0"
1218
+ onpointerdown={(event) => handleResizePointerDown(event, resolvedColumn)}
1219
+ onkeydown={(event) => handleResizeKeydown(event, resolvedColumn)}
1220
+ ></span>
1221
+ {/if}
1222
+ </th>
301
1223
  {/if}
302
- </th>
303
- {/each}
304
- </tr>
1224
+ {/each}
1225
+ </tr>
1226
+ {/each}
305
1227
  </thead>
306
1228
 
307
- <tbody class="data-table__body">
308
- {#if loading}
1229
+ <tbody bind:this={tableBody} class="data-table__body">
1230
+ {#if errorMessage && !hasRenderedRows}
1231
+ <tr class="data-table__row data-table__row--error">
1232
+ <td
1233
+ class="data-table__cell data-table__cell--error"
1234
+ colspan={columnCount}
1235
+ >
1236
+ <div class="data-table__error-state" role="alert">
1237
+ <span>{errorMessage}</span>
1238
+ {#if onRetry}
1239
+ <button type="button" class="data-table__retry-button" onclick={onRetry}>
1240
+ {t(M['ui.data_table.retry'])}
1241
+ </button>
1242
+ {/if}
1243
+ </div>
1244
+ </td>
1245
+ </tr>
1246
+ {:else if isInitialLoading}
309
1247
  <tr class="data-table__row data-table__row--loading">
310
1248
  <td
311
1249
  class="data-table__cell data-table__cell--loading"
312
1250
  colspan={columnCount}
313
1251
  >
314
- <div class="data-table__loading-indicator">
1252
+ <div class="data-table__loading-indicator" role="status" aria-live="polite">
315
1253
  <span class="data-table__spinner"></span>
316
1254
  <span><Trans key={M['ui.data_table.loading']} /></span>
317
1255
  </div>
318
1256
  </td>
319
1257
  </tr>
320
- {:else if displayData.length === 0}
1258
+ {:else if displayRows.length === 0}
321
1259
  <tr class="data-table__row data-table__row--empty">
322
1260
  <td
323
1261
  class="data-table__cell data-table__cell--empty"
@@ -333,27 +1271,37 @@ const sizeClasses = {
333
1271
  </td>
334
1272
  </tr>
335
1273
  {:else}
336
- {#each displayData as row, index (getDisplayRowKey(row, index))}
337
- {@const key = getDisplayRowKey(row, index)}
338
- {@const isSelected = selected.has(key)}
339
- {@const isExpanded = expanded.has(key)}
340
- {@const rowCanExpand = canExpand?.(row, index) ?? true}
1274
+ {#if virtualizationWindow.topSpacerHeight > 0}
1275
+ <tr class="data-table__virtual-spacer" aria-hidden="true">
1276
+ <td colspan={columnCount} style:height={`${virtualizationWindow.topSpacerHeight}px`}></td>
1277
+ </tr>
1278
+ {/if}
1279
+ {#each renderedRows as entry, index (entry.rowId)}
1280
+ {@const row = entry.row}
1281
+ {@const key = entry.rowId}
1282
+ {@const displayIndex = virtualizationWindow.startIndex + index}
1283
+ {@const isSelected = tableState.selection.scope === 'allMatching' || selectedIds.has(key)}
1284
+ {@const isExpanded = expandedIds.has(key)}
1285
+ {@const rowCanExpand = canExpand?.(row, displayIndex) ?? true}
341
1286
  <tr
342
- class="data-table__row {rowClass?.(row, index) ?? ''}"
1287
+ class="data-table__row {rowClass?.(row, displayIndex) ?? ''}"
343
1288
  class:data-table__row--selected={isSelected}
344
- onclick={() => handleRowClick(row, index)}
345
- role={onRowClick ? 'button' : undefined}
346
- tabindex={onRowClick ? 0 : undefined}
347
- onkeydown={(e) => {
348
- if (onRowClick && (e.key === 'Enter' || e.key === ' ')) {
349
- e.preventDefault();
350
- handleRowClick(row, index);
351
- }
352
- }}
1289
+ class:data-table__row--striped={
1290
+ virtualizationWindow.enabled && striped && displayIndex % 2 === 1
1291
+ }
1292
+ class:data-table__row--interactive={Boolean(onRowClick)}
1293
+ data-row-id={dataTableRowIdKey(key)}
1294
+ aria-rowindex={virtualizationWindow.enabled
1295
+ ? virtualRowOffset + displayIndex + headerRowCount + 1
1296
+ : undefined}
1297
+ onclick={(event) => handleRowActivation(event, row, displayIndex)}
1298
+ tabindex={onRowClick || virtualizationWindow.enabled ? 0 : undefined}
1299
+ onfocusin={() => handleRowFocus(key)}
1300
+ onkeydown={(event) => handleRowKeydown(event, row, displayIndex)}
353
1301
  >
354
1302
  {#if expandedContent}
355
1303
  <td class="data-table__cell data-table__cell--expand">
356
- {#if rowCanExpand}<button type="button" class="data-table__expand-button" aria-label={isExpanded ? 'Collapse row' : 'Expand row'} aria-expanded={isExpanded} onclick={(event) => handleExpanded(key, event)}>{isExpanded ? '−' : '+'}</button>{/if}
1304
+ {#if rowCanExpand}<button type="button" class="data-table__expand-button" aria-label={isExpanded ? t(M['ui.data_table.collapse_row'], { row: getRowLabel(row, displayIndex) }) : t(M['ui.data_table.expand_row'], { row: getRowLabel(row, displayIndex) })} aria-expanded={isExpanded} aria-controls={expansionContentId(key)} onclick={(event) => handleExpanded(key, event)}>{isExpanded ? '−' : '+'}</button>{/if}
357
1305
  </td>
358
1306
  {/if}
359
1307
  {#if selectable}
@@ -361,23 +1309,36 @@ const sizeClasses = {
361
1309
  <input
362
1310
  type="checkbox"
363
1311
  checked={isSelected}
1312
+ disabled={tableState.selection.scope === 'allMatching'}
364
1313
  onchange={(e) => handleRowSelect(key, e)}
365
- aria-label={t(M['ui.data_table.select_row'])}
1314
+ aria-label={isSelected ? t(M['ui.data_table.deselect_row'], { row: getRowLabel(row, displayIndex) }) : t(M['ui.data_table.select_row'], { row: getRowLabel(row, displayIndex) })}
366
1315
  class="data-table__checkbox"
367
1316
  />
368
1317
  </td>
369
1318
  {/if}
370
1319
 
371
- {#each visibleColumns as column (column.id)}
1320
+ {#each visibleColumns as resolvedColumn (resolvedColumn.column.id)}
1321
+ {@const column = resolvedColumn.column}
372
1322
  {@const value = getCellValue(row, column)}
373
1323
  <td
374
1324
  class="data-table__cell {column.className ?? ''}"
1325
+ class:data-table__cell--pinned-start={resolvedColumn.pin === 'start'}
1326
+ class:data-table__cell--pinned-end={resolvedColumn.pin === 'end'}
1327
+ data-column-id={column.id}
1328
+ data-column-role={column.role}
1329
+ data-responsive-priority={column.responsive?.priority}
1330
+ data-keep-visible={column.responsive?.keepVisible ? 'true' : undefined}
1331
+ style:width={widthForColumn(resolvedColumn)}
1332
+ style:min-width={column.minWidth}
1333
+ style:max-width={column.maxWidth}
1334
+ style:left={resolvedColumn.pin === 'start' ? resolvedColumn.stickyOffset : undefined}
1335
+ style:right={resolvedColumn.pin === 'end' ? resolvedColumn.stickyOffset : undefined}
375
1336
  style:text-align={column.align}
376
1337
  >
377
1338
  {#if cell}
378
- {@render cell({ column, row, value, index })}
1339
+ {@render cell({ column, row, value, index: displayIndex })}
379
1340
  {:else if column.cell}
380
- {@render column.cell({ row, value, index })}
1341
+ {@render column.cell({ row, value, index: displayIndex })}
381
1342
  {:else}
382
1343
  {value ?? ''}
383
1344
  {/if}
@@ -385,26 +1346,206 @@ const sizeClasses = {
385
1346
  {/each}
386
1347
  </tr>
387
1348
  {#if expandedContent && isExpanded}
388
- <tr class="data-table__row data-table__row--expanded">
389
- <td class="data-table__cell data-table__cell--expanded" colspan={columnCount}>{@render expandedContent({ row, index })}</td>
1349
+ <tr id={expansionContentId(key)} class="data-table__row data-table__row--expanded">
1350
+ <td class="data-table__cell data-table__cell--expanded" colspan={columnCount}>{@render expandedContent({ row, index: displayIndex })}</td>
390
1351
  </tr>
391
1352
  {/if}
392
1353
  {/each}
1354
+ {#if virtualizationWindow.bottomSpacerHeight > 0}
1355
+ <tr class="data-table__virtual-spacer" aria-hidden="true">
1356
+ <td colspan={columnCount} style:height={`${virtualizationWindow.bottomSpacerHeight}px`}></td>
1357
+ </tr>
1358
+ {/if}
393
1359
  {/if}
394
1360
  </tbody>
395
- {#if footer}
396
- <tfoot><tr><td class="data-table__cell data-table__footer" colspan={columnCount}>{@render footer({ rows: displayData })}</td></tr></tfoot>
1361
+ {#if dataBodyStructuralRows.length > 0}
1362
+ <tbody
1363
+ bind:this={tableStructuralBody}
1364
+ class="data-table__body data-table__body--structural"
1365
+ >
1366
+ {#each dataBodyStructuralRows as structuralRow, structuralIndex (structuralRow.id)}
1367
+ {@const labelColumnId = structuralLabelColumnId(structuralRow)}
1368
+ <tr
1369
+ class="data-table__row data-table__row--structural"
1370
+ data-row-kind={structuralRow.kind}
1371
+ aria-label={structuralRowLabel(structuralRow)}
1372
+ aria-rowindex={virtualizationWindow.enabled
1373
+ ? virtualRowOffset + displayRows.length + headerRowCount + structuralIndex + 1
1374
+ : undefined}
1375
+ >
1376
+ {#if expandedContent}<td class="data-table__cell data-table__cell--expand"></td>{/if}
1377
+ {#if selectable}<td class="data-table__cell data-table__cell--checkbox"></td>{/if}
1378
+ {#each visibleColumns as resolvedColumn (resolvedColumn.column.id)}
1379
+ {@const column = resolvedColumn.column}
1380
+ {@const value = structuralCellValue(structuralRow, column)}
1381
+ {#if column.id === labelColumnId}
1382
+ <th
1383
+ class="data-table__cell data-table__cell--structural-label {column.className ?? ''}"
1384
+ class:data-table__cell--pinned-start={resolvedColumn.pin === 'start'}
1385
+ class:data-table__cell--pinned-end={resolvedColumn.pin === 'end'}
1386
+ scope="row"
1387
+ data-column-id={column.id}
1388
+ style:width={widthForColumn(resolvedColumn)}
1389
+ style:min-width={column.minWidth}
1390
+ style:max-width={column.maxWidth}
1391
+ style:left={resolvedColumn.pin === 'start' ? resolvedColumn.stickyOffset : undefined}
1392
+ style:right={resolvedColumn.pin === 'end' ? resolvedColumn.stickyOffset : undefined}
1393
+ style:text-align={column.align}
1394
+ >
1395
+ <span class="sr-only">{structuralRowLabel(structuralRow)}: </span>
1396
+ {#if structuralRow.cell}
1397
+ {@render structuralRow.cell({ row: structuralRow, column, value })}
1398
+ {:else}
1399
+ {value ?? ''}
1400
+ {/if}
1401
+ </th>
1402
+ {:else}
1403
+ <td
1404
+ class="data-table__cell {column.className ?? ''}"
1405
+ class:data-table__cell--pinned-start={resolvedColumn.pin === 'start'}
1406
+ class:data-table__cell--pinned-end={resolvedColumn.pin === 'end'}
1407
+ data-column-id={column.id}
1408
+ style:width={widthForColumn(resolvedColumn)}
1409
+ style:min-width={column.minWidth}
1410
+ style:max-width={column.maxWidth}
1411
+ style:left={resolvedColumn.pin === 'start' ? resolvedColumn.stickyOffset : undefined}
1412
+ style:right={resolvedColumn.pin === 'end' ? resolvedColumn.stickyOffset : undefined}
1413
+ style:text-align={column.align}
1414
+ >
1415
+ {#if structuralRow.cell}
1416
+ {@render structuralRow.cell({ row: structuralRow, column, value })}
1417
+ {:else}
1418
+ {value ?? ''}
1419
+ {/if}
1420
+ </td>
1421
+ {/if}
1422
+ {/each}
1423
+ </tr>
1424
+ {/each}
1425
+ </tbody>
1426
+ {/if}
1427
+ {#if footer || footerStructuralRows.length > 0}
1428
+ <tfoot bind:this={tableFooter}>
1429
+ {#each footerStructuralRows as structuralRow, structuralIndex (structuralRow.id)}
1430
+ {@const labelColumnId = structuralLabelColumnId(structuralRow)}
1431
+ <tr
1432
+ class="data-table__row data-table__row--structural data-table__row--footer"
1433
+ data-row-kind={structuralRow.kind}
1434
+ aria-label={structuralRowLabel(structuralRow)}
1435
+ aria-rowindex={virtualizationWindow.enabled
1436
+ ? virtualRowCount + headerRowCount + dataBodyStructuralRows.length + structuralIndex + 1
1437
+ : undefined}
1438
+ >
1439
+ {#if expandedContent}<td class="data-table__cell data-table__cell--expand"></td>{/if}
1440
+ {#if selectable}<td class="data-table__cell data-table__cell--checkbox"></td>{/if}
1441
+ {#each visibleColumns as resolvedColumn (resolvedColumn.column.id)}
1442
+ {@const column = resolvedColumn.column}
1443
+ {@const value = structuralCellValue(structuralRow, column)}
1444
+ {#if column.id === labelColumnId}
1445
+ <th
1446
+ class="data-table__cell data-table__cell--structural-label {column.className ?? ''}"
1447
+ class:data-table__cell--pinned-start={resolvedColumn.pin === 'start'}
1448
+ class:data-table__cell--pinned-end={resolvedColumn.pin === 'end'}
1449
+ scope="row"
1450
+ data-column-id={column.id}
1451
+ style:width={widthForColumn(resolvedColumn)}
1452
+ style:min-width={column.minWidth}
1453
+ style:max-width={column.maxWidth}
1454
+ style:left={resolvedColumn.pin === 'start' ? resolvedColumn.stickyOffset : undefined}
1455
+ style:right={resolvedColumn.pin === 'end' ? resolvedColumn.stickyOffset : undefined}
1456
+ style:text-align={column.align}
1457
+ >
1458
+ <span class="sr-only">{structuralRowLabel(structuralRow)}: </span>
1459
+ {#if structuralRow.cell}
1460
+ {@render structuralRow.cell({ row: structuralRow, column, value })}
1461
+ {:else}
1462
+ {value ?? ''}
1463
+ {/if}
1464
+ </th>
1465
+ {:else}
1466
+ <td
1467
+ class="data-table__cell {column.className ?? ''}"
1468
+ class:data-table__cell--pinned-start={resolvedColumn.pin === 'start'}
1469
+ class:data-table__cell--pinned-end={resolvedColumn.pin === 'end'}
1470
+ data-column-id={column.id}
1471
+ style:width={widthForColumn(resolvedColumn)}
1472
+ style:min-width={column.minWidth}
1473
+ style:max-width={column.maxWidth}
1474
+ style:left={resolvedColumn.pin === 'start' ? resolvedColumn.stickyOffset : undefined}
1475
+ style:right={resolvedColumn.pin === 'end' ? resolvedColumn.stickyOffset : undefined}
1476
+ style:text-align={column.align}
1477
+ >
1478
+ {#if structuralRow.cell}
1479
+ {@render structuralRow.cell({ row: structuralRow, column, value })}
1480
+ {:else}
1481
+ {value ?? ''}
1482
+ {/if}
1483
+ </td>
1484
+ {/if}
1485
+ {/each}
1486
+ </tr>
1487
+ {/each}
1488
+ {#if footer}
1489
+ <tr aria-rowindex={virtualizationWindow.enabled
1490
+ ? virtualRowCount + headerRowCount + structuralRowCount
1491
+ : undefined}>
1492
+ <td class="data-table__cell data-table__footer" colspan={columnCount}>
1493
+ {@render footer({ rows: displayRows.map(({ row }) => row) })}
1494
+ </td>
1495
+ </tr>
1496
+ {/if}
1497
+ </tfoot>
397
1498
  {/if}
398
1499
  </table>
1500
+ {#if errorMessage && hasRenderedRows}
1501
+ <div class="data-table__async-status data-table__async-status--error" role="alert">
1502
+ <span>{errorMessage}</span>
1503
+ {#if onRetry}
1504
+ <button type="button" class="data-table__retry-button" onclick={onRetry}>
1505
+ {t(M['ui.data_table.retry'])}
1506
+ </button>
1507
+ {/if}
1508
+ </div>
1509
+ {:else if isRefreshing || stale || partialResults}
1510
+ <div class="data-table__async-status" role="status" aria-live="polite" aria-atomic="true">
1511
+ {#if isRefreshing}<span>{t(M['ui.data_table.refreshing'])}</span>{/if}
1512
+ {#if stale}<span>{t(M['ui.data_table.stale'])}</span>{/if}
1513
+ {#if partialResults}<span>{t(M['ui.data_table.partial_results'])}</span>{/if}
1514
+ </div>
1515
+ {/if}
399
1516
  </div>
400
- {#if pageSize && totalPages > 1}<Pagination currentPage={page} {totalPages} onPageChange={handlePageChange} aria-label="Table pages" />{/if}
1517
+ {#if hasHorizontalOverflow}
1518
+ <div class="data-table__overflow-cue" aria-hidden="true">
1519
+ {#if canScrollLeft}<span>← {t(M['ui.data_table.more_columns'])}</span>{/if}
1520
+ {#if canScrollRight}<span>{t(M['ui.data_table.more_columns'])} →</span>{/if}
1521
+ </div>
1522
+ {/if}
1523
+ {#if tableState.pageSize && totalPages && totalPages > 1}<Pagination currentPage={tableState.page} {totalPages} onPageChange={handlePageChange} aria-label={t(M['ui.data_table.pagination'])} />{/if}
401
1524
 
402
1525
  <style>
403
1526
  .data-table-container {
1527
+ position: relative;
404
1528
  width: 100%;
405
1529
  overflow-x: auto;
406
1530
  }
407
1531
 
1532
+ .data-table-container--overflowing:focus-visible {
1533
+ outline: 2px solid var(--smrt-color-primary, #3b82f6);
1534
+ outline-offset: 2px;
1535
+ }
1536
+
1537
+ .data-table__overflow-cue {
1538
+ display: flex;
1539
+ justify-content: space-between;
1540
+ padding: var(--smrt-spacing-2, 0.5rem) var(--smrt-spacing-3, 0.75rem);
1541
+ border-top: 1px solid var(--smrt-color-outline-variant, #e5e7eb);
1542
+ background: var(--smrt-color-surface-container-low, #f9fafb);
1543
+ color: var(--smrt-color-on-surface-variant, #6b7280);
1544
+ font-size: var(--smrt-typography-label-small-size, 0.75rem);
1545
+ font-weight: var(--smrt-typography-weight-medium, 500);
1546
+ pointer-events: none;
1547
+ }
1548
+
408
1549
  .data-table-toolbar { margin-bottom: var(--smrt-spacing-2); }
409
1550
 
410
1551
  .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@@ -414,6 +1555,23 @@ const sizeClasses = {
414
1555
  overflow-y: auto;
415
1556
  }
416
1557
 
1558
+ .data-table-container--virtualized {
1559
+ overflow: auto;
1560
+ }
1561
+
1562
+ /* Keep structural context fixed so scrollTop is measured from data row zero. */
1563
+ .data-table-container--virtualized .data-table__caption {
1564
+ position: sticky;
1565
+ top: 0;
1566
+ z-index: 2;
1567
+ }
1568
+
1569
+ .data-table-container--sticky.data-table-container--virtualized .data-table__head {
1570
+ position: sticky;
1571
+ top: var(--data-table-caption-height, 0px);
1572
+ z-index: 1;
1573
+ }
1574
+
417
1575
  .data-table {
418
1576
  width: 100%;
419
1577
  border-collapse: collapse;
@@ -448,6 +1606,7 @@ const sizeClasses = {
448
1606
  }
449
1607
 
450
1608
  .data-table__cell--header {
1609
+ position: relative;
451
1610
  padding: var(--smrt-spacing-3, 0.75rem) var(--smrt-spacing-4, 1rem);
452
1611
  font-weight: var(--smrt-typography-weight-semibold, 600);
453
1612
  text-align: left;
@@ -473,6 +1632,45 @@ const sizeClasses = {
473
1632
  cursor: pointer;
474
1633
  }
475
1634
 
1635
+ .data-table__header-content {
1636
+ display: inline-flex;
1637
+ align-items: center;
1638
+ gap: var(--smrt-spacing-1, 0.25rem);
1639
+ }
1640
+
1641
+ .data-table__cell--header-group {
1642
+ color: var(--smrt-color-on-surface-variant, #6b7280);
1643
+ font: var(--smrt-typography-label-medium-font);
1644
+ letter-spacing: 0.02em;
1645
+ }
1646
+
1647
+ .data-table__resize-handle {
1648
+ position: absolute;
1649
+ top: 0;
1650
+ right: -0.35rem;
1651
+ z-index: 3;
1652
+ display: block;
1653
+ width: 0.7rem;
1654
+ height: 100%;
1655
+ cursor: col-resize;
1656
+ touch-action: none;
1657
+ }
1658
+
1659
+ .data-table__resize-handle::after {
1660
+ position: absolute;
1661
+ top: 20%;
1662
+ right: 0.3rem;
1663
+ width: 1px;
1664
+ height: 60%;
1665
+ background: var(--smrt-color-outline, #9ca3af);
1666
+ content: '';
1667
+ }
1668
+
1669
+ .data-table__resize-handle:focus-visible {
1670
+ outline: 2px solid var(--smrt-color-primary, #3b82f6);
1671
+ outline-offset: -2px;
1672
+ }
1673
+
476
1674
  .data-table__sort-button:hover {
477
1675
  color: var(--smrt-color-primary, #3b82f6);
478
1676
  }
@@ -497,6 +1695,11 @@ const sizeClasses = {
497
1695
  transition: background-color var(--smrt-duration-fast, 150ms) var(--smrt-easing-standard, ease);
498
1696
  }
499
1697
 
1698
+ .data-table__virtual-spacer td {
1699
+ padding: 0;
1700
+ border: 0;
1701
+ }
1702
+
500
1703
  .data-table--hoverable .data-table__row:hover:not(.data-table__row--loading):not(.data-table__row--empty) {
501
1704
  background: var(--smrt-color-surface-container-low, #f9fafb);
502
1705
  }
@@ -505,15 +1708,21 @@ const sizeClasses = {
505
1708
  background: var(--smrt-color-primary-container, #dbeafe) !important;
506
1709
  }
507
1710
 
508
- .data-table--striped .data-table__row:nth-child(even) {
1711
+ .data-table--striped:not(.data-table--virtualized) .data-table__row:nth-child(even),
1712
+ .data-table__row--striped {
509
1713
  background: var(--smrt-color-surface-container-lowest, #fafafa);
510
1714
  }
511
1715
 
512
- .data-table__row[role='button'] {
1716
+ .data-table__row--interactive {
513
1717
  cursor: pointer;
514
1718
  }
515
1719
 
516
- .data-table__row[role='button']:focus-visible {
1720
+ .data-table__row--interactive:focus-visible {
1721
+ outline: 2px solid var(--smrt-color-primary, #3b82f6);
1722
+ outline-offset: -2px;
1723
+ }
1724
+
1725
+ .data-table-container--virtualized .data-table__row:focus-visible {
517
1726
  outline: 2px solid var(--smrt-color-primary, #3b82f6);
518
1727
  outline-offset: -2px;
519
1728
  }
@@ -523,6 +1732,24 @@ const sizeClasses = {
523
1732
  vertical-align: middle;
524
1733
  }
525
1734
 
1735
+ .data-table__cell--pinned-start,
1736
+ .data-table__cell--pinned-end {
1737
+ position: sticky;
1738
+ z-index: 1;
1739
+ background: var(--smrt-color-surface, #ffffff);
1740
+ }
1741
+
1742
+ .data-table__cell--header.data-table__cell--pinned-start,
1743
+ .data-table__cell--header.data-table__cell--pinned-end {
1744
+ z-index: 3;
1745
+ background: var(--smrt-color-surface-container, #f3f4f6);
1746
+ }
1747
+
1748
+ .data-table__row--selected .data-table__cell--pinned-start,
1749
+ .data-table__row--selected .data-table__cell--pinned-end {
1750
+ background: var(--smrt-color-primary-container, #dbeafe);
1751
+ }
1752
+
526
1753
  .data-table__cell--checkbox {
527
1754
  width: 48px;
528
1755
  text-align: center;
@@ -533,6 +1760,9 @@ const sizeClasses = {
533
1760
  .data-table__expand-button:focus-visible { outline: 2px solid var(--smrt-color-primary); outline-offset: 2px; }
534
1761
  .data-table__cell--expanded { padding: var(--smrt-spacing-4); background: var(--smrt-color-surface-container-low); }
535
1762
  .data-table__footer { border-top: 2px solid var(--smrt-color-outline-variant); font-weight: var(--smrt-typography-weight-medium); }
1763
+ .data-table__row--structural { background: var(--smrt-color-surface-container-low, #f9fafb); }
1764
+ .data-table__row--footer { border-top: 2px solid var(--smrt-color-outline-variant); }
1765
+ .data-table__cell--structural-label { font-weight: var(--smrt-typography-weight-semibold, 600); }
536
1766
 
537
1767
  .data-table__checkbox {
538
1768
  width: 18px;
@@ -543,7 +1773,8 @@ const sizeClasses = {
543
1773
 
544
1774
  /* Loading */
545
1775
  .data-table__cell--loading,
546
- .data-table__cell--empty {
1776
+ .data-table__cell--empty,
1777
+ .data-table__cell--error {
547
1778
  padding: var(--smrt-spacing-8, 2rem);
548
1779
  text-align: center;
549
1780
  }
@@ -575,6 +1806,42 @@ const sizeClasses = {
575
1806
  color: var(--smrt-color-on-surface-variant, #6b7280);
576
1807
  }
577
1808
 
1809
+ .data-table__error-state,
1810
+ .data-table__async-status {
1811
+ display: flex;
1812
+ align-items: center;
1813
+ justify-content: center;
1814
+ flex-wrap: wrap;
1815
+ gap: var(--smrt-spacing-3, 0.75rem);
1816
+ color: var(--smrt-color-on-surface-variant, #6b7280);
1817
+ }
1818
+
1819
+ .data-table__error-state,
1820
+ .data-table__async-status--error {
1821
+ color: var(--smrt-color-error, #b3261e);
1822
+ }
1823
+
1824
+ .data-table__async-status {
1825
+ padding: var(--smrt-spacing-3, 0.75rem);
1826
+ border-top: 1px solid var(--smrt-color-outline-variant, #e5e7eb);
1827
+ font: var(--smrt-typography-body-small-font);
1828
+ }
1829
+
1830
+ .data-table__retry-button {
1831
+ border: 1px solid currentColor;
1832
+ border-radius: var(--smrt-radius-small, 0.25rem);
1833
+ background: transparent;
1834
+ color: inherit;
1835
+ cursor: pointer;
1836
+ font: inherit;
1837
+ padding: var(--smrt-spacing-1, 0.25rem) var(--smrt-spacing-3, 0.75rem);
1838
+ }
1839
+
1840
+ .data-table__retry-button:focus-visible {
1841
+ outline: 2px solid var(--smrt-color-primary, #3b82f6);
1842
+ outline-offset: 2px;
1843
+ }
1844
+
578
1845
  /* Size variants */
579
1846
  .data-table--sm .data-table__cell {
580
1847
  padding: var(--smrt-spacing-2, 0.5rem) var(--smrt-spacing-3, 0.75rem);
@@ -604,6 +1871,11 @@ const sizeClasses = {
604
1871
  /* Loading overlay */
605
1872
  .data-table--loading {
606
1873
  opacity: 0.7;
607
- pointer-events: none;
1874
+ }
1875
+
1876
+ @media (prefers-reduced-motion: reduce) {
1877
+ .data-table__spinner {
1878
+ animation: none;
1879
+ }
608
1880
  }
609
1881
  </style>