@happyvertical/smrt-ui 0.42.4 → 0.42.6
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.
- package/README.md +157 -3
- package/dist/components/data/DataTable.svelte +1097 -111
- package/dist/components/data/DataTable.svelte.d.ts +1 -1
- package/dist/components/data/DataTable.svelte.d.ts.map +1 -1
- package/dist/components/data/DataTableController.d.ts +45 -5
- package/dist/components/data/DataTableController.d.ts.map +1 -1
- package/dist/components/data/DataTableController.js +153 -11
- package/dist/components/data/DataTableLayout.d.ts +37 -0
- package/dist/components/data/DataTableLayout.d.ts.map +1 -0
- package/dist/components/data/DataTableLayout.js +135 -0
- package/dist/components/data/DataTablePerformance.d.ts +31 -0
- package/dist/components/data/DataTablePerformance.d.ts.map +1 -0
- package/dist/components/data/DataTablePerformance.js +46 -0
- package/dist/components/data/DataTableVirtualization.d.ts +71 -0
- package/dist/components/data/DataTableVirtualization.d.ts.map +1 -0
- package/dist/components/data/DataTableVirtualization.js +100 -0
- package/dist/components/data/__benchmarks__/DataTable.bench.d.ts +2 -0
- package/dist/components/data/__benchmarks__/DataTable.bench.d.ts.map +1 -0
- package/dist/components/data/__benchmarks__/DataTable.bench.js +53 -0
- package/dist/components/data/__fixtures__/DataTableConformanceFixture.d.ts +27 -0
- package/dist/components/data/__fixtures__/DataTableConformanceFixture.d.ts.map +1 -0
- package/dist/components/data/__fixtures__/DataTableConformanceFixture.js +141 -0
- package/dist/components/data/__fixtures__/DataTablePerformanceFixture.d.ts +10 -0
- package/dist/components/data/__fixtures__/DataTablePerformanceFixture.d.ts.map +1 -0
- package/dist/components/data/__fixtures__/DataTablePerformanceFixture.js +23 -0
- package/dist/components/data/__tests__/DataTable.test.js +393 -8
- package/dist/components/data/__tests__/DataTableConformance.test.js +212 -0
- package/dist/components/data/__tests__/DataTableController.test.js +124 -2
- package/dist/components/data/__tests__/DataTableLayout.test.js +195 -0
- package/dist/components/data/__tests__/DataTablePerformance.test.js +21 -0
- package/dist/components/data/__tests__/DataTableVirtualization.test.js +80 -0
- package/dist/components/data/__tests__/DataTableVirtualizationComponent.test.js +255 -0
- package/dist/components/data/__tests__/data-surface.test.js +675 -0
- package/dist/components/data/data-surface.d.ts +246 -0
- package/dist/components/data/data-surface.d.ts.map +1 -0
- package/dist/components/data/data-surface.js +1102 -0
- package/dist/components/data/index.d.ts +3 -0
- package/dist/components/data/index.d.ts.map +1 -1
- package/dist/components/data/index.js +3 -0
- package/dist/components/data/types.d.ts +83 -1
- package/dist/components/data/types.d.ts.map +1 -1
- package/dist/i18n/strings.d.ts +25 -0
- package/dist/i18n/strings.d.ts.map +1 -1
- package/dist/i18n/strings.js +27 -2
- package/dist/svelte/playground/DataTablePreview.svelte +374 -5
- package/dist/svelte/playground/DataTablePreview.svelte.d.ts.map +1 -1
- package/dist/svelte/playground.js +2 -2
- 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('
|
|
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.
|
|
248
|
-
await userEvent.click(screen.
|
|
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('
|
|
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
|
-
|
|
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
|
+
});
|