mvc-kit 2.12.0 → 2.12.2

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 (139) hide show
  1. package/agent-config/bin/postinstall.mjs +5 -3
  2. package/agent-config/bin/setup.mjs +3 -4
  3. package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
  4. package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
  5. package/agent-config/lib/install-claude.mjs +19 -33
  6. package/dist/Model.cjs +9 -1
  7. package/dist/Model.cjs.map +1 -1
  8. package/dist/Model.d.ts +1 -1
  9. package/dist/Model.d.ts.map +1 -1
  10. package/dist/Model.js +9 -1
  11. package/dist/Model.js.map +1 -1
  12. package/dist/ViewModel.cjs +9 -1
  13. package/dist/ViewModel.cjs.map +1 -1
  14. package/dist/ViewModel.d.ts +1 -1
  15. package/dist/ViewModel.d.ts.map +1 -1
  16. package/dist/ViewModel.js +9 -1
  17. package/dist/ViewModel.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/mvc-kit.cjs +3 -0
  21. package/dist/mvc-kit.cjs.map +1 -1
  22. package/dist/mvc-kit.js +3 -0
  23. package/dist/mvc-kit.js.map +1 -1
  24. package/dist/produceDraft.cjs +105 -0
  25. package/dist/produceDraft.cjs.map +1 -0
  26. package/dist/produceDraft.d.ts +19 -0
  27. package/dist/produceDraft.d.ts.map +1 -0
  28. package/dist/produceDraft.js +105 -0
  29. package/dist/produceDraft.js.map +1 -0
  30. package/package.json +4 -2
  31. package/src/Channel.md +408 -0
  32. package/src/Channel.test.ts +957 -0
  33. package/src/Channel.ts +429 -0
  34. package/src/Collection.md +533 -0
  35. package/src/Collection.test.ts +1559 -0
  36. package/src/Collection.ts +653 -0
  37. package/src/Controller.md +306 -0
  38. package/src/Controller.test.ts +380 -0
  39. package/src/Controller.ts +90 -0
  40. package/src/EventBus.md +308 -0
  41. package/src/EventBus.test.ts +295 -0
  42. package/src/EventBus.ts +110 -0
  43. package/src/Feed.md +218 -0
  44. package/src/Feed.test.ts +442 -0
  45. package/src/Feed.ts +101 -0
  46. package/src/Model.md +524 -0
  47. package/src/Model.test.ts +642 -0
  48. package/src/Model.ts +260 -0
  49. package/src/Pagination.md +168 -0
  50. package/src/Pagination.test.ts +244 -0
  51. package/src/Pagination.ts +92 -0
  52. package/src/Pending.md +380 -0
  53. package/src/Pending.test.ts +1719 -0
  54. package/src/Pending.ts +390 -0
  55. package/src/PersistentCollection.md +183 -0
  56. package/src/PersistentCollection.test.ts +649 -0
  57. package/src/PersistentCollection.ts +375 -0
  58. package/src/Resource.ViewModel.test.ts +503 -0
  59. package/src/Resource.md +239 -0
  60. package/src/Resource.test.ts +786 -0
  61. package/src/Resource.ts +231 -0
  62. package/src/Selection.md +155 -0
  63. package/src/Selection.test.ts +326 -0
  64. package/src/Selection.ts +117 -0
  65. package/src/Service.md +440 -0
  66. package/src/Service.test.ts +241 -0
  67. package/src/Service.ts +72 -0
  68. package/src/Sorting.md +170 -0
  69. package/src/Sorting.test.ts +334 -0
  70. package/src/Sorting.ts +135 -0
  71. package/src/Trackable.md +166 -0
  72. package/src/Trackable.test.ts +236 -0
  73. package/src/Trackable.ts +129 -0
  74. package/src/ViewModel.async.test.ts +813 -0
  75. package/src/ViewModel.derived.test.ts +1583 -0
  76. package/src/ViewModel.md +1111 -0
  77. package/src/ViewModel.test.ts +1236 -0
  78. package/src/ViewModel.ts +800 -0
  79. package/src/bindPublicMethods.test.ts +126 -0
  80. package/src/bindPublicMethods.ts +48 -0
  81. package/src/env.d.ts +5 -0
  82. package/src/errors.test.ts +155 -0
  83. package/src/errors.ts +133 -0
  84. package/src/index.ts +49 -0
  85. package/src/produceDraft.md +90 -0
  86. package/src/produceDraft.test.ts +394 -0
  87. package/src/produceDraft.ts +168 -0
  88. package/src/react/components/CardList.md +97 -0
  89. package/src/react/components/CardList.test.tsx +142 -0
  90. package/src/react/components/CardList.tsx +68 -0
  91. package/src/react/components/DataTable.md +179 -0
  92. package/src/react/components/DataTable.test.tsx +599 -0
  93. package/src/react/components/DataTable.tsx +267 -0
  94. package/src/react/components/InfiniteScroll.md +116 -0
  95. package/src/react/components/InfiniteScroll.test.tsx +218 -0
  96. package/src/react/components/InfiniteScroll.tsx +70 -0
  97. package/src/react/components/types.ts +90 -0
  98. package/src/react/derived.test.tsx +261 -0
  99. package/src/react/guards.ts +24 -0
  100. package/src/react/index.ts +40 -0
  101. package/src/react/provider.test.tsx +143 -0
  102. package/src/react/provider.tsx +55 -0
  103. package/src/react/strict-mode.test.tsx +266 -0
  104. package/src/react/types.ts +25 -0
  105. package/src/react/use-event-bus.md +214 -0
  106. package/src/react/use-event-bus.test.tsx +168 -0
  107. package/src/react/use-event-bus.ts +40 -0
  108. package/src/react/use-instance.md +204 -0
  109. package/src/react/use-instance.test.tsx +350 -0
  110. package/src/react/use-instance.ts +60 -0
  111. package/src/react/use-local.md +457 -0
  112. package/src/react/use-local.rapid-remount.test.tsx +503 -0
  113. package/src/react/use-local.test.tsx +692 -0
  114. package/src/react/use-local.ts +165 -0
  115. package/src/react/use-model.md +364 -0
  116. package/src/react/use-model.test.tsx +394 -0
  117. package/src/react/use-model.ts +161 -0
  118. package/src/react/use-singleton.md +415 -0
  119. package/src/react/use-singleton.test.tsx +296 -0
  120. package/src/react/use-singleton.ts +69 -0
  121. package/src/react/use-subscribe-only.ts +39 -0
  122. package/src/react/use-teardown.md +169 -0
  123. package/src/react/use-teardown.test.tsx +86 -0
  124. package/src/react/use-teardown.ts +27 -0
  125. package/src/react-native/NativeCollection.test.ts +250 -0
  126. package/src/react-native/NativeCollection.ts +138 -0
  127. package/src/react-native/index.ts +1 -0
  128. package/src/singleton.md +310 -0
  129. package/src/singleton.test.ts +204 -0
  130. package/src/singleton.ts +70 -0
  131. package/src/types.ts +70 -0
  132. package/src/walkPrototypeChain.ts +22 -0
  133. package/src/web/IndexedDBCollection.test.ts +235 -0
  134. package/src/web/IndexedDBCollection.ts +66 -0
  135. package/src/web/WebStorageCollection.test.ts +214 -0
  136. package/src/web/WebStorageCollection.ts +116 -0
  137. package/src/web/idb.ts +184 -0
  138. package/src/web/index.ts +2 -0
  139. package/src/wrapAsyncMethods.ts +249 -0
@@ -0,0 +1,599 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { render, screen, fireEvent } from '@testing-library/react';
5
+ import { vi, describe, it, expect } from 'vitest';
6
+ import { DataTable } from './DataTable';
7
+ import type { Column, SelectionState, PaginationState, SortHeaderProps } from './types';
8
+
9
+ interface User {
10
+ id: string;
11
+ name: string;
12
+ age: number;
13
+ role: string;
14
+ }
15
+
16
+ const users: User[] = [
17
+ { id: '1', name: 'Alice', age: 30, role: 'admin' },
18
+ { id: '2', name: 'Bob', age: 25, role: 'user' },
19
+ { id: '3', name: 'Charlie', age: 35, role: 'user' },
20
+ ];
21
+
22
+ const columns: Column<User>[] = [
23
+ { key: 'name', header: 'Name', render: (u) => u.name, sortable: true },
24
+ { key: 'age', header: 'Age', render: (u) => String(u.age), sortable: true },
25
+ { key: 'role', header: 'Role', render: (u) => u.role },
26
+ ];
27
+
28
+ describe('DataTable', () => {
29
+ describe('basic rendering', () => {
30
+ it('renders columns and rows', () => {
31
+ render(<DataTable items={users} columns={columns} />);
32
+ expect(screen.getByText('Name')).toBeDefined();
33
+ expect(screen.getByText('Age')).toBeDefined();
34
+ expect(screen.getByText('Role')).toBeDefined();
35
+ expect(screen.getByText('Alice')).toBeDefined();
36
+ expect(screen.getByText('Bob')).toBeDefined();
37
+ expect(screen.getByText('Charlie')).toBeDefined();
38
+ });
39
+
40
+ it('renders with role="grid"', () => {
41
+ const { container } = render(<DataTable items={users} columns={columns} />);
42
+ expect(container.querySelector('table[role="grid"]')).not.toBeNull();
43
+ });
44
+
45
+ it('renders with data-component', () => {
46
+ const { container } = render(<DataTable items={users} columns={columns} />);
47
+ expect(container.querySelector('[data-component="data-table"]')).not.toBeNull();
48
+ });
49
+
50
+ it('applies className', () => {
51
+ const { container } = render(
52
+ <DataTable items={users} columns={columns} className="my-table" />,
53
+ );
54
+ expect(container.querySelector('.my-table')).not.toBeNull();
55
+ });
56
+
57
+ it('applies aria-label', () => {
58
+ const { container } = render(
59
+ <DataTable items={users} columns={columns} aria-label="Users table" />,
60
+ );
61
+ expect(container.querySelector('table')!.getAttribute('aria-label')).toBe('Users table');
62
+ });
63
+ });
64
+
65
+ describe('empty / loading / error states', () => {
66
+ it('renders empty state', () => {
67
+ render(
68
+ <DataTable
69
+ items={[]}
70
+ columns={columns}
71
+ renderEmpty={() => <p>No users</p>}
72
+ />,
73
+ );
74
+ expect(screen.getByText('No users')).toBeDefined();
75
+ });
76
+
77
+ it('renders loading state', () => {
78
+ render(
79
+ <DataTable
80
+ items={users}
81
+ columns={columns}
82
+ loading={true}
83
+ renderLoading={() => <p>Loading...</p>}
84
+ />,
85
+ );
86
+ expect(screen.getByText('Loading...')).toBeDefined();
87
+ expect(screen.queryByText('Alice')).toBeNull();
88
+ });
89
+
90
+ it('renders error state', () => {
91
+ render(
92
+ <DataTable
93
+ items={users}
94
+ columns={columns}
95
+ error="Failed to load"
96
+ renderError={(err) => <p>Error: {err}</p>}
97
+ />,
98
+ );
99
+ expect(screen.getByText('Error: Failed to load')).toBeDefined();
100
+ expect(screen.queryByText('Alice')).toBeNull();
101
+ });
102
+ });
103
+
104
+ describe('sort headers', () => {
105
+ it('renders sortable column headers as buttons', () => {
106
+ const onSort = vi.fn();
107
+ const { container } = render(
108
+ <DataTable
109
+ items={users}
110
+ columns={columns}
111
+ sort={[]}
112
+ onSort={onSort}
113
+ />,
114
+ );
115
+
116
+ // Name and Age are sortable, Role is not
117
+ const buttons = container.querySelectorAll('th button');
118
+ expect(buttons.length).toBe(2);
119
+ });
120
+
121
+ it('calls onSort when header clicked', () => {
122
+ const onSort = vi.fn();
123
+ render(
124
+ <DataTable
125
+ items={users}
126
+ columns={columns}
127
+ sort={[]}
128
+ onSort={onSort}
129
+ />,
130
+ );
131
+
132
+ // Click the "Name" sort button
133
+ const nameButton = screen.getByRole('button', { name: /Name/ });
134
+ fireEvent.click(nameButton);
135
+ expect(onSort).toHaveBeenCalledWith('name');
136
+ });
137
+
138
+ it('renders aria-sort on sortable columns', () => {
139
+ const { container } = render(
140
+ <DataTable
141
+ items={users}
142
+ columns={columns}
143
+ sort={[{ key: 'name', direction: 'asc' }]}
144
+ onSort={() => {}}
145
+ />,
146
+ );
147
+
148
+ const headers = container.querySelectorAll('th');
149
+ // Skip selection column (not present) — first is Name
150
+ const nameHeader = Array.from(headers).find(h => h.textContent?.includes('Name'))!;
151
+ expect(nameHeader.getAttribute('aria-sort')).toBe('ascending');
152
+
153
+ const ageHeader = Array.from(headers).find(h => h.textContent?.includes('Age'))!;
154
+ expect(ageHeader.getAttribute('aria-sort')).toBe('none');
155
+ });
156
+
157
+ it('renders descending aria-sort', () => {
158
+ const { container } = render(
159
+ <DataTable
160
+ items={users}
161
+ columns={columns}
162
+ sort={[{ key: 'age', direction: 'desc' }]}
163
+ onSort={() => {}}
164
+ />,
165
+ );
166
+
167
+ const ageHeader = Array.from(container.querySelectorAll('th'))
168
+ .find(h => h.textContent?.includes('Age'))!;
169
+ expect(ageHeader.getAttribute('aria-sort')).toBe('descending');
170
+ });
171
+
172
+ it('renders custom sort indicator', () => {
173
+ const renderSortIndicator = (props: SortHeaderProps) => (
174
+ <span data-testid="sort-indicator">
175
+ {props.active ? (props.direction === 'asc' ? '↑' : '↓') : '-'}
176
+ </span>
177
+ );
178
+
179
+ render(
180
+ <DataTable
181
+ items={users}
182
+ columns={columns}
183
+ sort={[{ key: 'name', direction: 'asc' }]}
184
+ onSort={() => {}}
185
+ renderSortIndicator={renderSortIndicator}
186
+ />,
187
+ );
188
+
189
+ const indicators = screen.getAllByTestId('sort-indicator');
190
+ expect(indicators.length).toBe(2); // Name and Age columns
191
+ expect(indicators[0].textContent).toBe('↑');
192
+ expect(indicators[1].textContent).toBe('-');
193
+ });
194
+ });
195
+
196
+ describe('selection', () => {
197
+ it('renders select-all checkbox in header', () => {
198
+ const selection: SelectionState = {
199
+ selected: new Set(),
200
+ onToggle: vi.fn(),
201
+ onToggleAll: vi.fn(),
202
+ };
203
+
204
+ const { container } = render(
205
+ <DataTable items={users} columns={columns} selection={selection} />,
206
+ );
207
+
208
+ const selectAllCheckbox = container.querySelector(
209
+ 'th[data-column="select"] input[type="checkbox"]',
210
+ ) as HTMLInputElement;
211
+ expect(selectAllCheckbox).not.toBeNull();
212
+ expect(selectAllCheckbox.checked).toBe(false);
213
+ });
214
+
215
+ it('renders row checkboxes', () => {
216
+ const selection: SelectionState = {
217
+ selected: new Set(['1']),
218
+ onToggle: vi.fn(),
219
+ onToggleAll: vi.fn(),
220
+ };
221
+
222
+ const { container } = render(
223
+ <DataTable items={users} columns={columns} selection={selection} />,
224
+ );
225
+
226
+ const rowCheckboxes = container.querySelectorAll(
227
+ 'td[data-column="select"] input[type="checkbox"]',
228
+ );
229
+ expect(rowCheckboxes.length).toBe(3);
230
+ expect((rowCheckboxes[0] as HTMLInputElement).checked).toBe(true);
231
+ expect((rowCheckboxes[1] as HTMLInputElement).checked).toBe(false);
232
+ });
233
+
234
+ it('calls onToggle when row checkbox clicked', () => {
235
+ const onToggle = vi.fn();
236
+ const selection: SelectionState = {
237
+ selected: new Set(),
238
+ onToggle,
239
+ onToggleAll: vi.fn(),
240
+ };
241
+
242
+ const { container } = render(
243
+ <DataTable items={users} columns={columns} selection={selection} />,
244
+ );
245
+
246
+ const rowCheckboxes = container.querySelectorAll(
247
+ 'td[data-column="select"] input[type="checkbox"]',
248
+ );
249
+ fireEvent.click(rowCheckboxes[1]);
250
+ expect(onToggle).toHaveBeenCalledWith('2');
251
+ });
252
+
253
+ it('calls onToggleAll with allKeys when header checkbox clicked', () => {
254
+ const onToggleAll = vi.fn();
255
+ const selection: SelectionState = {
256
+ selected: new Set(),
257
+ onToggle: vi.fn(),
258
+ onToggleAll,
259
+ };
260
+
261
+ const { container } = render(
262
+ <DataTable items={users} columns={columns} selection={selection} />,
263
+ );
264
+
265
+ const selectAll = container.querySelector(
266
+ 'th[data-column="select"] input[type="checkbox"]',
267
+ )!;
268
+ fireEvent.click(selectAll);
269
+ expect(onToggleAll).toHaveBeenCalledWith(['1', '2', '3']);
270
+ });
271
+
272
+ it('sets indeterminate state when partially selected', () => {
273
+ const selection: SelectionState = {
274
+ selected: new Set(['1']),
275
+ onToggle: vi.fn(),
276
+ onToggleAll: vi.fn(),
277
+ };
278
+
279
+ const { container } = render(
280
+ <DataTable items={users} columns={columns} selection={selection} />,
281
+ );
282
+
283
+ const selectAll = container.querySelector(
284
+ 'th[data-column="select"] input[type="checkbox"]',
285
+ ) as HTMLInputElement;
286
+ expect(selectAll.indeterminate).toBe(true);
287
+ expect(selectAll.checked).toBe(false);
288
+ });
289
+
290
+ it('checks select-all when all selected', () => {
291
+ const selection: SelectionState = {
292
+ selected: new Set(['1', '2', '3']),
293
+ onToggle: vi.fn(),
294
+ onToggleAll: vi.fn(),
295
+ };
296
+
297
+ const { container } = render(
298
+ <DataTable items={users} columns={columns} selection={selection} />,
299
+ );
300
+
301
+ const selectAll = container.querySelector(
302
+ 'th[data-column="select"] input[type="checkbox"]',
303
+ ) as HTMLInputElement;
304
+ expect(selectAll.checked).toBe(true);
305
+ expect(selectAll.indeterminate).toBe(false);
306
+ });
307
+
308
+ it('sets data-selected on selected rows', () => {
309
+ const selection: SelectionState = {
310
+ selected: new Set(['2']),
311
+ onToggle: vi.fn(),
312
+ onToggleAll: vi.fn(),
313
+ };
314
+
315
+ const { container } = render(
316
+ <DataTable items={users} columns={columns} selection={selection} />,
317
+ );
318
+
319
+ const rows = container.querySelectorAll('tbody tr');
320
+ expect(rows[0].hasAttribute('data-selected')).toBe(false);
321
+ expect(rows[1].hasAttribute('data-selected')).toBe(true);
322
+ expect(rows[2].hasAttribute('data-selected')).toBe(false);
323
+ });
324
+ });
325
+
326
+ describe('controlled pagination', () => {
327
+ it('renders pagination info via renderPagination', () => {
328
+ const pagination: PaginationState = {
329
+ page: 2,
330
+ total: 100,
331
+ onPageChange: vi.fn(),
332
+ };
333
+
334
+ render(
335
+ <DataTable
336
+ items={users}
337
+ columns={columns}
338
+ pageSize={10}
339
+ pagination={pagination}
340
+ renderPagination={(info) => (
341
+ <div data-testid="pagination">
342
+ Page {info.page} of {info.pageCount}
343
+ </div>
344
+ )}
345
+ />,
346
+ );
347
+
348
+ expect(screen.getByText('Page 2 of 10')).toBeDefined();
349
+ });
350
+
351
+ it('provides correct pagination info', () => {
352
+ const onPageChange = vi.fn();
353
+ const pagination: PaginationState = {
354
+ page: 1,
355
+ total: 25,
356
+ onPageChange,
357
+ };
358
+
359
+ let capturedInfo: any;
360
+ render(
361
+ <DataTable
362
+ items={users}
363
+ columns={columns}
364
+ pageSize={10}
365
+ pagination={pagination}
366
+ renderPagination={(info) => {
367
+ capturedInfo = info;
368
+ return (
369
+ <div>
370
+ <button onClick={info.goNext}>Next</button>
371
+ <button onClick={info.goPrev}>Prev</button>
372
+ </div>
373
+ );
374
+ }}
375
+ />,
376
+ );
377
+
378
+ expect(capturedInfo.page).toBe(1);
379
+ expect(capturedInfo.pageCount).toBe(3);
380
+ expect(capturedInfo.total).toBe(25);
381
+ expect(capturedInfo.pageSize).toBe(10);
382
+ expect(capturedInfo.hasPrev).toBe(false);
383
+ expect(capturedInfo.hasNext).toBe(true);
384
+
385
+ fireEvent.click(screen.getByText('Next'));
386
+ expect(onPageChange).toHaveBeenCalledWith(2);
387
+ });
388
+ });
389
+
390
+ describe('uncontrolled pagination', () => {
391
+ it('slices items to first page when pageSize given without pagination', () => {
392
+ const items = Array.from({ length: 10 }, (_, i) => ({
393
+ id: String(i),
394
+ name: `User ${i}`,
395
+ age: 20 + i,
396
+ role: 'user',
397
+ }));
398
+
399
+ const { container } = render(
400
+ <DataTable items={items} columns={columns} pageSize={3} />,
401
+ );
402
+
403
+ const rows = container.querySelectorAll('tbody tr');
404
+ expect(rows.length).toBe(3);
405
+ });
406
+ });
407
+
408
+ describe('render slots', () => {
409
+ it('renderRow wraps row content', () => {
410
+ render(
411
+ <DataTable
412
+ items={users.slice(0, 1)}
413
+ columns={columns}
414
+ renderRow={(item, index, cells) => (
415
+ <>{cells}<td data-testid="extra">Extra</td></>
416
+ )}
417
+ />,
418
+ );
419
+ expect(screen.getByTestId('extra')).toBeDefined();
420
+ });
421
+ });
422
+
423
+ describe('column alignment', () => {
424
+ it('sets data-align attribute on th and td', () => {
425
+ const cols: Column<User>[] = [
426
+ { key: 'name', header: 'Name', render: (u) => u.name },
427
+ { key: 'age', header: 'Age', render: (u) => String(u.age), align: 'right' },
428
+ ];
429
+
430
+ const { container } = render(
431
+ <DataTable items={users.slice(0, 1)} columns={cols} />,
432
+ );
433
+
434
+ const headers = container.querySelectorAll('th');
435
+ expect(headers[1].getAttribute('data-align')).toBe('right');
436
+
437
+ const cells = container.querySelectorAll('td');
438
+ expect(cells[1].getAttribute('data-align')).toBe('right');
439
+ });
440
+ });
441
+
442
+ describe('column width', () => {
443
+ it('sets width style on th', () => {
444
+ const cols: Column<User>[] = [
445
+ { key: 'name', header: 'Name', render: (u) => u.name, width: '200px' },
446
+ ];
447
+
448
+ const { container } = render(
449
+ <DataTable items={users.slice(0, 1)} columns={cols} />,
450
+ );
451
+
452
+ const th = container.querySelector('th')!;
453
+ expect((th as HTMLElement).style.width).toBe('200px');
454
+ });
455
+ });
456
+
457
+ describe('helper instance integration', () => {
458
+ it('Selection helper: toggle called on row click', () => {
459
+ const helper = {
460
+ selected: new Set<string | number>(),
461
+ toggle: vi.fn(),
462
+ toggleAll: vi.fn(),
463
+ };
464
+
465
+ const { container } = render(
466
+ <DataTable items={users} columns={columns} selection={helper} />,
467
+ );
468
+
469
+ const rowCheckboxes = container.querySelectorAll(
470
+ 'td[data-column="select"] input[type="checkbox"]',
471
+ );
472
+ fireEvent.click(rowCheckboxes[1]);
473
+ expect(helper.toggle).toHaveBeenCalledWith('2');
474
+ });
475
+
476
+ it('Selection helper: toggleAll called with visible item keys on select-all', () => {
477
+ const helper = {
478
+ selected: new Set<string | number>(),
479
+ toggle: vi.fn(),
480
+ toggleAll: vi.fn(),
481
+ };
482
+
483
+ const { container } = render(
484
+ <DataTable items={users} columns={columns} selection={helper} />,
485
+ );
486
+
487
+ const selectAll = container.querySelector(
488
+ 'th[data-column="select"] input[type="checkbox"]',
489
+ )!;
490
+ fireEvent.click(selectAll);
491
+ expect(helper.toggleAll).toHaveBeenCalledWith(['1', '2', '3']);
492
+ });
493
+
494
+ it('Pagination helper: reads page/pageSize from helper, calls setPage', () => {
495
+ const helper = {
496
+ page: 2,
497
+ pageSize: 10,
498
+ setPage: vi.fn(),
499
+ };
500
+
501
+ let capturedInfo: any;
502
+ render(
503
+ <DataTable
504
+ items={users}
505
+ columns={columns}
506
+ pagination={helper}
507
+ paginationTotal={25}
508
+ renderPagination={(info) => {
509
+ capturedInfo = info;
510
+ return <button onClick={info.goNext}>Next</button>;
511
+ }}
512
+ />,
513
+ );
514
+
515
+ expect(capturedInfo.page).toBe(2);
516
+ expect(capturedInfo.pageSize).toBe(10);
517
+ expect(capturedInfo.total).toBe(25);
518
+ expect(capturedInfo.pageCount).toBe(3);
519
+
520
+ fireEvent.click(screen.getByText('Next'));
521
+ expect(helper.setPage).toHaveBeenCalledWith(3);
522
+ });
523
+
524
+ it('Sorting helper: reads sorts for display, calls toggle on header click', () => {
525
+ const helper = {
526
+ sorts: [{ key: 'name', direction: 'asc' as const }],
527
+ toggle: vi.fn(),
528
+ };
529
+
530
+ const { container } = render(
531
+ <DataTable items={users} columns={columns} sort={helper} />,
532
+ );
533
+
534
+ // aria-sort should be set
535
+ const nameHeader = Array.from(container.querySelectorAll('th'))
536
+ .find(h => h.textContent?.includes('Name'))!;
537
+ expect(nameHeader.getAttribute('aria-sort')).toBe('ascending');
538
+
539
+ // Click sort button
540
+ const nameButton = screen.getByRole('button', { name: /Name/ });
541
+ fireEvent.click(nameButton);
542
+ expect(helper.toggle).toHaveBeenCalledWith('name');
543
+ });
544
+
545
+ it('Sorting helper ignores onSort prop when helper provided', () => {
546
+ const helper = {
547
+ sorts: [{ key: 'name', direction: 'asc' as const }],
548
+ toggle: vi.fn(),
549
+ };
550
+ const onSort = vi.fn();
551
+
552
+ render(
553
+ <DataTable items={users} columns={columns} sort={helper} onSort={onSort} />,
554
+ );
555
+
556
+ const nameButton = screen.getByRole('button', { name: /Name/ });
557
+ fireEvent.click(nameButton);
558
+ expect(helper.toggle).toHaveBeenCalledWith('name');
559
+ expect(onSort).not.toHaveBeenCalled();
560
+ });
561
+
562
+ it('mixed: helper selection + object-literal pagination', () => {
563
+ const selectionHelper = {
564
+ selected: new Set<string | number>(['1']),
565
+ toggle: vi.fn(),
566
+ toggleAll: vi.fn(),
567
+ };
568
+
569
+ const pagination: PaginationState = {
570
+ page: 1,
571
+ total: 3,
572
+ onPageChange: vi.fn(),
573
+ };
574
+
575
+ const { container } = render(
576
+ <DataTable
577
+ items={users}
578
+ columns={columns}
579
+ selection={selectionHelper}
580
+ pagination={pagination}
581
+ pageSize={10}
582
+ renderPagination={(info) => <div>Page {info.page}</div>}
583
+ />,
584
+ );
585
+
586
+ // Selection helper works
587
+ const rowCheckboxes = container.querySelectorAll(
588
+ 'td[data-column="select"] input[type="checkbox"]',
589
+ );
590
+ expect((rowCheckboxes[0] as HTMLInputElement).checked).toBe(true);
591
+
592
+ fireEvent.click(rowCheckboxes[1]);
593
+ expect(selectionHelper.toggle).toHaveBeenCalledWith('2');
594
+
595
+ // Object-literal pagination works
596
+ expect(screen.getByText('Page 1')).toBeDefined();
597
+ });
598
+ });
599
+ });