@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
@@ -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
+ });
@@ -0,0 +1,336 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { assertDataTableSelectionCurrent, createDataTableController, hydrateDataTableSnapshot, transitionDataTableState, } from '../DataTableController.js';
3
+ const state = {
4
+ search: '',
5
+ filters: [],
6
+ sorting: [],
7
+ page: 3,
8
+ pageSize: 10,
9
+ columnOrder: ['name', 'age'],
10
+ columnVisibility: [
11
+ { columnId: 'age', visible: true },
12
+ { columnId: 'name', visible: true },
13
+ ],
14
+ columnWidths: [],
15
+ columnPinning: [],
16
+ selection: { scope: 'explicit', rowIds: [] },
17
+ selectedRowIds: [],
18
+ expandedRowIds: [],
19
+ };
20
+ describe('DataTableController', () => {
21
+ it('uses canonical JSON-safe snapshots regardless of insertion order', () => {
22
+ const first = createDataTableController({
23
+ initialState: {
24
+ ...state,
25
+ filters: [
26
+ {
27
+ columnId: 'age',
28
+ operator: 'gte',
29
+ value: { minimum: 18, maximum: 65 },
30
+ },
31
+ { columnId: 'name', operator: 'contains', value: 'a' },
32
+ ],
33
+ selectedRowIds: ['2', 1, '1', 2],
34
+ expandedRowIds: [3, '3', 1],
35
+ },
36
+ });
37
+ const second = createDataTableController({
38
+ initialState: {
39
+ ...state,
40
+ filters: [
41
+ { columnId: 'name', operator: 'contains', value: 'a' },
42
+ {
43
+ columnId: 'age',
44
+ operator: 'gte',
45
+ value: { maximum: 65, minimum: 18 },
46
+ },
47
+ ],
48
+ selectedRowIds: [2, '1', 1, '2'],
49
+ expandedRowIds: [1, '3', 3],
50
+ },
51
+ });
52
+ expect(JSON.stringify(first.snapshot())).toBe(JSON.stringify(second.snapshot()));
53
+ expect(JSON.parse(JSON.stringify(first.snapshot()))).toEqual(first.snapshot());
54
+ });
55
+ it('omits ignored null-check filter values from canonical snapshots', () => {
56
+ const withoutValue = createDataTableController({
57
+ initialState: {
58
+ ...state,
59
+ filters: [{ columnId: 'name', operator: 'isNull' }],
60
+ },
61
+ });
62
+ const withIgnoredValue = createDataTableController({
63
+ initialState: {
64
+ ...state,
65
+ filters: [
66
+ {
67
+ columnId: 'name',
68
+ operator: 'isNull',
69
+ value: undefined,
70
+ },
71
+ ],
72
+ },
73
+ });
74
+ expect(withIgnoredValue.snapshot()).toEqual(withoutValue.snapshot());
75
+ });
76
+ it('resets page only when a query-shape value changes and clamps reliable totals', () => {
77
+ const controller = createDataTableController({ initialState: state });
78
+ expect(controller.dispatch({ type: 'setSearch', search: '' }).changed).toBe(false);
79
+ expect(controller.snapshot().state.page).toBe(3);
80
+ controller.dispatch({ type: 'setSearch', search: 'ada' });
81
+ expect(controller.snapshot().state.page).toBe(1);
82
+ controller.replaceState({ ...controller.getState(), page: 4 });
83
+ controller.dispatch({ type: 'setColumnOrder', columnIds: ['age', 'name'] });
84
+ expect(controller.snapshot().state.page).toBe(4);
85
+ controller.clampPage(11);
86
+ expect(controller.snapshot().state.page).toBe(2);
87
+ controller.clampPage(0);
88
+ expect(controller.snapshot().state.page).toBe(1);
89
+ expect(() => controller.clampPage(1.5)).toThrow(/totalRows/);
90
+ });
91
+ it('keeps multi-sort priority while cycling a column through asc, desc, and clear', () => {
92
+ const controller = createDataTableController({ initialState: state });
93
+ controller.dispatch({ type: 'toggleSorting', columnId: 'name' });
94
+ controller.dispatch({
95
+ type: 'toggleSorting',
96
+ columnId: 'age',
97
+ multi: true,
98
+ });
99
+ expect(controller.snapshot().state.sorting).toEqual([
100
+ { columnId: 'name', direction: 'asc' },
101
+ { columnId: 'age', direction: 'asc' },
102
+ ]);
103
+ controller.dispatch({
104
+ type: 'toggleSorting',
105
+ columnId: 'name',
106
+ multi: true,
107
+ });
108
+ expect(controller.snapshot().state.sorting).toEqual([
109
+ { columnId: 'name', direction: 'desc' },
110
+ { columnId: 'age', direction: 'asc' },
111
+ ]);
112
+ controller.dispatch({
113
+ type: 'toggleSorting',
114
+ columnId: 'name',
115
+ multi: true,
116
+ });
117
+ expect(controller.snapshot().state.sorting).toEqual([
118
+ { columnId: 'age', direction: 'asc' },
119
+ ]);
120
+ });
121
+ it('proposes controlled transitions without mutating until the host reconciles state', () => {
122
+ const onStateChange = vi.fn();
123
+ const controller = createDataTableController({ state, onStateChange });
124
+ const transition = controller.dispatch({ type: 'setPage', page: 2 });
125
+ expect(transition.next.state.page).toBe(2);
126
+ expect(controller.snapshot().state.page).toBe(3);
127
+ expect(onStateChange).toHaveBeenCalledWith(expect.objectContaining({ page: 2 }), { type: 'setPage', page: 2 });
128
+ controller.replaceState(transition.next.state);
129
+ expect(controller.snapshot().state.page).toBe(2);
130
+ });
131
+ it('notifies subscribers with defensive selection and expansion snapshots', () => {
132
+ const controller = createDataTableController({ initialState: state });
133
+ const listener = vi.fn();
134
+ const unsubscribe = controller.subscribe(listener);
135
+ controller.dispatch({ type: 'toggleRowSelection', rowId: 'row-1' });
136
+ controller.dispatch({ type: 'toggleRowExpansion', rowId: 2 });
137
+ expect(listener).toHaveBeenCalledTimes(2);
138
+ expect(listener.mock.calls[1][0].next.state).toMatchObject({
139
+ selectedRowIds: ['row-1'],
140
+ expandedRowIds: [2],
141
+ });
142
+ listener.mock.calls[1][0].next.state.selectedRowIds.push('mutated');
143
+ expect(controller.snapshot().state.selectedRowIds).toEqual(['row-1']);
144
+ unsubscribe();
145
+ controller.dispatch({ type: 'setSearch', search: 'Ada' });
146
+ expect(listener).toHaveBeenCalledTimes(2);
147
+ });
148
+ it('defensively copies state and validates persisted snapshots', () => {
149
+ const controller = createDataTableController({ initialState: state });
150
+ const snapshot = controller.snapshot();
151
+ snapshot.state.selectedRowIds.push('unexpected');
152
+ expect(controller.snapshot().state.selectedRowIds).toEqual([]);
153
+ expect(hydrateDataTableSnapshot(JSON.parse(JSON.stringify(controller.snapshot())))).toEqual(controller.snapshot());
154
+ const { selection: _selection, ...legacyState } = state;
155
+ expect(hydrateDataTableSnapshot({
156
+ version: 1,
157
+ modes: controller.getModes(),
158
+ state: legacyState,
159
+ })).toMatchObject({
160
+ version: 3,
161
+ state: { selection: { scope: 'explicit', rowIds: [] } },
162
+ });
163
+ expect(() => hydrateDataTableSnapshot({ version: 4 })).toThrow(/version/);
164
+ expect(() => transitionDataTableState(state, { type: 'setPageSize', pageSize: 0 })).toThrow(/pageSize/);
165
+ for (const page of [0, -1, 1.5]) {
166
+ expect(() => transitionDataTableState(state, { type: 'setPage', page })).toThrow(/page/);
167
+ }
168
+ });
169
+ it('persists canonical column widths and pinning without resetting the table order', () => {
170
+ const controller = createDataTableController({
171
+ columnIds: ['name', 'age', 'status'],
172
+ initialState: {
173
+ ...state,
174
+ columnOrder: ['status', 'name', 'age'],
175
+ columnWidths: [
176
+ { columnId: 'status', width: 96 },
177
+ { columnId: 'name', width: 220 },
178
+ ],
179
+ columnPinning: [
180
+ { columnId: 'status', position: 'end' },
181
+ { columnId: 'name', position: 'start' },
182
+ ],
183
+ },
184
+ });
185
+ expect(controller.snapshot()).toMatchObject({
186
+ version: 3,
187
+ state: {
188
+ columnOrder: ['status', 'name', 'age'],
189
+ columnWidths: [
190
+ { columnId: 'name', width: 220 },
191
+ { columnId: 'status', width: 96 },
192
+ ],
193
+ columnPinning: [
194
+ { columnId: 'name', position: 'start' },
195
+ { columnId: 'status', position: 'end' },
196
+ ],
197
+ },
198
+ });
199
+ controller.dispatch({ type: 'setColumnWidth', columnId: 'age', width: 80 });
200
+ controller.dispatch({
201
+ type: 'setColumnPin',
202
+ columnId: 'age',
203
+ position: 'start',
204
+ });
205
+ expect(controller.getState().columnWidths).toEqual([
206
+ { columnId: 'age', width: 80 },
207
+ { columnId: 'name', width: 220 },
208
+ { columnId: 'status', width: 96 },
209
+ ]);
210
+ expect(controller.getState().columnPinning).toEqual([
211
+ { columnId: 'age', position: 'start' },
212
+ { columnId: 'name', position: 'start' },
213
+ { columnId: 'status', position: 'end' },
214
+ ]);
215
+ controller.dispatch({
216
+ type: 'setColumnWidth',
217
+ columnId: 'age',
218
+ width: null,
219
+ });
220
+ controller.dispatch({
221
+ type: 'setColumnPin',
222
+ columnId: 'age',
223
+ position: null,
224
+ });
225
+ expect(controller.getState().columnWidths).not.toContainEqual({
226
+ columnId: 'age',
227
+ width: 80,
228
+ });
229
+ expect(controller.getState().columnPinning).not.toContainEqual({
230
+ columnId: 'age',
231
+ position: 'start',
232
+ });
233
+ });
234
+ it('never restores or commands a static hidden column visible', () => {
235
+ const controller = createDataTableController({
236
+ columnIds: ['name', 'internal', 'age'],
237
+ hiddenColumnIds: ['internal'],
238
+ initialState: {
239
+ ...state,
240
+ columnVisibility: [
241
+ { columnId: 'internal', visible: true },
242
+ { columnId: 'name', visible: true },
243
+ ],
244
+ },
245
+ });
246
+ expect(controller.getState().columnVisibility).toContainEqual({
247
+ columnId: 'internal',
248
+ visible: false,
249
+ });
250
+ controller.dispatch({
251
+ type: 'setColumnVisibility',
252
+ columns: [{ columnId: 'internal', visible: true }],
253
+ });
254
+ expect(controller.getState().columnVisibility).toContainEqual({
255
+ columnId: 'internal',
256
+ visible: false,
257
+ });
258
+ controller.replaceState({
259
+ ...controller.getState(),
260
+ columnVisibility: [{ columnId: 'internal', visible: true }],
261
+ });
262
+ expect(controller.getState().columnVisibility).toContainEqual({
263
+ columnId: 'internal',
264
+ visible: false,
265
+ });
266
+ });
267
+ it('restores the saved visibility when a static hidden constraint is removed', () => {
268
+ const controller = createDataTableController({
269
+ columnIds: ['name', 'internal'],
270
+ initialState: {
271
+ ...state,
272
+ columnVisibility: [
273
+ { columnId: 'name', visible: true },
274
+ { columnId: 'internal', visible: true },
275
+ ],
276
+ },
277
+ });
278
+ controller.setColumnIds(['name', 'internal'], ['internal']);
279
+ expect(controller.getState().columnVisibility).toContainEqual({
280
+ columnId: 'internal',
281
+ visible: false,
282
+ });
283
+ controller.setColumnIds(['name', 'internal']);
284
+ expect(controller.getState().columnVisibility).toContainEqual({
285
+ columnId: 'internal',
286
+ visible: true,
287
+ });
288
+ });
289
+ it('models current-page and explicit row selections separately', () => {
290
+ const controller = createDataTableController({ initialState: state });
291
+ controller.dispatch({
292
+ type: 'setPageSelection',
293
+ rowIds: ['row-2', 'row-1'],
294
+ });
295
+ expect(controller.getState().selection).toEqual({
296
+ scope: 'page',
297
+ rowIds: ['row-1', 'row-2'],
298
+ });
299
+ controller.dispatch({ type: 'setPage', page: 4 });
300
+ expect(controller.getState().selection).toEqual({
301
+ scope: 'page',
302
+ rowIds: [],
303
+ });
304
+ controller.dispatch({ type: 'setSelectedRows', rowIds: ['row-2'] });
305
+ controller.dispatch({ type: 'setPage', page: 5 });
306
+ expect(controller.getState().selection).toEqual({
307
+ scope: 'explicit',
308
+ rowIds: ['row-2'],
309
+ });
310
+ });
311
+ it('binds all-matching selection to an exact query revision and invalidates it on query changes', () => {
312
+ const controller = createDataTableController({ initialState: state });
313
+ controller.dispatch({
314
+ type: 'selectAllMatching',
315
+ queryFingerprint: 'dq1_example',
316
+ queryRevision: 'revision-7',
317
+ expectedCount: 42,
318
+ });
319
+ expect(controller.getState().selection).toEqual({
320
+ scope: 'allMatching',
321
+ queryFingerprint: 'dq1_example',
322
+ queryRevision: 'revision-7',
323
+ expectedCount: 42,
324
+ });
325
+ expect(controller.getState().selectedRowIds).toEqual([]);
326
+ expect(() => assertDataTableSelectionCurrent(controller.getState().selection, {
327
+ queryFingerprint: 'dq1_example',
328
+ queryRevision: 'revision-8',
329
+ })).toThrow(/stale/);
330
+ controller.dispatch({ type: 'setSearch', search: 'Ada' });
331
+ expect(controller.getState().selection).toEqual({
332
+ scope: 'explicit',
333
+ rowIds: [],
334
+ });
335
+ });
336
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolveDataTableRows } from '../DataTableIdentity.js';
3
+ const rows = [
4
+ { id: 'ada', name: 'Ada' },
5
+ { id: 'linus', name: 'Linus' },
6
+ ];
7
+ describe('resolveDataTableRows', () => {
8
+ it('uses stable canonical IDs and preserves source indexes', () => {
9
+ expect(resolveDataTableRows(rows, 'id', { requireStableIdentity: true })).toEqual([
10
+ { row: rows[0], sourceIndex: 0, rowId: 'ada' },
11
+ { row: rows[1], sourceIndex: 1, rowId: 'linus' },
12
+ ]);
13
+ });
14
+ it('keeps the index fallback only for presentational local tables', () => {
15
+ expect(resolveDataTableRows(rows, undefined)).toEqual([
16
+ { row: rows[0], sourceIndex: 0, rowId: 0 },
17
+ { row: rows[1], sourceIndex: 1, rowId: 1 },
18
+ ]);
19
+ expect(() => resolveDataTableRows(rows, undefined, { requireStableIdentity: true })).toThrow(/rowKey is required/);
20
+ });
21
+ it('rejects duplicate, empty, and non-finite stable row IDs', () => {
22
+ expect(() => resolveDataTableRows([
23
+ { id: 'same', name: 'Ada' },
24
+ { id: 'same', name: 'Linus' },
25
+ ], 'id')).toThrow(/unique/);
26
+ expect(() => resolveDataTableRows([{ id: '', name: 'Ada' }], 'id')).toThrow(/non-empty/);
27
+ expect(() => resolveDataTableRows([{ id: Number.NaN, name: 'Ada' }], (row) => row.id)).toThrow(/finite/);
28
+ });
29
+ });