@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
@@ -20,6 +20,14 @@ const data = [
20
20
  { id: 'ada', name: 'Ada', age: 36 },
21
21
  { id: 'linus', name: 'Linus', age: 54 },
22
22
  ];
23
+ function setHorizontalOverflow(container, clientWidth, scrollWidth) {
24
+ Object.defineProperties(container, {
25
+ clientWidth: { configurable: true, value: clientWidth },
26
+ scrollWidth: { configurable: true, value: scrollWidth },
27
+ scrollLeft: { configurable: true, value: 0, writable: true },
28
+ });
29
+ window.dispatchEvent(new Event('resize'));
30
+ }
23
31
  describe('DataTable', () => {
24
32
  it('renders caption, headers, cells, and a row per datum', () => {
25
33
  render(DataTable, { props: { data, columns, caption: 'People' } });
@@ -33,13 +41,67 @@ describe('DataTable', () => {
33
41
  render(DataTable, { props: { data: [], columns } });
34
42
  expect(screen.getByText('No data available')).toBeInTheDocument();
35
43
  });
36
- it('toggles aria-sort when a sortable header is activated', async () => {
44
+ it('uses action-oriented labels and complete aria-sort transitions for sortable headers', async () => {
37
45
  render(DataTable, { props: { data, columns, sortable: true } });
38
46
  const nameHeader = screen.getByRole('columnheader', { name: 'Name' });
39
47
  // aria-sort is only present on the actively-sorted column.
40
48
  expect(nameHeader).not.toHaveAttribute('aria-sort');
41
- await userEvent.click(screen.getByRole('button', { name: 'Name' }));
49
+ await userEvent.click(screen.getByRole('button', { name: 'Sort Name ascending' }));
42
50
  expect(nameHeader).toHaveAttribute('aria-sort', 'ascending');
51
+ await userEvent.click(screen.getByRole('button', { name: 'Sort Name descending' }));
52
+ expect(nameHeader).toHaveAttribute('aria-sort', 'descending');
53
+ await userEvent.click(screen.getByRole('button', { name: 'Clear sorting for Name' }));
54
+ expect(nameHeader).not.toHaveAttribute('aria-sort');
55
+ });
56
+ it('announces the next action for each rule in a multi-column sort', async () => {
57
+ const multiSortColumns = [columns[0], { ...columns[1], sortable: true }];
58
+ render(DataTable, {
59
+ props: { data, columns: multiSortColumns, sortable: true },
60
+ });
61
+ await userEvent.click(screen.getByRole('button', { name: 'Sort Name ascending' }));
62
+ await userEvent.click(screen.getByRole('button', { name: 'Sort Age ascending' }), { shiftKey: true });
63
+ expect(screen.getByRole('button', { name: 'Sort Age descending' })).toBeInTheDocument();
64
+ });
65
+ it('keeps custom sortable headers interactive unless they explicitly opt out', async () => {
66
+ const customHeader = createRawSnippet(() => ({
67
+ render: () => '<span>Custom name</span>',
68
+ }));
69
+ const customColumns = [{ ...columns[0], header: customHeader }, columns[1]];
70
+ render(DataTable, {
71
+ props: { data, columns: customColumns, sortable: true },
72
+ });
73
+ await userEvent.click(screen.getByRole('button', { name: 'Sort Name ascending' }));
74
+ expect(screen.getByText('Custom name')).toBeInTheDocument();
75
+ expect(screen.getByRole('columnheader', { name: 'Custom name' })).toHaveAttribute('aria-sort', 'ascending');
76
+ const { container } = render(DataTable, {
77
+ props: {
78
+ data,
79
+ columns: [
80
+ { ...columns[0], header: customHeader, headerSortMode: 'manual' },
81
+ columns[1],
82
+ ],
83
+ sortable: true,
84
+ },
85
+ });
86
+ expect(within(container).queryByRole('button', { name: 'Sort Name ascending' })).not.toBeInTheDocument();
87
+ });
88
+ it('keeps automatic sort controls separate from interactive custom headers', async () => {
89
+ const customHeader = createRawSnippet(() => ({
90
+ render: () => '<button type="button">Column actions</button>',
91
+ }));
92
+ const customColumns = [{ ...columns[0], header: customHeader }, columns[1]];
93
+ render(DataTable, {
94
+ props: { data, columns: customColumns, sortable: true },
95
+ });
96
+ const customAction = screen.getByRole('button', { name: 'Column actions' });
97
+ const sortAction = screen.getByRole('button', {
98
+ name: 'Sort Name ascending',
99
+ });
100
+ expect(sortAction).not.toContainElement(customAction);
101
+ await userEvent.click(customAction);
102
+ expect(screen.getAllByRole('columnheader')[0]).not.toHaveAttribute('aria-sort');
103
+ await userEvent.click(sortAction);
104
+ expect(screen.getAllByRole('columnheader')[0]).toHaveAttribute('aria-sort', 'ascending');
43
105
  });
44
106
  it('routes a human sort click through the same controller transition as a command', async () => {
45
107
  const controller = createDataTableController({
@@ -47,7 +109,7 @@ describe('DataTable', () => {
47
109
  });
48
110
  const before = controller.getState();
49
111
  render(DataTable, { props: { data, columns, sortable: true, controller } });
50
- await userEvent.click(screen.getByRole('button', { name: 'Name' }));
112
+ await userEvent.click(screen.getByRole('button', { name: 'Sort Name ascending' }));
51
113
  expect(controller.getState()).toEqual(transitionDataTableState(before, {
52
114
  type: 'toggleSorting',
53
115
  columnId: 'name',
@@ -66,7 +128,7 @@ describe('DataTable', () => {
66
128
  });
67
129
  render(DataTable, { props: { data, columns, sortable: true, controller } });
68
130
  const nameHeader = screen.getByRole('columnheader', { name: 'Name' });
69
- await userEvent.click(screen.getByRole('button', { name: 'Name' }));
131
+ await userEvent.click(screen.getByRole('button', { name: 'Sort Name ascending' }));
70
132
  expect(nameHeader).not.toHaveAttribute('aria-sort');
71
133
  controller.replaceState(onStateChange.mock.calls[0][0]);
72
134
  await vi.waitFor(() => expect(nameHeader).toHaveAttribute('aria-sort', 'ascending'));
@@ -244,8 +306,8 @@ describe('DataTable', () => {
244
306
  .find((cell) => ['Ada', 'Linus'].includes(cell.textContent ?? ''))
245
307
  ?.textContent)
246
308
  .filter((name) => name !== undefined);
247
- await userEvent.click(screen.getAllByRole('checkbox', { name: 'Select row' })[0]);
248
- await userEvent.click(screen.getAllByRole('button', { name: 'Expand row' })[0]);
309
+ await userEvent.click(screen.getByRole('checkbox', { name: 'Select row 1' }));
310
+ await userEvent.click(screen.getByRole('button', { name: 'Expand row 1' }));
249
311
  expectAdaSelectionAndExpansion();
250
312
  controller.dispatch({
251
313
  type: 'setSorting',
@@ -289,7 +351,7 @@ describe('DataTable', () => {
289
351
  props: { data, columns, modes: { pagination: 'manual' } },
290
352
  })).toThrow(/rowKey is required/);
291
353
  });
292
- it('reveals expandable row content', async () => {
354
+ it('links localized expansion controls to their row detail', async () => {
293
355
  render(DataTable, {
294
356
  props: {
295
357
  data,
@@ -300,8 +362,93 @@ describe('DataTable', () => {
300
362
  })),
301
363
  },
302
364
  });
303
- await userEvent.click(screen.getAllByRole('button', { name: 'Expand row' })[0]);
365
+ const expandButton = screen.getByRole('button', { name: 'Expand row 1' });
366
+ await userEvent.click(expandButton);
304
367
  expect(screen.getByText('Row detail')).toBeInTheDocument();
368
+ expect(expandButton).toHaveAttribute('aria-expanded', 'true');
369
+ const expansionId = expandButton.getAttribute('aria-controls');
370
+ expect(expansionId).toBeTruthy();
371
+ expect(document.getElementById(expansionId ?? '')).toHaveTextContent('Row detail');
372
+ expect(screen.getByRole('button', { name: 'Collapse row 1' })).toBe(expandButton);
373
+ });
374
+ it('keeps clickable rows semantic and ignores nested row actions', async () => {
375
+ const onRowClick = vi.fn();
376
+ const actionCell = createRawSnippet(() => ({
377
+ render: () => '<button type="button">Archive row</button>',
378
+ }));
379
+ const expandedContent = createRawSnippet(() => ({
380
+ render: () => '<p>Row detail</p>',
381
+ }));
382
+ render(DataTable, {
383
+ props: {
384
+ data,
385
+ columns: [
386
+ ...columns,
387
+ { id: 'actions', label: 'Actions', cell: actionCell },
388
+ ],
389
+ rowKey: 'id',
390
+ selectable: true,
391
+ expandedContent,
392
+ onRowClick,
393
+ rowLabel: (person) => person.name,
394
+ },
395
+ });
396
+ const adaRow = screen.getByRole('cell', { name: 'Ada' }).closest('tr');
397
+ expect(adaRow).not.toHaveAttribute('role');
398
+ expect(adaRow).toHaveAttribute('tabindex', '0');
399
+ await userEvent.click(screen.getByRole('checkbox', { name: 'Select Ada' }));
400
+ await userEvent.click(screen.getByRole('button', { name: 'Expand Ada' }));
401
+ await userEvent.click(screen.getAllByRole('button', { name: 'Archive row' })[0]);
402
+ expect(onRowClick).not.toHaveBeenCalled();
403
+ await userEvent.click(screen.getByRole('cell', { name: 'Ada' }));
404
+ expect(onRowClick).toHaveBeenLastCalledWith(data[0], 0);
405
+ adaRow?.focus();
406
+ await userEvent.keyboard('{Enter}');
407
+ expect(onRowClick).toHaveBeenLastCalledWith(data[0], 0);
408
+ });
409
+ it('announces async states without replacing usable stale rows', async () => {
410
+ const onRetry = vi.fn();
411
+ const props = {
412
+ data,
413
+ columns,
414
+ rowKey: 'id',
415
+ selectable: true,
416
+ loading: true,
417
+ stale: true,
418
+ partialResults: true,
419
+ onRetry,
420
+ };
421
+ const { rerender } = render(DataTable, { props });
422
+ expect(screen.getByRole('table')).toHaveAttribute('aria-busy', 'true');
423
+ expect(screen.getByRole('cell', { name: 'Ada' })).toBeInTheDocument();
424
+ expect(screen.getByRole('status')).toHaveTextContent('Refreshing table data');
425
+ expect(screen.getByRole('status')).toHaveTextContent('Showing stale results');
426
+ expect(screen.getByRole('status')).toHaveTextContent('Showing partial results');
427
+ await userEvent.click(screen.getByRole('checkbox', { name: 'Select row 1' }));
428
+ await rerender({ ...props, error: 'Request failed' });
429
+ expect(screen.getByRole('cell', { name: 'Ada' })).toBeInTheDocument();
430
+ expect(screen.getByRole('table')).toHaveAttribute('aria-busy', 'false');
431
+ expect(screen.getByRole('alert')).toHaveTextContent('Request failed');
432
+ await userEvent.click(screen.getByRole('button', { name: 'Retry' }));
433
+ expect(onRetry).toHaveBeenCalledTimes(1);
434
+ await rerender({
435
+ ...props,
436
+ data: [],
437
+ stale: false,
438
+ partialResults: false,
439
+ error: null,
440
+ });
441
+ expect(screen.getByRole('status')).toHaveTextContent('Loading table data');
442
+ expect(screen.queryByText('No data available')).not.toBeInTheDocument();
443
+ await rerender({
444
+ ...props,
445
+ data: [],
446
+ loading: false,
447
+ error: 'Request failed',
448
+ });
449
+ expect(screen.getByRole('alert')).toHaveTextContent('Request failed');
450
+ await rerender({ ...props, data: [], loading: false, error: '' });
451
+ expect(screen.getByRole('alert')).toHaveTextContent('Unable to load table data');
305
452
  });
306
453
  it('rejects duplicate stable row keys and local totals that imply server paging', () => {
307
454
  expect(() => render(DataTable, {
@@ -337,6 +484,244 @@ describe('DataTable', () => {
337
484
  .map((row) => within(row).getAllByRole('cell')[0].textContent);
338
485
  expect(names).toEqual(['Ada', 'Zed']);
339
486
  });
487
+ it('makes only an overflowing narrow table a named, focusable scroll region', async () => {
488
+ const { container } = render(DataTable, {
489
+ props: { data, columns, caption: 'People' },
490
+ });
491
+ const tableContainer = container.querySelector('.data-table-container');
492
+ expect(tableContainer).not.toHaveAttribute('role');
493
+ expect(tableContainer).not.toHaveAttribute('tabindex');
494
+ setHorizontalOverflow(tableContainer, 320, 960);
495
+ await vi.waitFor(() => {
496
+ expect(screen.getByRole('region', {
497
+ name: 'People table, scroll horizontally to view more columns',
498
+ })).toBe(tableContainer);
499
+ expect(tableContainer).toHaveAttribute('tabindex', '0');
500
+ expect(screen.getByText('More columns →')).toBeVisible();
501
+ expect(tableContainer).not.toContainElement(screen.getByText('More columns →'));
502
+ });
503
+ await expectNoA11yViolations(container);
504
+ setHorizontalOverflow(tableContainer, 320, 320);
505
+ await vi.waitFor(() => {
506
+ expect(tableContainer).not.toHaveAttribute('role');
507
+ expect(tableContainer).not.toHaveAttribute('tabindex');
508
+ expect(screen.queryByText('More columns →')).not.toBeInTheDocument();
509
+ });
510
+ });
511
+ it('scrolls an overflowing narrow table with the keyboard', async () => {
512
+ const user = userEvent.setup();
513
+ const { container } = render(DataTable, {
514
+ props: { data, columns, caption: 'People' },
515
+ });
516
+ const tableContainer = container.querySelector('.data-table-container');
517
+ setHorizontalOverflow(tableContainer, 320, 960);
518
+ await vi.waitFor(() => expect(tableContainer).toHaveAttribute('role', 'region'));
519
+ tableContainer.focus();
520
+ await user.keyboard('{ArrowRight}');
521
+ expect(tableContainer.scrollLeft).toBe(256);
522
+ expect(screen.getByText('← More columns')).toBeVisible();
523
+ await user.keyboard('{End}');
524
+ expect(tableContainer.scrollLeft).toBe(640);
525
+ await user.keyboard('{Home}');
526
+ expect(tableContainer.scrollLeft).toBe(0);
527
+ });
528
+ it('renders grouped headers from final visible leaf columns without losing leaf sort controls', async () => {
529
+ const groupedColumns = [
530
+ {
531
+ ...columns[0],
532
+ headerPath: [{ id: 'identity', label: 'Identity' }],
533
+ },
534
+ {
535
+ ...columns[1],
536
+ sortable: true,
537
+ headerPath: [{ id: 'measures', label: 'Measures' }],
538
+ },
539
+ ];
540
+ render(DataTable, {
541
+ props: { data, columns: groupedColumns, sortable: true },
542
+ });
543
+ const identity = screen.getByRole('columnheader', { name: 'Identity' });
544
+ expect(identity).toHaveAttribute('scope', 'colgroup');
545
+ expect(identity).toHaveAttribute('colspan', '1');
546
+ expect(screen.getAllByRole('row')).toHaveLength(data.length + 2);
547
+ await userEvent.click(screen.getByRole('button', { name: 'Sort Name ascending' }));
548
+ expect(screen.getByRole('columnheader', { name: 'Name' })).toHaveAttribute('aria-sort', 'ascending');
549
+ });
550
+ it('keeps a group header aligned when all of its leaves are pinned', () => {
551
+ const controller = createDataTableController({
552
+ columnIds: columns.map((column) => column.id),
553
+ initialState: {
554
+ columnPinning: [
555
+ { columnId: 'name', position: 'start' },
556
+ { columnId: 'age', position: 'start' },
557
+ ],
558
+ },
559
+ });
560
+ render(DataTable, {
561
+ props: {
562
+ data,
563
+ controller,
564
+ columns: columns.map((column) => ({
565
+ ...column,
566
+ headerPath: [{ id: 'detail', label: 'Detail' }],
567
+ })),
568
+ },
569
+ });
570
+ const group = screen.getByRole('columnheader', { name: 'Detail' });
571
+ expect(group).toHaveClass('data-table__cell--pinned-start');
572
+ expect(group).toHaveStyle({ left: '0px' });
573
+ });
574
+ it('keeps report structural rows semantic and outside data-row selection', () => {
575
+ const { container } = render(DataTable, {
576
+ props: {
577
+ data,
578
+ columns,
579
+ rowKey: 'id',
580
+ selectable: true,
581
+ structuralRows: [
582
+ {
583
+ id: 'subtotal',
584
+ kind: 'subtotal',
585
+ label: 'Current page total',
586
+ values: { age: 90 },
587
+ },
588
+ {
589
+ id: 'footer',
590
+ kind: 'footer',
591
+ label: 'All people',
592
+ values: { age: 90 },
593
+ },
594
+ ],
595
+ },
596
+ });
597
+ expect(screen.getByRole('rowheader', { name: /Subtotal: Current page total/ })).toBeInTheDocument();
598
+ expect(screen.getByRole('rowheader', { name: /Footer: All people/ })).toBeInTheDocument();
599
+ expect(screen.getAllByRole('checkbox')).toHaveLength(data.length + 1);
600
+ expect(container.querySelectorAll('tbody.data-table__body--structural input')).toHaveLength(0);
601
+ expect(container.querySelector('tfoot')).toContainElement(screen.getByRole('rowheader', { name: /Footer: All people/ }));
602
+ });
603
+ it('uses controlled width and pin state for the accessible header resize control', async () => {
604
+ const controller = createDataTableController({
605
+ columnIds: columns.map((column) => column.id),
606
+ initialState: {
607
+ columnWidths: [{ columnId: 'name', width: 120 }],
608
+ columnPinning: [{ columnId: 'name', position: 'start' }],
609
+ },
610
+ });
611
+ const { container } = render(DataTable, {
612
+ props: {
613
+ data,
614
+ columns: [{ ...columns[0], resizable: true }, columns[1]],
615
+ controller,
616
+ },
617
+ });
618
+ const nameHeader = screen.getByRole('columnheader', { name: 'Name' });
619
+ expect(nameHeader).toHaveClass('data-table__cell--pinned-start');
620
+ expect(nameHeader).toHaveStyle({ width: '120px', left: '0px' });
621
+ expect(nameHeader).toHaveAttribute('data-column-id', 'name');
622
+ const separator = screen.getByRole('separator', { name: 'Resize Name' });
623
+ separator.focus();
624
+ await userEvent.keyboard('{ArrowRight}');
625
+ expect(controller.getState().columnWidths).toContainEqual({
626
+ columnId: 'name',
627
+ width: 128,
628
+ });
629
+ expect(container.querySelector('[data-column-id="name"]')).toHaveAttribute('data-column-id', 'name');
630
+ });
631
+ it('uses rem constraints and rendered header widths for resize announcements', async () => {
632
+ const getBoundingClientRect = vi
633
+ .spyOn(HTMLElement.prototype, 'getBoundingClientRect')
634
+ .mockReturnValue({ width: 208 });
635
+ const originalRootFontSize = document.documentElement.style.fontSize;
636
+ try {
637
+ document.documentElement.style.fontSize = '16px';
638
+ const controller = createDataTableController({
639
+ columnIds: columns.map((column) => column.id),
640
+ });
641
+ render(DataTable, {
642
+ props: {
643
+ data,
644
+ controller,
645
+ columns: [
646
+ { ...columns[0], minWidth: '13rem', resizable: true },
647
+ columns[1],
648
+ ],
649
+ },
650
+ });
651
+ const separator = screen.getByRole('separator', { name: 'Resize Name' });
652
+ await vi.waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '208'));
653
+ separator.focus();
654
+ await userEvent.keyboard('{Home}');
655
+ expect(controller.getState().columnWidths).toContainEqual({
656
+ columnId: 'name',
657
+ width: 208,
658
+ });
659
+ }
660
+ finally {
661
+ getBoundingClientRect.mockRestore();
662
+ document.documentElement.style.fontSize = originalRootFontSize;
663
+ }
664
+ });
665
+ it('clamps restored widths to the declared header constraints', () => {
666
+ const controller = createDataTableController({
667
+ columnIds: columns.map((column) => column.id),
668
+ initialState: {
669
+ columnWidths: [{ columnId: 'name', width: 200 }],
670
+ },
671
+ });
672
+ render(DataTable, {
673
+ props: {
674
+ data,
675
+ controller,
676
+ columns: [
677
+ { ...columns[0], maxWidth: '100px', resizable: true },
678
+ columns[1],
679
+ ],
680
+ },
681
+ });
682
+ expect(screen.getByRole('columnheader', { name: 'Name' })).toHaveStyle({
683
+ width: '100px',
684
+ });
685
+ expect(screen.getByRole('separator', { name: 'Resize Name' })).toHaveAttribute('aria-valuenow', '100');
686
+ });
687
+ it('keeps custom resizable header content as the header name', () => {
688
+ const customHeader = createRawSnippet(() => ({
689
+ render: () => '<span>Account total in USD</span>',
690
+ }));
691
+ render(DataTable, {
692
+ props: {
693
+ data,
694
+ columns: [
695
+ { ...columns[0], header: customHeader, resizable: true },
696
+ columns[1],
697
+ ],
698
+ },
699
+ });
700
+ expect(screen.getByRole('columnheader', { name: /Account total in USD/ })).toBeInTheDocument();
701
+ });
702
+ it('keeps statically hidden columns hidden when a controller restores them visible', async () => {
703
+ const controller = createDataTableController({
704
+ columnIds: columns.map((column) => column.id),
705
+ initialState: {
706
+ columnVisibility: [
707
+ { columnId: 'name', visible: true },
708
+ { columnId: 'age', visible: true },
709
+ ],
710
+ },
711
+ });
712
+ render(DataTable, {
713
+ props: {
714
+ data,
715
+ columns: [columns[0], { ...columns[1], hidden: true }],
716
+ controller,
717
+ },
718
+ });
719
+ await vi.waitFor(() => expect(controller.getState().columnVisibility).toContainEqual({
720
+ columnId: 'age',
721
+ visible: false,
722
+ }));
723
+ expect(screen.queryByRole('columnheader', { name: 'Age' })).not.toBeInTheDocument();
724
+ });
340
725
  it('is axe-clean', async () => {
341
726
  const { container } = render(DataTable, {
342
727
  props: { data, columns, caption: 'People' },
@@ -0,0 +1,212 @@
1
+ import { fireEvent, render, screen } from '@testing-library/svelte';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { expectNoA11yViolations } from '../../../test-support/a11y';
5
+ import { createDataTableConformanceRows, dataTableConformanceColumns, dataTableConformanceRows, dataTableConformanceScenarios, dataTableConformanceStructuralRows, } from '../__fixtures__/DataTableConformanceFixture.js';
6
+ import DataTable from '../DataTable.svelte';
7
+ import { createDataTableController, } from '../DataTableController.js';
8
+ function isManualQueryCommand(command) {
9
+ return (command?.type === 'setSearch' ||
10
+ command?.type === 'setFilters' ||
11
+ command?.type === 'setSorting' ||
12
+ command?.type === 'toggleSorting' ||
13
+ command?.type === 'setPage' ||
14
+ command?.type === 'setPageSize');
15
+ }
16
+ /**
17
+ * A deterministic consumer-side model for the guide's manual-query contract.
18
+ * The table owns the query state; the consumer owns matching results and never
19
+ * lets a late response replace the current query's rows.
20
+ */
21
+ function createManualQueryHost(initialRows) {
22
+ let revision = 0;
23
+ let current = {
24
+ queryFingerprint: '',
25
+ queryRevision: 'revision-0',
26
+ };
27
+ let rows = initialRows;
28
+ return {
29
+ begin(state) {
30
+ revision += 1;
31
+ current = {
32
+ queryFingerprint: JSON.stringify({
33
+ search: state.search,
34
+ filters: state.filters,
35
+ sorting: state.sorting,
36
+ page: state.page,
37
+ pageSize: state.pageSize,
38
+ }),
39
+ queryRevision: `revision-${revision}`,
40
+ };
41
+ return { ...current };
42
+ },
43
+ resolve(response) {
44
+ if (response.queryFingerprint !== current.queryFingerprint ||
45
+ response.queryRevision !== current.queryRevision) {
46
+ return false;
47
+ }
48
+ rows = response.rows;
49
+ return true;
50
+ },
51
+ rows: () => rows,
52
+ };
53
+ }
54
+ function setHorizontalOverflow(container, clientWidth, scrollWidth) {
55
+ Object.defineProperties(container, {
56
+ clientWidth: { configurable: true, value: clientWidth },
57
+ scrollWidth: { configurable: true, value: scrollWidth },
58
+ scrollLeft: { configurable: true, value: 0, writable: true },
59
+ });
60
+ window.dispatchEvent(new Event('resize'));
61
+ }
62
+ describe('DataTable release conformance', () => {
63
+ it('keeps every release scenario available to both tests and the playground', () => {
64
+ expect(dataTableConformanceScenarios.map((scenario) => scenario.id)).toEqual([
65
+ 'semantic-interaction',
66
+ 'manual-query',
67
+ 'async-lifecycle',
68
+ 'responsive-overflow',
69
+ 'report-layout',
70
+ 'scale-virtualization',
71
+ ]);
72
+ expect(dataTableConformanceScenarios.every((scenario) => scenario.contracts.length > 0)).toBe(true);
73
+ });
74
+ it('renders an axe-clean report with grouped headers and structural summaries', async () => {
75
+ const controller = createDataTableController({
76
+ columnIds: dataTableConformanceColumns.map((column) => column.id),
77
+ initialState: {
78
+ columnWidths: [{ columnId: 'account', width: 240 }],
79
+ columnPinning: [{ columnId: 'account', position: 'start' }],
80
+ },
81
+ });
82
+ const { container } = render(DataTable, {
83
+ props: {
84
+ data: dataTableConformanceRows,
85
+ columns: dataTableConformanceColumns,
86
+ rowKey: 'id',
87
+ controller,
88
+ structuralRows: dataTableConformanceStructuralRows,
89
+ caption: 'Conformance forecast report',
90
+ },
91
+ });
92
+ expect(screen.getByRole('columnheader', { name: 'Dimensions' })).toHaveAttribute('scope', 'colgroup');
93
+ expect(screen.getByRole('rowheader', { name: /Current forecast/ })).toBeInTheDocument();
94
+ expect(screen.getByRole('rowheader', { name: /All accounts/ })).toBeInTheDocument();
95
+ await expectNoA11yViolations(container);
96
+ });
97
+ it('keeps a manual query usable through stale, failed, and retry states', async () => {
98
+ const onRetry = vi.fn();
99
+ const controller = createDataTableController({
100
+ columnIds: dataTableConformanceColumns.map((column) => column.id),
101
+ modes: { filtering: 'manual', sorting: 'manual', pagination: 'manual' },
102
+ initialState: { page: 2, pageSize: 25 },
103
+ });
104
+ const props = {
105
+ data: dataTableConformanceRows,
106
+ columns: dataTableConformanceColumns,
107
+ rowKey: 'id',
108
+ controller,
109
+ totalRows: 120,
110
+ loading: true,
111
+ stale: true,
112
+ partialResults: true,
113
+ onRetry,
114
+ caption: 'Manual query results',
115
+ };
116
+ const { rerender } = render(DataTable, { props });
117
+ expect(screen.getByRole('cell', { name: 'Subscription revenue' })).toBeInTheDocument();
118
+ expect(screen.getByRole('status')).toHaveTextContent('Showing stale results');
119
+ expect(screen.getByRole('table')).toHaveAttribute('aria-busy', 'true');
120
+ await rerender({ ...props, loading: false, error: 'Request failed' });
121
+ expect(screen.getByRole('alert')).toHaveTextContent('Request failed');
122
+ await userEvent.click(screen.getByRole('button', { name: 'Retry' }));
123
+ expect(onRetry).toHaveBeenCalledTimes(1);
124
+ });
125
+ it('commits only the current manually owned search, sort, filter, and page response', async () => {
126
+ const controller = createDataTableController({
127
+ columnIds: dataTableConformanceColumns.map((column) => column.id),
128
+ modes: { filtering: 'manual', sorting: 'manual', pagination: 'manual' },
129
+ initialState: { page: 1, pageSize: 1 },
130
+ });
131
+ const host = createManualQueryHost(dataTableConformanceRows);
132
+ const requests = [];
133
+ const unsubscribe = controller.subscribe((transition) => {
134
+ if (isManualQueryCommand(transition.command)) {
135
+ requests.push(host.begin(transition.next.state));
136
+ }
137
+ });
138
+ render(DataTable, {
139
+ props: {
140
+ data: dataTableConformanceRows,
141
+ columns: dataTableConformanceColumns,
142
+ rowKey: 'id',
143
+ controller,
144
+ sortable: true,
145
+ totalRows: 2,
146
+ caption: 'Manual host results',
147
+ },
148
+ });
149
+ await userEvent.click(screen.getByRole('button', { name: 'Sort Account ascending' }));
150
+ controller.dispatch({ type: 'setSearch', search: 'Growth' });
151
+ controller.dispatch({
152
+ type: 'setSorting',
153
+ sorting: [{ columnId: 'account', direction: 'desc' }],
154
+ });
155
+ controller.dispatch({
156
+ type: 'setFilters',
157
+ filters: [{ columnId: 'status', operator: 'equals', value: 'On track' }],
158
+ });
159
+ await userEvent.click(screen.getByRole('button', { name: 'Next page' }));
160
+ expect(requests).toHaveLength(5);
161
+ expect(JSON.parse(requests[1].queryFingerprint)).toMatchObject({
162
+ search: 'Growth',
163
+ });
164
+ expect(requests[0]).not.toEqual(requests[4]);
165
+ expect(host.resolve({
166
+ ...requests[0],
167
+ rows: [dataTableConformanceRows[1]],
168
+ })).toBe(false);
169
+ expect(host.rows()).toEqual(dataTableConformanceRows);
170
+ expect(host.resolve({
171
+ ...requests[4],
172
+ rows: [dataTableConformanceRows[0]],
173
+ })).toBe(true);
174
+ expect(host.rows()).toEqual([dataTableConformanceRows[0]]);
175
+ unsubscribe();
176
+ });
177
+ it('exposes only real horizontal overflow as a named keyboard-scroll region', async () => {
178
+ const { container } = render(DataTable, {
179
+ props: {
180
+ data: dataTableConformanceRows,
181
+ columns: dataTableConformanceColumns,
182
+ rowKey: 'id',
183
+ caption: 'Responsive conformance report',
184
+ },
185
+ });
186
+ const tableContainer = container.querySelector('.data-table-container');
187
+ setHorizontalOverflow(tableContainer, 320, 960);
188
+ await vi.waitFor(() => expect(tableContainer).toHaveAttribute('aria-label', 'Responsive conformance report table, scroll horizontally to view more columns'));
189
+ tableContainer.focus();
190
+ await fireEvent.keyDown(tableContainer, { key: 'End' });
191
+ expect(tableContainer.scrollLeft).toBe(640);
192
+ await expectNoA11yViolations(container);
193
+ });
194
+ it('keeps virtual rows keyed independently from their rendered window', async () => {
195
+ const rows = createDataTableConformanceRows(120);
196
+ const { container } = render(DataTable, {
197
+ props: {
198
+ data: rows,
199
+ columns: dataTableConformanceColumns.slice(0, 2),
200
+ rowKey: 'id',
201
+ virtualization: { rowHeight: 24, viewportHeight: 96, overscan: 1 },
202
+ caption: 'Virtual conformance rows',
203
+ },
204
+ });
205
+ const tableContainer = container.querySelector('.data-table-container');
206
+ expect(screen.getByRole('table')).toHaveAttribute('aria-rowcount', '122');
207
+ expect(screen.getByText('Subscription revenue 1')).toBeInTheDocument();
208
+ tableContainer.scrollTop = 240;
209
+ await fireEvent.scroll(tableContainer);
210
+ await vi.waitFor(() => expect(screen.getByText('Subscription revenue 11')).toBeInTheDocument());
211
+ });
212
+ });