@dashforge/tw 0.5.0-beta → 0.7.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 (105) hide show
  1. package/CHANGELOG.md +526 -0
  2. package/THEME-AUDIT.md +175 -0
  3. package/dist/index.esm.js +2504 -51
  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 +171 -0
  9. package/dist/src/components/DataGrid/dataGrid.types.d.ts.map +1 -0
  10. package/dist/src/components/DataGrid/dataGrid.variants.d.ts +216 -0
  11. package/dist/src/components/DataGrid/dataGrid.variants.d.ts.map +1 -0
  12. package/dist/src/components/Dialog/dialog.variants.d.ts.map +1 -1
  13. package/dist/src/components/Divider/divider.variants.d.ts.map +1 -1
  14. package/dist/src/components/Pagination/Pagination.d.ts.map +1 -1
  15. package/dist/src/components/Pagination/pagination.variants.d.ts.map +1 -1
  16. package/dist/src/components/Popover/popover.variants.d.ts.map +1 -1
  17. package/dist/src/components/Skeleton/skeleton.variants.d.ts.map +1 -1
  18. package/dist/src/components/Table/Table.d.ts +24 -0
  19. package/dist/src/components/Table/Table.d.ts.map +1 -0
  20. package/dist/src/components/Table/cells/RenderButton.d.ts +13 -0
  21. package/dist/src/components/Table/cells/RenderButton.d.ts.map +1 -0
  22. package/dist/src/components/Table/cells/RenderChip.d.ts +80 -0
  23. package/dist/src/components/Table/cells/RenderChip.d.ts.map +1 -0
  24. package/dist/src/components/Table/cells/RenderText.d.ts +21 -0
  25. package/dist/src/components/Table/cells/RenderText.d.ts.map +1 -0
  26. package/dist/src/components/Table/cells/RenderTwoLine.d.ts +20 -0
  27. package/dist/src/components/Table/cells/RenderTwoLine.d.ts.map +1 -0
  28. package/dist/src/components/Table/cells/RowActionsMenu.d.ts +36 -0
  29. package/dist/src/components/Table/cells/RowActionsMenu.d.ts.map +1 -0
  30. package/dist/src/components/Table/cells/index.d.ts +11 -0
  31. package/dist/src/components/Table/cells/index.d.ts.map +1 -0
  32. package/dist/src/components/Table/table.types.d.ts +312 -0
  33. package/dist/src/components/Table/table.types.d.ts.map +1 -0
  34. package/dist/src/components/Table/table.variants.d.ts +285 -0
  35. package/dist/src/components/Table/table.variants.d.ts.map +1 -0
  36. package/dist/src/components/Tabs/tabs.variants.d.ts.map +1 -1
  37. package/dist/src/components/Tooltip/tooltip.variants.d.ts.map +1 -1
  38. package/dist/src/components/Typography/typography.variants.d.ts.map +1 -1
  39. package/dist/src/components/_shared/data/getNestedValue.d.ts +20 -0
  40. package/dist/src/components/_shared/data/getNestedValue.d.ts.map +1 -0
  41. package/dist/src/components/_shared/data/useColumnAutoDetect.d.ts +45 -0
  42. package/dist/src/components/_shared/data/useColumnAutoDetect.d.ts.map +1 -0
  43. package/dist/src/components/_shared/data/useControllableState.d.ts +23 -0
  44. package/dist/src/components/_shared/data/useControllableState.d.ts.map +1 -0
  45. package/dist/src/components/_shared/data/useDebouncedValue.d.ts +10 -0
  46. package/dist/src/components/_shared/data/useDebouncedValue.d.ts.map +1 -0
  47. package/dist/src/components/_shared/data/useTableFilter.d.ts +13 -0
  48. package/dist/src/components/_shared/data/useTableFilter.d.ts.map +1 -0
  49. package/dist/src/components/_shared/data/useTableSearch.d.ts +23 -0
  50. package/dist/src/components/_shared/data/useTableSearch.d.ts.map +1 -0
  51. package/dist/src/components/_shared/data/useTableSelection.d.ts +25 -0
  52. package/dist/src/components/_shared/data/useTableSelection.d.ts.map +1 -0
  53. package/dist/src/components/_shared/data/useTableSort.d.ts +20 -0
  54. package/dist/src/components/_shared/data/useTableSort.d.ts.map +1 -0
  55. package/dist/src/components/_shared/data/useVirtualizer.d.ts +107 -0
  56. package/dist/src/components/_shared/data/useVirtualizer.d.ts.map +1 -0
  57. package/dist/src/index.d.ts +10 -1
  58. package/dist/src/index.d.ts.map +1 -1
  59. package/package.json +3 -3
  60. package/src/components/Accordion/accordion.variants.ts +5 -3
  61. package/src/components/Box/Box.test.tsx +32 -11
  62. package/src/components/Box/box.variants.ts +23 -4
  63. package/src/components/DataGrid/DataGrid.test.tsx +496 -0
  64. package/src/components/DataGrid/DataGrid.tsx +831 -0
  65. package/src/components/DataGrid/dataGrid.types.ts +189 -0
  66. package/src/components/DataGrid/dataGrid.variants.ts +144 -0
  67. package/src/components/Dialog/dialog.variants.ts +10 -6
  68. package/src/components/Divider/Divider.test.tsx +3 -1
  69. package/src/components/Divider/Divider.tsx +1 -1
  70. package/src/components/Divider/divider.variants.ts +2 -1
  71. package/src/components/Pagination/Pagination.tsx +3 -1
  72. package/src/components/Pagination/pagination.variants.ts +19 -13
  73. package/src/components/Popover/popover.variants.ts +10 -4
  74. package/src/components/Skeleton/skeleton.variants.ts +2 -1
  75. package/src/components/Table/Table.test.tsx +603 -0
  76. package/src/components/Table/Table.tsx +816 -0
  77. package/src/components/Table/cells/RenderButton.tsx +23 -0
  78. package/src/components/Table/cells/RenderChip.tsx +103 -0
  79. package/src/components/Table/cells/RenderText.tsx +37 -0
  80. package/src/components/Table/cells/RenderTwoLine.tsx +34 -0
  81. package/src/components/Table/cells/RowActionsMenu.tsx +147 -0
  82. package/src/components/Table/cells/index.ts +14 -0
  83. package/src/components/Table/table.types.ts +346 -0
  84. package/src/components/Table/table.variants.ts +207 -0
  85. package/src/components/Tabs/tabs.variants.ts +19 -8
  86. package/src/components/Tooltip/tooltip.variants.ts +12 -2
  87. package/src/components/Typography/Typography.test.tsx +3 -1
  88. package/src/components/Typography/typography.variants.ts +6 -2
  89. package/src/components/_shared/data/getNestedValue.test.ts +50 -0
  90. package/src/components/_shared/data/getNestedValue.ts +32 -0
  91. package/src/components/_shared/data/useColumnAutoDetect.test.ts +123 -0
  92. package/src/components/_shared/data/useColumnAutoDetect.ts +100 -0
  93. package/src/components/_shared/data/useControllableState.ts +48 -0
  94. package/src/components/_shared/data/useDebouncedValue.ts +24 -0
  95. package/src/components/_shared/data/useTableFilter.ts +37 -0
  96. package/src/components/_shared/data/useTableSearch.test.ts +115 -0
  97. package/src/components/_shared/data/useTableSearch.ts +66 -0
  98. package/src/components/_shared/data/useTableSelection.test.ts +100 -0
  99. package/src/components/_shared/data/useTableSelection.ts +100 -0
  100. package/src/components/_shared/data/useTableSort.test.ts +150 -0
  101. package/src/components/_shared/data/useTableSort.ts +141 -0
  102. package/src/components/_shared/data/useVirtualizer.test.ts +158 -0
  103. package/src/components/_shared/data/useVirtualizer.ts +199 -0
  104. package/src/components/_shared/themeIdentity.test.ts +113 -0
  105. package/src/index.ts +59 -1
@@ -0,0 +1,603 @@
1
+ // @vitest-environment jsdom
2
+ import * as React from 'react';
3
+ import { describe, it, expect, afterEach, beforeAll } from 'vitest';
4
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react';
5
+ import { Table } from './Table.js';
6
+ import type { TableColumn } from './table.types.js';
7
+
8
+ void React;
9
+ afterEach(() => cleanup());
10
+
11
+ // jsdom doesn't implement ResizeObserver — Radix Popover (used by
12
+ // RowActionsMenu in some tests) requires it.
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
+ });
22
+
23
+ interface User {
24
+ id: string;
25
+ name: string;
26
+ email: string;
27
+ age: number;
28
+ active: boolean;
29
+ joinedAt: Date;
30
+ meta: { city: string };
31
+ }
32
+
33
+ const baseCols: TableColumn<User>[] = [
34
+ { field: 'name', header: 'Name', sortable: true, searchable: true },
35
+ { field: 'email', header: 'Email', searchable: true },
36
+ { field: 'age', header: 'Age', sortable: true },
37
+ { field: 'meta.city' as never, header: 'City', searchable: true },
38
+ ];
39
+
40
+ const baseRows: User[] = [
41
+ {
42
+ id: 'u1', name: 'Charlie', email: 'charlie@example.com', age: 30,
43
+ active: true, joinedAt: new Date('2024-01-15'),
44
+ meta: { city: 'Roma' },
45
+ },
46
+ {
47
+ id: 'u2', name: 'Alice', email: 'alice@example.com', age: 25,
48
+ active: true, joinedAt: new Date('2024-03-01'),
49
+ meta: { city: 'Milano' },
50
+ },
51
+ {
52
+ id: 'u3', name: 'Bob', email: 'bob@example.org', age: 45,
53
+ active: false, joinedAt: new Date('2023-06-20'),
54
+ meta: { city: 'Torino' },
55
+ },
56
+ ];
57
+
58
+ const getRowId = (row: User) => row.id;
59
+
60
+ // ───────────────────────────────────────────────────────────────
61
+ // Rendering
62
+ // ───────────────────────────────────────────────────────────────
63
+
64
+ describe('Table — rendering', () => {
65
+ it('renders a <table> element', () => {
66
+ const { container } = render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} />);
67
+ expect(container.querySelector('table')).not.toBeNull();
68
+ });
69
+
70
+ it('renders all column headers', () => {
71
+ render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} />);
72
+ expect(screen.getByText('Name')).not.toBeNull();
73
+ expect(screen.getByText('Email')).not.toBeNull();
74
+ expect(screen.getByText('Age')).not.toBeNull();
75
+ expect(screen.getByText('City')).not.toBeNull();
76
+ });
77
+
78
+ it('renders all data rows', () => {
79
+ render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} />);
80
+ expect(screen.getByText('Charlie')).not.toBeNull();
81
+ expect(screen.getByText('Alice')).not.toBeNull();
82
+ expect(screen.getByText('Bob')).not.toBeNull();
83
+ });
84
+
85
+ it('resolves nested keys via getNestedValue', () => {
86
+ render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} />);
87
+ expect(screen.getByText('Roma')).not.toBeNull();
88
+ expect(screen.getByText('Milano')).not.toBeNull();
89
+ });
90
+
91
+ it('renders empty state when rows is empty', () => {
92
+ render(<Table rows={[]} cols={baseCols} getRowId={getRowId} />);
93
+ expect(screen.queryByText('No data')).not.toBeNull();
94
+ });
95
+
96
+ it('renders custom emptyState ReactNode', () => {
97
+ render(
98
+ <Table
99
+ rows={[]}
100
+ cols={baseCols}
101
+ getRowId={getRowId}
102
+ emptyState={<span>Nothing here yet</span>}
103
+ />,
104
+ );
105
+ expect(screen.queryByText('Nothing here yet')).not.toBeNull();
106
+ });
107
+
108
+ it('renders loading skeleton rows', () => {
109
+ const { container } = render(
110
+ <Table rows={[]} cols={baseCols} getRowId={getRowId} loading loadingRowCount={3} />,
111
+ );
112
+ const busyRows = container.querySelectorAll('tr[aria-busy="true"]');
113
+ expect(busyRows.length).toBe(3);
114
+ });
115
+
116
+ it('renders caption (sr-only by default)', () => {
117
+ const { container } = render(
118
+ <Table rows={baseRows} cols={baseCols} getRowId={getRowId} caption="My table" />,
119
+ );
120
+ const caption = container.querySelector('caption');
121
+ expect(caption?.textContent).toBe('My table');
122
+ expect(caption?.className).toContain('sr-only');
123
+ });
124
+
125
+ it('renders visible caption when showCaption=true', () => {
126
+ const { container } = render(
127
+ <Table rows={baseRows} cols={baseCols} getRowId={getRowId} caption="My table" showCaption />,
128
+ );
129
+ const caption = container.querySelector('caption');
130
+ expect(caption?.className).not.toContain('sr-only');
131
+ });
132
+ });
133
+
134
+ // ───────────────────────────────────────────────────────────────
135
+ // Smart-default column types
136
+ // ───────────────────────────────────────────────────────────────
137
+
138
+ describe('Table — smart defaults', () => {
139
+ it('right-aligns number columns (Age)', () => {
140
+ render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} />);
141
+ const ageHeader = screen.getByText('Age').closest('th');
142
+ expect(ageHeader?.className).toContain('text-right');
143
+ });
144
+
145
+ it('left-aligns string columns (Name)', () => {
146
+ render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} />);
147
+ const nameHeader = screen.getByText('Name').closest('th');
148
+ expect(nameHeader?.className).toContain('text-left');
149
+ });
150
+
151
+ it('auto-applies tabular-nums to number cells (digit alignment)', () => {
152
+ const { container } = render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} />);
153
+ const ageCell = container.querySelector('tbody tr td:nth-child(3)');
154
+ expect(ageCell?.className).toContain('tabular-nums');
155
+ });
156
+
157
+ it('does NOT auto-apply font-mono to number cells (font family is consumer choice)', () => {
158
+ const { container } = render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} />);
159
+ const ageCell = container.querySelector('tbody tr td:nth-child(3)');
160
+ expect(ageCell?.className).not.toContain('font-mono');
161
+ });
162
+
163
+ it('does NOT auto-apply font-mono to date cells either', () => {
164
+ const colsWithDate: TableColumn<User>[] = [
165
+ ...baseCols.slice(0, 3),
166
+ { field: 'joinedAt' as never, header: 'Joined' },
167
+ ];
168
+ const { container } = render(<Table rows={baseRows} cols={colsWithDate} getRowId={getRowId} />);
169
+ const dateCell = container.querySelector('tbody tr td:nth-child(4)');
170
+ expect(dateCell?.className).not.toContain('font-mono');
171
+ // Date columns DO get tabular-nums (ISO digits align).
172
+ expect(dateCell?.className).toContain('tabular-nums');
173
+ });
174
+
175
+ it('explicit align overrides auto-detected', () => {
176
+ const colsOverride: TableColumn<User>[] = [
177
+ { ...baseCols[0]!, align: 'right' },
178
+ ];
179
+ render(<Table rows={baseRows} cols={colsOverride} getRowId={getRowId} />);
180
+ const nameHeader = screen.getByText('Name').closest('th');
181
+ expect(nameHeader?.className).toContain('text-right');
182
+ });
183
+
184
+ it('explicit tabularNums=false overrides auto for number column', () => {
185
+ const colsOverride: TableColumn<User>[] = [
186
+ ...baseCols.slice(0, 2),
187
+ { field: 'age', header: 'Age', tabularNums: false },
188
+ ...baseCols.slice(3),
189
+ ];
190
+ const { container } = render(<Table rows={baseRows} cols={colsOverride} getRowId={getRowId} />);
191
+ const ageCell = container.querySelector('tbody tr td:nth-child(3)');
192
+ expect(ageCell?.className).not.toContain('tabular-nums');
193
+ });
194
+
195
+ it('explicit monospace=true opts into font-mono (consumer-controlled)', () => {
196
+ const colsOverride: TableColumn<User>[] = [
197
+ { ...baseCols[0]!, monospace: true },
198
+ ...baseCols.slice(1),
199
+ ];
200
+ const { container } = render(<Table rows={baseRows} cols={colsOverride} getRowId={getRowId} />);
201
+ const nameCell = container.querySelector('tbody tr td:nth-child(1)');
202
+ expect(nameCell?.className).toContain('font-mono');
203
+ });
204
+ });
205
+
206
+ // ───────────────────────────────────────────────────────────────
207
+ // Sort
208
+ // ───────────────────────────────────────────────────────────────
209
+
210
+ describe('Table — sort', () => {
211
+ it('sortable header renders as a button with aria-sort=none initially', () => {
212
+ render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} />);
213
+ const nameHeader = screen.getByText('Name').closest('th');
214
+ expect(nameHeader?.getAttribute('aria-sort')).toBe('none');
215
+ });
216
+
217
+ it('click sortable header → asc, aria-sort updates', () => {
218
+ render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} />);
219
+ const nameButton = screen.getByText('Name').closest('button')!;
220
+ fireEvent.click(nameButton);
221
+ const nameHeader = screen.getByText('Name').closest('th');
222
+ expect(nameHeader?.getAttribute('aria-sort')).toBe('ascending');
223
+ });
224
+
225
+ it('click twice → desc', () => {
226
+ render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} />);
227
+ const nameButton = screen.getByText('Name').closest('button')!;
228
+ fireEvent.click(nameButton);
229
+ fireEvent.click(nameButton);
230
+ expect(screen.getByText('Name').closest('th')?.getAttribute('aria-sort')).toBe('descending');
231
+ });
232
+
233
+ it('click thrice → clears (aria-sort=none)', () => {
234
+ render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} />);
235
+ const nameButton = screen.getByText('Name').closest('button')!;
236
+ fireEvent.click(nameButton);
237
+ fireEvent.click(nameButton);
238
+ fireEvent.click(nameButton);
239
+ expect(screen.getByText('Name').closest('th')?.getAttribute('aria-sort')).toBe('none');
240
+ });
241
+
242
+ it('non-sortable header does NOT render a button', () => {
243
+ render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} />);
244
+ const emailHeader = screen.getByText('Email').closest('th');
245
+ expect(emailHeader?.querySelector('button')).toBeNull();
246
+ });
247
+
248
+ it('asc sort actually rearranges rows (numeric)', () => {
249
+ const { container } = render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} />);
250
+ fireEvent.click(screen.getByText('Age').closest('button')!);
251
+ const firstRowAge = container.querySelector('tbody tr:first-child td:nth-child(3)')?.textContent;
252
+ expect(firstRowAge).toBe('25'); // Alice
253
+ });
254
+
255
+ it('emits onSortChange when controlled', () => {
256
+ let lastModel: unknown = null;
257
+ render(
258
+ <Table
259
+ rows={baseRows}
260
+ cols={baseCols}
261
+ getRowId={getRowId}
262
+ sortModel={[]}
263
+ onSortChange={(m) => (lastModel = m)}
264
+ />,
265
+ );
266
+ fireEvent.click(screen.getByText('Name').closest('button')!);
267
+ expect(lastModel).toEqual([{ field: 'name', direction: 'asc' }]);
268
+ });
269
+ });
270
+
271
+ // ───────────────────────────────────────────────────────────────
272
+ // Search
273
+ // ───────────────────────────────────────────────────────────────
274
+
275
+ describe('Table — search', () => {
276
+ it('does NOT render search input when enableSearch=false', () => {
277
+ render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} />);
278
+ expect(screen.queryByPlaceholderText('Search...')).toBeNull();
279
+ });
280
+
281
+ it('renders search input when enableSearch=true', () => {
282
+ render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} enableSearch />);
283
+ expect(screen.getByPlaceholderText('Search...')).not.toBeNull();
284
+ });
285
+
286
+ it('filters rows by global search (debounce=0 for test speed)', () => {
287
+ render(
288
+ <Table
289
+ rows={baseRows}
290
+ cols={baseCols}
291
+ getRowId={getRowId}
292
+ enableSearch
293
+ searchDebounceMs={0}
294
+ />,
295
+ );
296
+ const input = screen.getByPlaceholderText('Search...') as HTMLInputElement;
297
+ fireEvent.change(input, { target: { value: 'alice' } });
298
+ expect(screen.queryByText('Alice')).not.toBeNull();
299
+ expect(screen.queryByText('Charlie')).toBeNull();
300
+ });
301
+
302
+ it('honors custom searchPlaceholder via prop', () => {
303
+ render(
304
+ <Table
305
+ rows={baseRows}
306
+ cols={baseCols}
307
+ getRowId={getRowId}
308
+ enableSearch
309
+ searchPlaceholder="Cerca utenti..."
310
+ />,
311
+ );
312
+ expect(screen.getByPlaceholderText('Cerca utenti...')).not.toBeNull();
313
+ });
314
+
315
+ it('honors custom labels via labels prop', () => {
316
+ render(
317
+ <Table
318
+ rows={baseRows}
319
+ cols={baseCols}
320
+ getRowId={getRowId}
321
+ enableSearch
322
+ labels={{ searchPlaceholder: 'IT: cerca' }}
323
+ />,
324
+ );
325
+ expect(screen.getByPlaceholderText('IT: cerca')).not.toBeNull();
326
+ });
327
+ });
328
+
329
+ // ───────────────────────────────────────────────────────────────
330
+ // Selection
331
+ // ───────────────────────────────────────────────────────────────
332
+
333
+ describe('Table — selection', () => {
334
+ it('does NOT render selection column when rowSelection=none', () => {
335
+ const { container } = render(
336
+ <Table rows={baseRows} cols={baseCols} getRowId={getRowId} rowSelection="none" />,
337
+ );
338
+ expect(container.querySelector('input[type="checkbox"]')).toBeNull();
339
+ });
340
+
341
+ it('renders checkbox in each row when rowSelection=multiple', () => {
342
+ const { container } = render(
343
+ <Table rows={baseRows} cols={baseCols} getRowId={getRowId} rowSelection="multiple" />,
344
+ );
345
+ // 1 header checkbox + 3 row checkboxes = 4
346
+ expect(container.querySelectorAll('input[type="checkbox"]').length).toBe(4);
347
+ });
348
+
349
+ it('clicking a row checkbox toggles selection', () => {
350
+ const { container } = render(
351
+ <Table rows={baseRows} cols={baseCols} getRowId={getRowId} rowSelection="multiple" />,
352
+ );
353
+ const firstRowCheckbox = container.querySelector(
354
+ 'tbody tr:first-child input[type="checkbox"]',
355
+ ) as HTMLInputElement;
356
+ fireEvent.click(firstRowCheckbox);
357
+ expect(firstRowCheckbox.checked).toBe(true);
358
+ const row = firstRowCheckbox.closest('tr');
359
+ expect(row?.getAttribute('aria-selected')).toBe('true');
360
+ });
361
+
362
+ it('select-all toggles all rows', () => {
363
+ const { container } = render(
364
+ <Table rows={baseRows} cols={baseCols} getRowId={getRowId} rowSelection="multiple" />,
365
+ );
366
+ const headerCheckbox = container.querySelector(
367
+ 'thead input[type="checkbox"]',
368
+ ) as HTMLInputElement;
369
+ fireEvent.click(headerCheckbox);
370
+ const rowCheckboxes = container.querySelectorAll(
371
+ 'tbody input[type="checkbox"]',
372
+ ) as NodeListOf<HTMLInputElement>;
373
+ rowCheckboxes.forEach((cb) => expect(cb.checked).toBe(true));
374
+ });
375
+
376
+ it('renders bulk action footer ONLY when selection > 0', () => {
377
+ const { rerender } = render(
378
+ <Table
379
+ rows={baseRows}
380
+ cols={baseCols}
381
+ getRowId={getRowId}
382
+ rowSelection="multiple"
383
+ bulkActions={(rows) => <span>Bulk for {rows.length}</span>}
384
+ />,
385
+ );
386
+ expect(screen.queryByText(/Bulk for/)).toBeNull();
387
+
388
+ rerender(
389
+ <Table
390
+ rows={baseRows}
391
+ cols={baseCols}
392
+ getRowId={getRowId}
393
+ rowSelection="multiple"
394
+ selectedRowIds={['u1']}
395
+ bulkActions={(rows) => <span>Bulk for {rows.length}</span>}
396
+ />,
397
+ );
398
+ expect(screen.getByText('Bulk for 1')).not.toBeNull();
399
+ });
400
+
401
+ it('emits onSelectionChange when controlled', () => {
402
+ let lastIds: string[] = [];
403
+ const { container } = render(
404
+ <Table
405
+ rows={baseRows}
406
+ cols={baseCols}
407
+ getRowId={getRowId}
408
+ rowSelection="multiple"
409
+ selectedRowIds={[]}
410
+ onSelectionChange={(ids) => (lastIds = ids)}
411
+ />,
412
+ );
413
+ const firstCheckbox = container.querySelector(
414
+ 'tbody tr:first-child input[type="checkbox"]',
415
+ ) as HTMLInputElement;
416
+ fireEvent.click(firstCheckbox);
417
+ expect(lastIds).toEqual(['u1']);
418
+ });
419
+ });
420
+
421
+ // ───────────────────────────────────────────────────────────────
422
+ // Expandable rows
423
+ // ───────────────────────────────────────────────────────────────
424
+
425
+ describe('Table — expandable rows', () => {
426
+ it('does NOT render expand column when expandable not provided', () => {
427
+ render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} />);
428
+ expect(screen.queryByLabelText('Expand row')).toBeNull();
429
+ });
430
+
431
+ it('renders expand toggle when expandable provided', () => {
432
+ render(
433
+ <Table
434
+ rows={baseRows}
435
+ cols={baseCols}
436
+ getRowId={getRowId}
437
+ expandable={{ render: (r) => <div>Detail of {r.name}</div> }}
438
+ />,
439
+ );
440
+ const toggles = screen.getAllByLabelText('Expand row');
441
+ expect(toggles.length).toBe(3);
442
+ });
443
+
444
+ it('clicking toggle expands the row with detail content', () => {
445
+ render(
446
+ <Table
447
+ rows={baseRows}
448
+ cols={baseCols}
449
+ getRowId={getRowId}
450
+ expandable={{ render: (r) => <div>Detail of {r.name}</div> }}
451
+ />,
452
+ );
453
+ const firstToggle = screen.getAllByLabelText('Expand row')[0]!;
454
+ fireEvent.click(firstToggle);
455
+ expect(screen.getByText('Detail of Charlie')).not.toBeNull();
456
+ });
457
+
458
+ it('aria-expanded reflects state', () => {
459
+ render(
460
+ <Table
461
+ rows={baseRows}
462
+ cols={baseCols}
463
+ getRowId={getRowId}
464
+ expandable={{ render: () => <div>x</div> }}
465
+ />,
466
+ );
467
+ const firstToggle = screen.getAllByLabelText('Expand row')[0]!;
468
+ expect(firstToggle.getAttribute('aria-expanded')).toBe('false');
469
+ fireEvent.click(firstToggle);
470
+ expect(firstToggle.getAttribute('aria-label')).toBe('Collapse row');
471
+ expect(firstToggle.getAttribute('aria-expanded')).toBe('true');
472
+ });
473
+ });
474
+
475
+ // ───────────────────────────────────────────────────────────────
476
+ // Row actions
477
+ // ───────────────────────────────────────────────────────────────
478
+
479
+ describe('Table — row actions', () => {
480
+ it('renders the row actions cell when rowActions provided', () => {
481
+ render(
482
+ <Table
483
+ rows={baseRows}
484
+ cols={baseCols}
485
+ getRowId={getRowId}
486
+ rowActions={(row) => <button type="button">Edit {row.name}</button>}
487
+ />,
488
+ );
489
+ expect(screen.getByText('Edit Charlie')).not.toBeNull();
490
+ expect(screen.getByText('Edit Alice')).not.toBeNull();
491
+ expect(screen.getByText('Edit Bob')).not.toBeNull();
492
+ });
493
+ });
494
+
495
+ // ───────────────────────────────────────────────────────────────
496
+ // RBAC
497
+ // ───────────────────────────────────────────────────────────────
498
+
499
+ describe('Table — RBAC', () => {
500
+ it('per-column access=hide removes the column entirely', () => {
501
+ const colsWithHide: TableColumn<User>[] = [
502
+ baseCols[0]!,
503
+ {
504
+ ...baseCols[1]!,
505
+ access: { resource: 'email', action: 'read', onUnauthorized: 'hide' },
506
+ },
507
+ baseCols[2]!,
508
+ ];
509
+ render(<Table rows={baseRows} cols={colsWithHide} getRowId={getRowId} />);
510
+ expect(screen.queryByText('Email')).toBeNull();
511
+ expect(screen.queryByText('charlie@example.com')).toBeNull();
512
+ });
513
+ });
514
+
515
+ // ───────────────────────────────────────────────────────────────
516
+ // Variants + density
517
+ // ───────────────────────────────────────────────────────────────
518
+
519
+ describe('Table — variants + density', () => {
520
+ it.each(['plain', 'lines', 'striped', 'bordered', 'card'] as const)(
521
+ 'renders variant=%s without crashing',
522
+ (variant) => {
523
+ render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} variant={variant} />);
524
+ expect(screen.getByText('Charlie')).not.toBeNull();
525
+ },
526
+ );
527
+
528
+ it.each(['compact', 'comfortable', 'spacious'] as const)(
529
+ 'renders density=%s without crashing',
530
+ (density) => {
531
+ render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} density={density} />);
532
+ expect(screen.getByText('Charlie')).not.toBeNull();
533
+ },
534
+ );
535
+
536
+ it.each(['sm', 'md', 'lg'] as const)('renders size=%s without crashing', (size) => {
537
+ render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} size={size} />);
538
+ expect(screen.getByText('Charlie')).not.toBeNull();
539
+ });
540
+ });
541
+
542
+ // ───────────────────────────────────────────────────────────────
543
+ // sx + slotProps
544
+ // ───────────────────────────────────────────────────────────────
545
+
546
+ describe('Table — sx + slotProps', () => {
547
+ it('applies sx to root', () => {
548
+ const { container } = render(
549
+ <Table rows={baseRows} cols={baseCols} getRowId={getRowId} sx="my-root-cls" />,
550
+ );
551
+ expect(container.firstChild as HTMLElement).toHaveProperty('className');
552
+ expect((container.firstChild as HTMLElement).className).toContain('my-root-cls');
553
+ });
554
+
555
+ it('applies slotProps.headerCell to each header', () => {
556
+ render(
557
+ <Table
558
+ rows={baseRows}
559
+ cols={baseCols}
560
+ getRowId={getRowId}
561
+ slotProps={{ headerCell: { className: 'header-slot' } }}
562
+ />,
563
+ );
564
+ const nameHeader = screen.getByText('Name').closest('th');
565
+ expect(nameHeader?.className).toContain('header-slot');
566
+ });
567
+ });
568
+
569
+ // ───────────────────────────────────────────────────────────────
570
+ // i18n surface
571
+ // ───────────────────────────────────────────────────────────────
572
+
573
+ describe('Table — i18n', () => {
574
+ it('column headers accept translated strings directly', () => {
575
+ const colsTranslated: TableColumn<User>[] = [
576
+ { field: 'name', header: /* t('users.name') */ 'Nome', sortable: true },
577
+ ];
578
+ render(<Table rows={baseRows} cols={colsTranslated} getRowId={getRowId} />);
579
+ expect(screen.getByText('Nome')).not.toBeNull();
580
+ });
581
+
582
+ it('column headers accept render function for richer JSX', () => {
583
+ const colsJsx: TableColumn<User>[] = [
584
+ { field: 'name', header: () => <em>Name (em)</em>, sortable: true },
585
+ ];
586
+ render(<Table rows={baseRows} cols={colsJsx} getRowId={getRowId} />);
587
+ expect(screen.getByText('Name (em)').tagName).toBe('EM');
588
+ });
589
+
590
+ it('labels prop translates internal a11y strings', () => {
591
+ render(
592
+ <Table
593
+ rows={baseRows}
594
+ cols={baseCols}
595
+ getRowId={getRowId}
596
+ rowSelection="multiple"
597
+ labels={{ ariaSelectRow: 'Seleziona riga', ariaSelectAllRows: 'Seleziona tutte' }}
598
+ />,
599
+ );
600
+ expect(screen.queryAllByLabelText('Seleziona riga').length).toBe(3);
601
+ expect(screen.queryByLabelText('Seleziona tutte')).not.toBeNull();
602
+ });
603
+ });