@object-ui/plugin-grid 2.0.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 +42 -6
- package/CHANGELOG.md +22 -0
- package/dist/index.js +960 -641
- package/dist/index.umd.cjs +3 -3
- package/dist/packages/plugin-grid/src/InlineEditing.d.ts +28 -0
- package/dist/packages/plugin-grid/src/ObjectGrid.EdgeCases.stories.d.ts +25 -0
- 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__/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/ObjectGrid.EdgeCases.stories.tsx +147 -0
- package/src/ObjectGrid.stories.tsx +139 -0
- package/src/ObjectGrid.tsx +148 -16
- package/src/__tests__/InlineEditing.test.tsx +360 -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 +5 -0
- package/src/useGroupedData.ts +122 -0
- package/src/useRowColor.ts +74 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InlineEditing Component Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the grid inline editing component covering save/cancel,
|
|
5
|
+
* validation feedback, keyboard navigation, and display modes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
9
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
10
|
+
import userEvent from '@testing-library/user-event';
|
|
11
|
+
import '@testing-library/jest-dom';
|
|
12
|
+
import { InlineEditing } from '../InlineEditing';
|
|
13
|
+
|
|
14
|
+
// Mock lucide-react icons
|
|
15
|
+
vi.mock('lucide-react', () => ({
|
|
16
|
+
Check: (props: any) => <svg data-testid="icon-check" {...props} />,
|
|
17
|
+
X: (props: any) => <svg data-testid="icon-x" {...props} />,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// Mock cn utility to pass through classes
|
|
21
|
+
vi.mock('@object-ui/components', () => ({
|
|
22
|
+
cn: (...args: any[]) => args.filter(Boolean).join(' '),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
describe('InlineEditing', () => {
|
|
26
|
+
describe('Display mode', () => {
|
|
27
|
+
it('renders the value in display mode by default', () => {
|
|
28
|
+
render(<InlineEditing value="Hello" onSave={vi.fn()} />);
|
|
29
|
+
|
|
30
|
+
expect(screen.getByText('Hello')).toBeInTheDocument();
|
|
31
|
+
// Should not show an input
|
|
32
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('shows placeholder when value is empty', () => {
|
|
36
|
+
render(<InlineEditing value="" onSave={vi.fn()} placeholder="Enter text" />);
|
|
37
|
+
|
|
38
|
+
expect(screen.getByText('Enter text')).toBeInTheDocument();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('shows default placeholder when value is null', () => {
|
|
42
|
+
render(<InlineEditing value={null} onSave={vi.fn()} />);
|
|
43
|
+
|
|
44
|
+
expect(screen.getByText('Click to edit')).toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('has data-slot="inline-editing" on root', () => {
|
|
48
|
+
const { container } = render(<InlineEditing value="test" onSave={vi.fn()} />);
|
|
49
|
+
expect(container.querySelector('[data-slot="inline-editing"]')).toBeInTheDocument();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('applies custom className', () => {
|
|
53
|
+
const { container } = render(
|
|
54
|
+
<InlineEditing value="test" onSave={vi.fn()} className="custom-class" />,
|
|
55
|
+
);
|
|
56
|
+
const root = container.querySelector('[data-slot="inline-editing"]');
|
|
57
|
+
expect(root?.className).toContain('custom-class');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('Entering edit mode', () => {
|
|
62
|
+
it('enters edit mode on click', async () => {
|
|
63
|
+
const user = userEvent.setup();
|
|
64
|
+
render(<InlineEditing value="Hello" onSave={vi.fn()} />);
|
|
65
|
+
|
|
66
|
+
await user.click(screen.getByRole('button'));
|
|
67
|
+
|
|
68
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
69
|
+
expect((screen.getByRole('textbox') as HTMLInputElement).value).toBe('Hello');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('enters edit mode on Enter key press', () => {
|
|
73
|
+
render(<InlineEditing value="Hello" onSave={vi.fn()} />);
|
|
74
|
+
|
|
75
|
+
fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
|
|
76
|
+
|
|
77
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('enters edit mode on Space key press', () => {
|
|
81
|
+
render(<InlineEditing value="Hello" onSave={vi.fn()} />);
|
|
82
|
+
|
|
83
|
+
fireEvent.keyDown(screen.getByRole('button'), { key: ' ' });
|
|
84
|
+
|
|
85
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('starts in edit mode when editing prop is true', () => {
|
|
89
|
+
render(<InlineEditing value="Hello" onSave={vi.fn()} editing={true} />);
|
|
90
|
+
|
|
91
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('does not enter edit mode when disabled', async () => {
|
|
95
|
+
const user = userEvent.setup();
|
|
96
|
+
render(<InlineEditing value="Hello" onSave={vi.fn()} disabled />);
|
|
97
|
+
|
|
98
|
+
await user.click(screen.getByText('Hello'));
|
|
99
|
+
|
|
100
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('Save', () => {
|
|
105
|
+
it('saves on Enter key press', async () => {
|
|
106
|
+
const user = userEvent.setup();
|
|
107
|
+
const onSave = vi.fn();
|
|
108
|
+
render(<InlineEditing value="Hello" onSave={onSave} editing={true} />);
|
|
109
|
+
|
|
110
|
+
const input = screen.getByRole('textbox');
|
|
111
|
+
await user.clear(input);
|
|
112
|
+
await user.type(input, 'World');
|
|
113
|
+
await user.keyboard('{Enter}');
|
|
114
|
+
|
|
115
|
+
await waitFor(() => {
|
|
116
|
+
expect(onSave).toHaveBeenCalledWith('World');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('saves on save button click', async () => {
|
|
121
|
+
const user = userEvent.setup();
|
|
122
|
+
const onSave = vi.fn();
|
|
123
|
+
render(<InlineEditing value="Hello" onSave={onSave} editing={true} />);
|
|
124
|
+
|
|
125
|
+
const input = screen.getByRole('textbox');
|
|
126
|
+
await user.clear(input);
|
|
127
|
+
await user.type(input, 'Updated');
|
|
128
|
+
|
|
129
|
+
await user.click(screen.getByLabelText('Save'));
|
|
130
|
+
|
|
131
|
+
await waitFor(() => {
|
|
132
|
+
expect(onSave).toHaveBeenCalledWith('Updated');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('coerces value to number when type is number', async () => {
|
|
137
|
+
const user = userEvent.setup();
|
|
138
|
+
const onSave = vi.fn();
|
|
139
|
+
render(
|
|
140
|
+
<InlineEditing value={42} onSave={onSave} type="number" editing={true} />,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const input = screen.getByRole('spinbutton');
|
|
144
|
+
await user.clear(input);
|
|
145
|
+
await user.type(input, '99');
|
|
146
|
+
await user.click(screen.getByLabelText('Save'));
|
|
147
|
+
|
|
148
|
+
await waitFor(() => {
|
|
149
|
+
expect(onSave).toHaveBeenCalledWith(99);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('exits edit mode after successful save', async () => {
|
|
154
|
+
const user = userEvent.setup();
|
|
155
|
+
const onSave = vi.fn();
|
|
156
|
+
render(<InlineEditing value="Hello" onSave={onSave} editing={true} />);
|
|
157
|
+
|
|
158
|
+
await user.click(screen.getByLabelText('Save'));
|
|
159
|
+
|
|
160
|
+
await waitFor(() => {
|
|
161
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('Cancel', () => {
|
|
167
|
+
it('cancels on Escape key press', async () => {
|
|
168
|
+
const user = userEvent.setup();
|
|
169
|
+
const onCancel = vi.fn();
|
|
170
|
+
render(
|
|
171
|
+
<InlineEditing value="Hello" onSave={vi.fn()} onCancel={onCancel} editing={true} />,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const input = screen.getByRole('textbox');
|
|
175
|
+
await user.type(input, ' extra');
|
|
176
|
+
await user.keyboard('{Escape}');
|
|
177
|
+
|
|
178
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
179
|
+
expect(onCancel).toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('cancels on cancel button click', async () => {
|
|
183
|
+
const user = userEvent.setup();
|
|
184
|
+
const onCancel = vi.fn();
|
|
185
|
+
render(
|
|
186
|
+
<InlineEditing value="Hello" onSave={vi.fn()} onCancel={onCancel} editing={true} />,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
await user.click(screen.getByLabelText('Cancel'));
|
|
190
|
+
|
|
191
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
192
|
+
expect(onCancel).toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('restores original value after cancel', async () => {
|
|
196
|
+
const user = userEvent.setup();
|
|
197
|
+
render(
|
|
198
|
+
<InlineEditing value="Original" onSave={vi.fn()} editing={true} />,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const input = screen.getByRole('textbox');
|
|
202
|
+
await user.clear(input);
|
|
203
|
+
await user.type(input, 'Changed');
|
|
204
|
+
await user.keyboard('{Escape}');
|
|
205
|
+
|
|
206
|
+
// Back to display mode, showing original value
|
|
207
|
+
expect(screen.getByText('Original')).toBeInTheDocument();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('Validation', () => {
|
|
212
|
+
it('shows validation error and prevents save', async () => {
|
|
213
|
+
const user = userEvent.setup();
|
|
214
|
+
const onSave = vi.fn();
|
|
215
|
+
const validate = (val: any) =>
|
|
216
|
+
String(val).trim() === '' ? 'Required field' : undefined;
|
|
217
|
+
|
|
218
|
+
render(
|
|
219
|
+
<InlineEditing
|
|
220
|
+
value="Hello"
|
|
221
|
+
onSave={onSave}
|
|
222
|
+
validate={validate}
|
|
223
|
+
editing={true}
|
|
224
|
+
/>,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const input = screen.getByRole('textbox');
|
|
228
|
+
await user.clear(input);
|
|
229
|
+
await user.click(screen.getByLabelText('Save'));
|
|
230
|
+
|
|
231
|
+
expect(screen.getByRole('alert')).toHaveTextContent('Required field');
|
|
232
|
+
expect(onSave).not.toHaveBeenCalled();
|
|
233
|
+
// Should still be in edit mode
|
|
234
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('clears validation error on input change', async () => {
|
|
238
|
+
const user = userEvent.setup();
|
|
239
|
+
const validate = (val: any) =>
|
|
240
|
+
String(val).trim() === '' ? 'Required field' : undefined;
|
|
241
|
+
|
|
242
|
+
render(
|
|
243
|
+
<InlineEditing
|
|
244
|
+
value="Hello"
|
|
245
|
+
onSave={vi.fn()}
|
|
246
|
+
validate={validate}
|
|
247
|
+
editing={true}
|
|
248
|
+
/>,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const input = screen.getByRole('textbox');
|
|
252
|
+
await user.clear(input);
|
|
253
|
+
await user.click(screen.getByLabelText('Save'));
|
|
254
|
+
|
|
255
|
+
expect(screen.getByRole('alert')).toBeInTheDocument();
|
|
256
|
+
|
|
257
|
+
// Start typing to clear error
|
|
258
|
+
await user.type(input, 'A');
|
|
259
|
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('shows error returned from onSave', async () => {
|
|
263
|
+
const user = userEvent.setup();
|
|
264
|
+
const onSave = vi.fn().mockReturnValue('Server error');
|
|
265
|
+
|
|
266
|
+
render(
|
|
267
|
+
<InlineEditing value="Hello" onSave={onSave} editing={true} />,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
await user.click(screen.getByLabelText('Save'));
|
|
271
|
+
|
|
272
|
+
await waitFor(() => {
|
|
273
|
+
expect(screen.getByRole('alert')).toHaveTextContent('Server error');
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('marks input as aria-invalid when error is present', async () => {
|
|
278
|
+
const user = userEvent.setup();
|
|
279
|
+
const validate = () => 'Error';
|
|
280
|
+
|
|
281
|
+
render(
|
|
282
|
+
<InlineEditing value="" onSave={vi.fn()} validate={validate} editing={true} />,
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
await user.click(screen.getByLabelText('Save'));
|
|
286
|
+
|
|
287
|
+
expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true');
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe('Async save', () => {
|
|
292
|
+
it('handles async onSave', async () => {
|
|
293
|
+
const user = userEvent.setup();
|
|
294
|
+
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
295
|
+
|
|
296
|
+
render(<InlineEditing value="Hello" onSave={onSave} editing={true} />);
|
|
297
|
+
|
|
298
|
+
await user.click(screen.getByLabelText('Save'));
|
|
299
|
+
|
|
300
|
+
await waitFor(() => {
|
|
301
|
+
expect(onSave).toHaveBeenCalledWith('Hello');
|
|
302
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('shows error when async onSave throws', async () => {
|
|
307
|
+
const user = userEvent.setup();
|
|
308
|
+
const onSave = vi.fn().mockRejectedValue(new Error('Network error'));
|
|
309
|
+
|
|
310
|
+
render(<InlineEditing value="Hello" onSave={onSave} editing={true} />);
|
|
311
|
+
|
|
312
|
+
await user.click(screen.getByLabelText('Save'));
|
|
313
|
+
|
|
314
|
+
await waitFor(() => {
|
|
315
|
+
expect(screen.getByRole('alert')).toHaveTextContent('Network error');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// Should still be in edit mode
|
|
319
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe('Input types', () => {
|
|
324
|
+
it('renders text input by default', () => {
|
|
325
|
+
render(<InlineEditing value="text" onSave={vi.fn()} editing={true} />);
|
|
326
|
+
expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('renders number input when type is number', () => {
|
|
330
|
+
render(<InlineEditing value={42} onSave={vi.fn()} type="number" editing={true} />);
|
|
331
|
+
expect(screen.getByRole('spinbutton')).toHaveAttribute('type', 'number');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('renders email input when type is email', () => {
|
|
335
|
+
render(<InlineEditing value="a@b.com" onSave={vi.fn()} type="email" editing={true} />);
|
|
336
|
+
const input = screen.getByDisplayValue('a@b.com');
|
|
337
|
+
expect(input).toHaveAttribute('type', 'email');
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe('data-slot attributes', () => {
|
|
342
|
+
it('has correct data-slot attributes in display mode', () => {
|
|
343
|
+
const { container } = render(<InlineEditing value="test" onSave={vi.fn()} />);
|
|
344
|
+
|
|
345
|
+
expect(container.querySelector('[data-slot="inline-editing"]')).toBeInTheDocument();
|
|
346
|
+
expect(container.querySelector('[data-slot="inline-editing-display"]')).toBeInTheDocument();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('has correct data-slot attributes in edit mode', () => {
|
|
350
|
+
const { container } = render(
|
|
351
|
+
<InlineEditing value="test" onSave={vi.fn()} editing={true} />,
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
expect(container.querySelector('[data-slot="inline-editing"]')).toBeInTheDocument();
|
|
355
|
+
expect(container.querySelector('[data-slot="inline-editing-input"]')).toBeInTheDocument();
|
|
356
|
+
expect(container.querySelector('[data-slot="inline-editing-save"]')).toBeInTheDocument();
|
|
357
|
+
expect(container.querySelector('[data-slot="inline-editing-cancel"]')).toBeInTheDocument();
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
});
|
|
@@ -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
|
+
});
|