@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.
- package/CHANGELOG.md +526 -0
- package/THEME-AUDIT.md +175 -0
- package/dist/index.esm.js +2504 -51
- package/dist/src/components/Accordion/accordion.variants.d.ts.map +1 -1
- package/dist/src/components/Box/box.variants.d.ts.map +1 -1
- package/dist/src/components/DataGrid/DataGrid.d.ts +29 -0
- package/dist/src/components/DataGrid/DataGrid.d.ts.map +1 -0
- package/dist/src/components/DataGrid/dataGrid.types.d.ts +171 -0
- package/dist/src/components/DataGrid/dataGrid.types.d.ts.map +1 -0
- package/dist/src/components/DataGrid/dataGrid.variants.d.ts +216 -0
- package/dist/src/components/DataGrid/dataGrid.variants.d.ts.map +1 -0
- package/dist/src/components/Dialog/dialog.variants.d.ts.map +1 -1
- package/dist/src/components/Divider/divider.variants.d.ts.map +1 -1
- package/dist/src/components/Pagination/Pagination.d.ts.map +1 -1
- package/dist/src/components/Pagination/pagination.variants.d.ts.map +1 -1
- package/dist/src/components/Popover/popover.variants.d.ts.map +1 -1
- package/dist/src/components/Skeleton/skeleton.variants.d.ts.map +1 -1
- package/dist/src/components/Table/Table.d.ts +24 -0
- package/dist/src/components/Table/Table.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RenderButton.d.ts +13 -0
- package/dist/src/components/Table/cells/RenderButton.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RenderChip.d.ts +80 -0
- package/dist/src/components/Table/cells/RenderChip.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RenderText.d.ts +21 -0
- package/dist/src/components/Table/cells/RenderText.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RenderTwoLine.d.ts +20 -0
- package/dist/src/components/Table/cells/RenderTwoLine.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RowActionsMenu.d.ts +36 -0
- package/dist/src/components/Table/cells/RowActionsMenu.d.ts.map +1 -0
- package/dist/src/components/Table/cells/index.d.ts +11 -0
- package/dist/src/components/Table/cells/index.d.ts.map +1 -0
- package/dist/src/components/Table/table.types.d.ts +312 -0
- package/dist/src/components/Table/table.types.d.ts.map +1 -0
- package/dist/src/components/Table/table.variants.d.ts +285 -0
- package/dist/src/components/Table/table.variants.d.ts.map +1 -0
- package/dist/src/components/Tabs/tabs.variants.d.ts.map +1 -1
- package/dist/src/components/Tooltip/tooltip.variants.d.ts.map +1 -1
- package/dist/src/components/Typography/typography.variants.d.ts.map +1 -1
- package/dist/src/components/_shared/data/getNestedValue.d.ts +20 -0
- package/dist/src/components/_shared/data/getNestedValue.d.ts.map +1 -0
- package/dist/src/components/_shared/data/useColumnAutoDetect.d.ts +45 -0
- package/dist/src/components/_shared/data/useColumnAutoDetect.d.ts.map +1 -0
- package/dist/src/components/_shared/data/useControllableState.d.ts +23 -0
- package/dist/src/components/_shared/data/useControllableState.d.ts.map +1 -0
- package/dist/src/components/_shared/data/useDebouncedValue.d.ts +10 -0
- package/dist/src/components/_shared/data/useDebouncedValue.d.ts.map +1 -0
- package/dist/src/components/_shared/data/useTableFilter.d.ts +13 -0
- package/dist/src/components/_shared/data/useTableFilter.d.ts.map +1 -0
- package/dist/src/components/_shared/data/useTableSearch.d.ts +23 -0
- package/dist/src/components/_shared/data/useTableSearch.d.ts.map +1 -0
- package/dist/src/components/_shared/data/useTableSelection.d.ts +25 -0
- package/dist/src/components/_shared/data/useTableSelection.d.ts.map +1 -0
- package/dist/src/components/_shared/data/useTableSort.d.ts +20 -0
- package/dist/src/components/_shared/data/useTableSort.d.ts.map +1 -0
- package/dist/src/components/_shared/data/useVirtualizer.d.ts +107 -0
- package/dist/src/components/_shared/data/useVirtualizer.d.ts.map +1 -0
- package/dist/src/index.d.ts +10 -1
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/components/Accordion/accordion.variants.ts +5 -3
- package/src/components/Box/Box.test.tsx +32 -11
- package/src/components/Box/box.variants.ts +23 -4
- package/src/components/DataGrid/DataGrid.test.tsx +496 -0
- package/src/components/DataGrid/DataGrid.tsx +831 -0
- package/src/components/DataGrid/dataGrid.types.ts +189 -0
- package/src/components/DataGrid/dataGrid.variants.ts +144 -0
- package/src/components/Dialog/dialog.variants.ts +10 -6
- package/src/components/Divider/Divider.test.tsx +3 -1
- package/src/components/Divider/Divider.tsx +1 -1
- package/src/components/Divider/divider.variants.ts +2 -1
- package/src/components/Pagination/Pagination.tsx +3 -1
- package/src/components/Pagination/pagination.variants.ts +19 -13
- package/src/components/Popover/popover.variants.ts +10 -4
- package/src/components/Skeleton/skeleton.variants.ts +2 -1
- package/src/components/Table/Table.test.tsx +603 -0
- package/src/components/Table/Table.tsx +816 -0
- package/src/components/Table/cells/RenderButton.tsx +23 -0
- package/src/components/Table/cells/RenderChip.tsx +103 -0
- package/src/components/Table/cells/RenderText.tsx +37 -0
- package/src/components/Table/cells/RenderTwoLine.tsx +34 -0
- package/src/components/Table/cells/RowActionsMenu.tsx +147 -0
- package/src/components/Table/cells/index.ts +14 -0
- package/src/components/Table/table.types.ts +346 -0
- package/src/components/Table/table.variants.ts +207 -0
- package/src/components/Tabs/tabs.variants.ts +19 -8
- package/src/components/Tooltip/tooltip.variants.ts +12 -2
- package/src/components/Typography/Typography.test.tsx +3 -1
- package/src/components/Typography/typography.variants.ts +6 -2
- package/src/components/_shared/data/getNestedValue.test.ts +50 -0
- package/src/components/_shared/data/getNestedValue.ts +32 -0
- package/src/components/_shared/data/useColumnAutoDetect.test.ts +123 -0
- package/src/components/_shared/data/useColumnAutoDetect.ts +100 -0
- package/src/components/_shared/data/useControllableState.ts +48 -0
- package/src/components/_shared/data/useDebouncedValue.ts +24 -0
- package/src/components/_shared/data/useTableFilter.ts +37 -0
- package/src/components/_shared/data/useTableSearch.test.ts +115 -0
- package/src/components/_shared/data/useTableSearch.ts +66 -0
- package/src/components/_shared/data/useTableSelection.test.ts +100 -0
- package/src/components/_shared/data/useTableSelection.ts +100 -0
- package/src/components/_shared/data/useTableSort.test.ts +150 -0
- package/src/components/_shared/data/useTableSort.ts +141 -0
- package/src/components/_shared/data/useVirtualizer.test.ts +158 -0
- package/src/components/_shared/data/useVirtualizer.ts +199 -0
- package/src/components/_shared/themeIdentity.test.ts +113 -0
- 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
|
+
});
|