@happyvertical/smrt-ui 0.42.3 → 0.42.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +275 -0
  2. package/dist/components/data/DataTable.svelte +1444 -172
  3. package/dist/components/data/DataTable.svelte.d.ts +1 -1
  4. package/dist/components/data/DataTable.svelte.d.ts.map +1 -1
  5. package/dist/components/data/DataTableController.d.ts +235 -0
  6. package/dist/components/data/DataTableController.d.ts.map +1 -0
  7. package/dist/components/data/DataTableController.js +792 -0
  8. package/dist/components/data/DataTableIdentity.d.ts +21 -0
  9. package/dist/components/data/DataTableIdentity.d.ts.map +1 -0
  10. package/dist/components/data/DataTableIdentity.js +28 -0
  11. package/dist/components/data/DataTableLayout.d.ts +37 -0
  12. package/dist/components/data/DataTableLayout.d.ts.map +1 -0
  13. package/dist/components/data/DataTableLayout.js +135 -0
  14. package/dist/components/data/DataTablePerformance.d.ts +31 -0
  15. package/dist/components/data/DataTablePerformance.d.ts.map +1 -0
  16. package/dist/components/data/DataTablePerformance.js +46 -0
  17. package/dist/components/data/DataTableVirtualization.d.ts +71 -0
  18. package/dist/components/data/DataTableVirtualization.d.ts.map +1 -0
  19. package/dist/components/data/DataTableVirtualization.js +100 -0
  20. package/dist/components/data/__benchmarks__/DataTable.bench.d.ts +2 -0
  21. package/dist/components/data/__benchmarks__/DataTable.bench.d.ts.map +1 -0
  22. package/dist/components/data/__benchmarks__/DataTable.bench.js +53 -0
  23. package/dist/components/data/__fixtures__/DataTableConformanceFixture.d.ts +27 -0
  24. package/dist/components/data/__fixtures__/DataTableConformanceFixture.d.ts.map +1 -0
  25. package/dist/components/data/__fixtures__/DataTableConformanceFixture.js +141 -0
  26. package/dist/components/data/__fixtures__/DataTablePerformanceFixture.d.ts +10 -0
  27. package/dist/components/data/__fixtures__/DataTablePerformanceFixture.d.ts.map +1 -0
  28. package/dist/components/data/__fixtures__/DataTablePerformanceFixture.js +23 -0
  29. package/dist/components/data/__tests__/DataTable.test.js +644 -21
  30. package/dist/components/data/__tests__/DataTableConformance.test.js +212 -0
  31. package/dist/components/data/__tests__/DataTableController.test.js +336 -0
  32. package/dist/components/data/__tests__/DataTableIdentity.test.js +29 -0
  33. package/dist/components/data/__tests__/DataTableLayout.test.js +195 -0
  34. package/dist/components/data/__tests__/DataTablePerformance.test.js +21 -0
  35. package/dist/components/data/__tests__/DataTableVirtualization.test.js +80 -0
  36. package/dist/components/data/__tests__/DataTableVirtualizationComponent.test.js +255 -0
  37. package/dist/components/data/__tests__/data-surface.test.js +675 -0
  38. package/dist/components/data/data-surface.d.ts +246 -0
  39. package/dist/components/data/data-surface.d.ts.map +1 -0
  40. package/dist/components/data/data-surface.js +1102 -0
  41. package/dist/components/data/index.d.ts +5 -0
  42. package/dist/components/data/index.d.ts.map +1 -1
  43. package/dist/components/data/index.js +5 -0
  44. package/dist/components/data/types.d.ts +110 -2
  45. package/dist/components/data/types.d.ts.map +1 -1
  46. package/dist/i18n/strings.d.ts +26 -0
  47. package/dist/i18n/strings.d.ts.map +1 -1
  48. package/dist/i18n/strings.js +28 -2
  49. package/dist/svelte/playground/DataTablePreview.svelte +400 -10
  50. package/dist/svelte/playground/DataTablePreview.svelte.d.ts.map +1 -1
  51. package/dist/svelte/playground.js +2 -2
  52. package/package.json +3 -2
@@ -5,20 +5,29 @@
5
5
  * headers, cells, row count), the empty state, sortable-header interaction
6
6
  * (aria-sort transition), and axe-cleanliness.
7
7
  */
8
- import { render, screen } from '@testing-library/svelte';
8
+ import { render, screen, within } from '@testing-library/svelte';
9
9
  import userEvent from '@testing-library/user-event';
10
10
  import { createRawSnippet } from 'svelte';
11
11
  import { describe, expect, it, vi } from 'vitest';
12
12
  import { expectNoA11yViolations } from '../../../test-support/a11y';
13
13
  import DataTable from '../DataTable.svelte';
14
+ import { createDataTableController, transitionDataTableState, } from '../DataTableController.js';
14
15
  const columns = [
15
16
  { id: 'name', label: 'Name', accessor: 'name', sortable: true },
16
17
  { id: 'age', label: 'Age', accessor: 'age' },
17
18
  ];
18
19
  const data = [
19
- { name: 'Ada', age: 36 },
20
- { name: 'Linus', age: 54 },
20
+ { id: 'ada', name: 'Ada', age: 36 },
21
+ { id: 'linus', name: 'Linus', age: 54 },
21
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
+ }
22
31
  describe('DataTable', () => {
23
32
  it('renders caption, headers, cells, and a row per datum', () => {
24
33
  render(DataTable, { props: { data, columns, caption: 'People' } });
@@ -32,18 +41,102 @@ describe('DataTable', () => {
32
41
  render(DataTable, { props: { data: [], columns } });
33
42
  expect(screen.getByText('No data available')).toBeInTheDocument();
34
43
  });
35
- 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 () => {
36
45
  render(DataTable, { props: { data, columns, sortable: true } });
37
46
  const nameHeader = screen.getByRole('columnheader', { name: 'Name' });
38
47
  // aria-sort is only present on the actively-sorted column.
39
48
  expect(nameHeader).not.toHaveAttribute('aria-sort');
40
- await userEvent.click(screen.getByRole('button', { name: 'Name' }));
49
+ await userEvent.click(screen.getByRole('button', { name: 'Sort Name ascending' }));
41
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');
105
+ });
106
+ it('routes a human sort click through the same controller transition as a command', async () => {
107
+ const controller = createDataTableController({
108
+ columnIds: columns.map((column) => column.id),
109
+ });
110
+ const before = controller.getState();
111
+ render(DataTable, { props: { data, columns, sortable: true, controller } });
112
+ await userEvent.click(screen.getByRole('button', { name: 'Sort Name ascending' }));
113
+ expect(controller.getState()).toEqual(transitionDataTableState(before, {
114
+ type: 'toggleSorting',
115
+ columnId: 'name',
116
+ multi: false,
117
+ }));
118
+ });
119
+ it('waits for a controlled controller host before rendering a proposed interaction', async () => {
120
+ const initialState = createDataTableController({
121
+ columnIds: columns.map((column) => column.id),
122
+ }).getState();
123
+ const onStateChange = vi.fn();
124
+ const controller = createDataTableController({
125
+ state: initialState,
126
+ columnIds: columns.map((column) => column.id),
127
+ onStateChange,
128
+ });
129
+ render(DataTable, { props: { data, columns, sortable: true, controller } });
130
+ const nameHeader = screen.getByRole('columnheader', { name: 'Name' });
131
+ await userEvent.click(screen.getByRole('button', { name: 'Sort Name ascending' }));
132
+ expect(nameHeader).not.toHaveAttribute('aria-sort');
133
+ controller.replaceState(onStateChange.mock.calls[0][0]);
134
+ await vi.waitFor(() => expect(nameHeader).toHaveAttribute('aria-sort', 'ascending'));
42
135
  });
43
136
  it('filters and paginates client-side rows', async () => {
44
137
  render(DataTable, {
45
138
  props: {
46
- data: [...data, { name: 'Grace', age: 85 }],
139
+ data: [...data, { id: 'grace', name: 'Grace', age: 85 }],
47
140
  columns,
48
141
  filterFn: (person) => person.age > 40,
49
142
  pageSize: 1,
@@ -54,50 +147,580 @@ describe('DataTable', () => {
54
147
  await userEvent.click(screen.getByRole('button', { name: 'Next page' }));
55
148
  expect(screen.getByRole('cell', { name: 'Grace' })).toBeInTheDocument();
56
149
  });
57
- it('keeps fallback selection keys unique across pages', async () => {
150
+ it('ignores local filters targeting a non-filterable column', () => {
151
+ const controller = createDataTableController({
152
+ columnIds: columns.map((column) => column.id),
153
+ initialState: {
154
+ filters: [{ columnId: 'age', operator: 'gte', value: 40 }],
155
+ },
156
+ });
157
+ render(DataTable, {
158
+ props: {
159
+ data,
160
+ columns: [columns[0], { ...columns[1], filterable: false }],
161
+ controller,
162
+ },
163
+ });
164
+ expect(screen.getByRole('cell', { name: 'Ada' })).toBeInTheDocument();
165
+ expect(screen.getByRole('cell', { name: 'Linus' })).toBeInTheDocument();
166
+ });
167
+ it('clamps a controller page changed after mount against the current total', async () => {
168
+ const controller = createDataTableController({
169
+ columnIds: columns.map((column) => column.id),
170
+ initialState: { page: 1, pageSize: 1 },
171
+ });
172
+ render(DataTable, { props: { data, columns, controller } });
173
+ controller.replaceState({ ...controller.getState(), page: 4 });
174
+ await vi.waitFor(() => expect(controller.getState().page).toBe(2));
175
+ expect(screen.getByRole('cell', { name: 'Linus' })).toBeInTheDocument();
176
+ });
177
+ it.each([
178
+ [
179
+ 'local/local/local',
180
+ { filtering: 'local', sorting: 'local', pagination: 'local' },
181
+ ['Grace'],
182
+ ],
183
+ [
184
+ 'local/local/manual',
185
+ { filtering: 'local', sorting: 'local', pagination: 'manual' },
186
+ ['Grace', 'Linus'],
187
+ ],
188
+ [
189
+ 'local/manual/local',
190
+ { filtering: 'local', sorting: 'manual', pagination: 'local' },
191
+ ['Linus'],
192
+ ],
193
+ [
194
+ 'local/manual/manual',
195
+ { filtering: 'local', sorting: 'manual', pagination: 'manual' },
196
+ ['Linus', 'Grace'],
197
+ ],
198
+ [
199
+ 'manual/local/local',
200
+ { filtering: 'manual', sorting: 'local', pagination: 'local' },
201
+ ['Grace'],
202
+ ],
203
+ [
204
+ 'manual/local/manual',
205
+ { filtering: 'manual', sorting: 'local', pagination: 'manual' },
206
+ ['Grace', 'Linus', 'Ada'],
207
+ ],
208
+ [
209
+ 'manual/manual/local',
210
+ { filtering: 'manual', sorting: 'manual', pagination: 'local' },
211
+ ['Ada'],
212
+ ],
213
+ [
214
+ 'manual/manual/manual',
215
+ { filtering: 'manual', sorting: 'manual', pagination: 'manual' },
216
+ ['Ada', 'Linus', 'Grace'],
217
+ ],
218
+ ])('applies each local/manual mode combination exactly once (%s)', (_name, modes, expected) => {
219
+ const controller = createDataTableController({
220
+ modes,
221
+ initialState: {
222
+ filters: [{ columnId: 'age', operator: 'gte', value: 40 }],
223
+ sorting: [{ columnId: 'age', direction: 'desc' }],
224
+ page: 1,
225
+ pageSize: 1,
226
+ },
227
+ columnIds: columns.map((column) => column.id),
228
+ });
229
+ render(DataTable, {
230
+ props: {
231
+ data: [...data, { id: 'grace', name: 'Grace', age: 85 }],
232
+ columns,
233
+ controller,
234
+ rowKey: 'id',
235
+ },
236
+ });
237
+ const names = screen
238
+ .getAllByRole('row')
239
+ .slice(1)
240
+ .map((row) => within(row).getAllByRole('cell')[0].textContent);
241
+ expect(names).toEqual(expected);
242
+ });
243
+ it('keeps stable selection IDs across pages and labels the page scope', async () => {
58
244
  const onSelectionChange = vi.fn();
59
245
  render(DataTable, {
60
246
  props: {
61
247
  data,
62
248
  columns,
63
249
  pageSize: 1,
250
+ rowKey: 'id',
64
251
  selectable: true,
65
252
  onSelectionChange,
66
253
  },
67
254
  });
68
- await userEvent.click(screen.getByRole('checkbox', { name: 'Select all rows' }));
255
+ await userEvent.click(screen.getByRole('checkbox', { name: 'Select all rows on this page' }));
69
256
  await userEvent.click(screen.getByRole('button', { name: 'Next page' }));
70
- await userEvent.click(screen.getByRole('checkbox', { name: 'Select all rows' }));
71
- expect(onSelectionChange).toHaveBeenLastCalledWith(new Set([0, 1]));
257
+ await userEvent.click(screen.getByRole('checkbox', { name: 'Select all rows on this page' }));
258
+ expect(onSelectionChange).toHaveBeenLastCalledWith(new Set(['ada', 'linus']));
259
+ });
260
+ it('honors legacy selected bindings during controller reconciliation', async () => {
261
+ const props = {
262
+ data,
263
+ columns,
264
+ rowKey: 'id',
265
+ selectable: true,
266
+ selected: new Set(['ada']),
267
+ };
268
+ const { rerender } = render(DataTable, { props });
269
+ const expectSelected = (name) => {
270
+ expect(screen.getByRole('cell', { name }).closest('tr')).toHaveClass('data-table__row--selected');
271
+ };
272
+ await vi.waitFor(() => expectSelected('Ada'));
273
+ await rerender({ ...props, selected: new Set(['linus']) });
274
+ await vi.waitFor(() => {
275
+ expectSelected('Linus');
276
+ expect(screen.getByRole('cell', { name: 'Ada' }).closest('tr')).not.toHaveClass('data-table__row--selected');
277
+ });
72
278
  });
73
- it('does not carry fallback expansion keys onto the next page', async () => {
279
+ it('keeps stable selection and expansion through sort, filter, and data refresh', async () => {
280
+ const controller = createDataTableController({
281
+ columnIds: columns.map((column) => column.id),
282
+ });
283
+ const expandedContent = createRawSnippet(() => ({
284
+ render: () => '<p>Row detail</p>',
285
+ }));
286
+ const props = {
287
+ data,
288
+ columns,
289
+ rowKey: 'id',
290
+ selectable: true,
291
+ expandedContent,
292
+ controller,
293
+ };
294
+ const { rerender } = render(DataTable, { props });
295
+ const expectAdaSelectionAndExpansion = () => {
296
+ const adaRow = screen.getByRole('cell', { name: 'Ada' }).closest('tr');
297
+ expect(adaRow).toHaveClass('data-table__row--selected');
298
+ expect(adaRow?.nextElementSibling).toHaveTextContent('Row detail');
299
+ };
300
+ const visibleNames = () => screen
301
+ .getAllByRole('row')
302
+ .slice(1)
303
+ .filter((row) => !row.classList.contains('data-table__row--expanded'))
304
+ .map((row) => within(row)
305
+ .getAllByRole('cell')
306
+ .find((cell) => ['Ada', 'Linus'].includes(cell.textContent ?? ''))
307
+ ?.textContent)
308
+ .filter((name) => name !== undefined);
309
+ await userEvent.click(screen.getByRole('checkbox', { name: 'Select row 1' }));
310
+ await userEvent.click(screen.getByRole('button', { name: 'Expand row 1' }));
311
+ expectAdaSelectionAndExpansion();
312
+ controller.dispatch({
313
+ type: 'setSorting',
314
+ sorting: [{ columnId: 'age', direction: 'desc' }],
315
+ });
316
+ await vi.waitFor(() => {
317
+ expect(visibleNames()).toEqual(['Linus', 'Ada']);
318
+ expectAdaSelectionAndExpansion();
319
+ });
320
+ controller.dispatch({ type: 'setSearch', search: 'Ada' });
321
+ await vi.waitFor(() => {
322
+ expect(screen.queryByRole('cell', { name: 'Linus' })).not.toBeInTheDocument();
323
+ expectAdaSelectionAndExpansion();
324
+ });
325
+ controller.dispatch({ type: 'setSearch', search: '' });
326
+ await rerender({
327
+ ...props,
328
+ data: [
329
+ { id: 'linus', name: 'Linus', age: 10 },
330
+ { id: 'ada', name: 'Ada', age: 90 },
331
+ ],
332
+ });
333
+ await vi.waitFor(() => {
334
+ expect(visibleNames()).toEqual(['Ada', 'Linus']);
335
+ expectAdaSelectionAndExpansion();
336
+ });
337
+ });
338
+ it('fails closed when durable selection, expansion, manual, or agent modes lack a rowKey', () => {
339
+ expect(() => render(DataTable, { props: { data, columns, selectable: true } })).toThrow(/rowKey is required/);
340
+ expect(() => render(DataTable, {
341
+ props: {
342
+ data,
343
+ columns,
344
+ expandedContent: createRawSnippet(() => ({ render: () => '' })),
345
+ },
346
+ })).toThrow(/rowKey is required/);
347
+ expect(() => render(DataTable, {
348
+ props: { data, columns, agentAddressable: true },
349
+ })).toThrow(/rowKey is required/);
350
+ expect(() => render(DataTable, {
351
+ props: { data, columns, modes: { pagination: 'manual' } },
352
+ })).toThrow(/rowKey is required/);
353
+ });
354
+ it('links localized expansion controls to their row detail', async () => {
74
355
  render(DataTable, {
75
356
  props: {
76
357
  data,
77
358
  columns,
78
- pageSize: 1,
359
+ rowKey: 'id',
79
360
  expandedContent: createRawSnippet(() => ({
80
361
  render: () => '<p>Row detail</p>',
81
362
  })),
82
363
  },
83
364
  });
84
- await userEvent.click(screen.getByRole('button', { name: 'Expand row' }));
85
- await userEvent.click(screen.getByRole('button', { name: 'Next page' }));
86
- expect(screen.queryByText('Row detail')).not.toBeInTheDocument();
87
- expect(screen.getByRole('button', { name: 'Expand row' })).toBeInTheDocument();
365
+ const expandButton = screen.getByRole('button', { name: 'Expand row 1' });
366
+ await userEvent.click(expandButton);
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);
88
373
  });
89
- it('reveals expandable row content', async () => {
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
+ }));
90
382
  render(DataTable, {
91
383
  props: {
92
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');
452
+ });
453
+ it('rejects duplicate stable row keys and local totals that imply server paging', () => {
454
+ expect(() => render(DataTable, {
455
+ props: {
456
+ data: [data[0], { ...data[1], id: 'ada' }],
93
457
  columns,
94
- expandedContent: createRawSnippet(() => ({
95
- render: () => '<p>Row detail</p>',
458
+ rowKey: 'id',
459
+ },
460
+ })).toThrow(/unique row ids/);
461
+ expect(() => render(DataTable, {
462
+ props: { data, columns, rowKey: 'id', totalRows: 10 },
463
+ })).toThrow(/only valid when pagination mode is manual/);
464
+ });
465
+ it('uses stable row IDs as the final local sort tie-breaker', () => {
466
+ const controller = createDataTableController({
467
+ columnIds: columns.map((column) => column.id),
468
+ initialState: { sorting: [{ columnId: 'age', direction: 'asc' }] },
469
+ });
470
+ render(DataTable, {
471
+ props: {
472
+ data: [
473
+ { id: 'z', name: 'Zed', age: 36 },
474
+ { id: 'a', name: 'Ada', age: 36 },
475
+ ],
476
+ columns,
477
+ rowKey: 'id',
478
+ controller,
479
+ },
480
+ });
481
+ const names = screen
482
+ .getAllByRole('row')
483
+ .slice(1)
484
+ .map((row) => within(row).getAllByRole('cell')[0].textContent);
485
+ expect(names).toEqual(['Ada', 'Zed']);
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' }],
96
567
  })),
97
568
  },
98
569
  });
99
- await userEvent.click(screen.getAllByRole('button', { name: 'Expand row' })[0]);
100
- expect(screen.getByText('Row detail')).toBeInTheDocument();
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();
101
724
  });
102
725
  it('is axe-clean', async () => {
103
726
  const { container } = render(DataTable, {