@happyvertical/smrt-ui 0.42.4 → 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 (48) hide show
  1. package/README.md +157 -3
  2. package/dist/components/data/DataTable.svelte +1097 -111
  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 +45 -5
  6. package/dist/components/data/DataTableController.d.ts.map +1 -1
  7. package/dist/components/data/DataTableController.js +153 -11
  8. package/dist/components/data/DataTableLayout.d.ts +37 -0
  9. package/dist/components/data/DataTableLayout.d.ts.map +1 -0
  10. package/dist/components/data/DataTableLayout.js +135 -0
  11. package/dist/components/data/DataTablePerformance.d.ts +31 -0
  12. package/dist/components/data/DataTablePerformance.d.ts.map +1 -0
  13. package/dist/components/data/DataTablePerformance.js +46 -0
  14. package/dist/components/data/DataTableVirtualization.d.ts +71 -0
  15. package/dist/components/data/DataTableVirtualization.d.ts.map +1 -0
  16. package/dist/components/data/DataTableVirtualization.js +100 -0
  17. package/dist/components/data/__benchmarks__/DataTable.bench.d.ts +2 -0
  18. package/dist/components/data/__benchmarks__/DataTable.bench.d.ts.map +1 -0
  19. package/dist/components/data/__benchmarks__/DataTable.bench.js +53 -0
  20. package/dist/components/data/__fixtures__/DataTableConformanceFixture.d.ts +27 -0
  21. package/dist/components/data/__fixtures__/DataTableConformanceFixture.d.ts.map +1 -0
  22. package/dist/components/data/__fixtures__/DataTableConformanceFixture.js +141 -0
  23. package/dist/components/data/__fixtures__/DataTablePerformanceFixture.d.ts +10 -0
  24. package/dist/components/data/__fixtures__/DataTablePerformanceFixture.d.ts.map +1 -0
  25. package/dist/components/data/__fixtures__/DataTablePerformanceFixture.js +23 -0
  26. package/dist/components/data/__tests__/DataTable.test.js +393 -8
  27. package/dist/components/data/__tests__/DataTableConformance.test.js +212 -0
  28. package/dist/components/data/__tests__/DataTableController.test.js +124 -2
  29. package/dist/components/data/__tests__/DataTableLayout.test.js +195 -0
  30. package/dist/components/data/__tests__/DataTablePerformance.test.js +21 -0
  31. package/dist/components/data/__tests__/DataTableVirtualization.test.js +80 -0
  32. package/dist/components/data/__tests__/DataTableVirtualizationComponent.test.js +255 -0
  33. package/dist/components/data/__tests__/data-surface.test.js +675 -0
  34. package/dist/components/data/data-surface.d.ts +246 -0
  35. package/dist/components/data/data-surface.d.ts.map +1 -0
  36. package/dist/components/data/data-surface.js +1102 -0
  37. package/dist/components/data/index.d.ts +3 -0
  38. package/dist/components/data/index.d.ts.map +1 -1
  39. package/dist/components/data/index.js +3 -0
  40. package/dist/components/data/types.d.ts +83 -1
  41. package/dist/components/data/types.d.ts.map +1 -1
  42. package/dist/i18n/strings.d.ts +25 -0
  43. package/dist/i18n/strings.d.ts.map +1 -1
  44. package/dist/i18n/strings.js +27 -2
  45. package/dist/svelte/playground/DataTablePreview.svelte +374 -5
  46. package/dist/svelte/playground/DataTablePreview.svelte.d.ts.map +1 -1
  47. package/dist/svelte/playground.js +2 -2
  48. package/package.json +3 -2
@@ -11,7 +11,7 @@
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';
@@ -27,9 +27,24 @@ import {
27
27
  type DataTableSnapshot,
28
28
  type DataTableViewState,
29
29
  type DataTableViewStateInput,
30
+ dataTableRowIdKey,
30
31
  } from './DataTableController.js';
31
32
  import { resolveDataTableRows } from './DataTableIdentity.js';
32
- import type { DataTableColumn, DataTableProps, SortState } from './types.js';
33
+ import {
34
+ type DataTableResolvedColumn,
35
+ resolveDataTableLayout,
36
+ } from './DataTableLayout.js';
37
+ import {
38
+ maximumDataTableVirtualScrollTop,
39
+ resolveDataTableVirtualWindow,
40
+ scrollTopForDataTableRow,
41
+ } from './DataTableVirtualization.js';
42
+ import type {
43
+ DataTableColumn,
44
+ DataTableProps,
45
+ DataTableStructuralRow,
46
+ SortState,
47
+ } from './types.js';
33
48
  import { defaultSort, getNestedValue } from './types.js';
34
49
 
35
50
  const { t } = useI18n();
@@ -50,6 +65,7 @@ let {
50
65
  selected = $bindable(new Set<string | number>()),
51
66
  onSelectionChange,
52
67
  onRowClick,
68
+ rowLabel,
53
69
  sortable = false,
54
70
  sort = $bindable({ columnId: null, direction: null }),
55
71
  onSortChange,
@@ -58,6 +74,7 @@ let {
58
74
  page = $bindable(1),
59
75
  pageSize,
60
76
  manualPagination = false,
77
+ virtualization,
61
78
  totalRows,
62
79
  onPageChange,
63
80
  expanded = $bindable(new Set<string | number>()),
@@ -66,8 +83,14 @@ let {
66
83
  expandedContent,
67
84
  toolbar,
68
85
  footer,
86
+ structuralRows = [],
69
87
  visibleColumnIds,
70
88
  loading = false,
89
+ refreshing = false,
90
+ stale = false,
91
+ partialResults = false,
92
+ error = null,
93
+ onRetry,
71
94
  empty,
72
95
  rowClass,
73
96
  size = 'md',
@@ -84,6 +107,8 @@ let {
84
107
  modes,
85
108
  }: ExtendedProps<T> = $props();
86
109
 
110
+ const dataTableId = $props.id();
111
+
87
112
  function legacyModes(): DataTableModes {
88
113
  return {
89
114
  filtering: modes?.filtering ?? 'local',
@@ -93,21 +118,23 @@ function legacyModes(): DataTableModes {
93
118
  }
94
119
 
95
120
  function legacyViewState(): Partial<DataTableViewState> {
96
- return {
121
+ const next: Partial<DataTableViewState> = {
97
122
  sorting:
98
123
  sort.columnId && sort.direction
99
124
  ? [{ columnId: sort.columnId, direction: sort.direction }]
100
125
  : [],
101
126
  page,
102
127
  pageSize: pageSize ?? null,
103
- columnOrder: columns.map((column) => column.id),
104
- columnVisibility: columns.map((column) => ({
105
- columnId: column.id,
106
- visible: !visibleColumnIds || visibleColumnIds.has(column.id),
107
- })),
108
128
  selectedRowIds: [...selected],
109
129
  expandedRowIds: [...expanded],
110
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;
111
138
  }
112
139
 
113
140
  function mergedLegacyState(): Omit<DataTableViewState, 'selection'> {
@@ -121,8 +148,12 @@ const localController = createDataTableController({
121
148
  });
122
149
 
123
150
  let controllerSnapshot = $state<DataTableSnapshot>(localController.snapshot());
151
+ let measuredColumnWidths = $state<Record<string, number>>({});
124
152
  let publishedLegacySignature: string | undefined;
125
153
  let localControllerInitialized = false;
154
+ let hasHorizontalOverflow = $state(false);
155
+ let canScrollLeft = $state(false);
156
+ let canScrollRight = $state(false);
126
157
 
127
158
  function activeController(): DataTableController {
128
159
  return controller ?? localController;
@@ -187,7 +218,14 @@ $effect(() => {
187
218
 
188
219
  localController.setControlled(controlledState !== undefined);
189
220
  localController.setModes(legacyModes());
190
- localController.setColumnIds(columns.map((column) => column.id));
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);
191
229
 
192
230
  if (!localControllerInitialized) {
193
231
  localControllerInitialized = true;
@@ -196,9 +234,12 @@ $effect(() => {
196
234
  ...initialState,
197
235
  };
198
236
  localController.replaceState(nextState);
237
+ localController.setColumnIds(columnIds, hiddenColumnIds);
199
238
  return;
200
239
  }
201
240
 
241
+ localController.setColumnIds(columnIds, hiddenColumnIds);
242
+
202
243
  if (controlledState !== undefined) {
203
244
  localController.replaceState(controlledState);
204
245
  return;
@@ -214,7 +255,16 @@ $effect(() => {
214
255
 
215
256
  $effect(() => {
216
257
  const current = activeController();
217
- current.setColumnIds(columns.map((column) => column.id));
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
+ );
218
268
  controllerSnapshot = current.snapshot();
219
269
  return current.subscribe((transition) => {
220
270
  controllerSnapshot = transition.next;
@@ -234,13 +284,23 @@ $effect(() => {
234
284
 
235
285
  const tableState = $derived(controllerSnapshot.state);
236
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
+ }));
237
296
  const requiresStableRowIdentity = $derived(
238
297
  selectable ||
239
298
  Boolean(expandedContent) ||
240
299
  agentAddressable ||
241
300
  tableModes.filtering === 'manual' ||
242
301
  tableModes.sorting === 'manual' ||
243
- tableModes.pagination === 'manual',
302
+ tableModes.pagination === 'manual' ||
303
+ Boolean(virtualization),
244
304
  );
245
305
  const sourceRows = $derived.by(() =>
246
306
  resolveDataTableRows(data, rowKey, {
@@ -261,6 +321,25 @@ function handleSort(column: DataTableColumn<T>, event: MouseEvent) {
261
321
  });
262
322
  }
263
323
 
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 });
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
+ }
342
+
264
343
  function handleRowSelect(key: DataTableRowId, event: Event) {
265
344
  event.stopPropagation();
266
345
  dispatch({ type: 'toggleRowSelection', rowId: key });
@@ -305,6 +384,100 @@ function handleRowClick(row: T, index: number) {
305
384
  onRowClick?.(row, index);
306
385
  }
307
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
+
308
481
  // Action to set indeterminate state (can't be set via HTML attribute)
309
482
  function setIndeterminate(node: HTMLInputElement, value: boolean) {
310
483
  node.indeterminate = value;
@@ -315,23 +488,22 @@ function setIndeterminate(node: HTMLInputElement, value: boolean) {
315
488
  };
316
489
  }
317
490
 
318
- const visibleColumns = $derived.by(() => {
319
- const configuredOrder = new Map(
320
- tableState.columnOrder.map((id, index) => [id, index]),
321
- );
322
- const visibility = new Map(
323
- tableState.columnVisibility.map((entry) => [entry.columnId, entry.visible]),
324
- );
325
- return columns
326
- .filter((column) => !column.hidden && visibility.get(column.id) !== false)
327
- .slice()
328
- .sort((left, right) => {
329
- const leftOrder = configuredOrder.get(left.id) ?? Number.MAX_SAFE_INTEGER;
330
- const rightOrder =
331
- configuredOrder.get(right.id) ?? Number.MAX_SAFE_INTEGER;
332
- return leftOrder - rightOrder;
333
- });
334
- });
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'),
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);
335
507
 
336
508
  function sameFilterValue(value: unknown, expected: unknown): boolean {
337
509
  return Object.is(value, expected);
@@ -466,6 +638,169 @@ const displayRows = $derived.by(() => {
466
638
  return sortedRows.slice(start, start + tableState.pageSize);
467
639
  });
468
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,
675
+ );
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
+ });
772
+ });
773
+
774
+ $effect(() => {
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
+
469
804
  $effect(() => {
470
805
  const total = totalRows;
471
806
  if (total === undefined) return;
@@ -502,100 +837,419 @@ const someSelected = $derived(
502
837
  !allSelected,
503
838
  );
504
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
+
505
854
  const columnCount = $derived(
506
855
  visibleColumns.length + (selectable ? 1 : 0) + (expandedContent ? 1 : 0),
507
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
+ );
508
862
 
509
863
  function getCellValue(row: T, column: DataTableColumn<T>): unknown {
510
864
  const accessor = column.accessor ?? column.id;
511
865
  return getNestedValue(row, String(accessor));
512
866
  }
513
867
 
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
+
514
1027
  const sizeClasses = {
515
1028
  sm: 'data-table--sm',
516
1029
  md: 'data-table--md',
517
1030
  lg: 'data-table--lg',
518
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
+ });
519
1054
  </script>
520
1055
 
521
1056
  {#if toolbar}<div class="data-table-toolbar">{@render toolbar()}</div>{/if}
522
- <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
+ >
523
1083
  <table
524
1084
  class="data-table {sizeClasses[size]}"
525
1085
  class:data-table--striped={striped}
1086
+ class:data-table--virtualized={virtualizationWindow.enabled}
526
1087
  class:data-table--hoverable={hoverable}
527
1088
  class:data-table--dense={dense}
528
- 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`}
529
1095
  >
530
1096
  {#if caption}
531
- <caption class="data-table__caption">{caption}</caption>
1097
+ <caption bind:this={tableCaption} class="data-table__caption">{caption}</caption>
532
1098
  {/if}
533
1099
 
534
- <thead class="data-table__head">
535
- <tr class="data-table__row data-table__row--header">
536
- {#if expandedContent}<th class="data-table__cell data-table__cell--expand" scope="col"><span class="sr-only">Expand</span></th>{/if}
537
- {#if selectable}
538
- <th class="data-table__cell data-table__cell--checkbox" scope="col">
539
- <input
540
- type="checkbox"
541
- checked={allSelected}
542
- use:setIndeterminate={someSelected}
543
- onchange={handleSelectAll}
544
- aria-label={t(M['ui.data_table.select_current_page'])}
545
- class="data-table__checkbox"
546
- />
547
- </th>
548
- {/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}
549
1120
 
550
- {#each visibleColumns as column (column.id)}
551
- <th
552
- class="data-table__cell data-table__cell--header"
553
- class:data-table__cell--sortable={sortable && column.sortable}
554
- class:data-table__cell--sorted={currentSort.columnId === column.id}
555
- style:width={column.width}
556
- style:min-width={column.minWidth}
557
- style:max-width={column.maxWidth}
558
- style:text-align={column.align}
559
- scope="col"
560
- aria-sort={currentSort.columnId === column.id
561
- ? currentSort.direction === 'asc'
562
- ? 'ascending'
563
- : 'descending'
564
- : undefined}
565
- >
566
- {#if column.header}
567
- {@render column.header({ column })}
568
- {:else if sortable && column.sortable}
569
- <button
570
- type="button"
571
- class="data-table__sort-button"
572
- onclick={(event) => handleSort(column, event)}
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}
1132
+ >
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}
573
1165
  >
574
- <span>{column.label}</span>
575
- <span class="data-table__sort-icon" aria-hidden="true">
576
- {#if currentSort.columnId === column.id}
577
- {currentSort.direction === 'asc' ? '↑' : '↓'}
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>
578
1185
  {:else}
579
-
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>
580
1201
  {/if}
581
- </span>
582
- </button>
583
- {:else}
584
- {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>
585
1223
  {/if}
586
- </th>
587
- {/each}
588
- </tr>
1224
+ {/each}
1225
+ </tr>
1226
+ {/each}
589
1227
  </thead>
590
1228
 
591
- <tbody class="data-table__body">
592
- {#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}
593
1247
  <tr class="data-table__row data-table__row--loading">
594
1248
  <td
595
1249
  class="data-table__cell data-table__cell--loading"
596
1250
  colspan={columnCount}
597
1251
  >
598
- <div class="data-table__loading-indicator">
1252
+ <div class="data-table__loading-indicator" role="status" aria-live="polite">
599
1253
  <span class="data-table__spinner"></span>
600
1254
  <span><Trans key={M['ui.data_table.loading']} /></span>
601
1255
  </div>
@@ -617,28 +1271,37 @@ const sizeClasses = {
617
1271
  </td>
618
1272
  </tr>
619
1273
  {:else}
620
- {#each displayRows as entry, index (entry.rowId)}
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)}
621
1280
  {@const row = entry.row}
622
1281
  {@const key = entry.rowId}
1282
+ {@const displayIndex = virtualizationWindow.startIndex + index}
623
1283
  {@const isSelected = tableState.selection.scope === 'allMatching' || selectedIds.has(key)}
624
1284
  {@const isExpanded = expandedIds.has(key)}
625
- {@const rowCanExpand = canExpand?.(row, index) ?? true}
1285
+ {@const rowCanExpand = canExpand?.(row, displayIndex) ?? true}
626
1286
  <tr
627
- class="data-table__row {rowClass?.(row, index) ?? ''}"
1287
+ class="data-table__row {rowClass?.(row, displayIndex) ?? ''}"
628
1288
  class:data-table__row--selected={isSelected}
629
- onclick={() => handleRowClick(row, index)}
630
- role={onRowClick ? 'button' : undefined}
631
- tabindex={onRowClick ? 0 : undefined}
632
- onkeydown={(e) => {
633
- if (onRowClick && (e.key === 'Enter' || e.key === ' ')) {
634
- e.preventDefault();
635
- handleRowClick(row, index);
636
- }
637
- }}
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)}
638
1301
  >
639
1302
  {#if expandedContent}
640
1303
  <td class="data-table__cell data-table__cell--expand">
641
- {#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}
642
1305
  </td>
643
1306
  {/if}
644
1307
  {#if selectable}
@@ -648,22 +1311,34 @@ const sizeClasses = {
648
1311
  checked={isSelected}
649
1312
  disabled={tableState.selection.scope === 'allMatching'}
650
1313
  onchange={(e) => handleRowSelect(key, e)}
651
- 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) })}
652
1315
  class="data-table__checkbox"
653
1316
  />
654
1317
  </td>
655
1318
  {/if}
656
1319
 
657
- {#each visibleColumns as column (column.id)}
1320
+ {#each visibleColumns as resolvedColumn (resolvedColumn.column.id)}
1321
+ {@const column = resolvedColumn.column}
658
1322
  {@const value = getCellValue(row, column)}
659
1323
  <td
660
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}
661
1336
  style:text-align={column.align}
662
1337
  >
663
1338
  {#if cell}
664
- {@render cell({ column, row, value, index })}
1339
+ {@render cell({ column, row, value, index: displayIndex })}
665
1340
  {:else if column.cell}
666
- {@render column.cell({ row, value, index })}
1341
+ {@render column.cell({ row, value, index: displayIndex })}
667
1342
  {:else}
668
1343
  {value ?? ''}
669
1344
  {/if}
@@ -671,26 +1346,206 @@ const sizeClasses = {
671
1346
  {/each}
672
1347
  </tr>
673
1348
  {#if expandedContent && isExpanded}
674
- <tr class="data-table__row data-table__row--expanded">
675
- <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>
676
1351
  </tr>
677
1352
  {/if}
678
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}
679
1359
  {/if}
680
1360
  </tbody>
681
- {#if footer}
682
- <tfoot><tr><td class="data-table__cell data-table__footer" colspan={columnCount}>{@render footer({ rows: displayRows.map(({ row }) => row) })}</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>
683
1498
  {/if}
684
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}
685
1516
  </div>
686
- {#if tableState.pageSize && totalPages && totalPages > 1}<Pagination currentPage={tableState.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}
687
1524
 
688
1525
  <style>
689
1526
  .data-table-container {
1527
+ position: relative;
690
1528
  width: 100%;
691
1529
  overflow-x: auto;
692
1530
  }
693
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
+
694
1549
  .data-table-toolbar { margin-bottom: var(--smrt-spacing-2); }
695
1550
 
696
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; }
@@ -700,6 +1555,23 @@ const sizeClasses = {
700
1555
  overflow-y: auto;
701
1556
  }
702
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
+
703
1575
  .data-table {
704
1576
  width: 100%;
705
1577
  border-collapse: collapse;
@@ -734,6 +1606,7 @@ const sizeClasses = {
734
1606
  }
735
1607
 
736
1608
  .data-table__cell--header {
1609
+ position: relative;
737
1610
  padding: var(--smrt-spacing-3, 0.75rem) var(--smrt-spacing-4, 1rem);
738
1611
  font-weight: var(--smrt-typography-weight-semibold, 600);
739
1612
  text-align: left;
@@ -759,6 +1632,45 @@ const sizeClasses = {
759
1632
  cursor: pointer;
760
1633
  }
761
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
+
762
1674
  .data-table__sort-button:hover {
763
1675
  color: var(--smrt-color-primary, #3b82f6);
764
1676
  }
@@ -783,6 +1695,11 @@ const sizeClasses = {
783
1695
  transition: background-color var(--smrt-duration-fast, 150ms) var(--smrt-easing-standard, ease);
784
1696
  }
785
1697
 
1698
+ .data-table__virtual-spacer td {
1699
+ padding: 0;
1700
+ border: 0;
1701
+ }
1702
+
786
1703
  .data-table--hoverable .data-table__row:hover:not(.data-table__row--loading):not(.data-table__row--empty) {
787
1704
  background: var(--smrt-color-surface-container-low, #f9fafb);
788
1705
  }
@@ -791,15 +1708,21 @@ const sizeClasses = {
791
1708
  background: var(--smrt-color-primary-container, #dbeafe) !important;
792
1709
  }
793
1710
 
794
- .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 {
795
1713
  background: var(--smrt-color-surface-container-lowest, #fafafa);
796
1714
  }
797
1715
 
798
- .data-table__row[role='button'] {
1716
+ .data-table__row--interactive {
799
1717
  cursor: pointer;
800
1718
  }
801
1719
 
802
- .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 {
803
1726
  outline: 2px solid var(--smrt-color-primary, #3b82f6);
804
1727
  outline-offset: -2px;
805
1728
  }
@@ -809,6 +1732,24 @@ const sizeClasses = {
809
1732
  vertical-align: middle;
810
1733
  }
811
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
+
812
1753
  .data-table__cell--checkbox {
813
1754
  width: 48px;
814
1755
  text-align: center;
@@ -819,6 +1760,9 @@ const sizeClasses = {
819
1760
  .data-table__expand-button:focus-visible { outline: 2px solid var(--smrt-color-primary); outline-offset: 2px; }
820
1761
  .data-table__cell--expanded { padding: var(--smrt-spacing-4); background: var(--smrt-color-surface-container-low); }
821
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); }
822
1766
 
823
1767
  .data-table__checkbox {
824
1768
  width: 18px;
@@ -829,7 +1773,8 @@ const sizeClasses = {
829
1773
 
830
1774
  /* Loading */
831
1775
  .data-table__cell--loading,
832
- .data-table__cell--empty {
1776
+ .data-table__cell--empty,
1777
+ .data-table__cell--error {
833
1778
  padding: var(--smrt-spacing-8, 2rem);
834
1779
  text-align: center;
835
1780
  }
@@ -861,6 +1806,42 @@ const sizeClasses = {
861
1806
  color: var(--smrt-color-on-surface-variant, #6b7280);
862
1807
  }
863
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
+
864
1845
  /* Size variants */
865
1846
  .data-table--sm .data-table__cell {
866
1847
  padding: var(--smrt-spacing-2, 0.5rem) var(--smrt-spacing-3, 0.75rem);
@@ -890,6 +1871,11 @@ const sizeClasses = {
890
1871
  /* Loading overlay */
891
1872
  .data-table--loading {
892
1873
  opacity: 0.7;
893
- pointer-events: none;
1874
+ }
1875
+
1876
+ @media (prefers-reduced-motion: reduce) {
1877
+ .data-table__spinner {
1878
+ animation: none;
1879
+ }
894
1880
  }
895
1881
  </style>