import React from 'react'; import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'; import { vi } from 'vitest'; import { stubMatchMedia } from '../../../../vitest/matchMedia'; // jsdom has no CSS.supports, which react-aria's overlay positioning calls when the popover opens. if (globalThis.CSS === undefined) { // @ts-expect-error minimal polyfill for jsdom globalThis.CSS = { supports: () => false }; } // isolate:false shares the module graph per worker; reset it and import both from the fresh graph so // render's LocalizationProvider and SingleSelect share one context and one matchMedia breakpoint snapshot. let render: (typeof import('../../../../vitest/render'))['render']; let SingleSelect: (typeof import('./SingleSelect'))['SingleSelect']; let restoreMatchMedia: () => void; // The desktop picker renders only at the 'large' breakpoint. `useScreenHasMinWidth` snapshots its // media queries at module-import time, so force every query to match BEFORE importing SingleSelect. beforeAll(async () => { restoreMatchMedia = stubMatchMedia(true); vi.resetModules(); [{ render }, { SingleSelect }] = await Promise.all([import('../../../../vitest/render'), import('./SingleSelect')]); }); afterAll(() => { restoreMatchMedia(); vi.resetModules(); }); type Fruit = { id: string; name: string }; const options: Fruit[] = [ { id: 'a', name: 'Alpha' }, { id: 'b', name: 'Beta' }, { id: 'c', name: 'Gamma' }, ]; const common = { label: 'Fruit', name: 'fruit', options, getOptionValue: (option: Fruit) => option.id, getOptionLabel: (option: Fruit) => option.name, }; describe('SingleSelect desktop picker (core Select)', () => { it('renders a core trigger (not react-aria-components) showing the placeholder', () => { const { container } = render( {}} />); const trigger = screen.getByRole('button'); expect(container.querySelector('.singleSelectField__combobox')).not.toBeNull(); expect(trigger.dataset.rac).toBeUndefined(); expect(trigger.getAttribute('aria-haspopup')).toBe('listbox'); expect(trigger.getAttribute('aria-expanded')).toBe('false'); expect(screen.getByText('Pick one')).toBeTruthy(); }); it('opens the listbox and lists the options', () => { render( {}} />); fireEvent.click(screen.getByRole('button')); const listbox = screen.getByRole('listbox'); const optionLabels = within(listbox) .getAllByRole('option') .map(option => option.getAttribute('aria-label')); expect(optionLabels).toEqual(['Alpha', 'Beta', 'Gamma']); }); it('marks the selected value with aria-selected', () => { render( {}} />); fireEvent.click(screen.getByRole('button')); const selectedOption = screen.getByRole('option', { name: 'Beta' }); const unselectedOption = screen.getByRole('option', { name: 'Alpha' }); expect(selectedOption.getAttribute('aria-selected')).toBe('true'); expect(selectedOption.dataset.selected).toBe('true'); // Unselected options carry an explicit aria-selected="false" (VoiceOver needs it to announce state). expect(unselectedOption.getAttribute('aria-selected')).toBe('false'); expect(unselectedOption.dataset.selected).toBeUndefined(); }); it('reports the chosen value and closes on selection', () => { const onChange = vi.fn(); render(); fireEvent.click(screen.getByRole('button')); fireEvent.click(screen.getByRole('option', { name: 'Gamma' })); expect(onChange).toHaveBeenCalledWith('c'); expect(screen.queryByRole('listbox')).toBeNull(); }); it('renders the nullable empty option when enabled', () => { render( {}} />); fireEvent.click(screen.getByRole('button')); const listbox = screen.getByRole('listbox'); expect(within(listbox).getAllByRole('option')).toHaveLength(options.length + 1); }); }); // The keyboard contract the previous react-aria-components picker guaranteed — kept as regression // tests for the core Select's listbox-owned navigation. describe('SingleSelect desktop picker keyboard behavior', () => { it('closes the listbox on Escape', () => { render( {}} />); fireEvent.click(screen.getByRole('button')); expect(screen.getByRole('listbox')).toBeTruthy(); fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Escape' }); expect(screen.queryByRole('listbox')).toBeNull(); }); it('focuses the selected option when the listbox opens', () => { render( {}} />); fireEvent.click(screen.getByRole('button')); expect(document.activeElement).toBe(screen.getByRole('option', { name: 'Beta' })); }); it('opens the listbox on ArrowDown from the closed trigger and focuses the first option', () => { render( {}} />); fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowDown' }); expect(screen.getByRole('listbox')).toBeTruthy(); expect(document.activeElement).toBe(screen.getByRole('option', { name: 'Alpha' })); }); it('moves focus with arrows, skipping disabled options', () => { render( {}} />); fireEvent.click(screen.getByRole('button')); expect(document.activeElement).toBe(screen.getByRole('option', { name: 'Alpha' })); fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowDown' }); expect(document.activeElement).toBe(screen.getByRole('option', { name: 'Gamma' })); fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowUp' }); expect(document.activeElement).toBe(screen.getByRole('option', { name: 'Alpha' })); }); it('moves focus to a matching option on typeahead inside the open listbox', () => { render( {}} />); fireEvent.click(screen.getByRole('button')); fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'g' }); expect(document.activeElement).toBe(screen.getByRole('option', { name: 'Gamma' })); }); it('opens the listbox when typing on the closed trigger, seeding the typeahead', () => { render( {}} />); fireEvent.keyDown(screen.getByRole('button'), { key: 'g' }); expect(screen.getByRole('listbox')).toBeTruthy(); expect(document.activeElement).toBe(screen.getByRole('option', { name: 'Gamma' })); }); it('closes on Tab without changing the value and returns focus to the trigger', async () => { // waitFor can't advance Vitest's fake timers (armed by the setup), so both the async focus // restore and waitFor's poll would stall. vi.useRealTimers(); const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const onChange = vi.fn(); render(); const trigger = screen.getByRole('button'); // jsdom's click does not focus the button the way a real pointer press would; FocusScope // restores focus to the previously focused element, so it must be the trigger. trigger.focus(); fireEvent.click(trigger); expect(screen.getByRole('listbox')).toBeTruthy(); fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Tab' }); expect(screen.queryByRole('listbox')).toBeNull(); expect(onChange).not.toHaveBeenCalled(); // FocusScope restores focus to the trigger asynchronously. await waitFor(() => expect(document.activeElement).toBe(trigger)); // The focus calls above land outside act(), so the trigger's useFocusRing state updates emit // act-environment warnings. Expected here: verify nothing else was logged and clear them. consoleErrorSpy.mock.calls.forEach(call => expect(String(call[0])).toContain('act(')); consoleErrorSpy.mockClear(); }); it('selects the focused option and closes on Alt+ArrowUp', () => { const onChange = vi.fn(); render(); fireEvent.click(screen.getByRole('button')); fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowDown' }); expect(document.activeElement).toBe(screen.getByRole('option', { name: 'Beta' })); fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowUp', altKey: true }); expect(onChange).toHaveBeenCalledWith('b'); expect(screen.queryByRole('listbox')).toBeNull(); }); }); // A dedicated 15-option fixture so PageUp/PageDown's 10-step jumps have room to move and clamp. const manyOptions: Fruit[] = Array.from({ length: 15 }, (_, index) => ({ id: String(index), name: `Option ${String(index).padStart(2, '0')}`, })); const manyCommon = { label: 'Fruit', name: 'fruit', options: manyOptions, getOptionValue: (option: Fruit) => option.id, getOptionLabel: (option: Fruit) => option.name, }; describe('SingleSelect desktop picker page navigation', () => { it('PageDown moves focus 10 options forward, clamped to the last', () => { render( {}} />); fireEvent.click(screen.getByRole('button')); expect(document.activeElement).toBe(screen.getByRole('option', { name: 'Option 00' })); fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'PageDown' }); expect(document.activeElement).toBe(screen.getByRole('option', { name: 'Option 10' })); fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'PageDown' }); expect(document.activeElement).toBe(screen.getByRole('option', { name: 'Option 14' })); }); it('PageUp moves focus 10 options backward, clamped to the first', () => { render( {}} />); fireEvent.click(screen.getByRole('button')); fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'End' }); expect(document.activeElement).toBe(screen.getByRole('option', { name: 'Option 14' })); fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'PageUp' }); expect(document.activeElement).toBe(screen.getByRole('option', { name: 'Option 04' })); fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'PageUp' }); expect(document.activeElement).toBe(screen.getByRole('option', { name: 'Option 00' })); }); }); // The DOM contract of the react-aria-components era, which shared E2E commands still rely on: the // `data-testid` sits on the `.singleSelectField__combobox` wrapper, and a hidden native `