import React, { useMemo, useState } from 'react'; import { fireEvent, screen, within } from '@testing-library/react'; import { vi } from 'vitest'; import { stubMatchMedia } from '../../../../vitest/matchMedia'; // 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; // `useScreenHasMinWidth` snapshots its media queries at module-import time. Pin `matchMedia` to // `matches: false` so `useScreenSize()` resolves to 'small' — exercising the InputDialog-hosted // picker and search branches (mirror of the desktop suite, which forces every query to match). beforeAll(async () => { restoreMatchMedia = stubMatchMedia(false); 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 mobile picker (core Select in InputDialog)', () => { it('opens the dialog 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('commits 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')); expect(within(screen.getByRole('listbox')).getAllByRole('option')).toHaveLength(options.length + 1); }); it('focuses the selected option when the sheet opens', () => { render( {}} />); fireEvent.click(screen.getByRole('button')); expect(document.activeElement).toBe(screen.getByRole('option', { name: 'Beta' })); }); it('reflects the open state on the trigger via aria-expanded and aria-controls', () => { render( {}} />); const trigger = screen.getByRole('button'); expect(trigger.getAttribute('aria-expanded')).toBe('false'); expect(trigger.getAttribute('aria-controls')).toBeNull(); fireEvent.click(trigger); expect(trigger.getAttribute('aria-expanded')).toBe('true'); const dialogId = trigger.getAttribute('aria-controls'); expect(document.getElementById(dialogId!)?.getAttribute('role')).toBe('dialog'); }); it('hides the background from assistive technology while the sheet is open', () => { render( {}} />); const trigger = screen.getByRole('button'); fireEvent.click(trigger); expect(screen.getByRole('listbox')).toBeTruthy(); expect(trigger.closest('[aria-hidden="true"]')).not.toBeNull(); }); it('closes the sheet on pointer interaction outside the dialog', () => { render( {}} />); fireEvent.click(screen.getByRole('button')); expect(screen.getByRole('listbox')).toBeTruthy(); // react-aria listens for pointer events when PointerEvent exists, mouse events otherwise — // fire both so the test holds under either jsdom. fireEvent.pointerDown(document.body); fireEvent.pointerUp(document.body); fireEvent.mouseDown(document.body); fireEvent.mouseUp(document.body); fireEvent.click(document.body); expect(screen.queryByRole('listbox')).toBeNull(); }); }); function SearchHarness({ onChange, ...rest }: { onChange: (value: string | null) => void }) { rest satisfies Record; const [query, setQuery] = useState(null); const filtered = useMemo( () => (query ? options.filter(option => option.name.toLowerCase().includes(query.toLowerCase())) : options), [query] ); return ( option.id} getOptionLabel={(option: Fruit) => option.name} query={query} onQueryChange={setQuery} onChange={onChange} /> ); } describe('SingleSelect mobile search (core Combobox in InputDialog)', () => { it('keeps the named hidden input mounted while the sheet is closed', () => { const { container } = render( {}} onChange={() => {}} /> ); const hidden = container.querySelector('input[type="hidden"]'); expect(hidden?.getAttribute('name')).toBe('fruit'); expect(hidden?.getAttribute('value')).toBe(''); }); it('filters via the consumer options, moves aria-activedescendant on ArrowDown, and commits on Enter', () => { const onChange = vi.fn(); render(); fireEvent.click(screen.getByRole('button')); const input = screen.getByRole('combobox'); fireEvent.change(input, { target: { value: 'gam' } }); const optionLabels = screen.getAllByRole('option').map(option => option.getAttribute('aria-label')); expect(optionLabels).toEqual(['Gamma']); fireEvent.keyDown(input, { key: 'ArrowDown' }); const activeId = input.getAttribute('aria-activedescendant'); expect(activeId).toBeTruthy(); expect(document.getElementById(activeId!)?.getAttribute('aria-label')).toBe('Gamma'); fireEvent.keyDown(input, { key: 'Enter' }); expect(onChange).toHaveBeenCalledWith('c'); }); it('clears the selection when the input is emptied', () => { const onChange = vi.fn(); render(); fireEvent.click(screen.getByRole('button')); const input = screen.getByRole('combobox'); fireEvent.change(input, { target: { value: 'gam' } }); fireEvent.change(input, { target: { value: '' } }); expect(onChange).toHaveBeenCalledWith(null); }); });