import { screen, waitFor, within } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { mockAnimationsApi } from 'jsdom-testing-mocks'; import { render, mockMatchMedia, mockResizeObserver } from '../../test-utils'; import { SelectInput, SelectInputOptionContent, type SelectInputOptionItem, type SelectInputProps, } from '.'; import { Field } from '../../field/Field'; mockMatchMedia(); mockResizeObserver(); mockAnimationsApi(); describe('SelectInput', () => { it('renders placeholder', () => { render( , ); expect(screen.getByText('Currency')).toBeInTheDocument(); }); it('renders footer', async () => { render( normalizedQuery != null ? ( <>Showing results for '{normalizedQuery}' ) : ( <>All items shown ) } filterable />, ); await userEvent.tab(); await userEvent.keyboard(' '); const footer = screen.getByText('All items shown'); expect(footer).toBeInTheDocument(); await userEvent.keyboard('u'); expect(footer).toHaveTextContent(/'u'$/); await userEvent.keyboard('r'); expect(footer).toHaveTextContent(/'ur'$/); await userEvent.keyboard('x'); expect(footer).toHaveTextContent(/'urx'$/); await userEvent.keyboard('{Backspace}'); expect(footer).toHaveTextContent(/'ur'$/); }); it('allows navigating the listbox with cursors', async () => { render( } filterable />, ); // opened the dropbox, with search focused await userEvent.tab(); await userEvent.keyboard(' '); expect(screen.getByRole('combobox')).toHaveFocus(); // search still focused but listbox can be navigated via keyboard await userEvent.keyboard('{ArrowDown}'); expect(screen.getByRole('combobox')).toHaveFocus(); expect(screen.getByRole('option', { name: 'EUR' })).toHaveClass( 'np-select-input-option-container--active', ); // tab moves focus to listbox await userEvent.tab(); expect(screen.getByRole('listbox')).toHaveFocus(); expect(screen.getByRole('combobox')).not.toHaveFocus(); // arrows still navigate within listbox await userEvent.keyboard('{ArrowDown}'); expect(screen.getByRole('option', { name: 'USD' })).toHaveClass( 'np-select-input-option-container--active', ); // tab moves focus to footer but highlighted option is retained await userEvent.tab(); expect(screen.getByRole('listbox')).not.toHaveFocus(); expect(screen.getByRole('combobox')).not.toHaveFocus(); expect(screen.getByText('Footer button')).toHaveFocus(); expect(screen.getByRole('option', { name: 'USD' })).toHaveClass( 'np-select-input-option-container--active', ); // shift+tab moves focus back to listbox await userEvent.tab({ shift: true }); expect(screen.getByRole('listbox')).toHaveFocus(); expect(screen.getByRole('combobox')).not.toHaveFocus(); expect(screen.getByText('Footer button')).not.toHaveFocus(); // previously highlighted option is still active within listbox expect(screen.getByRole('option', { name: 'USD' })).toHaveClass( 'np-select-input-option-container--active', ); // arrows continue to navigate within listbox await userEvent.keyboard('{ArrowUp}'); expect(screen.getByRole('option', { name: 'EUR' })).toHaveClass( 'np-select-input-option-container--active', ); // shift+tab moves focus back to search input await userEvent.tab({ shift: true }); expect(screen.getByRole('combobox')).toHaveFocus(); // arrows continue to navigate within listbox await userEvent.keyboard('{ArrowUp}'); expect(screen.getByRole('option', { name: 'GBP' })).toHaveClass( 'np-select-input-option-container--active', ); }); it('shows item selected via mouse', async () => { const handleClose = jest.fn(); render( , ); expect(screen.queryByText('EUR')).not.toBeInTheDocument(); const trigger = screen.getByRole('combobox'); await userEvent.click(trigger); expect(handleClose).not.toHaveBeenCalled(); const listbox = screen.getByRole('listbox'); const option = within(listbox).getByRole('option', { name: 'EUR' }); await userEvent.click(option); expect(handleClose).toHaveBeenCalledTimes(1); expect(trigger).toHaveTextContent('EUR'); }); it('filters items via keyboard', async () => { const handleClose = jest.fn(); render( , ); const trigger = screen.getByRole('combobox'); await userEvent.tab(); await userEvent.keyboard(' '); expect(handleClose).not.toHaveBeenCalled(); const listbox = screen.getByRole('listbox'); expect(within(listbox).getAllByRole('option')).toHaveLength(3); await userEvent.keyboard('u'); expect(within(listbox).getAllByRole('option')).toHaveLength(2); await userEvent.keyboard('r'); expect(within(listbox).getByRole('option')).toBeInTheDocument(); await userEvent.keyboard('x'); expect(within(listbox).queryByRole('option')).not.toBeInTheDocument(); await userEvent.keyboard('{Backspace}'); expect(within(listbox).getByRole('option')).toBeInTheDocument(); const option = within(listbox).getAllByRole('option')[0]; await userEvent.click(option); expect(handleClose).toHaveBeenCalledTimes(1); expect(trigger).toHaveTextContent('EUR'); }); it('clears filter query on close', async () => { const handleFilterChange = jest.fn(); render( , ); const trigger = screen.getByRole('combobox'); await userEvent.tab(); await userEvent.keyboard(' '); expect(handleFilterChange).not.toHaveBeenCalled(); await userEvent.keyboard(' x'); expect(handleFilterChange).toHaveBeenLastCalledWith({ query: ' x', queryNormalized: 'x', }); await userEvent.keyboard('{Escape}'); await waitFor(() => { expect(handleFilterChange).toHaveBeenLastCalledWith({ query: '', queryNormalized: null, }); }); await userEvent.click(trigger); const listbox = screen.getByRole('listbox'); expect(within(listbox).getAllByRole('option')).toHaveLength(2); }); it('filters items ignoring diacritics/accents', async () => { render( , ); await userEvent.tab(); await userEvent.keyboard(' '); const listbox = screen.getByRole('listbox'); expect(within(listbox).getAllByRole('option')).toHaveLength(4); await userEvent.keyboard('aland'); expect(within(listbox).getAllByRole('option')).toHaveLength(1); expect(within(listbox).getByRole('option')).toHaveTextContent('AX'); const searchInput = screen.getByRole('combobox', { expanded: true }); await userEvent.clear(searchInput); await userEvent.keyboard('reunion'); expect(within(listbox).getAllByRole('option')).toHaveLength(1); expect(within(listbox).getByRole('option')).toHaveTextContent('RE'); await userEvent.clear(searchInput); await userEvent.keyboard('Åland'); expect(within(listbox).getAllByRole('option')).toHaveLength(1); expect(within(listbox).getByRole('option')).toHaveTextContent('AX'); await userEvent.clear(searchInput); await userEvent.keyboard('Rèunion'); expect(within(listbox).getAllByRole('option')).toHaveLength(1); expect(within(listbox).getByRole('option')).toHaveTextContent('RE'); }); it('selects multiple options', async () => { render( , ); const trigger = screen.getByRole('combobox'); await userEvent.click(trigger); const listbox = screen.getByRole('listbox'); const options = within(listbox).getAllByRole('option'); for (const option of options) { await userEvent.click(option); } expect(trigger).toHaveTextContent('USD, EUR'); }); it('supports custom `id` attribute', () => { render(); const trigger = screen.getByRole('combobox'); expect(trigger).toHaveAttribute('id', 'custom'); }); it('supports `Field` for labeling', () => { render( , ); expect(screen.getByLabelText(/Currency/)).toHaveAttribute('aria-haspopup'); }); it('deduplicates search results across groups using compareValues as key', async () => { interface Currency { code: string; name: string; } const usdInGroup1: Currency = { code: 'USD', name: 'US Dollar' }; const usdInGroup2: Currency = { code: 'USD', name: 'US Dollar' }; const eur: Currency = { code: 'EUR', name: 'Euro' }; const gbp: Currency = { code: 'GBP', name: 'British Pound' }; render( items={[ { type: 'group', label: 'Popular', options: [ { type: 'option', value: usdInGroup1 }, { type: 'option', value: eur }, ], }, { type: 'group', label: 'All currencies', options: [ { type: 'option', value: usdInGroup2 }, { type: 'option', value: gbp }, ], }, ]} compareValues="code" renderValue={(currency) => currency.name} filterable />, ); const trigger = screen.getByRole('combobox'); await userEvent.click(trigger); const listbox = screen.getByRole('listbox'); // Before filtering, should show all 4 options (no deduplication yet) let options = within(listbox).getAllByRole('option'); expect(options).toHaveLength(4); const usdOptions = within(listbox).getAllByText('US Dollar'); expect(usdOptions).toHaveLength(2); // Start filtering - type a search query to trigger deduplication const searchInput = screen.getByRole('combobox', { expanded: true }); await userEvent.type(searchInput, 'u'); // After filtering with 'u', should show 3 unique options (USD deduplicated, EUR, GBP) options = within(listbox).getAllByRole('option'); expect(options).toHaveLength(3); expect(within(listbox).getByText('Euro')).toBeInTheDocument(); expect(within(listbox).getByText('British Pound')).toBeInTheDocument(); // Filter more specifically for 'dollar' await userEvent.clear(searchInput); await userEvent.type(searchInput, 'dollar'); const filteredOptions = within(listbox).getAllByRole('option'); // Should only show 1 USD option, not 2 expect(filteredOptions).toHaveLength(1); expect(within(listbox).getByText('US Dollar')).toBeInTheDocument(); }); it('deduplicates search results across groups using compareValues as function', async () => { interface Item { id: number; label: string; } const item1Group1: Item = { id: 1, label: 'Item One' }; const item2Group1: Item = { id: 2, label: 'Item Two' }; const item1Group2: Item = { id: 1, label: 'Item One' }; render( items={[ { type: 'group', label: 'Group A', options: [ { type: 'option', value: item1Group1 }, { type: 'option', value: item2Group1 }, ], }, { type: 'group', label: 'Group B', options: [{ type: 'option', value: item1Group2 }], }, ]} compareValues={(a, b) => a?.id === b?.id} renderValue={(item) => item.label} filterable />, ); const trigger = screen.getByRole('combobox'); await userEvent.click(trigger); const listbox = screen.getByRole('listbox'); // Before filtering, should show all 3 options (no deduplication yet) let options = within(listbox).getAllByRole('option'); expect(options).toHaveLength(3); // Start filtering - type a search query to trigger deduplication const searchInput = screen.getByRole('combobox', { expanded: true }); await userEvent.type(searchInput, 'item'); // After filtering, should show 2 unique options (item with id:1 deduplicated, item with id:2) options = within(listbox).getAllByRole('option'); expect(options).toHaveLength(2); expect(within(listbox).getByText('Item One')).toBeInTheDocument(); expect(within(listbox).getByText('Item Two')).toBeInTheDocument(); }); it('sorts filtered options using sortFilteredOptions prop', async () => { interface Country { code: string; name: string; keywords: string[]; } const countries: Country[] = [ { code: 'AD', name: 'Andorra', keywords: ['united states dollar'] }, { code: 'DE', name: 'Germany', keywords: ['EUR'] }, { code: 'US', name: 'United States', keywords: ['United States dollar', 'USD'] }, { code: 'ZM', name: 'Zambia', keywords: ['USD', 'united states dollar'] }, ]; render( items={countries.map((country) => ({ type: 'option', value: country, filterMatchers: country.keywords, }))} renderValue={(country) => country.name} filterable sortFilteredOptions={(a, b, searchQuery) => { const query = searchQuery.toLowerCase(); const nameA = a.value.name.toLowerCase(); const nameB = b.value.name.toLowerCase(); const aMatch = nameA.includes(query); const bMatch = nameB.includes(query); if (aMatch && !bMatch) return -1; if (!aMatch && bMatch) return 1; return nameA.localeCompare(nameB); }} />, ); const trigger = screen.getByRole('combobox'); await userEvent.click(trigger); const searchInput = screen.getByRole('combobox', { expanded: true }); await userEvent.type(searchInput, 'united'); const listbox = screen.getByRole('listbox'); const options = within(listbox).getAllByRole('option'); expect(options).toHaveLength(3); expect(options[0]).toHaveTextContent('United States'); expect(options[1]).toHaveTextContent('Andorra'); expect(options[2]).toHaveTextContent('Zambia'); }); it('adds class to description wrapper when description is present', () => { interface Currency { code: string; name: string; description: string; } const usd: Currency = { code: 'USD', name: 'US Dollar', description: 'United States Dollar' }; const eur: Currency = { code: 'EUR', name: 'Euro', description: 'European Currency' }; render( items={[ { type: 'option', value: usd }, { type: 'option', value: eur }, ]} renderValue={(currency) => ( )} value={usd} />, ); const descriptionElement = screen.getByText('United States Dollar'); expect(descriptionElement).toHaveClass('np-select-input-option-description-in-trigger'); }); describe('listbox label', () => { const fieldLabel = 'Fruits'; const triggerLabel = 'Select fruit'; const options: SelectInputOptionItem[] = [ { type: 'option', value: 'Banana' }, { type: 'option', value: 'Orange' }, { type: 'option', value: 'Olive' }, ]; const requiredTriggerButtonProps = { id: undefined, 'aria-labelledby': undefined, 'aria-describedby': undefined, 'aria-invalid': undefined, 'aria-label': undefined, }; const renderSelectInput = (props: Omit, 'items'> = {}) => render( , ); it("should propagate trigger's label if nothing is selected", async () => { renderSelectInput({ UNSAFE_triggerButtonProps: { ...requiredTriggerButtonProps, 'aria-label': triggerLabel, }, }); const trigger = screen.getByRole('combobox'); await userEvent.click(trigger); expect(screen.getByRole('listbox', { name: triggerLabel })).toBeInTheDocument(); }); it("should propagate trigger's label if an option is selected", async () => { renderSelectInput({ UNSAFE_triggerButtonProps: { ...requiredTriggerButtonProps, 'aria-label': triggerLabel, }, value: options[1].value, }); const trigger = screen.getByRole('combobox'); await userEvent.click(trigger); expect(screen.getByRole('listbox', { name: triggerLabel })).toBeInTheDocument(); }); it("should propagate trigger's label by id", async () => { const customLabelId = 'customLabelId'; renderSelectInput({ UNSAFE_triggerButtonProps: { ...requiredTriggerButtonProps, 'aria-labelledby': customLabelId, }, }); const trigger = screen.getByRole('combobox'); await userEvent.click(trigger); expect(screen.getByRole('listbox')).toHaveAttribute('aria-labelledby', customLabelId); }); it("should propagate input's label by id", async () => { renderSelectInput(); const trigger = screen.getByRole('combobox'); await userEvent.click(trigger); expect(screen.getByRole('listbox', { name: fieldLabel })).toBeInTheDocument(); }); it('should prefer explicit label over label ids', async () => { const customLabelId = 'customLabelId'; renderSelectInput({ UNSAFE_triggerButtonProps: { ...requiredTriggerButtonProps, 'aria-labelledby': customLabelId, 'aria-label': triggerLabel, }, }); const trigger = screen.getByRole('combobox'); await userEvent.click(trigger); expect(screen.getByRole('listbox', { name: triggerLabel })).toBeInTheDocument(); expect(screen.getByRole('listbox')).not.toHaveAttribute('aria-labelledby'); }); it('should have no label if none of the above are provided', async () => { render(); const trigger = screen.getByRole('combobox'); await userEvent.click(trigger); const listBox = screen.getByRole('listbox'); expect(listBox).not.toHaveAttribute('aria-label'); expect(listBox).not.toHaveAttribute('aria-labelledby'); }); }); });