@object-ui/plugin-grid 0.5.0 → 3.0.0
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/.turbo/turbo-build.log +51 -6
- package/CHANGELOG.md +37 -0
- package/README.md +97 -0
- package/dist/index.js +994 -584
- package/dist/index.umd.cjs +3 -3
- package/dist/packages/plugin-grid/src/InlineEditing.d.ts +28 -0
- package/dist/packages/plugin-grid/src/ListColumnExtensions.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/ListColumnSchema.test.d.ts +1 -0
- package/dist/packages/plugin-grid/src/ObjectGrid.EdgeCases.stories.d.ts +25 -0
- package/dist/packages/plugin-grid/src/ObjectGrid.d.ts +7 -1
- package/dist/packages/plugin-grid/src/ObjectGrid.stories.d.ts +33 -0
- package/dist/packages/plugin-grid/src/__tests__/InlineEditing.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/__tests__/VirtualGrid.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/__tests__/accessibility.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/__tests__/performance-benchmark.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/__tests__/view-states.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/index.d.ts +5 -0
- package/dist/packages/plugin-grid/src/useGroupedData.d.ts +30 -0
- package/dist/packages/plugin-grid/src/useRowColor.d.ts +8 -0
- package/package.json +11 -10
- package/src/InlineEditing.tsx +235 -0
- package/src/ListColumnExtensions.test.tsx +374 -0
- package/src/ListColumnSchema.test.ts +88 -0
- package/src/ObjectGrid.EdgeCases.stories.tsx +147 -0
- package/src/ObjectGrid.msw.test.tsx +24 -1
- package/src/ObjectGrid.stories.tsx +139 -0
- package/src/ObjectGrid.tsx +409 -113
- package/src/__tests__/InlineEditing.test.tsx +360 -0
- package/src/__tests__/VirtualGrid.test.tsx +438 -0
- package/src/__tests__/accessibility.test.tsx +254 -0
- package/src/__tests__/performance-benchmark.test.tsx +182 -0
- package/src/__tests__/view-states.test.tsx +203 -0
- package/src/index.tsx +17 -2
- package/src/useGroupedData.ts +122 -0
- package/src/useRowColor.ts +74 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
10
|
+
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
|
11
|
+
import '@testing-library/jest-dom';
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import type { VirtualGridColumn, VirtualGridProps } from '../VirtualGrid';
|
|
14
|
+
|
|
15
|
+
// --- Mock @tanstack/react-virtual ---
|
|
16
|
+
// The vitest setup file pre-loads @object-ui/plugin-grid which caches the
|
|
17
|
+
// real virtualizer. We call vi.resetModules() in beforeEach so that the
|
|
18
|
+
// dynamic import of VirtualGrid picks up our mock instead.
|
|
19
|
+
vi.mock('@tanstack/react-virtual', () => ({
|
|
20
|
+
useVirtualizer: (opts: any) => {
|
|
21
|
+
const count: number = opts.count;
|
|
22
|
+
const size: number = opts.estimateSize();
|
|
23
|
+
const items = [];
|
|
24
|
+
for (let i = 0; i < count; i++) {
|
|
25
|
+
items.push({ index: i, key: String(i), start: i * size, size });
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
getVirtualItems: () => items,
|
|
29
|
+
getTotalSize: () => count * size,
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// --- Test helpers ---
|
|
35
|
+
const sampleColumns: VirtualGridColumn[] = [
|
|
36
|
+
{ header: 'Name', accessorKey: 'name' },
|
|
37
|
+
{ header: 'Email', accessorKey: 'email' },
|
|
38
|
+
{ header: 'Age', accessorKey: 'age' },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const sampleData = [
|
|
42
|
+
{ name: 'Alice', email: 'alice@test.com', age: 30 },
|
|
43
|
+
{ name: 'Bob', email: 'bob@test.com', age: 25 },
|
|
44
|
+
{ name: 'Charlie', email: 'charlie@test.com', age: 40 },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
type VirtualGridComponent = React.FC<VirtualGridProps>;
|
|
48
|
+
|
|
49
|
+
let VirtualGrid: VirtualGridComponent;
|
|
50
|
+
|
|
51
|
+
beforeEach(async () => {
|
|
52
|
+
cleanup();
|
|
53
|
+
vi.resetModules();
|
|
54
|
+
const mod = await import('../VirtualGrid');
|
|
55
|
+
VirtualGrid = mod.VirtualGrid;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function renderGrid(overrides: Partial<VirtualGridProps> = {}) {
|
|
59
|
+
const props: VirtualGridProps = {
|
|
60
|
+
data: sampleData,
|
|
61
|
+
columns: sampleColumns,
|
|
62
|
+
...overrides,
|
|
63
|
+
};
|
|
64
|
+
return render(<VirtualGrid {...props} />);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// =========================================================================
|
|
68
|
+
// 1. Basic rendering
|
|
69
|
+
// =========================================================================
|
|
70
|
+
describe('VirtualGrid: basic rendering', () => {
|
|
71
|
+
it('renders column headers', () => {
|
|
72
|
+
renderGrid();
|
|
73
|
+
|
|
74
|
+
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
75
|
+
expect(screen.getByText('Email')).toBeInTheDocument();
|
|
76
|
+
expect(screen.getByText('Age')).toBeInTheDocument();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('renders row cell values', () => {
|
|
80
|
+
renderGrid();
|
|
81
|
+
|
|
82
|
+
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
83
|
+
expect(screen.getByText('bob@test.com')).toBeInTheDocument();
|
|
84
|
+
expect(screen.getByText('40')).toBeInTheDocument();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('renders footer with row count', () => {
|
|
88
|
+
renderGrid();
|
|
89
|
+
|
|
90
|
+
expect(
|
|
91
|
+
screen.getByText(/Showing 3 of 3 rows/),
|
|
92
|
+
).toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// =========================================================================
|
|
97
|
+
// 2. Empty data
|
|
98
|
+
// =========================================================================
|
|
99
|
+
describe('VirtualGrid: empty data', () => {
|
|
100
|
+
it('renders headers with no rows when data is empty', () => {
|
|
101
|
+
renderGrid({ data: [] });
|
|
102
|
+
|
|
103
|
+
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
104
|
+
expect(screen.getByText('Email')).toBeInTheDocument();
|
|
105
|
+
expect(screen.getByText(/Showing 0 of 0 rows/)).toBeInTheDocument();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('does not render any data cells when data is empty', () => {
|
|
109
|
+
renderGrid({ data: [] });
|
|
110
|
+
|
|
111
|
+
expect(screen.queryByText('Alice')).not.toBeInTheDocument();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// =========================================================================
|
|
116
|
+
// 3. Custom className / headerClassName
|
|
117
|
+
// =========================================================================
|
|
118
|
+
describe('VirtualGrid: className support', () => {
|
|
119
|
+
it('applies custom className to the root element', () => {
|
|
120
|
+
const { container } = renderGrid({ className: 'my-custom-grid' });
|
|
121
|
+
const root = container.firstElementChild as HTMLElement;
|
|
122
|
+
expect(root).toHaveClass('my-custom-grid');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('applies headerClassName to the header row', () => {
|
|
126
|
+
const { container } = renderGrid({ headerClassName: 'header-custom' });
|
|
127
|
+
const headerRow = container.querySelector('.header-custom');
|
|
128
|
+
expect(headerRow).toBeInTheDocument();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('uses empty className by default', () => {
|
|
132
|
+
const { container } = renderGrid();
|
|
133
|
+
const root = container.firstElementChild as HTMLElement;
|
|
134
|
+
expect(root.className).toBe('');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// =========================================================================
|
|
139
|
+
// 4. Column alignment
|
|
140
|
+
// =========================================================================
|
|
141
|
+
describe('VirtualGrid: column alignment', () => {
|
|
142
|
+
it('defaults to left alignment', () => {
|
|
143
|
+
renderGrid({
|
|
144
|
+
columns: [{ header: 'Name', accessorKey: 'name' }],
|
|
145
|
+
data: [{ name: 'Alice' }],
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(screen.getByText('Name')).toHaveClass('text-left');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('applies center alignment to header and cells', () => {
|
|
152
|
+
renderGrid({
|
|
153
|
+
columns: [{ header: 'Count', accessorKey: 'count', align: 'center' }],
|
|
154
|
+
data: [{ count: 42 }],
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(screen.getByText('Count')).toHaveClass('text-center');
|
|
158
|
+
expect(screen.getByText('42')).toHaveClass('text-center');
|
|
159
|
+
expect(screen.getByText('42')).toHaveClass('justify-center');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('applies right alignment to header and cells', () => {
|
|
163
|
+
renderGrid({
|
|
164
|
+
columns: [{ header: 'Price', accessorKey: 'price', align: 'right' }],
|
|
165
|
+
data: [{ price: 99 }],
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(screen.getByText('Price')).toHaveClass('text-right');
|
|
169
|
+
expect(screen.getByText('99')).toHaveClass('text-right');
|
|
170
|
+
expect(screen.getByText('99')).toHaveClass('justify-end');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// =========================================================================
|
|
175
|
+
// 5. Custom cell renderer
|
|
176
|
+
// =========================================================================
|
|
177
|
+
describe('VirtualGrid: custom cell renderer', () => {
|
|
178
|
+
it('uses custom cell function when provided', () => {
|
|
179
|
+
renderGrid({
|
|
180
|
+
columns: [
|
|
181
|
+
{
|
|
182
|
+
header: 'Name',
|
|
183
|
+
accessorKey: 'name',
|
|
184
|
+
cell: (value: string) => <strong data-testid="bold-name">{value.toUpperCase()}</strong>,
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
data: [{ name: 'Alice' }],
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const cell = screen.getByTestId('bold-name');
|
|
191
|
+
expect(cell).toBeInTheDocument();
|
|
192
|
+
expect(cell.tagName).toBe('STRONG');
|
|
193
|
+
expect(cell).toHaveTextContent('ALICE');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('passes both value and row to custom cell function', () => {
|
|
197
|
+
const cellFn = vi.fn((_value, row) => (
|
|
198
|
+
<span data-testid="composite">{row.name} ({row.age})</span>
|
|
199
|
+
));
|
|
200
|
+
|
|
201
|
+
renderGrid({
|
|
202
|
+
columns: [{ header: 'Info', accessorKey: 'name', cell: cellFn }],
|
|
203
|
+
data: [{ name: 'Alice', age: 30 }],
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(cellFn).toHaveBeenCalledWith('Alice', { name: 'Alice', age: 30 });
|
|
207
|
+
expect(screen.getByTestId('composite')).toHaveTextContent('Alice (30)');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('renders raw value when no cell function is provided', () => {
|
|
211
|
+
renderGrid({
|
|
212
|
+
columns: [{ header: 'Name', accessorKey: 'name' }],
|
|
213
|
+
data: [{ name: 'Bob' }],
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
expect(screen.getByText('Bob')).toBeInTheDocument();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// =========================================================================
|
|
221
|
+
// 6. Column widths
|
|
222
|
+
// =========================================================================
|
|
223
|
+
describe('VirtualGrid: column widths', () => {
|
|
224
|
+
it('uses 1fr default when no width specified', () => {
|
|
225
|
+
const { container } = renderGrid({
|
|
226
|
+
columns: [
|
|
227
|
+
{ header: 'A', accessorKey: 'a' },
|
|
228
|
+
{ header: 'B', accessorKey: 'b' },
|
|
229
|
+
],
|
|
230
|
+
data: [{ a: '1', b: '2' }],
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const headerRow = container.querySelector('.grid.border-b.sticky') as HTMLElement;
|
|
234
|
+
expect(headerRow.style.gridTemplateColumns).toBe('1fr 1fr');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('applies custom column widths', () => {
|
|
238
|
+
const { container } = renderGrid({
|
|
239
|
+
columns: [
|
|
240
|
+
{ header: 'A', accessorKey: 'a', width: 200 },
|
|
241
|
+
{ header: 'B', accessorKey: 'b', width: '2fr' },
|
|
242
|
+
],
|
|
243
|
+
data: [{ a: '1', b: '2' }],
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const headerRow = container.querySelector('.grid.border-b.sticky') as HTMLElement;
|
|
247
|
+
expect(headerRow.style.gridTemplateColumns).toBe('200 2fr');
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// =========================================================================
|
|
252
|
+
// 7. Row click handler
|
|
253
|
+
// =========================================================================
|
|
254
|
+
describe('VirtualGrid: onRowClick', () => {
|
|
255
|
+
it('calls onRowClick with row data and index when row is clicked', () => {
|
|
256
|
+
const onRowClick = vi.fn();
|
|
257
|
+
renderGrid({ onRowClick });
|
|
258
|
+
|
|
259
|
+
const aliceCell = screen.getByText('Alice');
|
|
260
|
+
const row = aliceCell.closest('[style*="position: absolute"]') as HTMLElement;
|
|
261
|
+
fireEvent.click(row);
|
|
262
|
+
|
|
263
|
+
expect(onRowClick).toHaveBeenCalledTimes(1);
|
|
264
|
+
expect(onRowClick).toHaveBeenCalledWith(
|
|
265
|
+
{ name: 'Alice', email: 'alice@test.com', age: 30 },
|
|
266
|
+
0,
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('passes correct index for different rows', () => {
|
|
271
|
+
const onRowClick = vi.fn();
|
|
272
|
+
renderGrid({ onRowClick });
|
|
273
|
+
|
|
274
|
+
const charlieCell = screen.getByText('Charlie');
|
|
275
|
+
const row = charlieCell.closest('[style*="position: absolute"]') as HTMLElement;
|
|
276
|
+
fireEvent.click(row);
|
|
277
|
+
|
|
278
|
+
expect(onRowClick).toHaveBeenCalledWith(
|
|
279
|
+
{ name: 'Charlie', email: 'charlie@test.com', age: 40 },
|
|
280
|
+
2,
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('does not error when onRowClick is not provided', () => {
|
|
285
|
+
renderGrid();
|
|
286
|
+
const aliceCell = screen.getByText('Alice');
|
|
287
|
+
const row = aliceCell.closest('[style*="position: absolute"]') as HTMLElement;
|
|
288
|
+
|
|
289
|
+
expect(() => fireEvent.click(row)).not.toThrow();
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// =========================================================================
|
|
294
|
+
// 8. Row className (static and dynamic)
|
|
295
|
+
// =========================================================================
|
|
296
|
+
describe('VirtualGrid: rowClassName', () => {
|
|
297
|
+
it('applies static rowClassName to all rows', () => {
|
|
298
|
+
renderGrid({ rowClassName: 'row-highlight' });
|
|
299
|
+
|
|
300
|
+
const aliceCell = screen.getByText('Alice');
|
|
301
|
+
const row = aliceCell.closest('[style*="position: absolute"]') as HTMLElement;
|
|
302
|
+
expect(row).toHaveClass('row-highlight');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('applies dynamic rowClassName function', () => {
|
|
306
|
+
renderGrid({
|
|
307
|
+
rowClassName: (_row, index) => (index % 2 === 0 ? 'even-row' : 'odd-row'),
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const aliceRow = screen.getByText('Alice').closest('[style*="position: absolute"]') as HTMLElement;
|
|
311
|
+
expect(aliceRow).toHaveClass('even-row');
|
|
312
|
+
|
|
313
|
+
const bobRow = screen.getByText('Bob').closest('[style*="position: absolute"]') as HTMLElement;
|
|
314
|
+
expect(bobRow).toHaveClass('odd-row');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('defaults to empty string when rowClassName is not provided', () => {
|
|
318
|
+
renderGrid();
|
|
319
|
+
const row = screen.getByText('Alice').closest('[style*="position: absolute"]') as HTMLElement;
|
|
320
|
+
expect(row.className).toContain('grid');
|
|
321
|
+
expect(row.className).toContain('border-b');
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// =========================================================================
|
|
326
|
+
// 9. Virtual scrolling props
|
|
327
|
+
// =========================================================================
|
|
328
|
+
describe('VirtualGrid: virtual scrolling configuration', () => {
|
|
329
|
+
it('uses default height of 600px', () => {
|
|
330
|
+
const { container } = renderGrid();
|
|
331
|
+
const scrollContainer = container.querySelector('.overflow-auto') as HTMLElement;
|
|
332
|
+
expect(scrollContainer.style.height).toBe('600px');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('accepts numeric height', () => {
|
|
336
|
+
const { container } = renderGrid({ height: 400 });
|
|
337
|
+
const scrollContainer = container.querySelector('.overflow-auto') as HTMLElement;
|
|
338
|
+
expect(scrollContainer.style.height).toBe('400px');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('accepts string height', () => {
|
|
342
|
+
const { container } = renderGrid({ height: '80vh' });
|
|
343
|
+
const scrollContainer = container.querySelector('.overflow-auto') as HTMLElement;
|
|
344
|
+
expect(scrollContainer.style.height).toBe('80vh');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('renders a relative-positioned inner container for virtual positioning', () => {
|
|
348
|
+
const { container } = renderGrid();
|
|
349
|
+
const innerContainer = container.querySelector(
|
|
350
|
+
'.overflow-auto > div',
|
|
351
|
+
) as HTMLElement;
|
|
352
|
+
expect(innerContainer.style.position).toBe('relative');
|
|
353
|
+
expect(innerContainer.style.width).toBe('100%');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('positions rows absolutely with translateY', () => {
|
|
357
|
+
renderGrid({ rowHeight: 50 });
|
|
358
|
+
|
|
359
|
+
const aliceRow = screen.getByText('Alice').closest(
|
|
360
|
+
'[style*="position: absolute"]',
|
|
361
|
+
) as HTMLElement;
|
|
362
|
+
expect(aliceRow.style.position).toBe('absolute');
|
|
363
|
+
expect(aliceRow.style.transform).toBe('translateY(0px)');
|
|
364
|
+
|
|
365
|
+
const bobRow = screen.getByText('Bob').closest(
|
|
366
|
+
'[style*="position: absolute"]',
|
|
367
|
+
) as HTMLElement;
|
|
368
|
+
expect(bobRow.style.transform).toBe('translateY(50px)');
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('sets total height on inner container based on data length and row height', () => {
|
|
372
|
+
const { container } = renderGrid({ rowHeight: 50 });
|
|
373
|
+
const innerContainer = container.querySelector(
|
|
374
|
+
'.overflow-auto > div',
|
|
375
|
+
) as HTMLElement;
|
|
376
|
+
// 3 rows × 50px = 150px
|
|
377
|
+
expect(innerContainer.style.height).toBe('150px');
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// =========================================================================
|
|
382
|
+
// 10. Different data types and edge cases
|
|
383
|
+
// =========================================================================
|
|
384
|
+
describe('VirtualGrid: different column types', () => {
|
|
385
|
+
it('renders numeric values correctly', () => {
|
|
386
|
+
renderGrid({
|
|
387
|
+
columns: [{ header: 'Count', accessorKey: 'count' }],
|
|
388
|
+
data: [{ count: 0 }, { count: 100 }, { count: -5 }],
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
expect(screen.getByText('0')).toBeInTheDocument();
|
|
392
|
+
expect(screen.getByText('100')).toBeInTheDocument();
|
|
393
|
+
expect(screen.getByText('-5')).toBeInTheDocument();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('handles null / undefined values gracefully', () => {
|
|
397
|
+
renderGrid({
|
|
398
|
+
columns: [
|
|
399
|
+
{ header: 'Name', accessorKey: 'name' },
|
|
400
|
+
{ header: 'Email', accessorKey: 'email' },
|
|
401
|
+
],
|
|
402
|
+
data: [
|
|
403
|
+
{ name: 'Alice', email: null },
|
|
404
|
+
{ name: undefined, email: 'bob@test.com' },
|
|
405
|
+
],
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
expect(screen.getByText('bob@test.com')).toBeInTheDocument();
|
|
409
|
+
expect(screen.getByText(/Showing 2 of 2 rows/)).toBeInTheDocument();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('renders many columns without error', () => {
|
|
413
|
+
const cols: VirtualGridColumn[] = Array.from({ length: 10 }, (_, i) => ({
|
|
414
|
+
header: `Col ${i}`,
|
|
415
|
+
accessorKey: `field${i}`,
|
|
416
|
+
}));
|
|
417
|
+
const data = [Object.fromEntries(cols.map((c) => [c.accessorKey, `val-${c.accessorKey}`]))];
|
|
418
|
+
|
|
419
|
+
renderGrid({ columns: cols, data });
|
|
420
|
+
|
|
421
|
+
expect(screen.getByText('Col 0')).toBeInTheDocument();
|
|
422
|
+
expect(screen.getByText('Col 9')).toBeInTheDocument();
|
|
423
|
+
expect(screen.getByText('val-field5')).toBeInTheDocument();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('applies gridTemplateColumns to each data row matching header', () => {
|
|
427
|
+
const { container } = renderGrid({
|
|
428
|
+
columns: [
|
|
429
|
+
{ header: 'A', accessorKey: 'a', width: '100px' },
|
|
430
|
+
{ header: 'B', accessorKey: 'b', width: '200px' },
|
|
431
|
+
],
|
|
432
|
+
data: [{ a: '1', b: '2' }],
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const dataRow = container.querySelector('[style*="position: absolute"]') as HTMLElement;
|
|
436
|
+
expect(dataRow.style.gridTemplateColumns).toBe('100px 200px');
|
|
437
|
+
});
|
|
438
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Screen reader experience tests for VirtualGrid.
|
|
11
|
+
*
|
|
12
|
+
* Tests ARIA attributes, roles, landmarks, keyboard navigation,
|
|
13
|
+
* and screen reader announcements for the grid plugin.
|
|
14
|
+
* Part of P2.3 Accessibility & Inclusive Design roadmap.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
18
|
+
import { render, screen, cleanup } from '@testing-library/react';
|
|
19
|
+
import '@testing-library/jest-dom';
|
|
20
|
+
import React from 'react';
|
|
21
|
+
import type { VirtualGridColumn, VirtualGridProps } from '../VirtualGrid';
|
|
22
|
+
|
|
23
|
+
// Mock @tanstack/react-virtual (same pattern as VirtualGrid.test.tsx)
|
|
24
|
+
vi.mock('@tanstack/react-virtual', () => ({
|
|
25
|
+
useVirtualizer: (opts: any) => {
|
|
26
|
+
const count: number = opts.count;
|
|
27
|
+
const size: number = opts.estimateSize();
|
|
28
|
+
const items = [];
|
|
29
|
+
for (let i = 0; i < count; i++) {
|
|
30
|
+
items.push({ index: i, key: String(i), start: i * size, size });
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
getVirtualItems: () => items,
|
|
34
|
+
getTotalSize: () => count * size,
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
const sampleColumns: VirtualGridColumn[] = [
|
|
40
|
+
{ header: 'Name', accessorKey: 'name' },
|
|
41
|
+
{ header: 'Email', accessorKey: 'email' },
|
|
42
|
+
{ header: 'Role', accessorKey: 'role' },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const sampleData = [
|
|
46
|
+
{ name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin' },
|
|
47
|
+
{ name: 'Bob Smith', email: 'bob@example.com', role: 'Editor' },
|
|
48
|
+
{ name: 'Charlie Brown', email: 'charlie@example.com', role: 'Viewer' },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
type VirtualGridComponent = React.FC<VirtualGridProps>;
|
|
52
|
+
let VirtualGrid: VirtualGridComponent;
|
|
53
|
+
|
|
54
|
+
beforeEach(async () => {
|
|
55
|
+
cleanup();
|
|
56
|
+
vi.resetModules();
|
|
57
|
+
const mod = await import('../VirtualGrid');
|
|
58
|
+
VirtualGrid = mod.VirtualGrid;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function renderGrid(overrides: Partial<VirtualGridProps> = {}) {
|
|
62
|
+
const props: VirtualGridProps = {
|
|
63
|
+
data: sampleData,
|
|
64
|
+
columns: sampleColumns,
|
|
65
|
+
...overrides,
|
|
66
|
+
};
|
|
67
|
+
return render(<VirtualGrid {...props} />);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('VirtualGrid: Screen Reader & Accessibility', () => {
|
|
71
|
+
describe('ARIA attributes and roles', () => {
|
|
72
|
+
it('renders column headers as identifiable elements', () => {
|
|
73
|
+
renderGrid();
|
|
74
|
+
|
|
75
|
+
const nameHeader = screen.getByText('Name');
|
|
76
|
+
const emailHeader = screen.getByText('Email');
|
|
77
|
+
const roleHeader = screen.getByText('Role');
|
|
78
|
+
|
|
79
|
+
expect(nameHeader).toBeInTheDocument();
|
|
80
|
+
expect(emailHeader).toBeInTheDocument();
|
|
81
|
+
expect(roleHeader).toBeInTheDocument();
|
|
82
|
+
|
|
83
|
+
// Headers should have font-semibold styling to indicate importance
|
|
84
|
+
expect(nameHeader).toHaveClass('font-semibold');
|
|
85
|
+
expect(emailHeader).toHaveClass('font-semibold');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('renders data cells with proper content for screen readers', () => {
|
|
89
|
+
renderGrid();
|
|
90
|
+
|
|
91
|
+
expect(screen.getByText('Alice Johnson')).toBeInTheDocument();
|
|
92
|
+
expect(screen.getByText('bob@example.com')).toBeInTheDocument();
|
|
93
|
+
expect(screen.getByText('Viewer')).toBeInTheDocument();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('footer announces row count for screen readers', () => {
|
|
97
|
+
renderGrid();
|
|
98
|
+
|
|
99
|
+
const footer = screen.getByText(/Showing 3 of 3 rows/);
|
|
100
|
+
expect(footer).toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('empty grid announces zero rows', () => {
|
|
104
|
+
renderGrid({ data: [] });
|
|
105
|
+
|
|
106
|
+
const footer = screen.getByText(/Showing 0 of 0 rows/);
|
|
107
|
+
expect(footer).toBeInTheDocument();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('grid structure for assistive technology', () => {
|
|
112
|
+
it('header row uses CSS grid layout for structural organization', () => {
|
|
113
|
+
const { container } = renderGrid();
|
|
114
|
+
|
|
115
|
+
const headerRow = container.querySelector('.grid.border-b.sticky');
|
|
116
|
+
expect(headerRow).toBeInTheDocument();
|
|
117
|
+
expect(headerRow).toHaveStyle({ gridTemplateColumns: '1fr 1fr 1fr' });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('data rows use CSS grid with matching column structure', () => {
|
|
121
|
+
const { container } = renderGrid();
|
|
122
|
+
|
|
123
|
+
const dataRows = container.querySelectorAll('[style*="position: absolute"]');
|
|
124
|
+
expect(dataRows.length).toBe(3);
|
|
125
|
+
|
|
126
|
+
dataRows.forEach((row) => {
|
|
127
|
+
expect((row as HTMLElement).style.gridTemplateColumns).toBe('1fr 1fr 1fr');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('each cell contains text content accessible to screen readers', () => {
|
|
132
|
+
renderGrid();
|
|
133
|
+
|
|
134
|
+
// Verify all data is available in the DOM for screen readers
|
|
135
|
+
sampleData.forEach((row) => {
|
|
136
|
+
expect(screen.getByText(row.name)).toBeInTheDocument();
|
|
137
|
+
expect(screen.getByText(row.email)).toBeInTheDocument();
|
|
138
|
+
expect(screen.getByText(row.role)).toBeInTheDocument();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('column alignment and visual hierarchy', () => {
|
|
144
|
+
it('left-aligned columns use text-left class', () => {
|
|
145
|
+
renderGrid({
|
|
146
|
+
columns: [{ header: 'Name', accessorKey: 'name', align: 'left' }],
|
|
147
|
+
data: [{ name: 'Alice' }],
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(screen.getByText('Name')).toHaveClass('text-left');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('center-aligned columns indicate alignment for assistive technology', () => {
|
|
154
|
+
renderGrid({
|
|
155
|
+
columns: [{ header: 'Count', accessorKey: 'count', align: 'center' }],
|
|
156
|
+
data: [{ count: 42 }],
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const header = screen.getByText('Count');
|
|
160
|
+
expect(header).toHaveClass('text-center');
|
|
161
|
+
|
|
162
|
+
const cell = screen.getByText('42');
|
|
163
|
+
expect(cell).toHaveClass('text-center');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('right-aligned columns indicate alignment for assistive technology', () => {
|
|
167
|
+
renderGrid({
|
|
168
|
+
columns: [{ header: 'Price', accessorKey: 'price', align: 'right' }],
|
|
169
|
+
data: [{ price: 99.99 }],
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const header = screen.getByText('Price');
|
|
173
|
+
expect(header).toHaveClass('text-right');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('interactive rows', () => {
|
|
178
|
+
it('clickable rows have cursor-pointer for visual indication', () => {
|
|
179
|
+
const onRowClick = vi.fn();
|
|
180
|
+
const { container } = renderGrid({ onRowClick });
|
|
181
|
+
|
|
182
|
+
const rows = container.querySelectorAll('[style*="position: absolute"]');
|
|
183
|
+
rows.forEach((row) => {
|
|
184
|
+
expect((row as HTMLElement).className).toContain('cursor-pointer');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('rows have hover styling for visual feedback', () => {
|
|
189
|
+
const { container } = renderGrid();
|
|
190
|
+
|
|
191
|
+
const rows = container.querySelectorAll('[style*="position: absolute"]');
|
|
192
|
+
rows.forEach((row) => {
|
|
193
|
+
// Rows have hover background styling
|
|
194
|
+
expect((row as HTMLElement).className).toContain('hover:bg-muted');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('custom cell renderers preserve accessibility', () => {
|
|
200
|
+
it('custom cell renderers can include ARIA labels', () => {
|
|
201
|
+
renderGrid({
|
|
202
|
+
columns: [
|
|
203
|
+
{
|
|
204
|
+
header: 'Status',
|
|
205
|
+
accessorKey: 'status',
|
|
206
|
+
cell: (value: string) => (
|
|
207
|
+
<span role="status" aria-label={`Status: ${value}`}>
|
|
208
|
+
{value}
|
|
209
|
+
</span>
|
|
210
|
+
),
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
data: [{ status: 'Active' }],
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const statusCell = screen.getByRole('status');
|
|
217
|
+
expect(statusCell).toHaveAttribute('aria-label', 'Status: Active');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('custom cell renderers can include interactive elements', () => {
|
|
221
|
+
renderGrid({
|
|
222
|
+
columns: [
|
|
223
|
+
{
|
|
224
|
+
header: 'Actions',
|
|
225
|
+
accessorKey: 'id',
|
|
226
|
+
cell: (value: string) => (
|
|
227
|
+
<button aria-label={`Edit record ${value}`}>Edit</button>
|
|
228
|
+
),
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
data: [{ id: '123' }],
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const editBtn = screen.getByRole('button', { name: 'Edit record 123' });
|
|
235
|
+
expect(editBtn).toBeInTheDocument();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('loading and empty states', () => {
|
|
240
|
+
it('displays zero-row footer for empty data', () => {
|
|
241
|
+
renderGrid({ data: [] });
|
|
242
|
+
|
|
243
|
+
expect(screen.getByText(/Showing 0 of 0 rows/)).toBeInTheDocument();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('headers remain visible in empty state for context', () => {
|
|
247
|
+
renderGrid({ data: [] });
|
|
248
|
+
|
|
249
|
+
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
250
|
+
expect(screen.getByText('Email')).toBeInTheDocument();
|
|
251
|
+
expect(screen.getByText('Role')).toBeInTheDocument();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
});
|