@dashforge/tw 0.6.0-beta → 0.8.0-beta

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 (107) hide show
  1. package/CHANGELOG.md +451 -0
  2. package/THEME-AUDIT.md +175 -0
  3. package/dist/index.esm.js +2009 -87
  4. package/dist/src/components/Accordion/accordion.variants.d.ts.map +1 -1
  5. package/dist/src/components/Box/box.variants.d.ts.map +1 -1
  6. package/dist/src/components/DataGrid/DataGrid.d.ts +29 -0
  7. package/dist/src/components/DataGrid/DataGrid.d.ts.map +1 -0
  8. package/dist/src/components/DataGrid/dataGrid.types.d.ts +214 -0
  9. package/dist/src/components/DataGrid/dataGrid.types.d.ts.map +1 -0
  10. package/dist/src/components/DataGrid/dataGrid.variants.d.ts +222 -0
  11. package/dist/src/components/DataGrid/dataGrid.variants.d.ts.map +1 -0
  12. package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts +23 -0
  13. package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts.map +1 -0
  14. package/dist/src/components/DataGrid/reorder/useColumnReorder.d.ts +50 -0
  15. package/dist/src/components/DataGrid/reorder/useColumnReorder.d.ts.map +1 -0
  16. package/dist/src/components/DataGrid/resize/useColumnResize.d.ts +32 -0
  17. package/dist/src/components/DataGrid/resize/useColumnResize.d.ts.map +1 -0
  18. package/dist/src/components/DataGrid/visibility/ColumnVisibilityDialog.d.ts +33 -0
  19. package/dist/src/components/DataGrid/visibility/ColumnVisibilityDialog.d.ts.map +1 -0
  20. package/dist/src/components/Dialog/dialog.variants.d.ts.map +1 -1
  21. package/dist/src/components/Divider/divider.variants.d.ts.map +1 -1
  22. package/dist/src/components/Pagination/Pagination.d.ts.map +1 -1
  23. package/dist/src/components/Pagination/pagination.variants.d.ts.map +1 -1
  24. package/dist/src/components/Popover/popover.variants.d.ts.map +1 -1
  25. package/dist/src/components/Skeleton/skeleton.variants.d.ts.map +1 -1
  26. package/dist/src/components/Table/Table.d.ts.map +1 -1
  27. package/dist/src/components/Table/table.types.d.ts +77 -5
  28. package/dist/src/components/Table/table.types.d.ts.map +1 -1
  29. package/dist/src/components/Tabs/tabs.variants.d.ts.map +1 -1
  30. package/dist/src/components/Tooltip/tooltip.variants.d.ts.map +1 -1
  31. package/dist/src/components/Typography/typography.variants.d.ts.map +1 -1
  32. package/dist/src/components/_shared/data/getNestedValue.d.ts.map +1 -0
  33. package/dist/src/components/{Table/_internal → _shared/data}/useColumnAutoDetect.d.ts +1 -1
  34. package/dist/src/components/_shared/data/useColumnAutoDetect.d.ts.map +1 -0
  35. package/dist/src/components/_shared/data/useControllableState.d.ts.map +1 -0
  36. package/dist/src/components/_shared/data/useDebouncedValue.d.ts.map +1 -0
  37. package/dist/src/components/_shared/data/useTableFilter.d.ts +36 -0
  38. package/dist/src/components/_shared/data/useTableFilter.d.ts.map +1 -0
  39. package/dist/src/components/{Table/_internal → _shared/data}/useTableSearch.d.ts +1 -1
  40. package/dist/src/components/_shared/data/useTableSearch.d.ts.map +1 -0
  41. package/dist/src/components/{Table/_internal → _shared/data}/useTableSelection.d.ts +1 -1
  42. package/dist/src/components/_shared/data/useTableSelection.d.ts.map +1 -0
  43. package/dist/src/components/{Table/_internal → _shared/data}/useTableSort.d.ts +1 -1
  44. package/dist/src/components/_shared/data/useTableSort.d.ts.map +1 -0
  45. package/dist/src/components/_shared/data/useVirtualizer.d.ts +107 -0
  46. package/dist/src/components/_shared/data/useVirtualizer.d.ts.map +1 -0
  47. package/dist/src/index.d.ts +6 -3
  48. package/dist/src/index.d.ts.map +1 -1
  49. package/package.json +5 -5
  50. package/src/components/Accordion/accordion.variants.ts +5 -3
  51. package/src/components/Box/Box.test.tsx +32 -11
  52. package/src/components/Box/box.variants.ts +23 -4
  53. package/src/components/DataGrid/DataGrid.test.tsx +917 -0
  54. package/src/components/DataGrid/DataGrid.tsx +1199 -0
  55. package/src/components/DataGrid/dataGrid.types.ts +238 -0
  56. package/src/components/DataGrid/dataGrid.variants.ts +152 -0
  57. package/src/components/DataGrid/filters/ColumnFilters.tsx +432 -0
  58. package/src/components/DataGrid/reorder/useColumnReorder.test.ts +38 -0
  59. package/src/components/DataGrid/reorder/useColumnReorder.ts +144 -0
  60. package/src/components/DataGrid/resize/useColumnResize.ts +106 -0
  61. package/src/components/DataGrid/visibility/ColumnVisibilityDialog.tsx +200 -0
  62. package/src/components/Dialog/dialog.variants.ts +10 -6
  63. package/src/components/Divider/Divider.test.tsx +3 -1
  64. package/src/components/Divider/Divider.tsx +1 -1
  65. package/src/components/Divider/divider.variants.ts +2 -1
  66. package/src/components/Pagination/Pagination.tsx +3 -1
  67. package/src/components/Pagination/pagination.variants.ts +19 -13
  68. package/src/components/Popover/popover.variants.ts +10 -4
  69. package/src/components/Skeleton/skeleton.variants.ts +2 -1
  70. package/src/components/Table/Table.tsx +22 -8
  71. package/src/components/Table/table.types.ts +83 -5
  72. package/src/components/Tabs/tabs.variants.ts +19 -8
  73. package/src/components/Tooltip/tooltip.variants.ts +12 -2
  74. package/src/components/Typography/Typography.test.tsx +3 -1
  75. package/src/components/Typography/typography.variants.ts +6 -2
  76. package/src/components/{Table/_internal → _shared/data}/useColumnAutoDetect.test.ts +1 -1
  77. package/src/components/{Table/_internal → _shared/data}/useColumnAutoDetect.ts +1 -1
  78. package/src/components/_shared/data/useTableFilter.test.ts +166 -0
  79. package/src/components/_shared/data/useTableFilter.ts +118 -0
  80. package/src/components/{Table/_internal → _shared/data}/useTableSearch.test.ts +1 -1
  81. package/src/components/{Table/_internal → _shared/data}/useTableSearch.ts +1 -1
  82. package/src/components/{Table/_internal → _shared/data}/useTableSelection.ts +1 -1
  83. package/src/components/{Table/_internal → _shared/data}/useTableSort.test.ts +1 -1
  84. package/src/components/{Table/_internal → _shared/data}/useTableSort.ts +1 -1
  85. package/src/components/_shared/data/useVirtualizer.test.ts +158 -0
  86. package/src/components/_shared/data/useVirtualizer.ts +199 -0
  87. package/src/components/_shared/themeIdentity.test.ts +113 -0
  88. package/src/index.ts +20 -2
  89. package/dist/src/components/Table/_internal/getNestedValue.d.ts.map +0 -1
  90. package/dist/src/components/Table/_internal/useColumnAutoDetect.d.ts.map +0 -1
  91. package/dist/src/components/Table/_internal/useControllableState.d.ts.map +0 -1
  92. package/dist/src/components/Table/_internal/useDebouncedValue.d.ts.map +0 -1
  93. package/dist/src/components/Table/_internal/useTableFilter.d.ts +0 -13
  94. package/dist/src/components/Table/_internal/useTableFilter.d.ts.map +0 -1
  95. package/dist/src/components/Table/_internal/useTableSearch.d.ts.map +0 -1
  96. package/dist/src/components/Table/_internal/useTableSelection.d.ts.map +0 -1
  97. package/dist/src/components/Table/_internal/useTableSort.d.ts.map +0 -1
  98. package/src/components/Table/_internal/themeIdentity.test.ts +0 -84
  99. package/src/components/Table/_internal/useTableFilter.ts +0 -37
  100. /package/dist/src/components/{Table/_internal → _shared/data}/getNestedValue.d.ts +0 -0
  101. /package/dist/src/components/{Table/_internal → _shared/data}/useControllableState.d.ts +0 -0
  102. /package/dist/src/components/{Table/_internal → _shared/data}/useDebouncedValue.d.ts +0 -0
  103. /package/src/components/{Table/_internal → _shared/data}/getNestedValue.test.ts +0 -0
  104. /package/src/components/{Table/_internal → _shared/data}/getNestedValue.ts +0 -0
  105. /package/src/components/{Table/_internal → _shared/data}/useControllableState.ts +0 -0
  106. /package/src/components/{Table/_internal → _shared/data}/useDebouncedValue.ts +0 -0
  107. /package/src/components/{Table/_internal → _shared/data}/useTableSelection.test.ts +0 -0
@@ -0,0 +1,917 @@
1
+ // @vitest-environment jsdom
2
+ import * as React from 'react';
3
+ import { describe, it, expect, afterEach, beforeAll, vi } from 'vitest';
4
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react';
5
+ import { DataGrid } from './DataGrid.js';
6
+ import type { TableColumn } from '../Table/table.types.js';
7
+
8
+ void React;
9
+ afterEach(() => cleanup());
10
+
11
+ // jsdom doesn't implement ResizeObserver / IntersectionObserver —
12
+ // virtualizer + Radix Popover (used via shared cells lib) need them.
13
+ beforeAll(() => {
14
+ if (typeof globalThis.ResizeObserver === 'undefined') {
15
+ (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
16
+ observe() {}
17
+ unobserve() {}
18
+ disconnect() {}
19
+ };
20
+ }
21
+ // jsdom returns 0 for clientHeight by default; the virtualizer
22
+ // uses initialViewportHeight=400 in the absence of a real measurement.
23
+ });
24
+
25
+ interface User {
26
+ id: string;
27
+ name: string;
28
+ email: string;
29
+ age: number;
30
+ status: 'active' | 'pending';
31
+ meta: { city: string };
32
+ }
33
+
34
+ const baseCols: TableColumn<User>[] = [
35
+ { field: 'name', header: 'Name', sortable: true, searchable: true },
36
+ { field: 'email', header: 'Email', searchable: true },
37
+ { field: 'age', header: 'Age', sortable: true },
38
+ { field: 'meta.city' as never, header: 'City' },
39
+ ];
40
+
41
+ function generateUsers(count: number): User[] {
42
+ return Array.from({ length: count }).map((_, i) => ({
43
+ id: `u${i}`,
44
+ name: `User ${i}`,
45
+ email: `user${i}@example.com`,
46
+ age: 20 + (i % 50),
47
+ status: i % 2 === 0 ? ('active' as const) : ('pending' as const),
48
+ meta: { city: ['Roma', 'Milano', 'Berlin'][i % 3] as string },
49
+ }));
50
+ }
51
+
52
+ const getRowId = (row: User) => row.id;
53
+
54
+ describe('DataGrid — rendering', () => {
55
+ it('renders a <table> element', () => {
56
+ const { container } = render(
57
+ <DataGrid
58
+ rows={generateUsers(50)}
59
+ cols={baseCols}
60
+ getRowId={getRowId}
61
+ rowHeight={48}
62
+ height="400px"
63
+ />,
64
+ );
65
+ expect(container.querySelector('table')).not.toBeNull();
66
+ });
67
+
68
+ it('renders column headers', () => {
69
+ render(
70
+ <DataGrid
71
+ rows={generateUsers(20)}
72
+ cols={baseCols}
73
+ getRowId={getRowId}
74
+ rowHeight={48}
75
+ height="400px"
76
+ />,
77
+ );
78
+ expect(screen.queryByText('Name')).not.toBeNull();
79
+ expect(screen.queryByText('Email')).not.toBeNull();
80
+ expect(screen.queryByText('Age')).not.toBeNull();
81
+ });
82
+
83
+ it('renders only a window of rows (virtualized)', () => {
84
+ const { container } = render(
85
+ <DataGrid
86
+ rows={generateUsers(1000)}
87
+ cols={baseCols}
88
+ getRowId={getRowId}
89
+ rowHeight={48}
90
+ height="400px"
91
+ />,
92
+ );
93
+ // Visible window with 400px height + 48px rows = ~9 rows + overscan
94
+ // (5 top + 5 bottom). Initial viewport height fallback = 400px.
95
+ // So at scrollTop=0 we render rows 0 to ~13 (no top overscan needed).
96
+ const dataRows = container.querySelectorAll('tbody tr[data-selected]');
97
+ expect(dataRows.length).toBeLessThan(50); // certainly not all 1000
98
+ expect(dataRows.length).toBeGreaterThan(5); // at least the visible window
99
+ });
100
+
101
+ it('renders spacer rows above and below the visible window', () => {
102
+ const { container } = render(
103
+ <DataGrid
104
+ rows={generateUsers(1000)}
105
+ cols={baseCols}
106
+ getRowId={getRowId}
107
+ rowHeight={48}
108
+ height="400px"
109
+ />,
110
+ );
111
+ // Spacer rows are <tr aria-hidden="true"> with inline height style.
112
+ const spacers = container.querySelectorAll('tbody tr[aria-hidden="true"]');
113
+ expect(spacers.length).toBeGreaterThanOrEqual(1);
114
+ });
115
+
116
+ it('renders empty state when rows is empty', () => {
117
+ render(
118
+ <DataGrid
119
+ rows={[]}
120
+ cols={baseCols}
121
+ getRowId={getRowId}
122
+ rowHeight={48}
123
+ height="400px"
124
+ />,
125
+ );
126
+ expect(screen.queryByText('No data')).not.toBeNull();
127
+ });
128
+
129
+ it('renders loading skeleton rows', () => {
130
+ const { container } = render(
131
+ <DataGrid
132
+ rows={[]}
133
+ cols={baseCols}
134
+ getRowId={getRowId}
135
+ rowHeight={48}
136
+ height="400px"
137
+ loading
138
+ loadingRowCount={6}
139
+ />,
140
+ );
141
+ const busyRows = container.querySelectorAll('tr[aria-busy="true"]');
142
+ expect(busyRows.length).toBe(6);
143
+ });
144
+ });
145
+
146
+ describe('DataGrid — sort', () => {
147
+ it('sortable header has aria-sort=none initially', () => {
148
+ render(
149
+ <DataGrid
150
+ rows={generateUsers(20)}
151
+ cols={baseCols}
152
+ getRowId={getRowId}
153
+ rowHeight={48}
154
+ height="400px"
155
+ />,
156
+ );
157
+ const header = screen.getByText('Name').closest('th');
158
+ expect(header?.getAttribute('aria-sort')).toBe('none');
159
+ });
160
+
161
+ it('click sortable header → asc', () => {
162
+ render(
163
+ <DataGrid
164
+ rows={generateUsers(20)}
165
+ cols={baseCols}
166
+ getRowId={getRowId}
167
+ rowHeight={48}
168
+ height="400px"
169
+ />,
170
+ );
171
+ fireEvent.click(screen.getByText('Name').closest('button')!);
172
+ expect(screen.getByText('Name').closest('th')?.getAttribute('aria-sort')).toBe(
173
+ 'ascending',
174
+ );
175
+ });
176
+
177
+ it('serverSideSort prevents local sort, emits onSortChange', () => {
178
+ let lastModel: unknown = null;
179
+ const rows = generateUsers(20);
180
+ const { container } = render(
181
+ <DataGrid
182
+ rows={rows}
183
+ cols={baseCols}
184
+ getRowId={getRowId}
185
+ rowHeight={48}
186
+ height="400px"
187
+ serverSideSort
188
+ onSortChange={(m) => (lastModel = m)}
189
+ />,
190
+ );
191
+ const firstNameBeforeSort = container.querySelector('tbody tr[data-selected] td')?.textContent;
192
+ fireEvent.click(screen.getByText('Name').closest('button')!);
193
+ expect(lastModel).toEqual([{ field: 'name', direction: 'asc' }]);
194
+ // Row order didn't change since server-side: still original order.
195
+ const firstNameAfterSort = container.querySelector('tbody tr[data-selected] td')?.textContent;
196
+ expect(firstNameAfterSort).toBe(firstNameBeforeSort);
197
+ });
198
+ });
199
+
200
+ describe('DataGrid — search', () => {
201
+ it('renders search input when enableSearch', () => {
202
+ render(
203
+ <DataGrid
204
+ rows={generateUsers(20)}
205
+ cols={baseCols}
206
+ getRowId={getRowId}
207
+ rowHeight={48}
208
+ height="400px"
209
+ enableSearch
210
+ />,
211
+ );
212
+ expect(screen.getByPlaceholderText('Search...')).not.toBeNull();
213
+ });
214
+
215
+ it('filters rows by search query (client-side)', () => {
216
+ const { container } = render(
217
+ <DataGrid
218
+ rows={generateUsers(20)}
219
+ cols={baseCols}
220
+ getRowId={getRowId}
221
+ rowHeight={48}
222
+ height="400px"
223
+ enableSearch
224
+ searchDebounceMs={0}
225
+ />,
226
+ );
227
+ const input = screen.getByPlaceholderText('Search...') as HTMLInputElement;
228
+ fireEvent.change(input, { target: { value: 'User 5' } });
229
+ // Only rows with 'User 5' in name/email survive (User 5, User 15, ...)
230
+ const rows = container.querySelectorAll('tbody tr[data-selected]');
231
+ expect(rows.length).toBeGreaterThan(0);
232
+ expect(rows.length).toBeLessThan(20);
233
+ });
234
+
235
+ it('serverSideSearch prevents local filter, emits onSearchQueryChange', () => {
236
+ let lastQuery = '';
237
+ render(
238
+ <DataGrid
239
+ rows={generateUsers(20)}
240
+ cols={baseCols}
241
+ getRowId={getRowId}
242
+ rowHeight={48}
243
+ height="400px"
244
+ enableSearch
245
+ searchDebounceMs={0}
246
+ serverSideSearch
247
+ onSearchQueryChange={(q) => (lastQuery = q)}
248
+ />,
249
+ );
250
+ const input = screen.getByPlaceholderText('Search...') as HTMLInputElement;
251
+ fireEvent.change(input, { target: { value: 'alice' } });
252
+ expect(lastQuery).toBe('alice');
253
+ });
254
+ });
255
+
256
+ describe('DataGrid — selection', () => {
257
+ it('renders checkbox for each visible row when rowSelection=multiple', () => {
258
+ const { container } = render(
259
+ <DataGrid
260
+ rows={generateUsers(20)}
261
+ cols={baseCols}
262
+ getRowId={getRowId}
263
+ rowHeight={48}
264
+ height="400px"
265
+ rowSelection="multiple"
266
+ />,
267
+ );
268
+ // 1 header + N row checkboxes (N = visible window).
269
+ const checkboxes = container.querySelectorAll('input[type="checkbox"]');
270
+ expect(checkboxes.length).toBeGreaterThan(2);
271
+ });
272
+
273
+ it('toggleRow updates selectedRowIds (controlled)', () => {
274
+ let lastIds: string[] = [];
275
+ const { container } = render(
276
+ <DataGrid
277
+ rows={generateUsers(10)}
278
+ cols={baseCols}
279
+ getRowId={getRowId}
280
+ rowHeight={48}
281
+ height="400px"
282
+ rowSelection="multiple"
283
+ selectedRowIds={[]}
284
+ onSelectionChange={(ids) => (lastIds = ids)}
285
+ />,
286
+ );
287
+ const firstRowCheckbox = container.querySelector(
288
+ 'tbody tr[data-selected] input[type="checkbox"]',
289
+ ) as HTMLInputElement;
290
+ fireEvent.click(firstRowCheckbox);
291
+ expect(lastIds.length).toBe(1);
292
+ });
293
+
294
+ it('selectAllScope=allLoaded toggles every row in the sorted set', () => {
295
+ let lastIds: string[] = [];
296
+ const { container } = render(
297
+ <DataGrid
298
+ rows={generateUsers(50)}
299
+ cols={baseCols}
300
+ getRowId={getRowId}
301
+ rowHeight={48}
302
+ height="400px"
303
+ rowSelection="multiple"
304
+ selectAllScope="allLoaded"
305
+ selectedRowIds={[]}
306
+ onSelectionChange={(ids) => (lastIds = ids)}
307
+ />,
308
+ );
309
+ const headerCheckbox = container.querySelector(
310
+ 'thead input[type="checkbox"]',
311
+ ) as HTMLInputElement;
312
+ fireEvent.click(headerCheckbox);
313
+ expect(lastIds.length).toBe(50); // all 50 rows toggled
314
+ });
315
+
316
+ it('selectAllScope=visible toggles only rendered rows', () => {
317
+ let lastIds: string[] = [];
318
+ const { container } = render(
319
+ <DataGrid
320
+ rows={generateUsers(1000)}
321
+ cols={baseCols}
322
+ getRowId={getRowId}
323
+ rowHeight={48}
324
+ height="400px"
325
+ rowSelection="multiple"
326
+ selectAllScope="visible"
327
+ selectedRowIds={[]}
328
+ onSelectionChange={(ids) => (lastIds = ids)}
329
+ />,
330
+ );
331
+ const headerCheckbox = container.querySelector(
332
+ 'thead input[type="checkbox"]',
333
+ ) as HTMLInputElement;
334
+ fireEvent.click(headerCheckbox);
335
+ // Only visible window — way less than 1000
336
+ expect(lastIds.length).toBeLessThan(50);
337
+ expect(lastIds.length).toBeGreaterThan(0);
338
+ });
339
+
340
+ it('renders bulk action footer when selection > 0', () => {
341
+ const { rerender } = render(
342
+ <DataGrid
343
+ rows={generateUsers(10)}
344
+ cols={baseCols}
345
+ getRowId={getRowId}
346
+ rowHeight={48}
347
+ height="400px"
348
+ rowSelection="multiple"
349
+ bulkActions={(rows) => <span>Bulk × {rows.length}</span>}
350
+ />,
351
+ );
352
+ expect(screen.queryByText(/Bulk ×/)).toBeNull();
353
+ rerender(
354
+ <DataGrid
355
+ rows={generateUsers(10)}
356
+ cols={baseCols}
357
+ getRowId={getRowId}
358
+ rowHeight={48}
359
+ height="400px"
360
+ rowSelection="multiple"
361
+ selectedRowIds={['u0', 'u1']}
362
+ bulkActions={(rows) => <span>Bulk × {rows.length}</span>}
363
+ />,
364
+ );
365
+ expect(screen.getByText('Bulk × 2')).not.toBeNull();
366
+ });
367
+ });
368
+
369
+ describe('DataGrid — RBAC', () => {
370
+ it('per-column hide removes the column entirely', () => {
371
+ const colsWithHide: TableColumn<User>[] = [
372
+ baseCols[0]!,
373
+ {
374
+ ...baseCols[1]!,
375
+ access: { resource: 'email', action: 'read', onUnauthorized: 'hide' },
376
+ },
377
+ baseCols[2]!,
378
+ ];
379
+ render(
380
+ <DataGrid
381
+ rows={generateUsers(20)}
382
+ cols={colsWithHide}
383
+ getRowId={getRowId}
384
+ rowHeight={48}
385
+ height="400px"
386
+ />,
387
+ );
388
+ expect(screen.queryByText('Email')).toBeNull();
389
+ });
390
+ });
391
+
392
+ describe('DataGrid — sticky left column', () => {
393
+ it('applies sticky CSS class to sticky="left" columns', () => {
394
+ const colsSticky: TableColumn<User>[] = [
395
+ { ...baseCols[0]!, sticky: 'left' },
396
+ ...baseCols.slice(1),
397
+ ];
398
+ render(
399
+ <DataGrid
400
+ rows={generateUsers(20)}
401
+ cols={colsSticky}
402
+ getRowId={getRowId}
403
+ rowHeight={48}
404
+ height="400px"
405
+ />,
406
+ );
407
+ const nameHeader = screen.getByText('Name').closest('th');
408
+ expect(nameHeader?.className).toContain('sticky');
409
+ expect(nameHeader?.className).toContain('left-0');
410
+ });
411
+ });
412
+
413
+ describe('DataGrid — internal pagination', () => {
414
+ it('renders Pagination footer when pagination prop is set', () => {
415
+ render(
416
+ <DataGrid
417
+ rows={generateUsers(100)}
418
+ cols={baseCols}
419
+ getRowId={getRowId}
420
+ rowHeight={48}
421
+ height="400px"
422
+ pagination={{
423
+ page: 1,
424
+ pageSize: 20,
425
+ onPageChange: () => {},
426
+ }}
427
+ />,
428
+ );
429
+ // Pagination renders a <nav aria-label="Pagination">
430
+ expect(screen.queryByRole('navigation', { name: 'Pagination' })).not.toBeNull();
431
+ });
432
+ });
433
+
434
+ describe('DataGrid — smart defaults', () => {
435
+ it('right-aligns number columns (Age)', () => {
436
+ render(
437
+ <DataGrid
438
+ rows={generateUsers(20)}
439
+ cols={baseCols}
440
+ getRowId={getRowId}
441
+ rowHeight={48}
442
+ height="400px"
443
+ />,
444
+ );
445
+ const ageHeader = screen.getByText('Age').closest('th');
446
+ expect(ageHeader?.className).toContain('text-right');
447
+ });
448
+
449
+ it('auto-applies tabular-nums to number cells (no font-mono)', () => {
450
+ const { container } = render(
451
+ <DataGrid
452
+ rows={generateUsers(5)}
453
+ cols={baseCols}
454
+ getRowId={getRowId}
455
+ rowHeight={48}
456
+ height="400px"
457
+ />,
458
+ );
459
+ const ageCell = container.querySelector('tbody tr[data-selected] td:nth-child(3)');
460
+ expect(ageCell?.className).toContain('tabular-nums');
461
+ expect(ageCell?.className).not.toContain('font-mono');
462
+ });
463
+ });
464
+
465
+ describe('DataGrid — i18n', () => {
466
+ it('column headers accept translated strings', () => {
467
+ const colsTranslated: TableColumn<User>[] = [
468
+ { field: 'name', header: /* t('users.name') */ 'Nome', sortable: true },
469
+ ];
470
+ render(
471
+ <DataGrid
472
+ rows={generateUsers(5)}
473
+ cols={colsTranslated}
474
+ getRowId={getRowId}
475
+ rowHeight={48}
476
+ height="400px"
477
+ />,
478
+ );
479
+ expect(screen.getByText('Nome')).not.toBeNull();
480
+ });
481
+
482
+ it('labels prop translates internal a11y strings', () => {
483
+ render(
484
+ <DataGrid
485
+ rows={generateUsers(5)}
486
+ cols={baseCols}
487
+ getRowId={getRowId}
488
+ rowHeight={48}
489
+ height="400px"
490
+ rowSelection="multiple"
491
+ labels={{ ariaSelectRow: 'Seleziona riga', ariaSelectAllRows: 'Seleziona tutto' }}
492
+ />,
493
+ );
494
+ expect(screen.queryByLabelText('Seleziona tutto')).not.toBeNull();
495
+ });
496
+ });
497
+
498
+ describe('DataGrid — per-column filter UI', () => {
499
+ it('renders a filter trigger button only for filterable columns', () => {
500
+ const cols: TableColumn<User>[] = [
501
+ { field: 'name', header: 'Name', filterable: true },
502
+ { field: 'email', header: 'Email' },
503
+ { field: 'age', header: 'Age', filterable: true },
504
+ ];
505
+ render(
506
+ <DataGrid
507
+ rows={generateUsers(5)}
508
+ cols={cols}
509
+ getRowId={getRowId}
510
+ rowHeight={48}
511
+ height="400px"
512
+ />,
513
+ );
514
+ // 2 filterable cols → 2 filter buttons
515
+ const triggers = screen.getAllByRole('button', { name: 'Filter' });
516
+ expect(triggers).toHaveLength(2);
517
+ });
518
+
519
+ it('shows active state (aria-pressed=true) when a filter is applied', () => {
520
+ const cols: TableColumn<User>[] = [
521
+ { field: 'name', header: 'Name', filterable: true },
522
+ ];
523
+ render(
524
+ <DataGrid
525
+ rows={generateUsers(5)}
526
+ cols={cols}
527
+ getRowId={getRowId}
528
+ rowHeight={48}
529
+ height="400px"
530
+ filterModel={[{ field: 'name', op: 'contains', value: 'User 1' }]}
531
+ />,
532
+ );
533
+ const trigger = screen.getByRole('button', { name: 'Filter' });
534
+ expect(trigger.getAttribute('aria-pressed')).toBe('true');
535
+ });
536
+
537
+ it('opens a text input when filterable string column is clicked', () => {
538
+ const cols: TableColumn<User>[] = [
539
+ { field: 'name', header: 'Name', filterable: true },
540
+ ];
541
+ render(
542
+ <DataGrid
543
+ rows={generateUsers(5)}
544
+ cols={cols}
545
+ getRowId={getRowId}
546
+ rowHeight={48}
547
+ height="400px"
548
+ />,
549
+ );
550
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
551
+ // Apply button means the Popover opened
552
+ expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeNull();
553
+ });
554
+
555
+ it('opens Min/Max inputs for filterable number column', () => {
556
+ const cols: TableColumn<User>[] = [
557
+ { field: 'age', header: 'Age', filterable: true },
558
+ ];
559
+ render(
560
+ <DataGrid
561
+ rows={generateUsers(5)}
562
+ cols={cols}
563
+ getRowId={getRowId}
564
+ rowHeight={48}
565
+ height="400px"
566
+ />,
567
+ );
568
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
569
+ expect(screen.queryByText('Min')).not.toBeNull();
570
+ expect(screen.queryByText('Max')).not.toBeNull();
571
+ });
572
+
573
+ it('explicit col.filterType="text" overrides number autodetect', () => {
574
+ const cols: TableColumn<User>[] = [
575
+ { field: 'age', header: 'Age', filterable: true, filterType: 'text' },
576
+ ];
577
+ render(
578
+ <DataGrid
579
+ rows={generateUsers(5)}
580
+ cols={cols}
581
+ getRowId={getRowId}
582
+ rowHeight={48}
583
+ height="400px"
584
+ />,
585
+ );
586
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
587
+ // Text filter has Apply but no Min/Max
588
+ expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeNull();
589
+ expect(screen.queryByText('Min')).toBeNull();
590
+ });
591
+
592
+ it('emits onFilterChange when Apply is clicked', () => {
593
+ const onFilterChange = vi.fn();
594
+ const cols: TableColumn<User>[] = [
595
+ { field: 'name', header: 'Name', filterable: true },
596
+ ];
597
+ render(
598
+ <DataGrid
599
+ rows={generateUsers(5)}
600
+ cols={cols}
601
+ getRowId={getRowId}
602
+ rowHeight={48}
603
+ height="400px"
604
+ onFilterChange={onFilterChange}
605
+ />,
606
+ );
607
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
608
+ const input = screen.getByPlaceholderText('…') as HTMLInputElement;
609
+ fireEvent.change(input, { target: { value: 'User 2' } });
610
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
611
+ expect(onFilterChange).toHaveBeenCalledWith([
612
+ { field: 'name', op: 'contains', value: 'User 2' },
613
+ ]);
614
+ });
615
+
616
+ it('translates filter labels via the labels prop', () => {
617
+ const cols: TableColumn<User>[] = [
618
+ { field: 'age', header: 'Età', filterable: true },
619
+ ];
620
+ render(
621
+ <DataGrid
622
+ rows={generateUsers(5)}
623
+ cols={cols}
624
+ getRowId={getRowId}
625
+ rowHeight={48}
626
+ height="400px"
627
+ labels={{
628
+ filterColumn: 'Filtra',
629
+ filterApply: 'Applica',
630
+ filterClear: 'Pulisci',
631
+ filterMin: 'Min',
632
+ filterMax: 'Max',
633
+ }}
634
+ />,
635
+ );
636
+ fireEvent.click(screen.getByRole('button', { name: 'Filtra' }));
637
+ expect(screen.queryByRole('button', { name: 'Applica' })).not.toBeNull();
638
+ expect(screen.queryByRole('button', { name: 'Pulisci' })).not.toBeNull();
639
+ });
640
+ });
641
+
642
+ describe('DataGrid — column visibility', () => {
643
+ it('renders the Columns toolbar button by default', () => {
644
+ render(
645
+ <DataGrid
646
+ rows={generateUsers(5)}
647
+ cols={baseCols}
648
+ getRowId={getRowId}
649
+ rowHeight={48}
650
+ height="400px"
651
+ />,
652
+ );
653
+ expect(screen.queryByRole('button', { name: 'Columns' })).not.toBeNull();
654
+ });
655
+
656
+ it('does NOT render the Columns button when enableColumnVisibility=false', () => {
657
+ render(
658
+ <DataGrid
659
+ rows={generateUsers(5)}
660
+ cols={baseCols}
661
+ getRowId={getRowId}
662
+ rowHeight={48}
663
+ height="400px"
664
+ enableColumnVisibility={false}
665
+ />,
666
+ );
667
+ expect(screen.queryByRole('button', { name: 'Columns' })).toBeNull();
668
+ });
669
+
670
+ it('opens dialog with one checkbox per hideable column', () => {
671
+ const cols: TableColumn<User>[] = [
672
+ { field: 'name', header: 'Name' },
673
+ { field: 'email', header: 'Email' },
674
+ // hideable: false → not shown in the dialog (structurally required)
675
+ { field: 'age', header: 'Age', hideable: false },
676
+ ];
677
+ render(
678
+ <DataGrid
679
+ rows={generateUsers(5)}
680
+ cols={cols}
681
+ getRowId={getRowId}
682
+ rowHeight={48}
683
+ height="400px"
684
+ />,
685
+ );
686
+ fireEvent.click(screen.getByRole('button', { name: 'Columns' }));
687
+ expect(screen.queryByRole('dialog')).not.toBeNull();
688
+ // The dialog should expose checkboxes for Name + Email (not Age).
689
+ const labels = screen.getAllByText(/Name|Email|Age/);
690
+ // Name + Email appear in BOTH the table header and the dialog; Age
691
+ // appears only in the header (hideable=false → excluded from dialog).
692
+ const dialog = screen.getByRole('dialog');
693
+ expect(dialog.textContent).toContain('Name');
694
+ expect(dialog.textContent).toContain('Email');
695
+ expect(dialog.textContent).not.toContain('Age');
696
+ void labels;
697
+ });
698
+
699
+ it('respects col.defaultHidden on first render', () => {
700
+ const cols: TableColumn<User>[] = [
701
+ { field: 'name', header: 'Name' },
702
+ { field: 'email', header: 'Email', defaultHidden: true },
703
+ ];
704
+ render(
705
+ <DataGrid
706
+ rows={generateUsers(5)}
707
+ cols={cols}
708
+ getRowId={getRowId}
709
+ rowHeight={48}
710
+ height="400px"
711
+ />,
712
+ );
713
+ // Email column header should NOT be rendered initially.
714
+ expect(screen.queryByText('Email')).toBeNull();
715
+ // Name column header SHOULD be rendered.
716
+ expect(screen.queryByText('Name')).not.toBeNull();
717
+ });
718
+
719
+ it('toggling a column hides it from the table after Done', () => {
720
+ const onHiddenColumnsChange = vi.fn();
721
+ render(
722
+ <DataGrid
723
+ rows={generateUsers(5)}
724
+ cols={baseCols}
725
+ getRowId={getRowId}
726
+ rowHeight={48}
727
+ height="400px"
728
+ onHiddenColumnsChange={onHiddenColumnsChange}
729
+ />,
730
+ );
731
+ fireEvent.click(screen.getByRole('button', { name: 'Columns' }));
732
+ // Uncheck "Email" inside the dialog
733
+ const dialog = screen.getByRole('dialog');
734
+ const emailCheckbox = Array.from(
735
+ dialog.querySelectorAll('input[type="checkbox"]'),
736
+ ).find((el) => {
737
+ const li = el.closest('label');
738
+ return li?.textContent?.includes('Email');
739
+ }) as HTMLInputElement | undefined;
740
+ expect(emailCheckbox).toBeDefined();
741
+ fireEvent.click(emailCheckbox!);
742
+ // Commit via Done
743
+ fireEvent.click(screen.getByRole('button', { name: 'Done' }));
744
+ expect(onHiddenColumnsChange).toHaveBeenCalledWith(['email']);
745
+ });
746
+
747
+ it('controlled hiddenColumns hides the matching columns', () => {
748
+ render(
749
+ <DataGrid
750
+ rows={generateUsers(5)}
751
+ cols={baseCols}
752
+ getRowId={getRowId}
753
+ rowHeight={48}
754
+ height="400px"
755
+ hiddenColumns={['email']}
756
+ />,
757
+ );
758
+ expect(screen.queryByText('Email')).toBeNull();
759
+ expect(screen.queryByText('Name')).not.toBeNull();
760
+ });
761
+ });
762
+
763
+ describe('DataGrid — column resize', () => {
764
+ it('renders a resize handle (separator) for each column by default', () => {
765
+ render(
766
+ <DataGrid
767
+ rows={generateUsers(5)}
768
+ cols={baseCols}
769
+ getRowId={getRowId}
770
+ rowHeight={48}
771
+ height="400px"
772
+ />,
773
+ );
774
+ // One separator per column (4 columns).
775
+ const handles = screen.getAllByRole('separator');
776
+ expect(handles.length).toBe(baseCols.length);
777
+ });
778
+
779
+ it('omits resize handles when enableColumnResize=false', () => {
780
+ render(
781
+ <DataGrid
782
+ rows={generateUsers(5)}
783
+ cols={baseCols}
784
+ getRowId={getRowId}
785
+ rowHeight={48}
786
+ height="400px"
787
+ enableColumnResize={false}
788
+ />,
789
+ );
790
+ expect(screen.queryAllByRole('separator')).toHaveLength(0);
791
+ });
792
+
793
+ it('omits resize handle for col.resizable=false', () => {
794
+ const cols: TableColumn<User>[] = [
795
+ { field: 'name', header: 'Name' },
796
+ { field: 'email', header: 'Email', resizable: false },
797
+ ];
798
+ render(
799
+ <DataGrid
800
+ rows={generateUsers(5)}
801
+ cols={cols}
802
+ getRowId={getRowId}
803
+ rowHeight={48}
804
+ height="400px"
805
+ />,
806
+ );
807
+ // Only 1 separator (Name); Email opted out via resizable=false.
808
+ expect(screen.getAllByRole('separator')).toHaveLength(1);
809
+ });
810
+
811
+ it('applies columnWidths to <th> style.width', () => {
812
+ const cols: TableColumn<User>[] = [
813
+ { field: 'name', header: 'Name', width: 100 },
814
+ ];
815
+ render(
816
+ <DataGrid
817
+ rows={generateUsers(5)}
818
+ cols={cols}
819
+ getRowId={getRowId}
820
+ rowHeight={48}
821
+ height="400px"
822
+ columnWidths={{ name: 250 }}
823
+ />,
824
+ );
825
+ const th = screen.getByText('Name').closest('th');
826
+ expect(th?.style.width).toBe('250px');
827
+ });
828
+
829
+ it('falls back to col.width when no resize state for the column', () => {
830
+ const cols: TableColumn<User>[] = [
831
+ { field: 'name', header: 'Name', width: 100 },
832
+ ];
833
+ render(
834
+ <DataGrid
835
+ rows={generateUsers(5)}
836
+ cols={cols}
837
+ getRowId={getRowId}
838
+ rowHeight={48}
839
+ height="400px"
840
+ />,
841
+ );
842
+ const th = screen.getByText('Name').closest('th');
843
+ expect(th?.style.width).toBe('100px');
844
+ });
845
+ });
846
+
847
+ describe('DataGrid — column reorder', () => {
848
+ it('applies the columnOrder prop to header order', () => {
849
+ const { container } = render(
850
+ <DataGrid
851
+ rows={generateUsers(3)}
852
+ cols={baseCols}
853
+ getRowId={getRowId}
854
+ rowHeight={48}
855
+ height="400px"
856
+ columnOrder={['age', 'name', 'email', 'meta.city']}
857
+ />,
858
+ );
859
+ const headerTexts = Array.from(
860
+ container.querySelectorAll('thead th'),
861
+ ).map((th) => th.textContent?.trim() ?? '');
862
+ // First three column headers should match the order
863
+ expect(headerTexts[0]).toContain('Age');
864
+ expect(headerTexts[1]).toContain('Name');
865
+ expect(headerTexts[2]).toContain('Email');
866
+ });
867
+
868
+ it('headers are draggable by default', () => {
869
+ const { container } = render(
870
+ <DataGrid
871
+ rows={generateUsers(3)}
872
+ cols={baseCols}
873
+ getRowId={getRowId}
874
+ rowHeight={48}
875
+ height="400px"
876
+ />,
877
+ );
878
+ const draggableThs = container.querySelectorAll('thead th[draggable="true"]');
879
+ expect(draggableThs.length).toBe(baseCols.length);
880
+ });
881
+
882
+ it('does NOT make headers draggable when enableColumnReorder=false', () => {
883
+ const { container } = render(
884
+ <DataGrid
885
+ rows={generateUsers(3)}
886
+ cols={baseCols}
887
+ getRowId={getRowId}
888
+ rowHeight={48}
889
+ height="400px"
890
+ enableColumnReorder={false}
891
+ />,
892
+ );
893
+ expect(
894
+ container.querySelectorAll('thead th[draggable="true"]'),
895
+ ).toHaveLength(0);
896
+ });
897
+
898
+ it('respects col.reorderable=false per-column', () => {
899
+ const cols: TableColumn<User>[] = [
900
+ { field: 'name', header: 'Name', reorderable: false },
901
+ { field: 'email', header: 'Email' },
902
+ ];
903
+ const { container } = render(
904
+ <DataGrid
905
+ rows={generateUsers(3)}
906
+ cols={cols}
907
+ getRowId={getRowId}
908
+ rowHeight={48}
909
+ height="400px"
910
+ />,
911
+ );
912
+ // Only the Email th should be draggable.
913
+ const draggableThs = container.querySelectorAll('thead th[draggable="true"]');
914
+ expect(draggableThs.length).toBe(1);
915
+ expect(draggableThs[0]?.textContent).toContain('Email');
916
+ });
917
+ });