@object-ui/plugin-view 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 +7 -6
- package/CHANGELOG.md +38 -0
- package/README.md +58 -0
- package/dist/index.js +1168 -349
- package/dist/index.umd.cjs +2 -2
- package/dist/plugin-view/src/FilterUI.d.ts +16 -0
- package/dist/plugin-view/src/ObjectView.d.ts +85 -5
- package/dist/plugin-view/src/SortUI.d.ts +16 -0
- package/dist/plugin-view/src/ViewSwitcher.d.ts +16 -0
- package/dist/plugin-view/src/index.d.ts +7 -1
- package/package.json +9 -8
- package/src/FilterUI.tsx +317 -0
- package/src/ObjectView.tsx +668 -148
- package/src/SortUI.tsx +210 -0
- package/src/ViewSwitcher.tsx +311 -0
- package/src/__tests__/FilterUI.test.tsx +544 -0
- package/src/__tests__/ObjectView.test.tsx +375 -0
- package/src/__tests__/SortUI.test.tsx +380 -0
- package/src/__tests__/registration.test.tsx +32 -0
- package/src/__tests__/toolbar-consistency.test.tsx +755 -0
- package/src/index.tsx +147 -5
- package/vite.config.ts +1 -0
- package/vitest.config.ts +12 -0
- package/vitest.setup.ts +1 -0
|
@@ -0,0 +1,755 @@
|
|
|
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
|
+
* Toolbar Consistency Tests
|
|
11
|
+
*
|
|
12
|
+
* Verifies that toolbar, filter, sort, and view-switcher controls render
|
|
13
|
+
* with consistent structure and accessible attributes across all view types.
|
|
14
|
+
*
|
|
15
|
+
* Strategy: Each section renders the relevant component in isolation using
|
|
16
|
+
* the same lightweight Shadcn mocks from sibling test files, then asserts
|
|
17
|
+
* ARIA roles, labels, and keyboard-accessibility invariants.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
21
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
22
|
+
import { ObjectView } from '../ObjectView';
|
|
23
|
+
import { FilterUI } from '../FilterUI';
|
|
24
|
+
import { SortUI } from '../SortUI';
|
|
25
|
+
import { ViewSwitcher } from '../ViewSwitcher';
|
|
26
|
+
import type {
|
|
27
|
+
ObjectViewSchema,
|
|
28
|
+
DataSource,
|
|
29
|
+
FilterUISchema,
|
|
30
|
+
SortUISchema,
|
|
31
|
+
ViewSwitcherSchema,
|
|
32
|
+
} from '@object-ui/types';
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Mocks – mirrors ObjectView.test.tsx
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
vi.mock('@object-ui/react', () => ({
|
|
38
|
+
SchemaRenderer: ({ schema }: any) => (
|
|
39
|
+
<div data-testid="schema-renderer" data-schema-type={schema?.type}>
|
|
40
|
+
{schema?.type}
|
|
41
|
+
</div>
|
|
42
|
+
),
|
|
43
|
+
SchemaRendererContext: null,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
vi.mock('@object-ui/plugin-grid', () => ({
|
|
47
|
+
ObjectGrid: ({ schema }: any) => (
|
|
48
|
+
<div data-testid="object-grid" data-object={schema?.objectName}>
|
|
49
|
+
Grid
|
|
50
|
+
</div>
|
|
51
|
+
),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
vi.mock('@object-ui/plugin-form', () => ({
|
|
55
|
+
ObjectForm: ({ schema }: any) => (
|
|
56
|
+
<div data-testid="object-form" data-mode={schema?.mode}>
|
|
57
|
+
Form ({schema?.mode})
|
|
58
|
+
</div>
|
|
59
|
+
),
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Mock @object-ui/components – lightweight Shadcn stand-ins
|
|
64
|
+
// (mirrors FilterUI.test.tsx / SortUI.test.tsx)
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
vi.mock('@object-ui/components', async () => {
|
|
67
|
+
const React = await import('react');
|
|
68
|
+
const cn = (...args: any[]) => args.filter(Boolean).join(' ');
|
|
69
|
+
|
|
70
|
+
const Button = ({ children, onClick, variant, size, type, ...rest }: any) => (
|
|
71
|
+
<button onClick={onClick} data-variant={variant} data-size={size} type={type} {...rest}>
|
|
72
|
+
{children}
|
|
73
|
+
</button>
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const Input = ({ value, onChange, placeholder, type, ...rest }: any) => (
|
|
77
|
+
<input
|
|
78
|
+
value={value}
|
|
79
|
+
onChange={onChange}
|
|
80
|
+
placeholder={placeholder}
|
|
81
|
+
type={type}
|
|
82
|
+
data-testid={`input-${type || 'text'}`}
|
|
83
|
+
{...rest}
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const Label = ({ children, className, htmlFor }: any) => (
|
|
88
|
+
<label className={className} htmlFor={htmlFor}>
|
|
89
|
+
{children}
|
|
90
|
+
</label>
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const Checkbox = ({ checked, onCheckedChange }: any) => (
|
|
94
|
+
<input
|
|
95
|
+
type="checkbox"
|
|
96
|
+
data-testid="checkbox"
|
|
97
|
+
checked={checked}
|
|
98
|
+
onChange={(e: any) => onCheckedChange?.(e.target.checked)}
|
|
99
|
+
/>
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Select family with context for onValueChange propagation
|
|
103
|
+
const SelectCtx = React.createContext<((v: string) => void) | undefined>(undefined);
|
|
104
|
+
|
|
105
|
+
const Select = ({ children, value, onValueChange }: any) => (
|
|
106
|
+
<SelectCtx.Provider value={onValueChange}>
|
|
107
|
+
<div data-testid="select-root" data-value={value}>
|
|
108
|
+
{children}
|
|
109
|
+
</div>
|
|
110
|
+
</SelectCtx.Provider>
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const SelectTrigger = ({ children, className }: any) => (
|
|
114
|
+
<button data-testid="select-trigger" className={className}>
|
|
115
|
+
{children}
|
|
116
|
+
</button>
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const SelectValue = ({ placeholder }: any) => (
|
|
120
|
+
<span data-testid="select-value">{placeholder}</span>
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const SelectContent = ({ children }: any) => (
|
|
124
|
+
<div data-testid="select-content">{children}</div>
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const SelectItem = ({ children, value }: any) => {
|
|
128
|
+
const onValueChange = React.useContext(SelectCtx);
|
|
129
|
+
return (
|
|
130
|
+
<div
|
|
131
|
+
data-testid="select-item"
|
|
132
|
+
data-value={value}
|
|
133
|
+
role="option"
|
|
134
|
+
onClick={() => onValueChange?.(String(value))}
|
|
135
|
+
>
|
|
136
|
+
{children}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const Popover = ({ children, open }: any) => (
|
|
142
|
+
<div data-testid="popover" data-open={open}>{children}</div>
|
|
143
|
+
);
|
|
144
|
+
const PopoverTrigger = ({ children }: any) => (
|
|
145
|
+
<div data-testid="popover-trigger">{children}</div>
|
|
146
|
+
);
|
|
147
|
+
const PopoverContent = ({ children }: any) => (
|
|
148
|
+
<div data-testid="popover-content">{children}</div>
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const Drawer = ({ children, open }: any) => (
|
|
152
|
+
<div data-testid="drawer" data-open={open}>{children}</div>
|
|
153
|
+
);
|
|
154
|
+
const DrawerContent = ({ children }: any) => (
|
|
155
|
+
<div data-testid="drawer-content">{children}</div>
|
|
156
|
+
);
|
|
157
|
+
const DrawerHeader = ({ children }: any) => (
|
|
158
|
+
<div data-testid="drawer-header">{children}</div>
|
|
159
|
+
);
|
|
160
|
+
const DrawerTitle = ({ children }: any) => (
|
|
161
|
+
<h2 data-testid="drawer-title">{children}</h2>
|
|
162
|
+
);
|
|
163
|
+
const DrawerDescription = ({ children }: any) => (
|
|
164
|
+
<p data-testid="drawer-description">{children}</p>
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const Dialog = ({ children, open }: any) => (
|
|
168
|
+
<div data-testid="dialog" data-open={open}>{children}</div>
|
|
169
|
+
);
|
|
170
|
+
const DialogContent = ({ children }: any) => (
|
|
171
|
+
<div data-testid="dialog-content">{children}</div>
|
|
172
|
+
);
|
|
173
|
+
const DialogHeader = ({ children }: any) => (
|
|
174
|
+
<div data-testid="dialog-header">{children}</div>
|
|
175
|
+
);
|
|
176
|
+
const DialogTitle = ({ children }: any) => (
|
|
177
|
+
<h2 data-testid="dialog-title">{children}</h2>
|
|
178
|
+
);
|
|
179
|
+
const DialogDescription = ({ children }: any) => (
|
|
180
|
+
<p data-testid="dialog-description">{children}</p>
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// Tabs family – render role="tablist" for TabsList
|
|
184
|
+
const TabsCtx = React.createContext<{ value: string; onValueChange?: (v: string) => void }>({
|
|
185
|
+
value: '',
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const Tabs = ({ children, value, onValueChange }: any) => (
|
|
189
|
+
<TabsCtx.Provider value={{ value, onValueChange }}>
|
|
190
|
+
<div data-testid="tabs">{children}</div>
|
|
191
|
+
</TabsCtx.Provider>
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const TabsList = ({ children, className }: any) => (
|
|
195
|
+
<div role="tablist" className={className}>{children}</div>
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const TabsTrigger = ({ children, value, className }: any) => {
|
|
199
|
+
const ctx = React.useContext(TabsCtx);
|
|
200
|
+
const isActive = ctx.value === value;
|
|
201
|
+
return (
|
|
202
|
+
<button
|
|
203
|
+
role="tab"
|
|
204
|
+
aria-selected={isActive}
|
|
205
|
+
data-state={isActive ? 'active' : 'inactive'}
|
|
206
|
+
className={className}
|
|
207
|
+
onClick={() => ctx.onValueChange?.(value)}
|
|
208
|
+
>
|
|
209
|
+
{children}
|
|
210
|
+
</button>
|
|
211
|
+
);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const SortBuilder = ({ fields, value, onChange }: any) => (
|
|
215
|
+
<div data-testid="sort-builder" data-fields={JSON.stringify(fields)} data-value={JSON.stringify(value)}>
|
|
216
|
+
<button
|
|
217
|
+
data-testid="sort-builder-change"
|
|
218
|
+
onClick={() => onChange?.([{ id: 'date-desc', field: 'date', order: 'desc' }])}
|
|
219
|
+
>
|
|
220
|
+
Change Sort
|
|
221
|
+
</button>
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
cn,
|
|
227
|
+
Button,
|
|
228
|
+
Input,
|
|
229
|
+
Label,
|
|
230
|
+
Checkbox,
|
|
231
|
+
Select,
|
|
232
|
+
SelectTrigger,
|
|
233
|
+
SelectValue,
|
|
234
|
+
SelectContent,
|
|
235
|
+
SelectItem,
|
|
236
|
+
Popover,
|
|
237
|
+
PopoverTrigger,
|
|
238
|
+
PopoverContent,
|
|
239
|
+
Drawer,
|
|
240
|
+
DrawerContent,
|
|
241
|
+
DrawerHeader,
|
|
242
|
+
DrawerTitle,
|
|
243
|
+
DrawerDescription,
|
|
244
|
+
Dialog,
|
|
245
|
+
DialogContent,
|
|
246
|
+
DialogHeader,
|
|
247
|
+
DialogTitle,
|
|
248
|
+
DialogDescription,
|
|
249
|
+
Tabs,
|
|
250
|
+
TabsList,
|
|
251
|
+
TabsTrigger,
|
|
252
|
+
SortBuilder,
|
|
253
|
+
};
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Shared helpers
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
const createMockDataSource = (overrides: Partial<DataSource> = {}): DataSource =>
|
|
260
|
+
({
|
|
261
|
+
find: vi.fn().mockResolvedValue([]),
|
|
262
|
+
findOne: vi.fn().mockResolvedValue(null),
|
|
263
|
+
create: vi.fn().mockResolvedValue({}),
|
|
264
|
+
update: vi.fn().mockResolvedValue({}),
|
|
265
|
+
delete: vi.fn().mockResolvedValue({}),
|
|
266
|
+
getObjectSchema: vi.fn().mockResolvedValue({
|
|
267
|
+
label: 'Contacts',
|
|
268
|
+
fields: {
|
|
269
|
+
name: { label: 'Name', type: 'text' },
|
|
270
|
+
email: { label: 'Email', type: 'text' },
|
|
271
|
+
status: {
|
|
272
|
+
label: 'Status',
|
|
273
|
+
type: 'select',
|
|
274
|
+
options: [
|
|
275
|
+
{ label: 'Active', value: 'active' },
|
|
276
|
+
{ label: 'Inactive', value: 'inactive' },
|
|
277
|
+
],
|
|
278
|
+
},
|
|
279
|
+
created_at: { label: 'Created', type: 'date' },
|
|
280
|
+
},
|
|
281
|
+
}),
|
|
282
|
+
...overrides,
|
|
283
|
+
} as DataSource);
|
|
284
|
+
|
|
285
|
+
// ===========================================================================
|
|
286
|
+
// 1. ObjectView renders a toolbar region
|
|
287
|
+
// ===========================================================================
|
|
288
|
+
describe('Toolbar consistency across view types', () => {
|
|
289
|
+
let mockDataSource: DataSource;
|
|
290
|
+
|
|
291
|
+
beforeEach(() => {
|
|
292
|
+
vi.clearAllMocks();
|
|
293
|
+
mockDataSource = createMockDataSource();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('ObjectView toolbar region', () => {
|
|
297
|
+
it('renders a toolbar container that wraps action buttons', () => {
|
|
298
|
+
const schema: ObjectViewSchema = {
|
|
299
|
+
type: 'object-view',
|
|
300
|
+
objectName: 'contacts',
|
|
301
|
+
title: 'Contacts',
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const { container } = render(
|
|
305
|
+
<ObjectView schema={schema} dataSource={mockDataSource} />,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
// The Create button lives inside the toolbar area
|
|
309
|
+
const createBtn = screen.getByText('Create');
|
|
310
|
+
expect(createBtn).toBeDefined();
|
|
311
|
+
// Toolbar wrapper is a parent div
|
|
312
|
+
expect(createBtn.closest('div')).toBeDefined();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('renders Create button as a focusable element', () => {
|
|
316
|
+
const schema: ObjectViewSchema = {
|
|
317
|
+
type: 'object-view',
|
|
318
|
+
objectName: 'contacts',
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
render(<ObjectView schema={schema} dataSource={mockDataSource} />);
|
|
322
|
+
|
|
323
|
+
const btn = screen.getByText('Create');
|
|
324
|
+
expect(btn.tagName).toBe('BUTTON');
|
|
325
|
+
// Buttons are keyboard-focusable by default (no tabIndex=-1)
|
|
326
|
+
expect(btn.getAttribute('tabindex')).not.toBe('-1');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('hides toolbar when no actions or tabs are needed', () => {
|
|
330
|
+
const schema: ObjectViewSchema = {
|
|
331
|
+
type: 'object-view',
|
|
332
|
+
objectName: 'contacts',
|
|
333
|
+
showCreate: false,
|
|
334
|
+
showViewSwitcher: false,
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const { container } = render(
|
|
338
|
+
<ObjectView schema={schema} dataSource={mockDataSource} />,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
// No Create button
|
|
342
|
+
expect(screen.queryByText('Create')).toBeNull();
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// =========================================================================
|
|
347
|
+
// 2. Filter controls are accessible
|
|
348
|
+
// =========================================================================
|
|
349
|
+
describe('FilterUI accessibility', () => {
|
|
350
|
+
const makeFilterSchema = (overrides: Partial<FilterUISchema> = {}): FilterUISchema => ({
|
|
351
|
+
type: 'filter-ui',
|
|
352
|
+
filters: [
|
|
353
|
+
{
|
|
354
|
+
field: 'status',
|
|
355
|
+
label: 'Status',
|
|
356
|
+
type: 'select',
|
|
357
|
+
options: [
|
|
358
|
+
{ label: 'Active', value: 'active' },
|
|
359
|
+
{ label: 'Inactive', value: 'inactive' },
|
|
360
|
+
],
|
|
361
|
+
},
|
|
362
|
+
{ field: 'name', label: 'Name', type: 'text' },
|
|
363
|
+
],
|
|
364
|
+
...overrides,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('renders a visible label for each filter field', () => {
|
|
368
|
+
render(<FilterUI schema={makeFilterSchema()} />);
|
|
369
|
+
|
|
370
|
+
expect(screen.getByText('Status')).toBeInTheDocument();
|
|
371
|
+
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('renders text filter inputs with descriptive placeholders', () => {
|
|
375
|
+
render(
|
|
376
|
+
<FilterUI
|
|
377
|
+
schema={makeFilterSchema({
|
|
378
|
+
filters: [{ field: 'search', label: 'Search', type: 'text' }],
|
|
379
|
+
})}
|
|
380
|
+
/>,
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
const input = screen.getByPlaceholderText('Filter by Search');
|
|
384
|
+
expect(input).toBeInTheDocument();
|
|
385
|
+
expect(input.tagName).toBe('INPUT');
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('renders select filters with option role="option" items', () => {
|
|
389
|
+
render(<FilterUI schema={makeFilterSchema()} />);
|
|
390
|
+
|
|
391
|
+
const options = screen.getAllByRole('option');
|
|
392
|
+
expect(options.length).toBeGreaterThanOrEqual(2);
|
|
393
|
+
expect(options[0]).toHaveAttribute('data-value', 'active');
|
|
394
|
+
expect(options[1]).toHaveAttribute('data-value', 'inactive');
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('renders filters consistently in popover layout', () => {
|
|
398
|
+
render(<FilterUI schema={makeFilterSchema({ layout: 'popover' })} />);
|
|
399
|
+
|
|
400
|
+
// Popover trigger should contain a "Filters" label
|
|
401
|
+
expect(screen.getByText('Filters')).toBeInTheDocument();
|
|
402
|
+
// Filter labels are still present inside popover content
|
|
403
|
+
expect(screen.getByText('Status')).toBeInTheDocument();
|
|
404
|
+
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// =========================================================================
|
|
409
|
+
// 3. Sort controls render consistently with proper attributes
|
|
410
|
+
// =========================================================================
|
|
411
|
+
describe('SortUI accessibility', () => {
|
|
412
|
+
const makeSortSchema = (overrides: Partial<SortUISchema> = {}): SortUISchema => ({
|
|
413
|
+
type: 'sort-ui',
|
|
414
|
+
fields: [
|
|
415
|
+
{ field: 'name', label: 'Name' },
|
|
416
|
+
{ field: 'date', label: 'Date' },
|
|
417
|
+
],
|
|
418
|
+
...overrides,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('renders sort buttons as focusable button elements', () => {
|
|
422
|
+
render(<SortUI schema={makeSortSchema({ variant: 'buttons' })} />);
|
|
423
|
+
|
|
424
|
+
const buttons = screen.getAllByRole('button');
|
|
425
|
+
expect(buttons).toHaveLength(2);
|
|
426
|
+
buttons.forEach((btn) => {
|
|
427
|
+
expect(btn.tagName).toBe('BUTTON');
|
|
428
|
+
expect(btn.getAttribute('tabindex')).not.toBe('-1');
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('renders active sort with secondary variant for visual distinction', () => {
|
|
433
|
+
render(
|
|
434
|
+
<SortUI
|
|
435
|
+
schema={makeSortSchema({
|
|
436
|
+
variant: 'buttons',
|
|
437
|
+
sort: [{ field: 'name', direction: 'asc' }],
|
|
438
|
+
})}
|
|
439
|
+
/>,
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
const nameBtn = screen.getByText('Name').closest('button')!;
|
|
443
|
+
expect(nameBtn).toHaveAttribute('data-variant', 'secondary');
|
|
444
|
+
|
|
445
|
+
const dateBtn = screen.getByText('Date').closest('button')!;
|
|
446
|
+
expect(dateBtn).toHaveAttribute('data-variant', 'outline');
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('renders dropdown variant with select controls', () => {
|
|
450
|
+
render(<SortUI schema={makeSortSchema({ variant: 'dropdown' })} />);
|
|
451
|
+
|
|
452
|
+
const selectRoots = screen.getAllByTestId('select-root');
|
|
453
|
+
// Field selector + direction selector
|
|
454
|
+
expect(selectRoots).toHaveLength(2);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('renders sort direction options (Ascending / Descending)', () => {
|
|
458
|
+
render(<SortUI schema={makeSortSchema({ variant: 'dropdown' })} />);
|
|
459
|
+
|
|
460
|
+
expect(screen.getByText('Ascending')).toBeInTheDocument();
|
|
461
|
+
expect(screen.getByText('Descending')).toBeInTheDocument();
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// =========================================================================
|
|
466
|
+
// 4. Toolbar actions are keyboard-accessible
|
|
467
|
+
// =========================================================================
|
|
468
|
+
describe('Toolbar keyboard accessibility', () => {
|
|
469
|
+
it('Create button responds to keyboard activation', () => {
|
|
470
|
+
const schema: ObjectViewSchema = {
|
|
471
|
+
type: 'object-view',
|
|
472
|
+
objectName: 'contacts',
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
render(<ObjectView schema={schema} dataSource={mockDataSource} />);
|
|
476
|
+
|
|
477
|
+
const createBtn = screen.getByText('Create');
|
|
478
|
+
// Simulate keyboard activation (Enter key on a focused button triggers click)
|
|
479
|
+
fireEvent.keyDown(createBtn, { key: 'Enter', code: 'Enter' });
|
|
480
|
+
fireEvent.keyUp(createBtn, { key: 'Enter', code: 'Enter' });
|
|
481
|
+
// Button is a native <button>, so keyboard activation is inherent
|
|
482
|
+
expect(createBtn.tagName).toBe('BUTTON');
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('sort buttons respond to click events (keyboard proxied)', () => {
|
|
486
|
+
const onChange = vi.fn();
|
|
487
|
+
render(
|
|
488
|
+
<SortUI
|
|
489
|
+
schema={{
|
|
490
|
+
type: 'sort-ui',
|
|
491
|
+
fields: [{ field: 'name', label: 'Name' }],
|
|
492
|
+
variant: 'buttons',
|
|
493
|
+
}}
|
|
494
|
+
onChange={onChange}
|
|
495
|
+
/>,
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
const btn = screen.getByText('Name').closest('button')!;
|
|
499
|
+
fireEvent.click(btn);
|
|
500
|
+
expect(onChange).toHaveBeenCalledWith([{ field: 'name', direction: 'asc' }]);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('filter text inputs accept keyboard input', () => {
|
|
504
|
+
const onChange = vi.fn();
|
|
505
|
+
render(
|
|
506
|
+
<FilterUI
|
|
507
|
+
schema={{
|
|
508
|
+
type: 'filter-ui',
|
|
509
|
+
filters: [{ field: 'query', label: 'Search', type: 'text' }],
|
|
510
|
+
}}
|
|
511
|
+
onChange={onChange}
|
|
512
|
+
/>,
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
const input = screen.getByPlaceholderText('Filter by Search');
|
|
516
|
+
fireEvent.change(input, { target: { value: 'test' } });
|
|
517
|
+
expect(onChange).toHaveBeenCalledWith({ query: 'test' });
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// =========================================================================
|
|
522
|
+
// 5. Search input in toolbar has proper label and role
|
|
523
|
+
// =========================================================================
|
|
524
|
+
describe('Search input accessibility', () => {
|
|
525
|
+
it('filter text input has an associated label element', () => {
|
|
526
|
+
render(
|
|
527
|
+
<FilterUI
|
|
528
|
+
schema={{
|
|
529
|
+
type: 'filter-ui',
|
|
530
|
+
filters: [{ field: 'search', label: 'Search', type: 'text' }],
|
|
531
|
+
}}
|
|
532
|
+
/>,
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
// Label with text "Search" should exist
|
|
536
|
+
expect(screen.getByText('Search')).toBeInTheDocument();
|
|
537
|
+
// Input should have descriptive placeholder
|
|
538
|
+
const input = screen.getByPlaceholderText('Filter by Search');
|
|
539
|
+
expect(input).toBeInTheDocument();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('filter text input is an <input> element that is focusable', () => {
|
|
543
|
+
render(
|
|
544
|
+
<FilterUI
|
|
545
|
+
schema={{
|
|
546
|
+
type: 'filter-ui',
|
|
547
|
+
filters: [{ field: 'q', label: 'Quick Search', type: 'text' }],
|
|
548
|
+
}}
|
|
549
|
+
/>,
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
const input = screen.getByPlaceholderText('Filter by Quick Search');
|
|
553
|
+
expect(input.tagName).toBe('INPUT');
|
|
554
|
+
expect(input.getAttribute('tabindex')).not.toBe('-1');
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// =========================================================================
|
|
559
|
+
// 6. View type selector renders with role="tablist"
|
|
560
|
+
// =========================================================================
|
|
561
|
+
describe('ViewSwitcher tablist rendering', () => {
|
|
562
|
+
const makeViewSwitcherSchema = (
|
|
563
|
+
overrides: Partial<ViewSwitcherSchema> = {},
|
|
564
|
+
): ViewSwitcherSchema => ({
|
|
565
|
+
type: 'view-switcher',
|
|
566
|
+
views: [
|
|
567
|
+
{ type: 'grid', label: 'Grid' },
|
|
568
|
+
{ type: 'kanban', label: 'Kanban' },
|
|
569
|
+
{ type: 'calendar', label: 'Calendar' },
|
|
570
|
+
],
|
|
571
|
+
variant: 'tabs',
|
|
572
|
+
...overrides,
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('renders tabs variant with role="tablist"', () => {
|
|
576
|
+
render(<ViewSwitcher schema={makeViewSwitcherSchema()} />);
|
|
577
|
+
|
|
578
|
+
const tablist = screen.getByRole('tablist');
|
|
579
|
+
expect(tablist).toBeInTheDocument();
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('renders individual tabs with role="tab"', () => {
|
|
583
|
+
render(<ViewSwitcher schema={makeViewSwitcherSchema()} />);
|
|
584
|
+
|
|
585
|
+
const tabs = screen.getAllByRole('tab');
|
|
586
|
+
expect(tabs).toHaveLength(3);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('marks the active tab with aria-selected="true"', () => {
|
|
590
|
+
render(
|
|
591
|
+
<ViewSwitcher
|
|
592
|
+
schema={makeViewSwitcherSchema({ activeView: 'kanban' })}
|
|
593
|
+
/>,
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
const tabs = screen.getAllByRole('tab');
|
|
597
|
+
const kanbanTab = tabs.find((t) => t.textContent?.includes('Kanban'));
|
|
598
|
+
expect(kanbanTab).toBeDefined();
|
|
599
|
+
expect(kanbanTab!.getAttribute('aria-selected')).toBe('true');
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('marks inactive tabs with aria-selected="false"', () => {
|
|
603
|
+
render(
|
|
604
|
+
<ViewSwitcher
|
|
605
|
+
schema={makeViewSwitcherSchema({ activeView: 'grid' })}
|
|
606
|
+
/>,
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
const tabs = screen.getAllByRole('tab');
|
|
610
|
+
const kanbanTab = tabs.find((t) => t.textContent?.includes('Kanban'));
|
|
611
|
+
expect(kanbanTab!.getAttribute('aria-selected')).toBe('false');
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('tabs are keyboard-activatable via click', () => {
|
|
615
|
+
const onViewChange = vi.fn();
|
|
616
|
+
render(
|
|
617
|
+
<ViewSwitcher
|
|
618
|
+
schema={makeViewSwitcherSchema()}
|
|
619
|
+
onViewChange={onViewChange}
|
|
620
|
+
/>,
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
const tabs = screen.getAllByRole('tab');
|
|
624
|
+
const calendarTab = tabs.find((t) => t.textContent?.includes('Calendar'));
|
|
625
|
+
fireEvent.click(calendarTab!);
|
|
626
|
+
|
|
627
|
+
expect(onViewChange).toHaveBeenCalledWith('calendar');
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('renders buttons variant without tablist role', () => {
|
|
631
|
+
render(
|
|
632
|
+
<ViewSwitcher
|
|
633
|
+
schema={makeViewSwitcherSchema({ variant: 'buttons' })}
|
|
634
|
+
/>,
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
|
|
638
|
+
// Should render plain buttons instead
|
|
639
|
+
const buttons = screen.getAllByRole('button');
|
|
640
|
+
expect(buttons.length).toBe(3);
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// =========================================================================
|
|
645
|
+
// 7. Density / view variant selector renders with accessible options
|
|
646
|
+
// =========================================================================
|
|
647
|
+
describe('ViewSwitcher dropdown variant accessibility', () => {
|
|
648
|
+
const makeDropdownSchema = (
|
|
649
|
+
overrides: Partial<ViewSwitcherSchema> = {},
|
|
650
|
+
): ViewSwitcherSchema => ({
|
|
651
|
+
type: 'view-switcher',
|
|
652
|
+
views: [
|
|
653
|
+
{ type: 'grid', label: 'Grid' },
|
|
654
|
+
{ type: 'kanban', label: 'Kanban' },
|
|
655
|
+
],
|
|
656
|
+
variant: 'dropdown',
|
|
657
|
+
...overrides,
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('renders dropdown with select-root controls', () => {
|
|
661
|
+
render(<ViewSwitcher schema={makeDropdownSchema()} />);
|
|
662
|
+
|
|
663
|
+
const selectRoot = screen.getByTestId('select-root');
|
|
664
|
+
expect(selectRoot).toBeInTheDocument();
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('renders all view options inside the select', () => {
|
|
668
|
+
render(<ViewSwitcher schema={makeDropdownSchema()} />);
|
|
669
|
+
|
|
670
|
+
const items = screen.getAllByTestId('select-item');
|
|
671
|
+
expect(items).toHaveLength(2);
|
|
672
|
+
expect(items[0]).toHaveTextContent('Grid');
|
|
673
|
+
expect(items[1]).toHaveTextContent('Kanban');
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it('renders option items with role="option"', () => {
|
|
677
|
+
render(<ViewSwitcher schema={makeDropdownSchema()} />);
|
|
678
|
+
|
|
679
|
+
const options = screen.getAllByRole('option');
|
|
680
|
+
expect(options.length).toBe(2);
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// =========================================================================
|
|
685
|
+
// Cross-view consistency: named list views use tablist
|
|
686
|
+
// =========================================================================
|
|
687
|
+
describe('Named list views tablist consistency', () => {
|
|
688
|
+
it('renders named view tabs with role="tablist" for multiple views', () => {
|
|
689
|
+
const schema: ObjectViewSchema = {
|
|
690
|
+
type: 'object-view',
|
|
691
|
+
objectName: 'contacts',
|
|
692
|
+
listViews: {
|
|
693
|
+
all: { label: 'All Contacts', type: 'grid' },
|
|
694
|
+
active: { label: 'Active', type: 'grid', filter: [['status', '=', 'active']] },
|
|
695
|
+
},
|
|
696
|
+
defaultListView: 'all',
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
render(<ObjectView schema={schema} dataSource={mockDataSource} />);
|
|
700
|
+
|
|
701
|
+
const tablist = screen.getByRole('tablist');
|
|
702
|
+
expect(tablist).toBeInTheDocument();
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('renders individual named view tabs with role="tab"', () => {
|
|
706
|
+
const schema: ObjectViewSchema = {
|
|
707
|
+
type: 'object-view',
|
|
708
|
+
objectName: 'contacts',
|
|
709
|
+
listViews: {
|
|
710
|
+
all: { label: 'All Contacts', type: 'grid' },
|
|
711
|
+
active: { label: 'Active', type: 'grid' },
|
|
712
|
+
archived: { label: 'Archived', type: 'grid' },
|
|
713
|
+
},
|
|
714
|
+
defaultListView: 'all',
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
render(<ObjectView schema={schema} dataSource={mockDataSource} />);
|
|
718
|
+
|
|
719
|
+
const tabs = screen.getAllByRole('tab');
|
|
720
|
+
expect(tabs).toHaveLength(3);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it('marks default named view tab as active', () => {
|
|
724
|
+
const schema: ObjectViewSchema = {
|
|
725
|
+
type: 'object-view',
|
|
726
|
+
objectName: 'contacts',
|
|
727
|
+
listViews: {
|
|
728
|
+
all: { label: 'All Contacts', type: 'grid' },
|
|
729
|
+
active: { label: 'Active', type: 'grid' },
|
|
730
|
+
},
|
|
731
|
+
defaultListView: 'all',
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
render(<ObjectView schema={schema} dataSource={mockDataSource} />);
|
|
735
|
+
|
|
736
|
+
const tabs = screen.getAllByRole('tab');
|
|
737
|
+
const allTab = tabs.find((t) => t.textContent?.includes('All Contacts'));
|
|
738
|
+
expect(allTab!.getAttribute('aria-selected')).toBe('true');
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('does not render tablist when only one named view exists', () => {
|
|
742
|
+
const schema: ObjectViewSchema = {
|
|
743
|
+
type: 'object-view',
|
|
744
|
+
objectName: 'contacts',
|
|
745
|
+
listViews: {
|
|
746
|
+
all: { label: 'All Contacts', type: 'grid' },
|
|
747
|
+
},
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
render(<ObjectView schema={schema} dataSource={mockDataSource} />);
|
|
751
|
+
|
|
752
|
+
expect(screen.queryByRole('tablist')).toBeNull();
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
});
|