@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.
@@ -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
+ });