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');
});
});
});