Combobox
An editable combobox with list autocomplete. Users can type to filter options or select from a popup listbox using keyboard or mouse.
Demo
- No results found
- Apple
- Banana
- Cherry
- Date
- Elderberry
- Fig
- Grape
- No results found
- Apple
- Banana
- Cherry
- Date
- Elderberry
- Fig
- Grape
- No results found
- Japan
- United States
- United Kingdom
- Germany
- France
- Italy
- Spain
- Australia
- No results found
- Apple
- Banana
- Cherry
- Date
- Elderberry
- Fig
- Grape
- No results found
- 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 Properties
role="combobox"
Identifies the input as a combobox
- Values
- -
- Required
- Yes
aria-controls
References the listbox popup (even when closed)
- Values
- ID reference
- Required
- Yes
aria-expanded
Indicates whether the popup is open
- Values
true|false- Required
- Yes
aria-autocomplete
Describes the autocomplete behavior
- Values
list|none|both- Required
- Yes
aria-activedescendant
References the currently focused option in the popup
- Values
- ID reference | empty
- Required
- Yes
aria-labelledby
References the label element
- Values
- ID reference
- Required
- Yes*
aria-selected
Indicates the currently focused option
- Values
true|false- Required
- Yes
aria-disabled
Indicates the option is disabled
- Values
true- Required
- No
Keyboard Support
| 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 |
| 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 |
- Listbox always in DOM: Keep listbox in DOM with
hiddenattribute when closed (foraria-controlsreference) - IME Handling: Track composition state to prevent filtering during IME input
- Click Outside: Use event listener to close popup on outside clicks
- Value Restoration: Store pre-edit value to restore on Escape
Focus Management
| Event | Behavior |
|---|---|
| Navigation via arrow keys | DOM focus remains on input; aria-activedescendant references the visually focused option |
| Popup closes or filter results are empty | aria-activedescendant is cleared |
| Disabled option encountered | Disabled options are skipped during navigation |
References
Source Code
---
/**
* APG Combobox Pattern - Astro Implementation
*
* An editable combobox with list autocomplete.
* Uses Web Components for enhanced control and proper focus management.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
*/
import { cn } from '@/lib/utils';
export interface ComboboxOption {
id: string;
label: string;
disabled?: boolean;
}
export interface Props {
/** Array of options */
options: ComboboxOption[];
/** Label text */
label: string;
/** Placeholder text */
placeholder?: string;
/** Default input value */
defaultInputValue?: string;
/** Default selected option ID */
defaultSelectedOptionId?: string;
/** Autocomplete type */
autocomplete?: 'none' | 'list' | 'both';
/** Disabled state */
disabled?: boolean;
/** Message shown when no results found */
noResultsMessage?: string;
/** Additional CSS class */
class?: string;
}
const {
options = [],
label,
placeholder = '',
defaultInputValue = '',
defaultSelectedOptionId,
autocomplete = 'list',
disabled = false,
noResultsMessage = 'No results found',
class: className = '',
} = Astro.props;
// Generate unique ID for this instance
const instanceId = `combobox-${Math.random().toString(36).slice(2, 11)}`;
const inputId = `${instanceId}-input`;
const labelId = `${instanceId}-label`;
const listboxId = `${instanceId}-listbox`;
// Calculate initial input value
const initialInputValue = defaultSelectedOptionId
? (options.find((o) => o.id === defaultSelectedOptionId)?.label ?? defaultInputValue)
: defaultInputValue;
---
<apg-combobox
data-autocomplete={autocomplete}
data-default-input-value={initialInputValue}
data-default-selected-id={defaultSelectedOptionId || ''}
>
<div class={cn('apg-combobox', className)}>
<label id={labelId} for={inputId} class="apg-combobox-label">
{label}
</label>
<div class="apg-combobox-input-wrapper">
<input
id={inputId}
type="text"
role="combobox"
class="apg-combobox-input"
aria-autocomplete={autocomplete}
aria-expanded="false"
aria-controls={listboxId}
aria-labelledby={labelId}
value={initialInputValue}
placeholder={placeholder}
disabled={disabled}
data-combobox-input
/>
<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"></path>
</svg>
</span>
</div>
<ul
id={listboxId}
role="listbox"
aria-labelledby={labelId}
class="apg-combobox-listbox"
hidden
data-combobox-listbox
>
<li class="apg-combobox-no-results" role="status" hidden data-no-results>
{noResultsMessage}
</li>
{
options.map((option) => (
<li
id={`${instanceId}-option-${option.id}`}
role="option"
class="apg-combobox-option"
aria-selected="false"
aria-disabled={option.disabled || undefined}
data-option-id={option.id}
data-option-label={option.label}
data-selected={option.id === defaultSelectedOptionId || undefined}
>
<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>
</apg-combobox>
<script>
class ApgCombobox extends HTMLElement {
private container: HTMLDivElement | null = null;
private input: HTMLInputElement | null = null;
private listbox: HTMLUListElement | null = null;
private rafId: number | null = null;
private isOpen = false;
private activeIndex = -1;
private isComposing = false;
private valueBeforeOpen = '';
private autocomplete: 'none' | 'list' | 'both' = 'list';
private allOptions: HTMLLIElement[] = [];
private noResultsElement: HTMLLIElement | null = null;
private isSearching = false;
private selectedId: string | null = null;
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.container = this.querySelector('.apg-combobox');
this.input = this.querySelector('[data-combobox-input]');
this.listbox = this.querySelector('[data-combobox-listbox]');
if (!this.input || !this.listbox) {
console.warn('apg-combobox: required elements not found');
return;
}
// Initialize state from data attributes
this.autocomplete = (this.dataset.autocomplete as 'none' | 'list' | 'both') || 'list';
this.allOptions = Array.from(this.listbox.querySelectorAll<HTMLLIElement>('[role="option"]'));
this.noResultsElement = this.listbox.querySelector<HTMLLIElement>('[data-no-results]');
this.selectedId = this.dataset.defaultSelectedId || null;
// Attach event listeners
this.input.addEventListener('input', this.handleInput);
this.input.addEventListener('keydown', this.handleKeyDown);
this.input.addEventListener('focus', this.handleFocus);
this.input.addEventListener('compositionstart', this.handleCompositionStart);
this.input.addEventListener('compositionend', this.handleCompositionEnd);
this.listbox.addEventListener('click', this.handleListboxClick);
this.listbox.addEventListener('mouseenter', this.handleListboxMouseEnter, true);
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
document.removeEventListener('pointerdown', this.handleClickOutside);
this.input?.removeEventListener('input', this.handleInput);
this.input?.removeEventListener('keydown', this.handleKeyDown);
this.input?.removeEventListener('focus', this.handleFocus);
this.input?.removeEventListener('compositionstart', this.handleCompositionStart);
this.input?.removeEventListener('compositionend', this.handleCompositionEnd);
this.listbox?.removeEventListener('click', this.handleListboxClick);
this.listbox?.removeEventListener('mouseenter', this.handleListboxMouseEnter, true);
}
private getSelectedLabel(): string {
if (!this.selectedId) {
return '';
}
const selectedOption = this.allOptions.find(
(option) => option.dataset.optionId === this.selectedId
);
return selectedOption?.dataset.optionLabel ?? '';
}
private getFilteredOptions(): HTMLLIElement[] {
if (!this.input) {
return [];
}
const inputValue = this.input.value;
const selectedLabel = this.getSelectedLabel();
// Don't filter if autocomplete is none
if (this.autocomplete === 'none') {
return this.allOptions;
}
// Don't filter if input is empty
if (!inputValue) {
return this.allOptions;
}
// Don't filter if not in search mode AND input matches selected label
if (!this.isSearching && inputValue === selectedLabel) {
return this.allOptions;
}
const lowerInputValue = inputValue.toLowerCase();
return this.allOptions.filter((option) => {
const { optionLabel } = option.dataset;
const label = optionLabel?.toLowerCase() ?? '';
return label.includes(lowerInputValue);
});
}
private getEnabledOptions(): HTMLLIElement[] {
return this.getFilteredOptions().filter(
(option) => option.getAttribute('aria-disabled') !== 'true'
);
}
private updateListboxVisibility() {
if (!this.listbox) return;
const filteredOptions = this.getFilteredOptions();
// Hide all options first
this.allOptions.forEach((option) => {
option.hidden = true;
});
// Show filtered options
filteredOptions.forEach((option) => {
option.hidden = false;
});
// Show/hide no results message
if (this.noResultsElement) {
this.noResultsElement.hidden = filteredOptions.length > 0;
}
}
private openPopup(focusPosition?: 'first' | 'last') {
if (!this.input || !this.listbox || this.isOpen) {
return;
}
this.valueBeforeOpen = this.input.value;
this.isOpen = true;
this.input.setAttribute('aria-expanded', 'true');
this.listbox.removeAttribute('hidden');
this.updateListboxVisibility();
document.addEventListener('pointerdown', this.handleClickOutside);
if (!focusPosition) {
return;
}
const enabledOptions = this.getEnabledOptions();
if (enabledOptions.length === 0) {
return;
}
const targetOption =
focusPosition === 'first' ? enabledOptions[0] : enabledOptions[enabledOptions.length - 1];
const filteredOptions = this.getFilteredOptions();
this.activeIndex = filteredOptions.indexOf(targetOption);
this.updateActiveDescendant();
}
private closePopup(restore = false) {
if (!this.input || !this.listbox) {
return;
}
this.isOpen = false;
this.activeIndex = -1;
this.isSearching = false;
this.input.setAttribute('aria-expanded', 'false');
this.input.removeAttribute('aria-activedescendant');
this.listbox.setAttribute('hidden', '');
// Reset aria-selected
this.allOptions.forEach((option) => {
option.setAttribute('aria-selected', 'false');
});
document.removeEventListener('pointerdown', this.handleClickOutside);
if (restore && this.input) {
this.input.value = this.valueBeforeOpen;
}
}
private updateSelectedState() {
this.allOptions.forEach((option) => {
const { optionId } = option.dataset;
if (optionId === this.selectedId) {
option.dataset.selected = 'true';
} else {
delete option.dataset.selected;
}
});
}
private selectOption(option: HTMLLIElement) {
if (!this.input || option.getAttribute('aria-disabled') === 'true') {
return;
}
const { optionLabel, optionId } = option.dataset;
const label = optionLabel ?? option.textContent?.trim() ?? '';
const id = optionId ?? '';
this.selectedId = id;
this.isSearching = false;
this.input.value = label;
this.updateSelectedState();
this.dispatchEvent(
new CustomEvent('select', {
detail: { id, label },
bubbles: true,
})
);
this.closePopup();
}
private updateActiveDescendant() {
if (!this.input) {
return;
}
const filteredOptions = this.getFilteredOptions();
// Reset all aria-selected
this.allOptions.forEach((option) => {
option.setAttribute('aria-selected', 'false');
});
if (this.activeIndex < 0 || this.activeIndex >= filteredOptions.length) {
this.input.removeAttribute('aria-activedescendant');
return;
}
const activeOption = filteredOptions[this.activeIndex];
this.input.setAttribute('aria-activedescendant', activeOption.id);
activeOption.setAttribute('aria-selected', 'true');
}
private findEnabledIndex(
startIndex: number,
direction: 'next' | 'prev' | 'first' | 'last'
): number {
const enabledOptions = this.getEnabledOptions();
const filteredOptions = this.getFilteredOptions();
if (enabledOptions.length === 0) {
return -1;
}
if (direction === 'first') {
return filteredOptions.indexOf(enabledOptions[0]);
}
if (direction === 'last') {
return filteredOptions.indexOf(enabledOptions[enabledOptions.length - 1]);
}
const currentOption = filteredOptions[startIndex];
const currentEnabledIndex = currentOption ? enabledOptions.indexOf(currentOption) : -1;
if (direction === 'next') {
if (currentEnabledIndex < 0) {
return filteredOptions.indexOf(enabledOptions[0]);
}
if (currentEnabledIndex >= enabledOptions.length - 1) {
return startIndex;
}
return filteredOptions.indexOf(enabledOptions[currentEnabledIndex + 1]);
}
// direction === 'prev'
if (currentEnabledIndex < 0) {
return filteredOptions.indexOf(enabledOptions[enabledOptions.length - 1]);
}
if (currentEnabledIndex <= 0) {
return startIndex;
}
return filteredOptions.indexOf(enabledOptions[currentEnabledIndex - 1]);
}
private handleInput = () => {
if (!this.input) {
return;
}
this.isSearching = true;
if (!this.isOpen && !this.isComposing) {
this.valueBeforeOpen = this.input.value;
this.openPopup();
}
this.updateListboxVisibility();
this.activeIndex = -1;
this.updateActiveDescendant();
// Reset search mode if input matches selected label or is empty
const selectedLabel = this.getSelectedLabel();
if (this.input.value === '' || this.input.value === selectedLabel) {
this.isSearching = false;
}
this.dispatchEvent(
new CustomEvent('inputchange', {
detail: { value: this.input.value },
bubbles: true,
})
);
};
private handleKeyDown = (event: KeyboardEvent) => {
if (this.isComposing) {
return;
}
const { key, altKey } = event;
switch (key) {
case 'ArrowDown': {
event.preventDefault();
if (altKey) {
if (this.isOpen) {
return;
}
this.openPopup();
return;
}
if (!this.isOpen) {
this.openPopup('first');
return;
}
const nextIndex = this.findEnabledIndex(this.activeIndex, 'next');
if (nextIndex >= 0) {
this.activeIndex = nextIndex;
this.updateActiveDescendant();
}
break;
}
case 'ArrowUp': {
event.preventDefault();
if (altKey) {
if (!this.isOpen || this.activeIndex < 0) {
return;
}
const filteredOptions = this.getFilteredOptions();
const option = filteredOptions[this.activeIndex];
if (!option || option.getAttribute('aria-disabled') === 'true') {
return;
}
this.selectOption(option);
return;
}
if (!this.isOpen) {
this.openPopup('last');
return;
}
const prevIndex = this.findEnabledIndex(this.activeIndex, 'prev');
if (prevIndex >= 0) {
this.activeIndex = prevIndex;
this.updateActiveDescendant();
}
break;
}
case 'Home': {
if (!this.isOpen) {
return;
}
event.preventDefault();
const firstIndex = this.findEnabledIndex(0, 'first');
if (firstIndex >= 0) {
this.activeIndex = firstIndex;
this.updateActiveDescendant();
}
break;
}
case 'End': {
if (!this.isOpen) {
return;
}
event.preventDefault();
const lastIndex = this.findEnabledIndex(0, 'last');
if (lastIndex >= 0) {
this.activeIndex = lastIndex;
this.updateActiveDescendant();
}
break;
}
case 'Enter': {
if (!this.isOpen || this.activeIndex < 0) {
return;
}
event.preventDefault();
const filteredOptions = this.getFilteredOptions();
const option = filteredOptions[this.activeIndex];
if (!option || option.getAttribute('aria-disabled') === 'true') {
return;
}
this.selectOption(option);
break;
}
case 'Escape': {
if (!this.isOpen) {
return;
}
event.preventDefault();
this.closePopup(true);
break;
}
case 'Tab': {
if (this.isOpen) {
this.closePopup();
}
break;
}
}
};
private handleListboxClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const option = target.closest('[role="option"]') as HTMLLIElement | null;
if (!option || option.getAttribute('aria-disabled') === 'true') {
return;
}
this.selectOption(option);
};
private handleListboxMouseEnter = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const option = target.closest('[role="option"]') as HTMLLIElement | null;
if (!option) {
return;
}
const filteredOptions = this.getFilteredOptions();
const index = filteredOptions.indexOf(option);
if (index < 0) {
return;
}
this.activeIndex = index;
this.updateActiveDescendant();
};
private handleCompositionStart = () => {
this.isComposing = true;
};
private handleCompositionEnd = () => {
this.isComposing = false;
};
// Handle focus - open popup when input receives focus
private handleFocus = () => {
if (this.isOpen || !this.input || this.input.disabled) {
return;
}
this.openPopup();
};
private handleClickOutside = (event: PointerEvent) => {
if (!this.container) {
return;
}
if (!this.container.contains(event.target as Node)) {
this.closePopup();
}
};
}
if (!customElements.get('apg-combobox')) {
customElements.define('apg-combobox', ApgCombobox);
}
</script> Usage
---
import Combobox from './Combobox.astro';
const options = [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry' },
];
---
<!-- 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"
/>
<!-- Listen to selection events (Web Component event) -->
<Combobox id="my-combobox" options={options} label="Fruit" />
<script>
const combobox = document.querySelector('#my-combobox');
combobox?.addEventListener('select', (e) => {
console.log('Selected:', e.detail);
});
combobox?.addEventListener('inputchange', (e) => {
console.log('Input:', e.detail.value);
});
</script> 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 |
Custom Events
| Event | Detail | Description |
|---|---|---|
select | {id: string, label: string} | Dispatched when an option is selected |
inputchange | {value: string} | Dispatched when input value changes |
Testing
Tests verify APG compliance for ARIA attributes, keyboard interactions, filtering behavior, and accessibility requirements. The Combobox component uses a two-layer testing strategy.
Testing Strategy
Unit Tests (Testing Library)
Verify the component's rendered output using framework-specific testing libraries. These tests ensure correct HTML structure and ARIA attributes.
- ARIA attributes (role, aria-controls, aria-expanded, etc.)
- Keyboard interaction (Arrow keys, Enter, Escape, etc.)
- Filtering behavior and option rendering
- Accessibility via jest-axe
E2E Tests (Playwright)
Verify component behavior in a real browser environment across all frameworks. These tests cover interactions and cross-framework consistency.
- Keyboard navigation and selection
- Mouse interactions (click, hover)
- ARIA structure in live browser
- Focus management with aria-activedescendant
- axe-core accessibility scanning
- Cross-framework consistency checks
Test Categories
High Priority: ARIA Attributes (Unit + E2E)
| 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: Keyboard - Popup Closed (Unit + E2E)
| 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 - Popup Open (Unit + E2E)
| 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 (Unit + E2E)
| 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 (Unit)
| 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 (E2E)
| 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 |
Testing Tools
- Vitest (opens in new tab) - Test runner for unit tests
- Testing Library (opens in new tab) - Framework-specific testing utilities (React, Vue, Svelte)
- Playwright (opens in new tab) - Browser automation for E2E tests
- axe-core/playwright (opens in new tab) - Automated accessibility testing in E2E
See testing-strategy.md (opens in new tab) for full documentation.
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