@happyvertical/smrt-ui 0.42.3 → 0.42.4

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.
@@ -0,0 +1,28 @@
1
+ import { assertDataTableRowId, dataTableRowIdKey, } from './DataTableController.js';
2
+ function readRowKey(row, rowKey) {
3
+ return typeof rowKey === 'function'
4
+ ? rowKey(row)
5
+ : row[rowKey];
6
+ }
7
+ /**
8
+ * Resolve the renderer's source rows once and fail closed for missing or
9
+ * duplicate durable identities. The index fallback only exists for
10
+ * presentational local tables that have no durable row state.
11
+ */
12
+ export function resolveDataTableRows(rows, rowKey, options = {}) {
13
+ if (options.requireStableIdentity && !rowKey) {
14
+ throw new TypeError('DataTable rowKey is required for selectable, expandable, manual, or agent-addressable tables');
15
+ }
16
+ const seen = new Set();
17
+ return rows.map((row, sourceIndex) => {
18
+ const rowId = rowKey
19
+ ? assertDataTableRowId(readRowKey(row, rowKey))
20
+ : sourceIndex;
21
+ const key = dataTableRowIdKey(rowId);
22
+ if (seen.has(key)) {
23
+ throw new TypeError(`DataTable rowKey must resolve to unique row ids; duplicate ${key}`);
24
+ }
25
+ seen.add(key);
26
+ return { row, sourceIndex, rowId };
27
+ });
28
+ }
@@ -5,19 +5,20 @@
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
  ];
22
23
  describe('DataTable', () => {
23
24
  it('renders caption, headers, cells, and a row per datum', () => {
@@ -40,10 +41,40 @@ describe('DataTable', () => {
40
41
  await userEvent.click(screen.getByRole('button', { name: 'Name' }));
41
42
  expect(nameHeader).toHaveAttribute('aria-sort', 'ascending');
42
43
  });
44
+ it('routes a human sort click through the same controller transition as a command', async () => {
45
+ const controller = createDataTableController({
46
+ columnIds: columns.map((column) => column.id),
47
+ });
48
+ const before = controller.getState();
49
+ render(DataTable, { props: { data, columns, sortable: true, controller } });
50
+ await userEvent.click(screen.getByRole('button', { name: 'Name' }));
51
+ expect(controller.getState()).toEqual(transitionDataTableState(before, {
52
+ type: 'toggleSorting',
53
+ columnId: 'name',
54
+ multi: false,
55
+ }));
56
+ });
57
+ it('waits for a controlled controller host before rendering a proposed interaction', async () => {
58
+ const initialState = createDataTableController({
59
+ columnIds: columns.map((column) => column.id),
60
+ }).getState();
61
+ const onStateChange = vi.fn();
62
+ const controller = createDataTableController({
63
+ state: initialState,
64
+ columnIds: columns.map((column) => column.id),
65
+ onStateChange,
66
+ });
67
+ render(DataTable, { props: { data, columns, sortable: true, controller } });
68
+ const nameHeader = screen.getByRole('columnheader', { name: 'Name' });
69
+ await userEvent.click(screen.getByRole('button', { name: 'Name' }));
70
+ expect(nameHeader).not.toHaveAttribute('aria-sort');
71
+ controller.replaceState(onStateChange.mock.calls[0][0]);
72
+ await vi.waitFor(() => expect(nameHeader).toHaveAttribute('aria-sort', 'ascending'));
73
+ });
43
74
  it('filters and paginates client-side rows', async () => {
44
75
  render(DataTable, {
45
76
  props: {
46
- data: [...data, { name: 'Grace', age: 85 }],
77
+ data: [...data, { id: 'grace', name: 'Grace', age: 85 }],
47
78
  columns,
48
79
  filterFn: (person) => person.age > 40,
49
80
  pageSize: 1,
@@ -54,43 +85,216 @@ describe('DataTable', () => {
54
85
  await userEvent.click(screen.getByRole('button', { name: 'Next page' }));
55
86
  expect(screen.getByRole('cell', { name: 'Grace' })).toBeInTheDocument();
56
87
  });
57
- it('keeps fallback selection keys unique across pages', async () => {
88
+ it('ignores local filters targeting a non-filterable column', () => {
89
+ const controller = createDataTableController({
90
+ columnIds: columns.map((column) => column.id),
91
+ initialState: {
92
+ filters: [{ columnId: 'age', operator: 'gte', value: 40 }],
93
+ },
94
+ });
95
+ render(DataTable, {
96
+ props: {
97
+ data,
98
+ columns: [columns[0], { ...columns[1], filterable: false }],
99
+ controller,
100
+ },
101
+ });
102
+ expect(screen.getByRole('cell', { name: 'Ada' })).toBeInTheDocument();
103
+ expect(screen.getByRole('cell', { name: 'Linus' })).toBeInTheDocument();
104
+ });
105
+ it('clamps a controller page changed after mount against the current total', async () => {
106
+ const controller = createDataTableController({
107
+ columnIds: columns.map((column) => column.id),
108
+ initialState: { page: 1, pageSize: 1 },
109
+ });
110
+ render(DataTable, { props: { data, columns, controller } });
111
+ controller.replaceState({ ...controller.getState(), page: 4 });
112
+ await vi.waitFor(() => expect(controller.getState().page).toBe(2));
113
+ expect(screen.getByRole('cell', { name: 'Linus' })).toBeInTheDocument();
114
+ });
115
+ it.each([
116
+ [
117
+ 'local/local/local',
118
+ { filtering: 'local', sorting: 'local', pagination: 'local' },
119
+ ['Grace'],
120
+ ],
121
+ [
122
+ 'local/local/manual',
123
+ { filtering: 'local', sorting: 'local', pagination: 'manual' },
124
+ ['Grace', 'Linus'],
125
+ ],
126
+ [
127
+ 'local/manual/local',
128
+ { filtering: 'local', sorting: 'manual', pagination: 'local' },
129
+ ['Linus'],
130
+ ],
131
+ [
132
+ 'local/manual/manual',
133
+ { filtering: 'local', sorting: 'manual', pagination: 'manual' },
134
+ ['Linus', 'Grace'],
135
+ ],
136
+ [
137
+ 'manual/local/local',
138
+ { filtering: 'manual', sorting: 'local', pagination: 'local' },
139
+ ['Grace'],
140
+ ],
141
+ [
142
+ 'manual/local/manual',
143
+ { filtering: 'manual', sorting: 'local', pagination: 'manual' },
144
+ ['Grace', 'Linus', 'Ada'],
145
+ ],
146
+ [
147
+ 'manual/manual/local',
148
+ { filtering: 'manual', sorting: 'manual', pagination: 'local' },
149
+ ['Ada'],
150
+ ],
151
+ [
152
+ 'manual/manual/manual',
153
+ { filtering: 'manual', sorting: 'manual', pagination: 'manual' },
154
+ ['Ada', 'Linus', 'Grace'],
155
+ ],
156
+ ])('applies each local/manual mode combination exactly once (%s)', (_name, modes, expected) => {
157
+ const controller = createDataTableController({
158
+ modes,
159
+ initialState: {
160
+ filters: [{ columnId: 'age', operator: 'gte', value: 40 }],
161
+ sorting: [{ columnId: 'age', direction: 'desc' }],
162
+ page: 1,
163
+ pageSize: 1,
164
+ },
165
+ columnIds: columns.map((column) => column.id),
166
+ });
167
+ render(DataTable, {
168
+ props: {
169
+ data: [...data, { id: 'grace', name: 'Grace', age: 85 }],
170
+ columns,
171
+ controller,
172
+ rowKey: 'id',
173
+ },
174
+ });
175
+ const names = screen
176
+ .getAllByRole('row')
177
+ .slice(1)
178
+ .map((row) => within(row).getAllByRole('cell')[0].textContent);
179
+ expect(names).toEqual(expected);
180
+ });
181
+ it('keeps stable selection IDs across pages and labels the page scope', async () => {
58
182
  const onSelectionChange = vi.fn();
59
183
  render(DataTable, {
60
184
  props: {
61
185
  data,
62
186
  columns,
63
187
  pageSize: 1,
188
+ rowKey: 'id',
64
189
  selectable: true,
65
190
  onSelectionChange,
66
191
  },
67
192
  });
68
- await userEvent.click(screen.getByRole('checkbox', { name: 'Select all rows' }));
193
+ await userEvent.click(screen.getByRole('checkbox', { name: 'Select all rows on this page' }));
69
194
  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]));
195
+ await userEvent.click(screen.getByRole('checkbox', { name: 'Select all rows on this page' }));
196
+ expect(onSelectionChange).toHaveBeenLastCalledWith(new Set(['ada', 'linus']));
72
197
  });
73
- it('does not carry fallback expansion keys onto the next page', async () => {
74
- render(DataTable, {
198
+ it('honors legacy selected bindings during controller reconciliation', async () => {
199
+ const props = {
200
+ data,
201
+ columns,
202
+ rowKey: 'id',
203
+ selectable: true,
204
+ selected: new Set(['ada']),
205
+ };
206
+ const { rerender } = render(DataTable, { props });
207
+ const expectSelected = (name) => {
208
+ expect(screen.getByRole('cell', { name }).closest('tr')).toHaveClass('data-table__row--selected');
209
+ };
210
+ await vi.waitFor(() => expectSelected('Ada'));
211
+ await rerender({ ...props, selected: new Set(['linus']) });
212
+ await vi.waitFor(() => {
213
+ expectSelected('Linus');
214
+ expect(screen.getByRole('cell', { name: 'Ada' }).closest('tr')).not.toHaveClass('data-table__row--selected');
215
+ });
216
+ });
217
+ it('keeps stable selection and expansion through sort, filter, and data refresh', async () => {
218
+ const controller = createDataTableController({
219
+ columnIds: columns.map((column) => column.id),
220
+ });
221
+ const expandedContent = createRawSnippet(() => ({
222
+ render: () => '<p>Row detail</p>',
223
+ }));
224
+ const props = {
225
+ data,
226
+ columns,
227
+ rowKey: 'id',
228
+ selectable: true,
229
+ expandedContent,
230
+ controller,
231
+ };
232
+ const { rerender } = render(DataTable, { props });
233
+ const expectAdaSelectionAndExpansion = () => {
234
+ const adaRow = screen.getByRole('cell', { name: 'Ada' }).closest('tr');
235
+ expect(adaRow).toHaveClass('data-table__row--selected');
236
+ expect(adaRow?.nextElementSibling).toHaveTextContent('Row detail');
237
+ };
238
+ const visibleNames = () => screen
239
+ .getAllByRole('row')
240
+ .slice(1)
241
+ .filter((row) => !row.classList.contains('data-table__row--expanded'))
242
+ .map((row) => within(row)
243
+ .getAllByRole('cell')
244
+ .find((cell) => ['Ada', 'Linus'].includes(cell.textContent ?? ''))
245
+ ?.textContent)
246
+ .filter((name) => name !== undefined);
247
+ await userEvent.click(screen.getAllByRole('checkbox', { name: 'Select row' })[0]);
248
+ await userEvent.click(screen.getAllByRole('button', { name: 'Expand row' })[0]);
249
+ expectAdaSelectionAndExpansion();
250
+ controller.dispatch({
251
+ type: 'setSorting',
252
+ sorting: [{ columnId: 'age', direction: 'desc' }],
253
+ });
254
+ await vi.waitFor(() => {
255
+ expect(visibleNames()).toEqual(['Linus', 'Ada']);
256
+ expectAdaSelectionAndExpansion();
257
+ });
258
+ controller.dispatch({ type: 'setSearch', search: 'Ada' });
259
+ await vi.waitFor(() => {
260
+ expect(screen.queryByRole('cell', { name: 'Linus' })).not.toBeInTheDocument();
261
+ expectAdaSelectionAndExpansion();
262
+ });
263
+ controller.dispatch({ type: 'setSearch', search: '' });
264
+ await rerender({
265
+ ...props,
266
+ data: [
267
+ { id: 'linus', name: 'Linus', age: 10 },
268
+ { id: 'ada', name: 'Ada', age: 90 },
269
+ ],
270
+ });
271
+ await vi.waitFor(() => {
272
+ expect(visibleNames()).toEqual(['Ada', 'Linus']);
273
+ expectAdaSelectionAndExpansion();
274
+ });
275
+ });
276
+ it('fails closed when durable selection, expansion, manual, or agent modes lack a rowKey', () => {
277
+ expect(() => render(DataTable, { props: { data, columns, selectable: true } })).toThrow(/rowKey is required/);
278
+ expect(() => render(DataTable, {
75
279
  props: {
76
280
  data,
77
281
  columns,
78
- pageSize: 1,
79
- expandedContent: createRawSnippet(() => ({
80
- render: () => '<p>Row detail</p>',
81
- })),
282
+ expandedContent: createRawSnippet(() => ({ render: () => '' })),
82
283
  },
83
- });
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();
284
+ })).toThrow(/rowKey is required/);
285
+ expect(() => render(DataTable, {
286
+ props: { data, columns, agentAddressable: true },
287
+ })).toThrow(/rowKey is required/);
288
+ expect(() => render(DataTable, {
289
+ props: { data, columns, modes: { pagination: 'manual' } },
290
+ })).toThrow(/rowKey is required/);
88
291
  });
89
292
  it('reveals expandable row content', async () => {
90
293
  render(DataTable, {
91
294
  props: {
92
295
  data,
93
296
  columns,
297
+ rowKey: 'id',
94
298
  expandedContent: createRawSnippet(() => ({
95
299
  render: () => '<p>Row detail</p>',
96
300
  })),
@@ -99,6 +303,40 @@ describe('DataTable', () => {
99
303
  await userEvent.click(screen.getAllByRole('button', { name: 'Expand row' })[0]);
100
304
  expect(screen.getByText('Row detail')).toBeInTheDocument();
101
305
  });
306
+ it('rejects duplicate stable row keys and local totals that imply server paging', () => {
307
+ expect(() => render(DataTable, {
308
+ props: {
309
+ data: [data[0], { ...data[1], id: 'ada' }],
310
+ columns,
311
+ rowKey: 'id',
312
+ },
313
+ })).toThrow(/unique row ids/);
314
+ expect(() => render(DataTable, {
315
+ props: { data, columns, rowKey: 'id', totalRows: 10 },
316
+ })).toThrow(/only valid when pagination mode is manual/);
317
+ });
318
+ it('uses stable row IDs as the final local sort tie-breaker', () => {
319
+ const controller = createDataTableController({
320
+ columnIds: columns.map((column) => column.id),
321
+ initialState: { sorting: [{ columnId: 'age', direction: 'asc' }] },
322
+ });
323
+ render(DataTable, {
324
+ props: {
325
+ data: [
326
+ { id: 'z', name: 'Zed', age: 36 },
327
+ { id: 'a', name: 'Ada', age: 36 },
328
+ ],
329
+ columns,
330
+ rowKey: 'id',
331
+ controller,
332
+ },
333
+ });
334
+ const names = screen
335
+ .getAllByRole('row')
336
+ .slice(1)
337
+ .map((row) => within(row).getAllByRole('cell')[0].textContent);
338
+ expect(names).toEqual(['Ada', 'Zed']);
339
+ });
102
340
  it('is axe-clean', async () => {
103
341
  const { container } = render(DataTable, {
104
342
  props: { data, columns, caption: 'People' },
@@ -0,0 +1,214 @@
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
+ selection: { scope: 'explicit', rowIds: [] },
15
+ selectedRowIds: [],
16
+ expandedRowIds: [],
17
+ };
18
+ describe('DataTableController', () => {
19
+ it('uses canonical JSON-safe snapshots regardless of insertion order', () => {
20
+ const first = createDataTableController({
21
+ initialState: {
22
+ ...state,
23
+ filters: [
24
+ {
25
+ columnId: 'age',
26
+ operator: 'gte',
27
+ value: { minimum: 18, maximum: 65 },
28
+ },
29
+ { columnId: 'name', operator: 'contains', value: 'a' },
30
+ ],
31
+ selectedRowIds: ['2', 1, '1', 2],
32
+ expandedRowIds: [3, '3', 1],
33
+ },
34
+ });
35
+ const second = createDataTableController({
36
+ initialState: {
37
+ ...state,
38
+ filters: [
39
+ { columnId: 'name', operator: 'contains', value: 'a' },
40
+ {
41
+ columnId: 'age',
42
+ operator: 'gte',
43
+ value: { maximum: 65, minimum: 18 },
44
+ },
45
+ ],
46
+ selectedRowIds: [2, '1', 1, '2'],
47
+ expandedRowIds: [1, '3', 3],
48
+ },
49
+ });
50
+ expect(JSON.stringify(first.snapshot())).toBe(JSON.stringify(second.snapshot()));
51
+ expect(JSON.parse(JSON.stringify(first.snapshot()))).toEqual(first.snapshot());
52
+ });
53
+ it('omits ignored null-check filter values from canonical snapshots', () => {
54
+ const withoutValue = createDataTableController({
55
+ initialState: {
56
+ ...state,
57
+ filters: [{ columnId: 'name', operator: 'isNull' }],
58
+ },
59
+ });
60
+ const withIgnoredValue = createDataTableController({
61
+ initialState: {
62
+ ...state,
63
+ filters: [
64
+ {
65
+ columnId: 'name',
66
+ operator: 'isNull',
67
+ value: undefined,
68
+ },
69
+ ],
70
+ },
71
+ });
72
+ expect(withIgnoredValue.snapshot()).toEqual(withoutValue.snapshot());
73
+ });
74
+ it('resets page only when a query-shape value changes and clamps reliable totals', () => {
75
+ const controller = createDataTableController({ initialState: state });
76
+ expect(controller.dispatch({ type: 'setSearch', search: '' }).changed).toBe(false);
77
+ expect(controller.snapshot().state.page).toBe(3);
78
+ controller.dispatch({ type: 'setSearch', search: 'ada' });
79
+ expect(controller.snapshot().state.page).toBe(1);
80
+ controller.replaceState({ ...controller.getState(), page: 4 });
81
+ controller.dispatch({ type: 'setColumnOrder', columnIds: ['age', 'name'] });
82
+ expect(controller.snapshot().state.page).toBe(4);
83
+ controller.clampPage(11);
84
+ expect(controller.snapshot().state.page).toBe(2);
85
+ controller.clampPage(0);
86
+ expect(controller.snapshot().state.page).toBe(1);
87
+ expect(() => controller.clampPage(1.5)).toThrow(/totalRows/);
88
+ });
89
+ it('keeps multi-sort priority while cycling a column through asc, desc, and clear', () => {
90
+ const controller = createDataTableController({ initialState: state });
91
+ controller.dispatch({ type: 'toggleSorting', columnId: 'name' });
92
+ controller.dispatch({
93
+ type: 'toggleSorting',
94
+ columnId: 'age',
95
+ multi: true,
96
+ });
97
+ expect(controller.snapshot().state.sorting).toEqual([
98
+ { columnId: 'name', direction: 'asc' },
99
+ { columnId: 'age', direction: 'asc' },
100
+ ]);
101
+ controller.dispatch({
102
+ type: 'toggleSorting',
103
+ columnId: 'name',
104
+ multi: true,
105
+ });
106
+ expect(controller.snapshot().state.sorting).toEqual([
107
+ { columnId: 'name', direction: 'desc' },
108
+ { columnId: 'age', direction: 'asc' },
109
+ ]);
110
+ controller.dispatch({
111
+ type: 'toggleSorting',
112
+ columnId: 'name',
113
+ multi: true,
114
+ });
115
+ expect(controller.snapshot().state.sorting).toEqual([
116
+ { columnId: 'age', direction: 'asc' },
117
+ ]);
118
+ });
119
+ it('proposes controlled transitions without mutating until the host reconciles state', () => {
120
+ const onStateChange = vi.fn();
121
+ const controller = createDataTableController({ state, onStateChange });
122
+ const transition = controller.dispatch({ type: 'setPage', page: 2 });
123
+ expect(transition.next.state.page).toBe(2);
124
+ expect(controller.snapshot().state.page).toBe(3);
125
+ expect(onStateChange).toHaveBeenCalledWith(expect.objectContaining({ page: 2 }), { type: 'setPage', page: 2 });
126
+ controller.replaceState(transition.next.state);
127
+ expect(controller.snapshot().state.page).toBe(2);
128
+ });
129
+ it('notifies subscribers with defensive selection and expansion snapshots', () => {
130
+ const controller = createDataTableController({ initialState: state });
131
+ const listener = vi.fn();
132
+ const unsubscribe = controller.subscribe(listener);
133
+ controller.dispatch({ type: 'toggleRowSelection', rowId: 'row-1' });
134
+ controller.dispatch({ type: 'toggleRowExpansion', rowId: 2 });
135
+ expect(listener).toHaveBeenCalledTimes(2);
136
+ expect(listener.mock.calls[1][0].next.state).toMatchObject({
137
+ selectedRowIds: ['row-1'],
138
+ expandedRowIds: [2],
139
+ });
140
+ listener.mock.calls[1][0].next.state.selectedRowIds.push('mutated');
141
+ expect(controller.snapshot().state.selectedRowIds).toEqual(['row-1']);
142
+ unsubscribe();
143
+ controller.dispatch({ type: 'setSearch', search: 'Ada' });
144
+ expect(listener).toHaveBeenCalledTimes(2);
145
+ });
146
+ it('defensively copies state and validates persisted snapshots', () => {
147
+ const controller = createDataTableController({ initialState: state });
148
+ const snapshot = controller.snapshot();
149
+ snapshot.state.selectedRowIds.push('unexpected');
150
+ expect(controller.snapshot().state.selectedRowIds).toEqual([]);
151
+ expect(hydrateDataTableSnapshot(JSON.parse(JSON.stringify(controller.snapshot())))).toEqual(controller.snapshot());
152
+ const { selection: _selection, ...legacyState } = state;
153
+ expect(hydrateDataTableSnapshot({
154
+ version: 1,
155
+ modes: controller.getModes(),
156
+ state: legacyState,
157
+ })).toMatchObject({
158
+ version: 2,
159
+ state: { selection: { scope: 'explicit', rowIds: [] } },
160
+ });
161
+ expect(() => hydrateDataTableSnapshot({ version: 3 })).toThrow(/version/);
162
+ expect(() => transitionDataTableState(state, { type: 'setPageSize', pageSize: 0 })).toThrow(/pageSize/);
163
+ for (const page of [0, -1, 1.5]) {
164
+ expect(() => transitionDataTableState(state, { type: 'setPage', page })).toThrow(/page/);
165
+ }
166
+ });
167
+ it('models current-page and explicit row selections separately', () => {
168
+ const controller = createDataTableController({ initialState: state });
169
+ controller.dispatch({
170
+ type: 'setPageSelection',
171
+ rowIds: ['row-2', 'row-1'],
172
+ });
173
+ expect(controller.getState().selection).toEqual({
174
+ scope: 'page',
175
+ rowIds: ['row-1', 'row-2'],
176
+ });
177
+ controller.dispatch({ type: 'setPage', page: 4 });
178
+ expect(controller.getState().selection).toEqual({
179
+ scope: 'page',
180
+ rowIds: [],
181
+ });
182
+ controller.dispatch({ type: 'setSelectedRows', rowIds: ['row-2'] });
183
+ controller.dispatch({ type: 'setPage', page: 5 });
184
+ expect(controller.getState().selection).toEqual({
185
+ scope: 'explicit',
186
+ rowIds: ['row-2'],
187
+ });
188
+ });
189
+ it('binds all-matching selection to an exact query revision and invalidates it on query changes', () => {
190
+ const controller = createDataTableController({ initialState: state });
191
+ controller.dispatch({
192
+ type: 'selectAllMatching',
193
+ queryFingerprint: 'dq1_example',
194
+ queryRevision: 'revision-7',
195
+ expectedCount: 42,
196
+ });
197
+ expect(controller.getState().selection).toEqual({
198
+ scope: 'allMatching',
199
+ queryFingerprint: 'dq1_example',
200
+ queryRevision: 'revision-7',
201
+ expectedCount: 42,
202
+ });
203
+ expect(controller.getState().selectedRowIds).toEqual([]);
204
+ expect(() => assertDataTableSelectionCurrent(controller.getState().selection, {
205
+ queryFingerprint: 'dq1_example',
206
+ queryRevision: 'revision-8',
207
+ })).toThrow(/stale/);
208
+ controller.dispatch({ type: 'setSearch', search: 'Ada' });
209
+ expect(controller.getState().selection).toEqual({
210
+ scope: 'explicit',
211
+ rowIds: [],
212
+ });
213
+ });
214
+ });
@@ -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
+ });
@@ -4,5 +4,7 @@
4
4
  export { default as CollectionList, default as ContentList, } from './CollectionList.svelte';
5
5
  export { default as CollectionToolbar } from './CollectionToolbar.svelte';
6
6
  export { default as DataTable } from './DataTable.svelte';
7
+ export * from './DataTableController.js';
8
+ export * from './DataTableIdentity.js';
7
9
  export * from './types.js';
8
10
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/data/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,OAAO,IAAI,cAAc,EACzB,OAAO,IAAI,WAAW,GACvB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,OAAO,IAAI,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AAC1E,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC1D,cAAc,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/data/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,OAAO,IAAI,cAAc,EACzB,OAAO,IAAI,WAAW,GACvB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,OAAO,IAAI,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AAC1E,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC1D,cAAc,0BAA0B,CAAC;AACzC,cAAc,wBAAwB,CAAC;AACvC,cAAc,YAAY,CAAC"}
@@ -4,4 +4,6 @@
4
4
  export { default as CollectionList, default as ContentList, } from './CollectionList.svelte';
5
5
  export { default as CollectionToolbar } from './CollectionToolbar.svelte';
6
6
  export { default as DataTable } from './DataTable.svelte';
7
+ export * from './DataTableController.js';
8
+ export * from './DataTableIdentity.js';
7
9
  export * from './types.js';