@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.
- package/README.md +275 -0
- package/dist/components/data/DataTable.svelte +1444 -172
- 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 +235 -0
- package/dist/components/data/DataTableController.d.ts.map +1 -0
- package/dist/components/data/DataTableController.js +792 -0
- package/dist/components/data/DataTableIdentity.d.ts +21 -0
- package/dist/components/data/DataTableIdentity.d.ts.map +1 -0
- package/dist/components/data/DataTableIdentity.js +28 -0
- 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 +644 -21
- package/dist/components/data/__tests__/DataTableConformance.test.js +212 -0
- package/dist/components/data/__tests__/DataTableController.test.js +336 -0
- package/dist/components/data/__tests__/DataTableIdentity.test.js +29 -0
- 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 +5 -0
- package/dist/components/data/index.d.ts.map +1 -1
- package/dist/components/data/index.js +5 -0
- package/dist/components/data/types.d.ts +110 -2
- package/dist/components/data/types.d.ts.map +1 -1
- package/dist/i18n/strings.d.ts +26 -0
- package/dist/i18n/strings.d.ts.map +1 -1
- package/dist/i18n/strings.js +28 -2
- package/dist/svelte/playground/DataTablePreview.svelte +400 -10
- package/dist/svelte/playground/DataTablePreview.svelte.d.ts.map +1 -1
- package/dist/svelte/playground.js +2 -2
- 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('
|
|
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('
|
|
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([
|
|
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('
|
|
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
|
-
|
|
359
|
+
rowKey: 'id',
|
|
79
360
|
expandedContent: createRawSnippet(() => ({
|
|
80
361
|
render: () => '<p>Row detail</p>',
|
|
81
362
|
})),
|
|
82
363
|
},
|
|
83
364
|
});
|
|
84
|
-
|
|
85
|
-
await userEvent.click(
|
|
86
|
-
expect(screen.
|
|
87
|
-
expect(
|
|
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('
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
100
|
-
expect(
|
|
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, {
|