import React from 'react'; import { fireEvent, screen } 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 }; } // jsdom reports zero-sized layout, so TanStack Virtual would see a zero-height scroll viewport and // render an empty window. TanStack measures the scroll element via `offsetHeight` (read once when it // starts observing — the setup ResizeObserver mock never fires afterwards), so stub `offsetHeight` on // the popover scroll element to a real height. Every other element keeps jsdom's zero default, so // option rows fall back to their estimated size. const SCROLL_VIEWPORT_HEIGHT = 300; beforeAll(() => { Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get(this: HTMLElement) { return this.classList.contains('singleSelectField__popover') ? SCROLL_VIEWPORT_HEIGHT : 0; }, }); }); afterAll(() => { delete (HTMLElement.prototype as { offsetHeight?: number }).offsetHeight; }); // 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; // Mirror the desktop suite: the virtualized search listbox renders in the desktop popover branch, // which requires 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 Item = { id: string; name: string }; const largeOptions: Item[] = Array.from({ length: 60 }, (_, index) => ({ id: `item-${index}`, name: `Item ${String(index).padStart(3, '0')}`, })); const common = { label: 'Item', name: 'item', getOptionValue: (option: Item) => option.id, getOptionLabel: (option: Item) => option.name, }; function VirtualizedHarness({ onChange, ...rest }: { onChange: (value: string | null) => void }) { rest satisfies Record; const [query, setQuery] = React.useState(null); const filtered = React.useMemo( () => query ? largeOptions.filter(option => option.name.toLowerCase().includes(query.toLowerCase())) : largeOptions, [query] ); return ( ); } describe('SingleSelect virtualized search (core Combobox + TanStack Virtual)', () => { it('windows the option list — far fewer than all 60 options are in the DOM', () => { render( {}} />); const input = screen.getByRole('combobox'); fireEvent.focus(input); const rendered = screen.getAllByRole('option'); expect(rendered.length).toBeGreaterThan(0); // Viewport ~300px / row estimate + overscan windows only a slice, not all 60 options. expect(rendered.length).toBeLessThan(largeOptions.length); }); it('force-mounts the active off-window option and commits its value', () => { const onChange = vi.fn(); render(); const input = screen.getByRole('combobox'); fireEvent.focus(input); // ArrowUp from no active option jumps straight to the last option (index 59) — far past the // rendered window — in one keystroke instead of stepping through every row. fireEvent.keyDown(input, { key: 'ArrowUp' }); const activeId = input.getAttribute('aria-activedescendant'); expect(activeId).toBeTruthy(); // The force-mount rangeExtractor guarantees the active row exists in the DOM even off-window. const activeElement = document.getElementById(activeId!); expect(activeElement).not.toBeNull(); expect(activeElement!.getAttribute('role')).toBe('option'); expect(activeElement!.getAttribute('aria-label')).toBe('Item 059'); // Enter commits the active value, resolved data-driven via navigableValues. fireEvent.keyDown(input, { key: 'Enter' }); expect(onChange).toHaveBeenCalledWith('item-59'); }); it('resolves navigation data-driven, skipping disabled options', () => { const onChange = vi.fn(); render( {}} /> ); const input = screen.getByRole('combobox'); fireEvent.focus(input); fireEvent.keyDown(input, { key: 'ArrowDown' }); // item-0 fireEvent.keyDown(input, { key: 'ArrowDown' }); // skips disabled item-1 -> item-2 fireEvent.keyDown(input, { key: 'Enter' }); expect(onChange).toHaveBeenCalledWith('item-2'); }); it('renders the empty state when the filtered list is empty', () => { function EmptyHarness() { const [query, setQuery] = React.useState('no-such-option'); const filtered = largeOptions.filter(option => option.name.includes(query ?? '')); return ( {}} /> ); } render(); fireEvent.focus(screen.getByRole('combobox')); // The empty state falls back to the plain listbox (no virtualized rows). `DataList.Empty` renders // as a disabled option, so there are no real option rows and the empty text is shown. expect(screen.getByRole('listbox')).toBeTruthy(); expect(screen.getByText('No options found')).toBeTruthy(); }); });