@object-ui/components 3.0.3 → 3.1.1
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 +12 -12
- package/CHANGELOG.md +9 -0
- package/dist/index.css +1 -1
- package/dist/index.js +24932 -23139
- package/dist/index.umd.cjs +37 -37
- package/dist/src/custom/config-field-renderer.d.ts +21 -0
- package/dist/src/custom/config-panel-renderer.d.ts +81 -0
- package/dist/src/custom/config-row.d.ts +27 -0
- package/dist/src/custom/filter-builder.d.ts +1 -1
- package/dist/src/custom/index.d.ts +5 -0
- package/dist/src/custom/mobile-dialog-content.d.ts +20 -0
- package/dist/src/custom/navigation-overlay.d.ts +8 -0
- package/dist/src/custom/section-header.d.ts +31 -0
- package/dist/src/debug/DebugPanel.d.ts +39 -0
- package/dist/src/debug/index.d.ts +9 -0
- package/dist/src/hooks/use-config-draft.d.ts +46 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/renderers/action/action-bar.d.ts +25 -0
- package/dist/src/renderers/action/action-button.d.ts +1 -0
- package/dist/src/types/config-panel.d.ts +92 -0
- package/dist/src/ui/sheet.d.ts +2 -0
- package/dist/src/ui/sidebar.d.ts +4 -0
- package/package.json +17 -17
- package/src/__tests__/__snapshots__/snapshot-critical.test.tsx.snap +3 -3
- package/src/__tests__/action-bar.test.tsx +206 -0
- package/src/__tests__/config-field-renderer.test.tsx +307 -0
- package/src/__tests__/config-panel-renderer.test.tsx +580 -0
- package/src/__tests__/config-primitives.test.tsx +106 -0
- package/src/__tests__/filter-builder.test.tsx +409 -0
- package/src/__tests__/mobile-accessibility.test.tsx +120 -0
- package/src/__tests__/navigation-overlay.test.tsx +97 -0
- package/src/__tests__/use-config-draft.test.tsx +295 -0
- package/src/custom/config-field-renderer.tsx +276 -0
- package/src/custom/config-panel-renderer.tsx +306 -0
- package/src/custom/config-row.tsx +50 -0
- package/src/custom/filter-builder.tsx +76 -25
- package/src/custom/index.ts +5 -0
- package/src/custom/mobile-dialog-content.tsx +67 -0
- package/src/custom/navigation-overlay.tsx +42 -4
- package/src/custom/section-header.tsx +68 -0
- package/src/debug/DebugPanel.tsx +313 -0
- package/src/debug/__tests__/DebugPanel.test.tsx +134 -0
- package/src/debug/index.ts +10 -0
- package/src/hooks/use-config-draft.ts +127 -0
- package/src/index.css +4 -0
- package/src/index.ts +15 -0
- package/src/renderers/action/action-bar.tsx +221 -0
- package/src/renderers/action/action-button.tsx +17 -6
- package/src/renderers/action/index.ts +1 -0
- package/src/renderers/complex/__tests__/data-table-airtable-ux.test.tsx +239 -0
- package/src/renderers/complex/__tests__/data-table.test.ts +16 -0
- package/src/renderers/complex/data-table.tsx +346 -43
- package/src/renderers/data-display/breadcrumb.tsx +3 -2
- package/src/renderers/form/form.tsx +4 -4
- package/src/renderers/navigation/header-bar.tsx +69 -10
- package/src/stories/ConfigPanel.stories.tsx +232 -0
- package/src/types/config-panel.ts +101 -0
- package/src/ui/dialog.tsx +20 -3
- package/src/ui/sheet.tsx +6 -3
- package/src/ui/sidebar.tsx +93 -9
|
@@ -0,0 +1,409 @@
|
|
|
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 } from '@testing-library/react';
|
|
11
|
+
import { FilterBuilder } from '../custom/filter-builder';
|
|
12
|
+
|
|
13
|
+
// Mock crypto.randomUUID for deterministic test IDs
|
|
14
|
+
let uuidCounter = 0;
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
uuidCounter = 0;
|
|
17
|
+
vi.spyOn(crypto, 'randomUUID').mockImplementation(
|
|
18
|
+
() => `test-uuid-${++uuidCounter}` as `${string}-${string}-${string}-${string}-${string}`,
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const selectFields = [
|
|
23
|
+
{
|
|
24
|
+
value: 'status',
|
|
25
|
+
label: 'Status',
|
|
26
|
+
type: 'select',
|
|
27
|
+
options: [
|
|
28
|
+
{ value: 'active', label: 'Active' },
|
|
29
|
+
{ value: 'inactive', label: 'Inactive' },
|
|
30
|
+
{ value: 'pending', label: 'Pending' },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
value: 'name',
|
|
35
|
+
label: 'Name',
|
|
36
|
+
type: 'text',
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const lookupFields = [
|
|
41
|
+
{
|
|
42
|
+
value: 'account',
|
|
43
|
+
label: 'Account',
|
|
44
|
+
type: 'lookup',
|
|
45
|
+
options: [
|
|
46
|
+
{ value: 'acme', label: 'Acme Corp' },
|
|
47
|
+
{ value: 'globex', label: 'Globex' },
|
|
48
|
+
{ value: 'soylent', label: 'Soylent Corp' },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
describe('FilterBuilder', () => {
|
|
54
|
+
describe('lookup field type', () => {
|
|
55
|
+
it('renders without error for lookup fields', () => {
|
|
56
|
+
const onChange = vi.fn();
|
|
57
|
+
const { container } = render(
|
|
58
|
+
<FilterBuilder
|
|
59
|
+
fields={lookupFields}
|
|
60
|
+
value={{
|
|
61
|
+
id: 'root',
|
|
62
|
+
logic: 'and',
|
|
63
|
+
conditions: [
|
|
64
|
+
{ id: 'c1', field: 'account', operator: 'equals', value: '' },
|
|
65
|
+
],
|
|
66
|
+
}}
|
|
67
|
+
onChange={onChange}
|
|
68
|
+
/>,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Should render the filter condition row
|
|
72
|
+
expect(container.querySelector('.space-y-3')).toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('renders multi-select checkboxes for lookup field with "in" operator', () => {
|
|
76
|
+
const onChange = vi.fn();
|
|
77
|
+
render(
|
|
78
|
+
<FilterBuilder
|
|
79
|
+
fields={lookupFields}
|
|
80
|
+
value={{
|
|
81
|
+
id: 'root',
|
|
82
|
+
logic: 'and',
|
|
83
|
+
conditions: [
|
|
84
|
+
{ id: 'c1', field: 'account', operator: 'in', value: [] },
|
|
85
|
+
],
|
|
86
|
+
}}
|
|
87
|
+
onChange={onChange}
|
|
88
|
+
/>,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Should render checkboxes for lookup options
|
|
92
|
+
const checkboxes = screen.getAllByRole('checkbox');
|
|
93
|
+
expect(checkboxes.length).toBe(3);
|
|
94
|
+
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
|
|
95
|
+
expect(screen.getByText('Globex')).toBeInTheDocument();
|
|
96
|
+
expect(screen.getByText('Soylent Corp')).toBeInTheDocument();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('multi-select with in/notIn operator', () => {
|
|
101
|
+
it('renders checkbox list for select field with "in" operator', () => {
|
|
102
|
+
const onChange = vi.fn();
|
|
103
|
+
render(
|
|
104
|
+
<FilterBuilder
|
|
105
|
+
fields={selectFields}
|
|
106
|
+
value={{
|
|
107
|
+
id: 'root',
|
|
108
|
+
logic: 'and',
|
|
109
|
+
conditions: [
|
|
110
|
+
{ id: 'c1', field: 'status', operator: 'in', value: [] },
|
|
111
|
+
],
|
|
112
|
+
}}
|
|
113
|
+
onChange={onChange}
|
|
114
|
+
/>,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Should render checkboxes for all options
|
|
118
|
+
const checkboxes = screen.getAllByRole('checkbox');
|
|
119
|
+
expect(checkboxes.length).toBe(3);
|
|
120
|
+
expect(screen.getByText('Active')).toBeInTheDocument();
|
|
121
|
+
expect(screen.getByText('Inactive')).toBeInTheDocument();
|
|
122
|
+
expect(screen.getByText('Pending')).toBeInTheDocument();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('checks selected items in multi-select', () => {
|
|
126
|
+
const onChange = vi.fn();
|
|
127
|
+
render(
|
|
128
|
+
<FilterBuilder
|
|
129
|
+
fields={selectFields}
|
|
130
|
+
value={{
|
|
131
|
+
id: 'root',
|
|
132
|
+
logic: 'and',
|
|
133
|
+
conditions: [
|
|
134
|
+
{ id: 'c1', field: 'status', operator: 'in', value: ['active', 'pending'] },
|
|
135
|
+
],
|
|
136
|
+
}}
|
|
137
|
+
onChange={onChange}
|
|
138
|
+
/>,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
|
|
142
|
+
// Active and Pending should be checked
|
|
143
|
+
expect(checkboxes[0]).toBeChecked(); // Active
|
|
144
|
+
expect(checkboxes[1]).not.toBeChecked(); // Inactive
|
|
145
|
+
expect(checkboxes[2]).toBeChecked(); // Pending
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('adds value when checkbox is checked', () => {
|
|
149
|
+
const onChange = vi.fn();
|
|
150
|
+
render(
|
|
151
|
+
<FilterBuilder
|
|
152
|
+
fields={selectFields}
|
|
153
|
+
value={{
|
|
154
|
+
id: 'root',
|
|
155
|
+
logic: 'and',
|
|
156
|
+
conditions: [
|
|
157
|
+
{ id: 'c1', field: 'status', operator: 'in', value: ['active'] },
|
|
158
|
+
],
|
|
159
|
+
}}
|
|
160
|
+
onChange={onChange}
|
|
161
|
+
/>,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Click the unchecked Inactive checkbox
|
|
165
|
+
const checkboxes = screen.getAllByRole('checkbox');
|
|
166
|
+
fireEvent.click(checkboxes[1]); // Inactive
|
|
167
|
+
|
|
168
|
+
expect(onChange).toHaveBeenCalled();
|
|
169
|
+
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
|
|
170
|
+
const condition = lastCall.conditions[0];
|
|
171
|
+
expect(condition.value).toEqual(['active', 'inactive']);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('removes value when checkbox is unchecked', () => {
|
|
175
|
+
const onChange = vi.fn();
|
|
176
|
+
render(
|
|
177
|
+
<FilterBuilder
|
|
178
|
+
fields={selectFields}
|
|
179
|
+
value={{
|
|
180
|
+
id: 'root',
|
|
181
|
+
logic: 'and',
|
|
182
|
+
conditions: [
|
|
183
|
+
{ id: 'c1', field: 'status', operator: 'in', value: ['active', 'inactive'] },
|
|
184
|
+
],
|
|
185
|
+
}}
|
|
186
|
+
onChange={onChange}
|
|
187
|
+
/>,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// Click the checked Active checkbox
|
|
191
|
+
const checkboxes = screen.getAllByRole('checkbox');
|
|
192
|
+
fireEvent.click(checkboxes[0]); // Active
|
|
193
|
+
|
|
194
|
+
expect(onChange).toHaveBeenCalled();
|
|
195
|
+
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
|
|
196
|
+
const condition = lastCall.conditions[0];
|
|
197
|
+
expect(condition.value).toEqual(['inactive']);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('renders checkbox list for notIn operator', () => {
|
|
201
|
+
const onChange = vi.fn();
|
|
202
|
+
render(
|
|
203
|
+
<FilterBuilder
|
|
204
|
+
fields={selectFields}
|
|
205
|
+
value={{
|
|
206
|
+
id: 'root',
|
|
207
|
+
logic: 'and',
|
|
208
|
+
conditions: [
|
|
209
|
+
{ id: 'c1', field: 'status', operator: 'notIn', value: [] },
|
|
210
|
+
],
|
|
211
|
+
}}
|
|
212
|
+
onChange={onChange}
|
|
213
|
+
/>,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const checkboxes = screen.getAllByRole('checkbox');
|
|
217
|
+
expect(checkboxes.length).toBe(3);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('does not render checkboxes for equals operator (single select)', () => {
|
|
221
|
+
const onChange = vi.fn();
|
|
222
|
+
render(
|
|
223
|
+
<FilterBuilder
|
|
224
|
+
fields={selectFields}
|
|
225
|
+
value={{
|
|
226
|
+
id: 'root',
|
|
227
|
+
logic: 'and',
|
|
228
|
+
conditions: [
|
|
229
|
+
{ id: 'c1', field: 'status', operator: 'equals', value: '' },
|
|
230
|
+
],
|
|
231
|
+
}}
|
|
232
|
+
onChange={onChange}
|
|
233
|
+
/>,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Should NOT render checkboxes (single select via Select component)
|
|
237
|
+
expect(screen.queryAllByRole('checkbox').length).toBe(0);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('currency/percent/rating fields use number input', () => {
|
|
242
|
+
const numericFields = [
|
|
243
|
+
{ value: 'amount', label: 'Amount', type: 'currency' },
|
|
244
|
+
{ value: 'rate', label: 'Rate', type: 'percent' },
|
|
245
|
+
{ value: 'score', label: 'Score', type: 'rating' },
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
it('renders number input for currency field', () => {
|
|
249
|
+
const onChange = vi.fn();
|
|
250
|
+
render(
|
|
251
|
+
<FilterBuilder
|
|
252
|
+
fields={numericFields}
|
|
253
|
+
value={{
|
|
254
|
+
id: 'root',
|
|
255
|
+
logic: 'and',
|
|
256
|
+
conditions: [
|
|
257
|
+
{ id: 'c1', field: 'amount', operator: 'equals', value: '' },
|
|
258
|
+
],
|
|
259
|
+
}}
|
|
260
|
+
onChange={onChange}
|
|
261
|
+
/>,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const input = screen.getByPlaceholderText('Value') as HTMLInputElement;
|
|
265
|
+
expect(input.type).toBe('number');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('renders number input for percent field', () => {
|
|
269
|
+
const onChange = vi.fn();
|
|
270
|
+
render(
|
|
271
|
+
<FilterBuilder
|
|
272
|
+
fields={numericFields}
|
|
273
|
+
value={{
|
|
274
|
+
id: 'root',
|
|
275
|
+
logic: 'and',
|
|
276
|
+
conditions: [
|
|
277
|
+
{ id: 'c1', field: 'rate', operator: 'equals', value: '' },
|
|
278
|
+
],
|
|
279
|
+
}}
|
|
280
|
+
onChange={onChange}
|
|
281
|
+
/>,
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const input = screen.getByPlaceholderText('Value') as HTMLInputElement;
|
|
285
|
+
expect(input.type).toBe('number');
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('datetime/time fields use appropriate input types', () => {
|
|
290
|
+
const dateTimeFields = [
|
|
291
|
+
{ value: 'created_at', label: 'Created At', type: 'datetime' },
|
|
292
|
+
{ value: 'start_time', label: 'Start Time', type: 'time' },
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
it('renders datetime-local input for datetime field', () => {
|
|
296
|
+
const onChange = vi.fn();
|
|
297
|
+
render(
|
|
298
|
+
<FilterBuilder
|
|
299
|
+
fields={dateTimeFields}
|
|
300
|
+
value={{
|
|
301
|
+
id: 'root',
|
|
302
|
+
logic: 'and',
|
|
303
|
+
conditions: [
|
|
304
|
+
{ id: 'c1', field: 'created_at', operator: 'equals', value: '' },
|
|
305
|
+
],
|
|
306
|
+
}}
|
|
307
|
+
onChange={onChange}
|
|
308
|
+
/>,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const input = screen.getByPlaceholderText('Value') as HTMLInputElement;
|
|
312
|
+
expect(input.type).toBe('datetime-local');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('renders time input for time field', () => {
|
|
316
|
+
const onChange = vi.fn();
|
|
317
|
+
render(
|
|
318
|
+
<FilterBuilder
|
|
319
|
+
fields={dateTimeFields}
|
|
320
|
+
value={{
|
|
321
|
+
id: 'root',
|
|
322
|
+
logic: 'and',
|
|
323
|
+
conditions: [
|
|
324
|
+
{ id: 'c1', field: 'start_time', operator: 'equals', value: '' },
|
|
325
|
+
],
|
|
326
|
+
}}
|
|
327
|
+
onChange={onChange}
|
|
328
|
+
/>,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const input = screen.getByPlaceholderText('Value') as HTMLInputElement;
|
|
332
|
+
expect(input.type).toBe('time');
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe('status field uses select operators and dropdown', () => {
|
|
337
|
+
const statusFields = [
|
|
338
|
+
{
|
|
339
|
+
value: 'pipeline',
|
|
340
|
+
label: 'Pipeline Stage',
|
|
341
|
+
type: 'status',
|
|
342
|
+
options: [
|
|
343
|
+
{ value: 'lead', label: 'Lead' },
|
|
344
|
+
{ value: 'qualified', label: 'Qualified' },
|
|
345
|
+
{ value: 'won', label: 'Won' },
|
|
346
|
+
],
|
|
347
|
+
},
|
|
348
|
+
];
|
|
349
|
+
|
|
350
|
+
it('renders multi-select checkboxes for status field with "in" operator', () => {
|
|
351
|
+
const onChange = vi.fn();
|
|
352
|
+
render(
|
|
353
|
+
<FilterBuilder
|
|
354
|
+
fields={statusFields}
|
|
355
|
+
value={{
|
|
356
|
+
id: 'root',
|
|
357
|
+
logic: 'and',
|
|
358
|
+
conditions: [
|
|
359
|
+
{ id: 'c1', field: 'pipeline', operator: 'in', value: [] },
|
|
360
|
+
],
|
|
361
|
+
}}
|
|
362
|
+
onChange={onChange}
|
|
363
|
+
/>,
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
const checkboxes = screen.getAllByRole('checkbox');
|
|
367
|
+
expect(checkboxes.length).toBe(3);
|
|
368
|
+
expect(screen.getByText('Lead')).toBeInTheDocument();
|
|
369
|
+
expect(screen.getByText('Qualified')).toBeInTheDocument();
|
|
370
|
+
expect(screen.getByText('Won')).toBeInTheDocument();
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
describe('user/owner field uses lookup operators and dropdown', () => {
|
|
375
|
+
const userFields = [
|
|
376
|
+
{
|
|
377
|
+
value: 'assigned_to',
|
|
378
|
+
label: 'Assigned To',
|
|
379
|
+
type: 'user',
|
|
380
|
+
options: [
|
|
381
|
+
{ value: 'user1', label: 'Alice' },
|
|
382
|
+
{ value: 'user2', label: 'Bob' },
|
|
383
|
+
],
|
|
384
|
+
},
|
|
385
|
+
];
|
|
386
|
+
|
|
387
|
+
it('renders multi-select checkboxes for user field with "in" operator', () => {
|
|
388
|
+
const onChange = vi.fn();
|
|
389
|
+
render(
|
|
390
|
+
<FilterBuilder
|
|
391
|
+
fields={userFields}
|
|
392
|
+
value={{
|
|
393
|
+
id: 'root',
|
|
394
|
+
logic: 'and',
|
|
395
|
+
conditions: [
|
|
396
|
+
{ id: 'c1', field: 'assigned_to', operator: 'in', value: [] },
|
|
397
|
+
],
|
|
398
|
+
}}
|
|
399
|
+
onChange={onChange}
|
|
400
|
+
/>,
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
const checkboxes = screen.getAllByRole('checkbox');
|
|
404
|
+
expect(checkboxes.length).toBe(2);
|
|
405
|
+
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
406
|
+
expect(screen.getByText('Bob')).toBeInTheDocument();
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
* Mobile Accessibility Audit — axe-core checks at mobile viewport widths.
|
|
11
|
+
*
|
|
12
|
+
* Verifies that common UI patterns (buttons, forms, navigation, dialogs,
|
|
13
|
+
* tables) remain WCAG 2.1 AA compliant when rendered in a narrow container
|
|
14
|
+
* that simulates a mobile viewport. Part of P3 Mobile Testing & QA.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from 'vitest';
|
|
18
|
+
import { render } from '@testing-library/react';
|
|
19
|
+
import { axe } from 'vitest-axe';
|
|
20
|
+
import React from 'react';
|
|
21
|
+
|
|
22
|
+
async function expectNoViolations(container: HTMLElement) {
|
|
23
|
+
const results = await axe(container);
|
|
24
|
+
const violations = (results as any).violations || [];
|
|
25
|
+
if (violations.length > 0) {
|
|
26
|
+
const messages = violations.map(
|
|
27
|
+
(v: any) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} instance(s))`,
|
|
28
|
+
);
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Expected no accessibility violations, but found ${violations.length}:\n${messages.join('\n')}`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const MOBILE_WIDTH = 375;
|
|
36
|
+
|
|
37
|
+
describe('Mobile Accessibility Audit', () => {
|
|
38
|
+
it('should have no axe violations for Button at mobile viewport', async () => {
|
|
39
|
+
const { container } = render(
|
|
40
|
+
<div style={{ width: `${MOBILE_WIDTH}px` }}>
|
|
41
|
+
<button type="button">Click me</button>
|
|
42
|
+
</div>,
|
|
43
|
+
);
|
|
44
|
+
await expectNoViolations(container);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should have no axe violations for form inputs at mobile viewport', async () => {
|
|
48
|
+
const { container } = render(
|
|
49
|
+
<div style={{ width: `${MOBILE_WIDTH}px` }}>
|
|
50
|
+
<form>
|
|
51
|
+
<label htmlFor="name">Name</label>
|
|
52
|
+
<input id="name" type="text" />
|
|
53
|
+
<label htmlFor="email">Email</label>
|
|
54
|
+
<input id="email" type="email" />
|
|
55
|
+
<button type="submit">Submit</button>
|
|
56
|
+
</form>
|
|
57
|
+
</div>,
|
|
58
|
+
);
|
|
59
|
+
await expectNoViolations(container);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should have no axe violations for navigation at mobile viewport', async () => {
|
|
63
|
+
const { container } = render(
|
|
64
|
+
<div style={{ width: `${MOBILE_WIDTH}px` }}>
|
|
65
|
+
<nav aria-label="Main navigation">
|
|
66
|
+
<ul>
|
|
67
|
+
<li>
|
|
68
|
+
<a href="/">Home</a>
|
|
69
|
+
</li>
|
|
70
|
+
<li>
|
|
71
|
+
<a href="/about">About</a>
|
|
72
|
+
</li>
|
|
73
|
+
</ul>
|
|
74
|
+
</nav>
|
|
75
|
+
</div>,
|
|
76
|
+
);
|
|
77
|
+
await expectNoViolations(container);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should have no axe violations for dialog at mobile viewport', async () => {
|
|
81
|
+
const { container } = render(
|
|
82
|
+
<div style={{ width: `${MOBILE_WIDTH}px` }}>
|
|
83
|
+
<div role="dialog" aria-label="Confirmation" aria-modal="true">
|
|
84
|
+
<h2>Confirm Action</h2>
|
|
85
|
+
<p>Are you sure?</p>
|
|
86
|
+
<button type="button">Yes</button>
|
|
87
|
+
<button type="button">No</button>
|
|
88
|
+
</div>
|
|
89
|
+
</div>,
|
|
90
|
+
);
|
|
91
|
+
await expectNoViolations(container);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should have no axe violations for data table at mobile viewport', async () => {
|
|
95
|
+
const { container } = render(
|
|
96
|
+
<div style={{ width: `${MOBILE_WIDTH}px`, overflow: 'auto' }}>
|
|
97
|
+
<table>
|
|
98
|
+
<caption>Sample data</caption>
|
|
99
|
+
<thead>
|
|
100
|
+
<tr>
|
|
101
|
+
<th>Name</th>
|
|
102
|
+
<th>Value</th>
|
|
103
|
+
</tr>
|
|
104
|
+
</thead>
|
|
105
|
+
<tbody>
|
|
106
|
+
<tr>
|
|
107
|
+
<td>Item 1</td>
|
|
108
|
+
<td>100</td>
|
|
109
|
+
</tr>
|
|
110
|
+
<tr>
|
|
111
|
+
<td>Item 2</td>
|
|
112
|
+
<td>200</td>
|
|
113
|
+
</tr>
|
|
114
|
+
</tbody>
|
|
115
|
+
</table>
|
|
116
|
+
</div>,
|
|
117
|
+
);
|
|
118
|
+
await expectNoViolations(container);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -219,6 +219,31 @@ describe('NavigationOverlay', () => {
|
|
|
219
219
|
);
|
|
220
220
|
expect(screen.getByText('Quick View')).toBeInTheDocument();
|
|
221
221
|
});
|
|
222
|
+
|
|
223
|
+
it('should render fallback dialog when no popoverTrigger is provided', () => {
|
|
224
|
+
render(
|
|
225
|
+
<NavigationOverlay
|
|
226
|
+
{...createProps({
|
|
227
|
+
mode: 'popover',
|
|
228
|
+
title: 'Preview',
|
|
229
|
+
})}
|
|
230
|
+
/>
|
|
231
|
+
);
|
|
232
|
+
expect(screen.getByText('Preview')).toBeInTheDocument();
|
|
233
|
+
expect(screen.getByText('Test Record')).toBeInTheDocument();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should not render fallback dialog when closed and no popoverTrigger', () => {
|
|
237
|
+
const { container } = render(
|
|
238
|
+
<NavigationOverlay
|
|
239
|
+
{...createProps({
|
|
240
|
+
mode: 'popover',
|
|
241
|
+
isOpen: false,
|
|
242
|
+
})}
|
|
243
|
+
/>
|
|
244
|
+
);
|
|
245
|
+
expect(container.innerHTML).toBe('');
|
|
246
|
+
});
|
|
222
247
|
});
|
|
223
248
|
|
|
224
249
|
// ============================================================
|
|
@@ -270,4 +295,76 @@ describe('NavigationOverlay', () => {
|
|
|
270
295
|
expect(screen.getByTestId('status')).toHaveTextContent('active');
|
|
271
296
|
});
|
|
272
297
|
});
|
|
298
|
+
|
|
299
|
+
// ============================================================
|
|
300
|
+
// renderView support
|
|
301
|
+
// ============================================================
|
|
302
|
+
|
|
303
|
+
describe('renderView support', () => {
|
|
304
|
+
it('should use renderView when both renderView and view are provided', () => {
|
|
305
|
+
render(
|
|
306
|
+
<NavigationOverlay
|
|
307
|
+
{...createProps({
|
|
308
|
+
mode: 'drawer',
|
|
309
|
+
view: 'contact-detail',
|
|
310
|
+
renderView: (record, viewName) => (
|
|
311
|
+
<div data-testid="custom-view">
|
|
312
|
+
<span data-testid="view-name">{viewName}</span>
|
|
313
|
+
<span data-testid="record-name">{String(record.name)}</span>
|
|
314
|
+
</div>
|
|
315
|
+
),
|
|
316
|
+
})}
|
|
317
|
+
/>
|
|
318
|
+
);
|
|
319
|
+
expect(screen.getByTestId('custom-view')).toBeInTheDocument();
|
|
320
|
+
expect(screen.getByTestId('view-name')).toHaveTextContent('contact-detail');
|
|
321
|
+
expect(screen.getByTestId('record-name')).toHaveTextContent('Test Record');
|
|
322
|
+
// Children should NOT be rendered
|
|
323
|
+
expect(screen.queryByTestId('record-content')).not.toBeInTheDocument();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should fallback to children when renderView is provided but view is not', () => {
|
|
327
|
+
render(
|
|
328
|
+
<NavigationOverlay
|
|
329
|
+
{...createProps({
|
|
330
|
+
mode: 'drawer',
|
|
331
|
+
renderView: (_record, _viewName) => (
|
|
332
|
+
<div data-testid="custom-view">Should not appear</div>
|
|
333
|
+
),
|
|
334
|
+
})}
|
|
335
|
+
/>
|
|
336
|
+
);
|
|
337
|
+
// Children should be rendered since view is undefined
|
|
338
|
+
expect(screen.getByTestId('record-content')).toBeInTheDocument();
|
|
339
|
+
expect(screen.queryByTestId('custom-view')).not.toBeInTheDocument();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('should fallback to children when view is provided but renderView is not', () => {
|
|
343
|
+
render(
|
|
344
|
+
<NavigationOverlay
|
|
345
|
+
{...createProps({
|
|
346
|
+
mode: 'drawer',
|
|
347
|
+
view: 'contact-detail',
|
|
348
|
+
})}
|
|
349
|
+
/>
|
|
350
|
+
);
|
|
351
|
+
// Children should be rendered since renderView is undefined
|
|
352
|
+
expect(screen.getByTestId('record-content')).toBeInTheDocument();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('should use renderView in modal mode', () => {
|
|
356
|
+
render(
|
|
357
|
+
<NavigationOverlay
|
|
358
|
+
{...createProps({
|
|
359
|
+
mode: 'modal',
|
|
360
|
+
view: 'edit-form',
|
|
361
|
+
renderView: (record, viewName) => (
|
|
362
|
+
<div data-testid="modal-custom-view">{viewName}: {String(record.name)}</div>
|
|
363
|
+
),
|
|
364
|
+
})}
|
|
365
|
+
/>
|
|
366
|
+
);
|
|
367
|
+
expect(screen.getByTestId('modal-custom-view')).toHaveTextContent('edit-form: Test Record');
|
|
368
|
+
});
|
|
369
|
+
});
|
|
273
370
|
});
|