Combobox
An editable combobox with list autocomplete. Users can type to filter options or select from a popup listbox using keyboard or mouse.
🤖 AI Implementation GuideDemo
- Apple
- Banana
- Cherry
- Date
- Elderberry
- Fig
- Grape
- Apple
- Banana
- Cherry
- Date
- Elderberry
- Fig
- Grape
- Japan
- United States
- United Kingdom
- Germany
- France
- Italy
- Spain
- Australia
- Apple
- Banana
- Cherry
- Date
- Elderberry
- Fig
- Grape
- Apple
- Banana
- Cherry
- Date
- Elderberry
- Fig
- Grape
Native HTML
Consider Native HTML First
Before using a custom combobox, consider native HTML alternatives. They provide built-in semantics, work without JavaScript, and have native browser support.
<!-- For simple dropdown selection -->
<label for="fruit">Choose a fruit</label>
<select id="fruit">
<option value="apple">Apple</option>
<option value="banana">Banana</option>
</select>
<!-- For basic autocomplete -->
<label for="browser">Choose your browser</label>
<input list="browsers" id="browser" name="browser">
<datalist id="browsers">
<option value="Chrome">
<option value="Firefox">
<option value="Safari">
</datalist> Use a custom combobox only when you need: custom styling, complex filtering logic, rich option rendering, or behaviors not supported by native elements.
| Use Case | Native HTML | Custom Implementation |
|---|---|---|
| Simple dropdown selection | <select> Recommended | Not needed |
| Basic autocomplete suggestions | <datalist> Recommended | Not needed |
| JavaScript disabled support | Works natively | Requires fallback |
| Custom option rendering (icons, descriptions) | Not supported | Full control |
| Custom filtering logic | Basic prefix matching | Custom algorithms |
| Consistent cross-browser styling | Limited (especially datalist) | Full control |
| Keyboard navigation customization | Browser defaults only | Customizable |
| Disabled options | <select> only | Fully supported |
The native <select> element provides excellent accessibility, form submission support,
and works without JavaScript. The <datalist> element provides basic autocomplete
functionality, but its appearance varies significantly across browsers and lacks support for disabled
options or custom rendering.
Accessibility Concerns with <datalist>
The <datalist> element has several known accessibility issues:
- Text zoom not supported: The font size of datalist options does not scale when users zoom the page, creating issues for users who rely on text magnification.
- Limited CSS styling: Options cannot be styled for high-contrast mode, preventing accommodation of users with visual impairments.
- Screen reader compatibility: Some screen reader and browser combinations (e.g., NVDA with Firefox) do not announce the contents of the autosuggest popup.
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
combobox | Input (<input>) | The text input element that users type into |
listbox | Popup (<ul>) | The popup containing selectable options |
option | Each item (<li>) | An individual selectable option |
WAI-ARIA combobox role (opens in new tab)
WAI-ARIA Properties (Input)
| Attribute | Values | Required | Description |
|---|---|---|---|
role="combobox" | - | Yes | Identifies the input as a combobox |
aria-controls | ID reference | Yes | References the listbox popup (even when closed) |
aria-expanded | true | false | Yes | Indicates whether the popup is open |
aria-autocomplete | list | none | both | Yes | Describes the autocomplete behavior |
aria-activedescendant | ID reference | empty | Yes | References the currently focused option in the popup |
aria-labelledby | ID reference | Yes* | References the label element |
WAI-ARIA Properties (Listbox & Options)
| Attribute | Target | Values | Required | Description |
|---|---|---|---|---|
aria-labelledby | listbox | ID reference | Yes | References the label element |
aria-selected | option | true | false | Yes | Indicates the currently focused option |
aria-disabled | option | true | No | Indicates the option is disabled |
Keyboard Support
Input (Popup Closed)
| Key | Action |
|---|---|
| Down Arrow | Open popup and focus first option |
| Up Arrow | Open popup and focus last option |
| Alt + Down Arrow | Open popup without changing focus position |
| Type characters | Filter options and open popup |
Input (Popup Open)
| Key | Action |
|---|---|
| Down Arrow | Move focus to next enabled option (no wrap) |
| Up Arrow | Move focus to previous enabled option (no wrap) |
| Home | Move focus to first enabled option |
| End | Move focus to last enabled option |
| Enter | Select focused option and close popup |
| Escape | Close popup and restore previous input value |
| Alt + Up Arrow | Select focused option and close popup |
| Tab | Close popup and move to next focusable element |
Focus Management
This component uses aria-activedescendant for virtual focus management:
- DOM focus remains on the input at all times
aria-activedescendantreferences the visually focused option- Arrow keys update
aria-activedescendantwithout moving DOM focus - Disabled options are skipped during navigation
-
aria-activedescendantis cleared when the popup closes or filter results are empty
Autocomplete Modes
| Mode | Behavior |
|---|---|
list | Options are filtered based on input value (default) |
none | All options shown regardless of input value |
both | Options filtered and first match auto-completed in input |
Hidden State
When closed, the listbox uses the hidden attribute to:
- Hide the popup from visual display
- Remove the popup from the accessibility tree
- The listbox element remains in the DOM so
aria-controlsreference is valid
Source Code
import { cn } from '@/lib/utils';
import type { HTMLAttributes, KeyboardEvent, ReactElement } from 'react';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
export interface ComboboxOption {
id: string;
label: string;
disabled?: boolean;
}
export interface ComboboxProps extends Omit<
HTMLAttributes<HTMLDivElement>,
'onChange' | 'onSelect'
> {
/** List of options */
options: ComboboxOption[];
/** Selected option ID (controlled) */
selectedOptionId?: string;
/** Default selected option ID */
defaultSelectedOptionId?: string;
/** Input value (controlled) */
inputValue?: string;
/** Default input value */
defaultInputValue?: string;
/** Label text */
label: string;
/** Placeholder */
placeholder?: string;
/** Disabled state */
disabled?: boolean;
/** Autocomplete type */
autocomplete?: 'none' | 'list' | 'both';
/** Message shown when no results found */
noResultsMessage?: string;
/** Selection callback */
onSelect?: (option: ComboboxOption) => void;
/** Input change callback */
onInputChange?: (value: string) => void;
/** Popup open/close callback */
onOpenChange?: (isOpen: boolean) => void;
}
export function Combobox({
options,
selectedOptionId: controlledSelectedId,
defaultSelectedOptionId,
inputValue: controlledInputValue,
defaultInputValue = '',
label,
placeholder,
disabled = false,
autocomplete = 'list',
noResultsMessage = 'No results found',
onSelect,
onInputChange,
onOpenChange,
className = '',
...restProps
}: ComboboxProps): ReactElement {
const instanceId = useId();
const inputId = `${instanceId}-input`;
const labelId = `${instanceId}-label`;
const listboxId = `${instanceId}-listbox`;
// Internal state
const [isOpen, setIsOpen] = useState(false);
const [internalInputValue, setInternalInputValue] = useState(() => {
if (!defaultSelectedOptionId) {
return defaultInputValue;
}
const option = options.find(({ id }) => id === defaultSelectedOptionId);
if (option === undefined) {
return defaultInputValue;
}
return option.label;
});
const [internalSelectedId, setInternalSelectedId] = useState<string | undefined>(
defaultSelectedOptionId
);
const [activeIndex, setActiveIndex] = useState(-1);
const [isSearching, setIsSearching] = useState(false);
// Track value before opening for Escape restoration
const valueBeforeOpen = useRef<string>('');
const isComposing = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Determine controlled vs uncontrolled
const inputValue = controlledInputValue ?? internalInputValue;
const selectedId = controlledSelectedId ?? internalSelectedId;
// Get selected option's label
const selectedLabel = useMemo(() => {
if (!selectedId) {
return '';
}
const option = options.find(({ id }) => id === selectedId);
return option?.label ?? '';
}, [options, selectedId]);
// Filter options based on input value and search mode
const filteredOptions = useMemo(() => {
// Don't filter if autocomplete is none
if (autocomplete === 'none') {
return options;
}
// Don't filter if input is empty
if (!inputValue) {
return options;
}
// Don't filter if not in search mode AND input matches selected label
if (!isSearching && inputValue === selectedLabel) {
return options;
}
const lowerInputValue = inputValue.toLowerCase();
return options.filter(({ label }) => label.toLowerCase().includes(lowerInputValue));
}, [options, inputValue, autocomplete, isSearching, selectedLabel]);
// Get enabled options from filtered list
const enabledOptions = useMemo(
() => filteredOptions.filter(({ disabled }) => !disabled),
[filteredOptions]
);
// Generate option IDs
const getOptionId = useCallback(
(optionId: string) => `${instanceId}-option-${optionId}`,
[instanceId]
);
// Get active descendant ID
const activeDescendantId = useMemo(() => {
if (activeIndex < 0 || activeIndex >= filteredOptions.length) {
return undefined;
}
const option = filteredOptions[activeIndex];
if (option === undefined) {
return undefined;
}
return getOptionId(option.id);
}, [activeIndex, filteredOptions, getOptionId]);
// Update input value
const updateInputValue = useCallback(
(value: string) => {
if (controlledInputValue === undefined) {
setInternalInputValue(value);
}
onInputChange?.(value);
},
[controlledInputValue, onInputChange]
);
// Open popup
const openPopup = useCallback(
(focusPosition?: 'first' | 'last') => {
if (isOpen) {
return;
}
valueBeforeOpen.current = inputValue;
setIsOpen(true);
onOpenChange?.(true);
if (!focusPosition || enabledOptions.length === 0) {
return;
}
const targetOption =
focusPosition === 'first' ? enabledOptions[0] : enabledOptions[enabledOptions.length - 1];
const { id: targetId } = targetOption;
const targetIndex = filteredOptions.findIndex(({ id }) => id === targetId);
setActiveIndex(targetIndex);
},
[isOpen, inputValue, enabledOptions, filteredOptions, onOpenChange]
);
// Close popup
const closePopup = useCallback(
(restore = false) => {
setIsOpen(false);
setActiveIndex(-1);
setIsSearching(false);
onOpenChange?.(false);
if (restore) {
updateInputValue(valueBeforeOpen.current);
}
},
[onOpenChange, updateInputValue]
);
// Select option
const selectOption = useCallback(
({ id, label, disabled }: ComboboxOption) => {
if (disabled) {
return;
}
if (controlledSelectedId === undefined) {
setInternalSelectedId(id);
}
setIsSearching(false);
updateInputValue(label);
onSelect?.({ id, label, disabled });
closePopup();
},
[controlledSelectedId, updateInputValue, onSelect, closePopup]
);
// Find next/previous enabled option index
const findEnabledIndex = useCallback(
(startIndex: number, direction: 'next' | 'prev' | 'first' | 'last'): number => {
if (enabledOptions.length === 0) {
return -1;
}
if (direction === 'first') {
const { id: firstId } = enabledOptions[0];
return filteredOptions.findIndex(({ id }) => id === firstId);
}
if (direction === 'last') {
const { id: lastId } = enabledOptions[enabledOptions.length - 1];
return filteredOptions.findIndex(({ id }) => id === lastId);
}
const currentOption = filteredOptions[startIndex];
const currentEnabledIndex = currentOption
? enabledOptions.findIndex(({ id }) => id === currentOption.id)
: -1;
if (direction === 'next') {
if (currentEnabledIndex < 0) {
const { id: firstId } = enabledOptions[0];
return filteredOptions.findIndex(({ id }) => id === firstId);
}
if (currentEnabledIndex >= enabledOptions.length - 1) {
return startIndex;
}
const { id: nextId } = enabledOptions[currentEnabledIndex + 1];
return filteredOptions.findIndex(({ id }) => id === nextId);
}
// direction === 'prev'
if (currentEnabledIndex < 0) {
const { id: lastId } = enabledOptions[enabledOptions.length - 1];
return filteredOptions.findIndex(({ id }) => id === lastId);
}
if (currentEnabledIndex <= 0) {
return startIndex;
}
const { id: prevId } = enabledOptions[currentEnabledIndex - 1];
return filteredOptions.findIndex(({ id }) => id === prevId);
},
[enabledOptions, filteredOptions]
);
// Handle input keydown
const handleInputKeyDown = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
if (isComposing.current) {
return;
}
const { key, altKey } = event;
switch (key) {
case 'ArrowDown': {
event.preventDefault();
if (altKey) {
if (isOpen) {
return;
}
valueBeforeOpen.current = inputValue;
setIsOpen(true);
onOpenChange?.(true);
return;
}
if (!isOpen) {
openPopup('first');
return;
}
const nextIndex = findEnabledIndex(activeIndex, 'next');
if (nextIndex >= 0) {
setActiveIndex(nextIndex);
}
break;
}
case 'ArrowUp': {
event.preventDefault();
if (altKey) {
if (!isOpen || activeIndex < 0) {
return;
}
const option = filteredOptions[activeIndex];
if (option === undefined || option.disabled) {
return;
}
selectOption(option);
return;
}
if (!isOpen) {
openPopup('last');
return;
}
const prevIndex = findEnabledIndex(activeIndex, 'prev');
if (prevIndex >= 0) {
setActiveIndex(prevIndex);
}
break;
}
case 'Home': {
if (!isOpen) {
return;
}
event.preventDefault();
const firstIndex = findEnabledIndex(0, 'first');
if (firstIndex >= 0) {
setActiveIndex(firstIndex);
}
break;
}
case 'End': {
if (!isOpen) {
return;
}
event.preventDefault();
const lastIndex = findEnabledIndex(0, 'last');
if (lastIndex >= 0) {
setActiveIndex(lastIndex);
}
break;
}
case 'Enter': {
if (!isOpen || activeIndex < 0) {
return;
}
event.preventDefault();
const option = filteredOptions[activeIndex];
if (option === undefined || option.disabled) {
return;
}
selectOption(option);
break;
}
case 'Escape': {
if (!isOpen) {
return;
}
event.preventDefault();
closePopup(true);
break;
}
case 'Tab': {
if (isOpen) {
closePopup();
}
break;
}
}
},
[
isOpen,
inputValue,
activeIndex,
filteredOptions,
openPopup,
closePopup,
selectOption,
findEnabledIndex,
onOpenChange,
]
);
// Handle input change
const handleInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setIsSearching(true);
updateInputValue(value);
if (!isOpen && !isComposing.current) {
valueBeforeOpen.current = inputValue;
setIsOpen(true);
onOpenChange?.(true);
}
setActiveIndex(-1);
},
[isOpen, inputValue, updateInputValue, onOpenChange]
);
// Handle option click
const handleOptionClick = useCallback(
(option: ComboboxOption) => {
if (option.disabled) {
return;
}
selectOption(option);
},
[selectOption]
);
// Handle option hover
const handleOptionHover = useCallback(
({ id }: ComboboxOption) => {
const index = filteredOptions.findIndex((option) => option.id === id);
if (index < 0) {
return;
}
setActiveIndex(index);
},
[filteredOptions]
);
// Handle IME composition
const handleCompositionStart = useCallback(() => {
isComposing.current = true;
}, []);
const handleCompositionEnd = useCallback(() => {
isComposing.current = false;
}, []);
// Handle focus - open popup when input receives focus
const handleFocus = useCallback(() => {
if (isOpen || disabled) {
return;
}
openPopup();
}, [isOpen, disabled, openPopup]);
// Click outside to close
useEffect(() => {
if (!isOpen) {
return;
}
const handleClickOutside = (event: MouseEvent) => {
const { current: container } = containerRef;
if (container === null) {
return;
}
if (event.target instanceof Node && !container.contains(event.target)) {
closePopup();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, closePopup]);
// Clear active index when filtered options change and no match exists
useEffect(() => {
if (activeIndex >= 0 && activeIndex >= filteredOptions.length) {
setActiveIndex(-1);
}
}, [activeIndex, filteredOptions.length]);
// Reset search mode when input value matches selected label or becomes empty
useEffect(() => {
if (inputValue === '' || inputValue === selectedLabel) {
setIsSearching(false);
}
}, [inputValue, selectedLabel]);
return (
<div ref={containerRef} className={cn('apg-combobox', className)} {...restProps}>
<label id={labelId} htmlFor={inputId} className="apg-combobox-label">
{label}
</label>
<div className="apg-combobox-input-wrapper">
<input
ref={inputRef}
id={inputId}
type="text"
role="combobox"
className="apg-combobox-input"
aria-autocomplete={autocomplete}
aria-expanded={isOpen}
aria-controls={listboxId}
aria-labelledby={labelId}
aria-activedescendant={activeDescendantId || undefined}
value={inputValue}
placeholder={placeholder}
disabled={disabled}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onFocus={handleFocus}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
/>
<span className="apg-combobox-caret" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clipRule="evenodd"
/>
</svg>
</span>
</div>
<ul
id={listboxId}
role="listbox"
aria-labelledby={labelId}
className="apg-combobox-listbox"
hidden={!isOpen || undefined}
>
{filteredOptions.length === 0 && (
<li className="apg-combobox-no-results" role="status">
{noResultsMessage}
</li>
)}
{filteredOptions.map(({ id, label: optionLabel, disabled: optionDisabled }, index) => {
const isActive = index === activeIndex;
const isSelected = id === selectedId;
return (
<li
key={id}
id={getOptionId(id)}
role="option"
className="apg-combobox-option"
aria-selected={isActive}
aria-disabled={optionDisabled || undefined}
onClick={() =>
handleOptionClick({ id, label: optionLabel, disabled: optionDisabled })
}
onMouseEnter={() =>
handleOptionHover({ id, label: optionLabel, disabled: optionDisabled })
}
data-selected={isSelected || undefined}
>
<span className="apg-combobox-option-icon" aria-hidden="true">
<svg viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path d="M10.28 2.28a.75.75 0 00-1.06-1.06L4.5 5.94 2.78 4.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.06 0l5.25-5.25z" />
</svg>
</span>
{optionLabel}
</li>
);
})}
</ul>
</div>
);
}
export default Combobox; Usage
import { Combobox } from './Combobox';
const options = [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry' },
];
function App() {
return (
<div>
{/* Basic usage */}
<Combobox
options={options}
label="Favorite Fruit"
placeholder="Type to search..."
/>
{/* With default value */}
<Combobox
options={options}
label="Fruit"
defaultSelectedOptionId="banana"
/>
{/* With disabled options */}
<Combobox
options={[
{ id: 'a', label: 'Option A' },
{ id: 'b', label: 'Option B', disabled: true },
{ id: 'c', label: 'Option C' },
]}
label="Select Option"
/>
{/* No filtering (autocomplete="none") */}
<Combobox
options={options}
label="Select"
autocomplete="none"
/>
{/* With callbacks */}
<Combobox
options={options}
label="Fruit"
onSelect={(option) => console.log('Selected:', option)}
onInputChange={(value) => console.log('Input:', value)}
onOpenChange={(isOpen) => console.log('Open:', isOpen)}
/>
</div>
);
} API
| Prop | Type | Default | Description |
|---|---|---|---|
options | ComboboxOption[] | Required | Array of options with id, label, and optional disabled |
label | string | Required | Visible label text |
placeholder | string | - | Placeholder text for input |
defaultInputValue | string | "" | Default input value |
defaultSelectedOptionId | string | - | ID of initially selected option |
inputValue | string | - | Controlled input value |
selectedOptionId | string | - | Controlled selected option ID |
autocomplete | "none" | "list" | "both" | "list" | Autocomplete behavior |
disabled | boolean | false | Whether the combobox is disabled |
onSelect | (option: ComboboxOption) => void | - | Callback when an option is selected |
onInputChange | (value: string) => void | - | Callback when input value changes |
onOpenChange | (isOpen: boolean) => void | - | Callback when popup opens/closes |
Testing
Tests verify APG compliance for ARIA attributes, keyboard interactions, filtering behavior, and accessibility requirements.
Test Categories
High Priority: ARIA Attributes
| Test | Description |
|---|---|
role="combobox" | Input element has the combobox role |
role="listbox" | Popup element has the listbox role |
role="option" | Each option has the option role |
aria-controls | Input references the listbox ID (always present) |
aria-expanded | Reflects popup open/closed state |
aria-autocomplete | Set to "list", "none", or "both" |
aria-activedescendant | References currently focused option |
aria-selected | Indicates the currently highlighted option |
aria-disabled | Indicates disabled options |
High Priority: Accessible Name
| Test | Description |
|---|---|
aria-labelledby | Input references visible label element |
aria-labelledby (listbox) | Listbox also references the label |
High Priority: Keyboard Interaction (Popup Closed)
| Test | Description |
|---|---|
Down Arrow | Opens popup and focuses first option |
Up Arrow | Opens popup and focuses last option |
Alt + Down Arrow | Opens popup without changing focus |
Typing | Opens popup and filters options |
High Priority: Keyboard Interaction (Popup Open)
| Test | Description |
|---|---|
Down Arrow | Moves to next enabled option (no wrap) |
Up Arrow | Moves to previous enabled option (no wrap) |
Home | Moves to first enabled option |
End | Moves to last enabled option |
Enter | Selects focused option and closes popup |
Escape | Closes popup and restores previous value |
Alt + Up Arrow | Selects focused option and closes popup |
Tab | Closes popup and moves to next focusable element |
High Priority: Focus Management
| Test | Description |
|---|---|
DOM focus on input | DOM focus remains on input at all times |
Virtual focus via aria-activedescendant | Visual focus controlled by aria-activedescendant |
Clear on close | aria-activedescendant cleared when popup closes |
Skip disabled options | Navigation skips disabled options |
Medium Priority: Filtering
| Test | Description |
|---|---|
Filter on typing | Options filtered as user types |
Case insensitive | Filtering is case insensitive |
No filter (autocomplete="none") | All options shown regardless of input |
Empty results | aria-activedescendant cleared when no matches |
Medium Priority: Mouse Interaction
| Test | Description |
|---|---|
Click option | Selects option and closes popup |
Hover option | Updates aria-activedescendant on hover |
Click disabled | Disabled options cannot be selected |
Click outside | Closes popup without selection |
Medium Priority: IME Composition
| Test | Description |
|---|---|
During composition | Keyboard navigation blocked during IME |
On composition end | Filtering updates after composition ends |
Medium Priority: Callbacks
| Test | Description |
|---|---|
onSelect | Called with option data when selected |
onInputChange | Called with input value on typing |
onOpenChange | Called when popup opens or closes |
Low Priority: HTML Attribute Inheritance
| Test | Description |
|---|---|
className | Custom class is applied to container |
placeholder | Placeholder text is shown in input |
disabled state | Component is disabled when disabled prop is set |
Testing Tools
- React: React Testing Library (opens in new tab)
- Vue: Vue Testing Library (opens in new tab)
- Svelte: Svelte Testing Library (opens in new tab)
- Astro: Vitest with JSDOM for Web Component unit tests
- Accessibility: axe-core (opens in new tab)
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Combobox, type ComboboxOption } from './Combobox';
// Default test options
const defaultOptions: ComboboxOption[] = [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry' },
];
// Options with disabled item
const optionsWithDisabled: ComboboxOption[] = [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana', disabled: true },
{ id: 'cherry', label: 'Cherry' },
];
// Options with first item disabled
const optionsWithFirstDisabled: ComboboxOption[] = [
{ id: 'apple', label: 'Apple', disabled: true },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry' },
];
// Options with last item disabled
const optionsWithLastDisabled: ComboboxOption[] = [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry', disabled: true },
];
// All disabled options
const allDisabledOptions: ComboboxOption[] = [
{ id: 'apple', label: 'Apple', disabled: true },
{ id: 'banana', label: 'Banana', disabled: true },
{ id: 'cherry', label: 'Cherry', disabled: true },
];
describe('Combobox', () => {
// 🔴 High Priority: APG ARIA Attributes
describe('APG: ARIA Attributes', () => {
it('input has role="combobox"', () => {
render(<Combobox options={defaultOptions} label="Fruit" />);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('has accessible name via aria-labelledby', () => {
render(<Combobox options={defaultOptions} label="Select a fruit" />);
const input = screen.getByRole('combobox');
expect(input).toHaveAccessibleName('Select a fruit');
});
it('has aria-controls pointing to listbox', () => {
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
const listboxId = input.getAttribute('aria-controls');
expect(listboxId).toBeTruthy();
expect(document.getElementById(listboxId!)).toHaveAttribute('role', 'listbox');
});
it('aria-controls points to existing listbox even when closed', () => {
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
const listboxId = input.getAttribute('aria-controls');
expect(listboxId).toBeTruthy();
const listbox = document.getElementById(listboxId!);
expect(listbox).toBeInTheDocument();
expect(listbox).toHaveAttribute('hidden');
});
it('has aria-expanded="false" when closed', () => {
render(<Combobox options={defaultOptions} label="Fruit" />);
expect(screen.getByRole('combobox')).toHaveAttribute('aria-expanded', 'false');
});
it('has aria-expanded="true" when opened', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-expanded', 'true');
});
it('has aria-autocomplete="list"', () => {
render(<Combobox options={defaultOptions} label="Fruit" />);
expect(screen.getByRole('combobox')).toHaveAttribute('aria-autocomplete', 'list');
});
it('has aria-autocomplete="none" when autocomplete is none', () => {
render(<Combobox options={defaultOptions} label="Fruit" autocomplete="none" />);
expect(screen.getByRole('combobox')).toHaveAttribute('aria-autocomplete', 'none');
});
it('has aria-activedescendant when option focused', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-activedescendant');
const activeId = input.getAttribute('aria-activedescendant');
expect(activeId).toBeTruthy();
expect(document.getElementById(activeId!)).toHaveTextContent('Apple');
});
it('clears aria-activedescendant when closed', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input.getAttribute('aria-activedescendant')).toBeTruthy();
await user.keyboard('{Escape}');
expect(input.getAttribute('aria-activedescendant')).toBeFalsy();
});
it('clears aria-activedescendant when list is empty after filtering', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input.getAttribute('aria-activedescendant')).toBeTruthy();
// Type something that matches no options
await user.clear(input);
await user.type(input, 'xyz');
expect(input.getAttribute('aria-activedescendant')).toBeFalsy();
});
it('listbox has role="listbox"', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
it('listbox is hidden when closed', () => {
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
const listboxId = input.getAttribute('aria-controls');
const listbox = document.getElementById(listboxId!);
expect(listbox).toHaveAttribute('hidden');
});
it('options have role="option"', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const options = screen.getAllByRole('option');
expect(options).toHaveLength(3);
});
it('focused option has aria-selected="true"', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const firstOption = screen.getByRole('option', { name: 'Apple' });
expect(firstOption).toHaveAttribute('aria-selected', 'true');
});
it('non-focused options have aria-selected="false"', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const secondOption = screen.getByRole('option', { name: 'Banana' });
const thirdOption = screen.getByRole('option', { name: 'Cherry' });
expect(secondOption).toHaveAttribute('aria-selected', 'false');
expect(thirdOption).toHaveAttribute('aria-selected', 'false');
});
it('disabled option has aria-disabled="true"', async () => {
const user = userEvent.setup();
render(<Combobox options={optionsWithDisabled} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const disabledOption = screen.getByRole('option', { name: 'Banana' });
expect(disabledOption).toHaveAttribute('aria-disabled', 'true');
});
});
// 🔴 High Priority: APG Keyboard Interaction (Input)
describe('APG: Keyboard Interaction (Input)', () => {
it('opens popup and focuses first enabled option on ArrowDown', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-expanded', 'true');
const activeId = input.getAttribute('aria-activedescendant');
expect(document.getElementById(activeId!)).toHaveTextContent('Apple');
});
it('opens popup and focuses last enabled option on ArrowUp', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowUp}');
expect(input).toHaveAttribute('aria-expanded', 'true');
const activeId = input.getAttribute('aria-activedescendant');
expect(document.getElementById(activeId!)).toHaveTextContent('Cherry');
});
it('skips disabled first option on ArrowDown', async () => {
const user = userEvent.setup();
render(<Combobox options={optionsWithFirstDisabled} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const activeId = input.getAttribute('aria-activedescendant');
expect(document.getElementById(activeId!)).toHaveTextContent('Banana');
});
it('skips disabled last option on ArrowUp', async () => {
const user = userEvent.setup();
render(<Combobox options={optionsWithLastDisabled} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowUp}');
const activeId = input.getAttribute('aria-activedescendant');
expect(document.getElementById(activeId!)).toHaveTextContent('Banana');
});
it('closes popup on Escape', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-expanded', 'true');
await user.keyboard('{Escape}');
expect(input).toHaveAttribute('aria-expanded', 'false');
});
it('restores input value on Escape', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" defaultInputValue="App" />);
const input = screen.getByRole('combobox');
expect(input).toHaveValue('App');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
// After navigation, input might show preview of Banana
await user.keyboard('{Escape}');
expect(input).toHaveValue('App');
});
it('selects option and closes popup on Enter', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<Combobox options={defaultOptions} label="Fruit" onSelect={onSelect} />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
expect(onSelect).toHaveBeenCalledWith(defaultOptions[0]);
expect(input).toHaveAttribute('aria-expanded', 'false');
expect(input).toHaveValue('Apple');
});
it('closes popup on Tab', async () => {
const user = userEvent.setup();
render(
<div>
<Combobox options={defaultOptions} label="Fruit" />
<button>Next</button>
</div>
);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-expanded', 'true');
await user.keyboard('{Tab}');
expect(input).toHaveAttribute('aria-expanded', 'false');
});
it('opens popup on typing', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'a');
expect(input).toHaveAttribute('aria-expanded', 'true');
});
it('Alt+ArrowDown opens without changing focus position', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{Alt>}{ArrowDown}{/Alt}');
expect(input).toHaveAttribute('aria-expanded', 'true');
// aria-activedescendant should not be set
expect(input.getAttribute('aria-activedescendant')).toBeFalsy();
});
it('Alt+ArrowUp commits selection and closes', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<Combobox options={defaultOptions} label="Fruit" onSelect={onSelect} />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
await user.keyboard('{Alt>}{ArrowUp}{/Alt}');
expect(onSelect).toHaveBeenCalledWith(defaultOptions[1]);
expect(input).toHaveAttribute('aria-expanded', 'false');
});
});
// 🔴 High Priority: APG Keyboard Interaction (Listbox Navigation)
describe('APG: Keyboard Interaction (Listbox)', () => {
it('moves to next enabled option on ArrowDown', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Apple');
await user.keyboard('{ArrowDown}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Banana');
});
it('moves to previous enabled option on ArrowUp', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Banana');
await user.keyboard('{ArrowUp}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Apple');
});
it('skips disabled option on ArrowDown', async () => {
const user = userEvent.setup();
render(<Combobox options={optionsWithDisabled} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Apple');
await user.keyboard('{ArrowDown}');
// Should skip Banana (disabled) and go to Cherry
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Cherry');
});
it('skips disabled option on ArrowUp', async () => {
const user = userEvent.setup();
render(<Combobox options={optionsWithDisabled} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowUp}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Cherry');
await user.keyboard('{ArrowUp}');
// Should skip Banana (disabled) and go to Apple
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Apple');
});
it('moves to first enabled option on Home', async () => {
const user = userEvent.setup();
render(<Combobox options={optionsWithFirstDisabled} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowUp}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Cherry');
await user.keyboard('{Home}');
// Should skip Apple (disabled) and go to Banana
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Banana');
});
it('moves to last enabled option on End', async () => {
const user = userEvent.setup();
render(<Combobox options={optionsWithLastDisabled} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Apple');
await user.keyboard('{End}');
// Should skip Cherry (disabled) and go to Banana
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Banana');
});
it('does not wrap on ArrowDown at last option', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Cherry');
await user.keyboard('{ArrowDown}');
// Should stay at Cherry, no wrap
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Cherry');
});
it('does not wrap on ArrowUp at first option', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Apple');
await user.keyboard('{ArrowUp}');
// Should stay at Apple, no wrap
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Apple');
});
});
// 🔴 High Priority: Focus Management
describe('APG: Focus Management', () => {
it('keeps DOM focus on input when navigating', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
expect(input).toHaveFocus();
});
it('updates aria-activedescendant on navigation', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const firstActiveId = input.getAttribute('aria-activedescendant');
expect(firstActiveId).toBeTruthy();
await user.keyboard('{ArrowDown}');
const secondActiveId = input.getAttribute('aria-activedescendant');
expect(secondActiveId).toBeTruthy();
expect(secondActiveId).not.toBe(firstActiveId);
});
it('aria-activedescendant references existing element', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const activeId = input.getAttribute('aria-activedescendant');
expect(activeId).toBeTruthy();
expect(document.getElementById(activeId!)).toBeInTheDocument();
});
it('maintains focus on input after selection', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
expect(input).toHaveFocus();
});
});
// 🔴 High Priority: Autocomplete
describe('Autocomplete', () => {
it('filters options based on input', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'app');
const options = screen.getAllByRole('option');
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Apple');
});
it('shows all options when input is empty', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const options = screen.getAllByRole('option');
expect(options).toHaveLength(3);
});
it('updates input value on selection', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
expect(input).toHaveValue('Banana');
});
it('does not filter when autocomplete="none"', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" autocomplete="none" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'xyz');
const options = screen.getAllByRole('option');
expect(options).toHaveLength(3);
});
it('case-insensitive filtering', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'APPLE');
const options = screen.getAllByRole('option');
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Apple');
});
it('shows no options message when filter results are empty', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'xyz');
expect(screen.queryAllByRole('option')).toHaveLength(0);
});
});
// 🔴 High Priority: Disabled Options
describe('Disabled Options', () => {
it('does not select disabled option on Enter', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<Combobox options={optionsWithFirstDisabled} label="Fruit" onSelect={onSelect} />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
// First enabled option is Banana
await user.keyboard('{ArrowUp}');
// Try to go to Apple (disabled) - should stay at Banana
await user.keyboard('{Enter}');
expect(onSelect).toHaveBeenCalledWith(optionsWithFirstDisabled[1]);
});
it('does not select disabled option on click', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<Combobox options={optionsWithDisabled} label="Fruit" onSelect={onSelect} />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const disabledOption = screen.getByRole('option', { name: 'Banana' });
await user.click(disabledOption);
expect(onSelect).not.toHaveBeenCalled();
expect(input).toHaveAttribute('aria-expanded', 'true');
});
it('shows disabled options in filtered results', async () => {
const user = userEvent.setup();
render(<Combobox options={optionsWithDisabled} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'ban');
const options = screen.getAllByRole('option');
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Banana');
expect(options[0]).toHaveAttribute('aria-disabled', 'true');
});
});
// 🔴 High Priority: Mouse Interaction
describe('Mouse Interaction', () => {
it('selects option on click', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<Combobox options={defaultOptions} label="Fruit" onSelect={onSelect} />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const option = screen.getByRole('option', { name: 'Banana' });
await user.click(option);
expect(onSelect).toHaveBeenCalledWith(defaultOptions[1]);
expect(input).toHaveValue('Banana');
});
it('closes popup on option click', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-expanded', 'true');
const option = screen.getByRole('option', { name: 'Banana' });
await user.click(option);
expect(input).toHaveAttribute('aria-expanded', 'false');
});
it('closes popup on outside click', async () => {
const user = userEvent.setup();
render(
<div>
<Combobox options={defaultOptions} label="Fruit" />
<button>Outside</button>
</div>
);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-expanded', 'true');
await user.click(screen.getByRole('button', { name: 'Outside' }));
expect(input).toHaveAttribute('aria-expanded', 'false');
});
it('does not select on outside click', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(
<div>
<Combobox options={defaultOptions} label="Fruit" onSelect={onSelect} />
<button>Outside</button>
</div>
);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.click(screen.getByRole('button', { name: 'Outside' }));
expect(onSelect).not.toHaveBeenCalled();
});
it('updates aria-selected on hover', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const bananaOption = screen.getByRole('option', { name: 'Banana' });
await user.hover(bananaOption);
expect(bananaOption).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('option', { name: 'Apple' })).toHaveAttribute(
'aria-selected',
'false'
);
});
});
// 🟡 Medium Priority: Accessibility Validation
describe('Accessibility', () => {
it('has no axe violations when closed', async () => {
const { container } = render(<Combobox options={defaultOptions} label="Fruit" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when open', async () => {
const user = userEvent.setup();
const { container } = render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with selection', async () => {
const user = userEvent.setup();
const { container } = render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with disabled options', async () => {
const user = userEvent.setup();
const { container } = render(<Combobox options={optionsWithDisabled} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟢 Low Priority: Props & Behavior
describe('Props & Behavior', () => {
it('calls onSelect when option selected', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<Combobox options={defaultOptions} label="Fruit" onSelect={onSelect} />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
expect(onSelect).toHaveBeenCalledWith(defaultOptions[0]);
expect(onSelect).toHaveBeenCalledTimes(1);
});
it('calls onInputChange when typing', async () => {
const user = userEvent.setup();
const onInputChange = vi.fn();
render(<Combobox options={defaultOptions} label="Fruit" onInputChange={onInputChange} />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'app');
expect(onInputChange).toHaveBeenCalledWith('a');
expect(onInputChange).toHaveBeenCalledWith('ap');
expect(onInputChange).toHaveBeenCalledWith('app');
});
it('calls onOpenChange when popup toggles', async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
render(<Combobox options={defaultOptions} label="Fruit" onOpenChange={onOpenChange} />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(onOpenChange).toHaveBeenCalledWith(true);
await user.keyboard('{Escape}');
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('applies className to container', () => {
const { container } = render(
<Combobox options={defaultOptions} label="Fruit" className="custom-class" />
);
expect(container.querySelector('.apg-combobox')).toHaveClass('custom-class');
});
it('supports disabled state on combobox', () => {
render(<Combobox options={defaultOptions} label="Fruit" disabled />);
const input = screen.getByRole('combobox');
expect(input).toBeDisabled();
});
it('supports placeholder', () => {
render(<Combobox options={defaultOptions} label="Fruit" placeholder="Choose a fruit..." />);
const input = screen.getByRole('combobox');
expect(input).toHaveAttribute('placeholder', 'Choose a fruit...');
});
it('supports defaultInputValue', () => {
render(<Combobox options={defaultOptions} label="Fruit" defaultInputValue="Ban" />);
const input = screen.getByRole('combobox');
expect(input).toHaveValue('Ban');
});
it('supports defaultSelectedOptionId', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" defaultSelectedOptionId="banana" />);
const input = screen.getByRole('combobox');
expect(input).toHaveValue('Banana');
// Open popup - should show all options (not filtered) since input matches selected label
await user.click(input);
// All options should be visible (defaultOptions has 3 items)
const options = screen.getAllByRole('option');
expect(options).toHaveLength(3);
// Banana should have data-selected (visually selected state)
const bananaOption = screen.getByRole('option', { name: 'Banana' });
expect(bananaOption).toHaveAttribute('data-selected', 'true');
// Navigate with ArrowDown - focuses first option (Apple)
await user.keyboard('{ArrowDown}');
const appleOption = screen.getByRole('option', { name: 'Apple' });
expect(appleOption).toHaveAttribute('aria-selected', 'true');
});
it('IDs do not conflict with multiple instances', () => {
render(
<>
<Combobox options={defaultOptions} label="Fruit 1" />
<Combobox options={defaultOptions} label="Fruit 2" />
</>
);
const inputs = screen.getAllByRole('combobox');
const listboxId1 = inputs[0].getAttribute('aria-controls');
const listboxId2 = inputs[1].getAttribute('aria-controls');
expect(listboxId1).not.toBe(listboxId2);
});
});
// Edge Cases
describe('Edge Cases', () => {
it('handles empty options array', () => {
expect(() => {
render(<Combobox options={[]} label="Fruit" />);
}).not.toThrow();
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('when all options are disabled, popup opens but no focus set', async () => {
const user = userEvent.setup();
render(<Combobox options={allDisabledOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-expanded', 'true');
expect(input.getAttribute('aria-activedescendant')).toBeFalsy();
});
it('handles rapid typing without errors', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'applebananacherry', { delay: 10 });
// Should not throw and should handle gracefully
expect(input).toHaveValue('applebananacherry');
});
});
}); Resources
- WAI-ARIA APG: Combobox Pattern (opens in new tab)
- MDN: <datalist> element (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist