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
<template>
<div ref="containerRef" :class="cn('apg-combobox', className)">
<label :id="labelId" :for="inputId" class="apg-combobox-label">
{{ label }}
</label>
<div class="apg-combobox-input-wrapper">
<input
ref="inputRef"
:id="inputId"
type="text"
role="combobox"
class="apg-combobox-input"
:aria-autocomplete="autocomplete"
:aria-expanded="isOpen"
:aria-controls="listboxId"
:aria-labelledby="labelId"
:aria-activedescendant="activeDescendantId || undefined"
:value="currentInputValue"
:placeholder="placeholder"
:disabled="disabled"
v-bind="$attrs"
@input="handleInput"
@keydown="handleKeyDown"
@focus="handleFocus"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
/>
<span class="apg-combobox-caret" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="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"
clip-rule="evenodd"
/>
</svg>
</span>
</div>
<ul
:id="listboxId"
role="listbox"
:aria-labelledby="labelId"
class="apg-combobox-listbox"
:hidden="!isOpen || undefined"
>
<li v-if="filteredOptions.length === 0" class="apg-combobox-no-results" role="status">
{{ noResultsMessage }}
</li>
<li
v-for="(option, index) in filteredOptions"
:key="option.id"
:id="getOptionId(option.id)"
role="option"
class="apg-combobox-option"
:aria-selected="index === activeIndex"
:aria-disabled="option.disabled || undefined"
:data-selected="option.id === currentSelectedId || undefined"
@click="handleOptionClick(option)"
@mouseenter="handleOptionHover(option)"
>
<span class="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>
{{ option.label }}
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { computed, onUnmounted, ref, useId, watch } from 'vue';
export interface ComboboxOption {
id: string;
label: string;
disabled?: boolean;
}
export interface ComboboxProps {
options: ComboboxOption[];
selectedOptionId?: string;
defaultSelectedOptionId?: string;
inputValue?: string;
defaultInputValue?: string;
label: string;
placeholder?: string;
disabled?: boolean;
autocomplete?: 'none' | 'list' | 'both';
noResultsMessage?: string;
className?: string;
}
const props = withDefaults(defineProps<ComboboxProps>(), {
defaultInputValue: '',
disabled: false,
autocomplete: 'list',
noResultsMessage: 'No results found',
className: '',
});
const emit = defineEmits<{
select: [option: ComboboxOption];
inputChange: [value: string];
openChange: [isOpen: boolean];
}>();
defineOptions({
inheritAttrs: false,
});
// Refs
const containerRef = ref<HTMLDivElement>();
const inputRef = ref<HTMLInputElement>();
const instanceId = useId();
// State
const isOpen = ref(false);
const activeIndex = ref(-1);
const isComposing = ref(false);
const valueBeforeOpen = ref('');
const isSearching = ref(false);
// Internal state for uncontrolled mode
const internalInputValue = ref(() => {
if (props.defaultSelectedOptionId) {
const option = props.options.find(({ id }) => id === props.defaultSelectedOptionId);
return option?.label ?? props.defaultInputValue;
}
return props.defaultInputValue;
});
const internalSelectedId = ref<string | undefined>(props.defaultSelectedOptionId);
// Computed
const inputId = computed(() => `${instanceId}-input`);
const labelId = computed(() => `${instanceId}-label`);
const listboxId = computed(() => `${instanceId}-listbox`);
const currentInputValue = computed(() => {
if (props.inputValue !== undefined) {
return props.inputValue;
}
// Handle both function ref (from initialization) and string value
const value = internalInputValue.value;
return typeof value === 'function' ? value() : value;
});
const currentSelectedId = computed(() => props.selectedOptionId ?? internalSelectedId.value);
// Get selected option's label
const selectedLabel = computed(() => {
if (!currentSelectedId.value) {
return '';
}
const option = props.options.find(({ id }) => id === currentSelectedId.value);
return option?.label ?? '';
});
const filteredOptions = computed(() => {
const { autocomplete, options } = props;
const inputValue = currentInputValue.value;
// 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.value && inputValue === selectedLabel.value) {
return options;
}
const lowerInputValue = inputValue.toLowerCase();
return options.filter(({ label }) => label.toLowerCase().includes(lowerInputValue));
});
const enabledOptions = computed(() => filteredOptions.value.filter(({ disabled }) => !disabled));
const activeDescendantId = computed(() => {
if (activeIndex.value < 0 || activeIndex.value >= filteredOptions.value.length) {
return undefined;
}
const option = filteredOptions.value[activeIndex.value];
return option ? getOptionId(option.id) : undefined;
});
// Helper functions
const getOptionId = (optionId: string) => `${instanceId}-option-${optionId}`;
const updateInputValue = (value: string) => {
if (props.inputValue === undefined) {
internalInputValue.value = value;
}
emit('inputChange', value);
};
const openPopup = (focusPosition?: 'first' | 'last') => {
if (isOpen.value) {
return;
}
valueBeforeOpen.value = currentInputValue.value;
isOpen.value = true;
emit('openChange', true);
if (!focusPosition || enabledOptions.value.length === 0) {
return;
}
const targetOption =
focusPosition === 'first'
? enabledOptions.value[0]
: enabledOptions.value[enabledOptions.value.length - 1];
const { id: targetId } = targetOption;
const targetIndex = filteredOptions.value.findIndex(({ id }) => id === targetId);
activeIndex.value = targetIndex;
};
const closePopup = (restore = false) => {
isOpen.value = false;
activeIndex.value = -1;
isSearching.value = false;
emit('openChange', false);
if (restore) {
updateInputValue(valueBeforeOpen.value);
}
};
const selectOption = ({ id, label, disabled }: ComboboxOption) => {
if (disabled) {
return;
}
if (props.selectedOptionId === undefined) {
internalSelectedId.value = id;
}
isSearching.value = false;
updateInputValue(label);
emit('select', { id, label, disabled });
closePopup();
};
const findEnabledIndex = (
startIndex: number,
direction: 'next' | 'prev' | 'first' | 'last'
): number => {
if (enabledOptions.value.length === 0) {
return -1;
}
if (direction === 'first') {
const { id: firstId } = enabledOptions.value[0];
return filteredOptions.value.findIndex(({ id }) => id === firstId);
}
if (direction === 'last') {
const { id: lastId } = enabledOptions.value[enabledOptions.value.length - 1];
return filteredOptions.value.findIndex(({ id }) => id === lastId);
}
const currentOption = filteredOptions.value[startIndex];
const currentEnabledIndex = currentOption
? enabledOptions.value.findIndex(({ id }) => id === currentOption.id)
: -1;
if (direction === 'next') {
if (currentEnabledIndex < 0) {
const { id: firstId } = enabledOptions.value[0];
return filteredOptions.value.findIndex(({ id }) => id === firstId);
}
if (currentEnabledIndex >= enabledOptions.value.length - 1) {
return startIndex;
}
const { id: nextId } = enabledOptions.value[currentEnabledIndex + 1];
return filteredOptions.value.findIndex(({ id }) => id === nextId);
}
// direction === 'prev'
if (currentEnabledIndex < 0) {
const { id: lastId } = enabledOptions.value[enabledOptions.value.length - 1];
return filteredOptions.value.findIndex(({ id }) => id === lastId);
}
if (currentEnabledIndex <= 0) {
return startIndex;
}
const { id: prevId } = enabledOptions.value[currentEnabledIndex - 1];
return filteredOptions.value.findIndex(({ id }) => id === prevId);
};
// Event handlers
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement;
const value = target.value;
isSearching.value = true;
updateInputValue(value);
if (!isOpen.value && !isComposing.value) {
valueBeforeOpen.value = currentInputValue.value;
isOpen.value = true;
emit('openChange', true);
}
activeIndex.value = -1;
};
const handleKeyDown = (event: KeyboardEvent) => {
if (isComposing.value) {
return;
}
const { key, altKey } = event;
switch (key) {
case 'ArrowDown': {
event.preventDefault();
if (altKey) {
if (isOpen.value) {
return;
}
valueBeforeOpen.value = currentInputValue.value;
isOpen.value = true;
emit('openChange', true);
return;
}
if (!isOpen.value) {
openPopup('first');
return;
}
const nextIndex = findEnabledIndex(activeIndex.value, 'next');
if (nextIndex >= 0) {
activeIndex.value = nextIndex;
}
break;
}
case 'ArrowUp': {
event.preventDefault();
if (altKey) {
if (!isOpen.value || activeIndex.value < 0) {
return;
}
const option = filteredOptions.value[activeIndex.value];
if (option === undefined || option.disabled) {
return;
}
selectOption(option);
return;
}
if (!isOpen.value) {
openPopup('last');
return;
}
const prevIndex = findEnabledIndex(activeIndex.value, 'prev');
if (prevIndex >= 0) {
activeIndex.value = prevIndex;
}
break;
}
case 'Home': {
if (!isOpen.value) {
return;
}
event.preventDefault();
const firstIndex = findEnabledIndex(0, 'first');
if (firstIndex >= 0) {
activeIndex.value = firstIndex;
}
break;
}
case 'End': {
if (!isOpen.value) {
return;
}
event.preventDefault();
const lastIndex = findEnabledIndex(0, 'last');
if (lastIndex >= 0) {
activeIndex.value = lastIndex;
}
break;
}
case 'Enter': {
if (!isOpen.value || activeIndex.value < 0) {
return;
}
event.preventDefault();
const option = filteredOptions.value[activeIndex.value];
if (option === undefined || option.disabled) {
return;
}
selectOption(option);
break;
}
case 'Escape': {
if (!isOpen.value) {
return;
}
event.preventDefault();
closePopup(true);
break;
}
case 'Tab': {
if (isOpen.value) {
closePopup();
}
break;
}
}
};
const handleOptionClick = (option: ComboboxOption) => {
if (option.disabled) {
return;
}
selectOption(option);
};
const handleOptionHover = ({ id }: ComboboxOption) => {
const index = filteredOptions.value.findIndex((option) => option.id === id);
if (index < 0) {
return;
}
activeIndex.value = index;
};
const handleCompositionStart = () => {
isComposing.value = true;
};
const handleCompositionEnd = () => {
isComposing.value = false;
};
// Handle focus - open popup when input receives focus
const handleFocus = () => {
if (isOpen.value || props.disabled) {
return;
}
openPopup();
};
// Click outside handler
const handleClickOutside = (event: MouseEvent) => {
const { value: container } = containerRef;
if (container === undefined) {
return;
}
if (!container.contains(event.target as Node)) {
closePopup();
}
};
watch(
() => isOpen.value,
(newIsOpen) => {
if (newIsOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
}
);
// Clear active index when filtered options change
watch(
() => filteredOptions.value.length,
(newLength) => {
if (activeIndex.value >= 0 && activeIndex.value >= newLength) {
activeIndex.value = -1;
}
}
);
// Reset search mode when input value matches selected label or becomes empty
watch(
() => currentInputValue.value,
(newValue) => {
if (newValue === '' || newValue === selectedLabel.value) {
isSearching.value = false;
}
}
);
onUnmounted(() => {
document.removeEventListener('mousedown', handleClickOutside);
});
</script> Usage
<script setup>
import Combobox from './Combobox.vue';
const options = [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry' },
];
function handleSelect(option) {
console.log('Selected:', option);
}
function handleInputChange(value) {
console.log('Input:', value);
}
function handleOpenChange(isOpen) {
console.log('Open:', isOpen);
}
</script>
<template>
<!-- Basic usage -->
<Combobox
:options="options"
label="Favorite Fruit"
placeholder="Type to search..."
/>
<!-- With default value -->
<Combobox
:options="options"
label="Fruit"
default-selected-option-id="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"
@select="handleSelect"
@inputchange="handleInputChange"
@openchange="handleOpenChange"
/>
</template> 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 |
autocomplete | "none" | "list" | "both" | "list" | Autocomplete behavior |
disabled | boolean | false | Whether the combobox is disabled |
Events
| Event | Payload | Description |
|---|---|---|
@select | ComboboxOption | Emitted when an option is selected |
@inputchange | string | Emitted when input value changes |
@openchange | boolean | Emitted 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/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Combobox from './Combobox.vue';
// Default test options
const defaultOptions = [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry' },
];
// Options with disabled item
const optionsWithDisabled = [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana', disabled: true },
{ id: 'cherry', label: 'Cherry' },
];
// Options with first item disabled
const optionsWithFirstDisabled = [
{ id: 'apple', label: 'Apple', disabled: true },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry' },
];
// Options with last item disabled
const optionsWithLastDisabled = [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry', disabled: true },
];
// All disabled options
const allDisabledOptions = [
{ id: 'apple', label: 'Apple', disabled: true },
{ id: 'banana', label: 'Banana', disabled: true },
{ id: 'cherry', label: 'Cherry', disabled: true },
];
describe('Combobox (Vue)', () => {
// 🔴 High Priority: APG ARIA Attributes
describe('APG: ARIA Attributes', () => {
it('input has role="combobox"', () => {
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('has accessible name via aria-labelledby', () => {
render(Combobox, {
props: { 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, {
props: { 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, {
props: { 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, {
props: { 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, {
props: { 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, {
props: { options: defaultOptions, label: 'Fruit' },
});
expect(screen.getByRole('combobox')).toHaveAttribute('aria-autocomplete', 'list');
});
it('has aria-autocomplete="none" when autocomplete is none', () => {
render(Combobox, {
props: { 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, {
props: { 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, {
props: { 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('listbox has role="listbox"', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
it('options have role="option"', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { 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, {
props: { 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('disabled option has aria-disabled="true"', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { 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, {
props: { 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, {
props: { 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, {
props: { 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, {
props: { 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, {
props: { 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('selects option and closes popup on Enter', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit', 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(Combobox, {
props: { 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('{Tab}');
expect(input).toHaveAttribute('aria-expanded', 'false');
});
it('opens popup on typing', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { 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, {
props: { 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');
expect(input.getAttribute('aria-activedescendant')).toBeFalsy();
});
it('Alt+ArrowUp commits selection and closes', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit', 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, {
props: { 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, {
props: { 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, {
props: { 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}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Cherry');
});
it('skips disabled option on ArrowUp', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { 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}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Apple');
});
it('moves to first enabled option on Home', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { 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}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Banana');
});
it('moves to last enabled option on End', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { 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}');
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, {
props: { 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}');
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, {
props: { 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}');
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, {
props: { 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, {
props: { 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('maintains focus on input after selection', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { 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, {
props: { 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, {
props: { 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, {
props: { 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, {
props: { 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);
});
});
// 🔴 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, {
props: { options: optionsWithFirstDisabled, label: 'Fruit', onSelect },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowUp}');
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, {
props: { options: optionsWithDisabled, label: 'Fruit', 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');
});
});
// 🔴 High Priority: Mouse Interaction
describe('Mouse Interaction', () => {
it('selects option on click', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit', 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, {
props: { 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(Combobox, {
props: { 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.click(document.body);
expect(input).toHaveAttribute('aria-expanded', 'false');
});
it('updates aria-selected on hover', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { 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, {
props: { 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, {
props: { 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, {
props: { 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();
});
});
// 🟢 Low Priority: Props & Behavior
describe('Props & Behavior', () => {
it('calls onSelect when option selected', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit', 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, {
props: { options: defaultOptions, label: 'Fruit', 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, {
props: { options: defaultOptions, label: 'Fruit', 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, {
props: { options: defaultOptions, label: 'Fruit', className: 'custom-class' },
});
expect(container.querySelector('.apg-combobox')).toHaveClass('custom-class');
});
it('supports disabled state on combobox', () => {
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit', disabled: true },
});
const input = screen.getByRole('combobox');
expect(input).toBeDisabled();
});
it('supports placeholder', () => {
render(Combobox, {
props: { 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, {
props: { options: defaultOptions, label: 'Fruit', defaultInputValue: 'Ban' },
});
const input = screen.getByRole('combobox');
expect(input).toHaveValue('Ban');
});
});
// Edge Cases
describe('Edge Cases', () => {
it('handles empty options array', () => {
expect(() => {
render(Combobox, {
props: { 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, {
props: { 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();
});
});
}); 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