Listbox
A widget that allows the user to select one or more items from a list of choices.
Demo
Single-Select (Default)
Selection follows focus. Use arrow keys to navigate and select.
- Apple
- Banana
- Cherry
- Date
- Elderberry
- Fig
- Grape
Multi-Select
Focus and selection are independent. Use Space to toggle, Shift+Arrow to extend selection.
- Red
- Orange
- Yellow
- Green
- Blue
- Indigo
- Purple
Tip: Use Space to toggle, Shift+Arrow to extend selection, Ctrl+A to select all
Horizontal Orientation
Use Left/Right arrow keys for navigation.
- Apple
- Banana
- Cherry
- Date
- Elderberry
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
listbox | Container (`
| Widget for selecting one or more items from a list |
option | Each item (` | Selectable option within the listbox |
WAI-ARIA listbox role (opens in new tab)
WAI-ARIA Properties
| Attribute | Target | Values | Required | Description |
|---|---|---|---|---|
aria-label | listbox | String | Yes* | Accessible name for the listbox |
aria-labelledby | listbox | ID reference | Yes* | References the labeling element |
aria-multiselectable | listbox | `true` | No | Enables multi-select mode |
aria-orientation | listbox | `"vertical"` | `"horizontal"` | No | Navigation direction (default: vertical) |
* Either aria-label or aria-labelledby is required
WAI-ARIA States
aria-selected
Indicates whether an option is selected.
| Target | option |
| Values | `true` | `false` |
| Required | Yes |
| Change Trigger | Click, Arrow keys (single-select), Space (multi-select) |
| Reference | aria-selected (opens in new tab) |
aria-disabled
Indicates that an option is not selectable.
| Target | option |
| Values | `true` |
| Required | No (only when disabled) |
| Change Trigger | When disabled |
| Reference | aria-disabled (opens in new tab) |
Keyboard Support
Common Navigation
| Key | Action |
|---|---|
| Down Arrow / Up Arrow | Move focus (vertical orientation) |
| Right Arrow / Left Arrow | Move focus (horizontal orientation) |
| Home | Move focus to first option |
| End | Move focus to last option |
| Type character | Type-ahead: focus option starting with typed character(s) |
Single-Select (Selection Follows Focus)
| Key | Action |
|---|---|
| Arrow keys | Move focus and selection simultaneously |
| Space / Enter | Confirm current selection |
Multi-Select
| Key | Action |
|---|---|
| Arrow keys | Move focus only (selection unchanged) |
| Space | Toggle selection of focused option |
| Shift + Arrow | Move focus and extend selection range |
| Shift + Home | Select from anchor to first option |
| Shift + End | Select from anchor to last option |
| Ctrl + A | Select all options |
Focus Management
This component uses the Roving Tabindex pattern for focus management:
- Only one option has `tabindex="0"` at a time (Roving Tabindex)
- Other options have `tabindex="-1"`
- Arrow keys move focus between options
- Disabled options are skipped during navigation
- Focus does not wrap (stops at edges)
Selection Model
- **Single-select:** Selection follows focus (arrow keys change selection)
- **Multi-select:** Focus and selection are independent (Space toggles selection)
Source Code
---
/**
* APG Listbox Pattern - Astro Implementation
*
* A widget that allows the user to select one or more items from a list of choices.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/listbox/
*/
export interface ListboxOption {
id: string;
label: string;
disabled?: boolean;
}
export interface Props {
/** Array of options */
options: ListboxOption[];
/** Enable multi-select mode */
multiselectable?: boolean;
/** Direction of the listbox */
orientation?: 'vertical' | 'horizontal';
/** Initially selected option IDs */
defaultSelectedIds?: string[];
/** Accessible label for the listbox */
'aria-label'?: string;
/** ID of element that labels the listbox */
'aria-labelledby'?: string;
/** Type-ahead timeout in ms */
typeAheadTimeout?: number;
/** Additional CSS class */
class?: string;
}
const {
options = [],
multiselectable = false,
orientation = 'vertical',
defaultSelectedIds = [],
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
typeAheadTimeout = 500,
class: className = '',
} = Astro.props;
const instanceId = `listbox-${Math.random().toString(36).slice(2, 11)}`;
const initialSelectedSet = new Set(defaultSelectedIds);
// For single-select, if no default selection, select first available option
const availableOptions = options.filter((opt) => !opt.disabled);
if (!multiselectable && initialSelectedSet.size === 0 && availableOptions.length > 0) {
initialSelectedSet.add(availableOptions[0].id);
}
const containerClass =
`apg-listbox ${orientation === 'horizontal' ? 'apg-listbox--horizontal' : ''} ${className}`.trim();
function getOptionClass(option: ListboxOption): string {
const classes = ['apg-listbox-option'];
if (initialSelectedSet.has(option.id)) {
classes.push('apg-listbox-option--selected');
}
if (option.disabled) {
classes.push('apg-listbox-option--disabled');
}
return classes.join(' ');
}
// Find initial focus index
const initialFocusId = [...initialSelectedSet][0];
const initialFocusIndex = initialFocusId
? availableOptions.findIndex((opt) => opt.id === initialFocusId)
: 0;
// If no available options, listbox itself needs tabIndex for keyboard access
const listboxTabIndex = availableOptions.length === 0 ? 0 : undefined;
---
<apg-listbox
data-multiselectable={multiselectable ? 'true' : undefined}
data-orientation={orientation}
data-type-ahead-timeout={typeAheadTimeout}
data-initial-selected={JSON.stringify([...initialSelectedSet])}
data-initial-focus-index={initialFocusIndex}
>
<ul
role="listbox"
aria-multiselectable={multiselectable || undefined}
aria-orientation={orientation}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
tabindex={listboxTabIndex}
class={containerClass}
>
{
options.map((option) => {
const availableIndex = availableOptions.findIndex((opt) => opt.id === option.id);
const isFocusTarget = availableIndex === initialFocusIndex;
const tabIndex = option.disabled ? -1 : isFocusTarget ? 0 : -1;
return (
<li
role="option"
id={`${instanceId}-option-${option.id}`}
data-option-id={option.id}
aria-selected={initialSelectedSet.has(option.id)}
aria-disabled={option.disabled || undefined}
tabindex={tabIndex}
class={getOptionClass(option)}
>
<span class="apg-listbox-option-icon" aria-hidden="true">
<svg viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
<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"
fill="currentColor"
/>
</svg>
</span>
{option.label}
</li>
);
})
}
</ul>
</apg-listbox>
<script>
class ApgListbox extends HTMLElement {
private listbox: HTMLElement | null = null;
private rafId: number | null = null;
private focusedIndex = 0;
private selectionAnchor = 0;
private selectedIds: Set<string> = new Set();
private typeAheadBuffer = '';
private typeAheadTimeoutId: number | null = null;
private observer: MutationObserver | null = null;
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.listbox = this.querySelector('[role="listbox"]');
if (!this.listbox) {
console.warn('apg-listbox: listbox element not found');
return;
}
// Initialize selected IDs from data attribute
const initialSelected = this.dataset.initialSelected;
if (initialSelected) {
try {
const ids = JSON.parse(initialSelected);
this.selectedIds = new Set(ids);
} catch {
this.selectedIds = new Set();
}
}
// Initialize focus index and anchor from data attribute
const initialFocusIndex = parseInt(this.dataset.initialFocusIndex || '0', 10);
this.focusedIndex = initialFocusIndex;
this.selectionAnchor = initialFocusIndex;
this.listbox.addEventListener('keydown', this.handleKeyDown);
this.listbox.addEventListener('click', this.handleClick);
this.listbox.addEventListener('focusin', this.handleFocus);
// Observe DOM changes
this.observer = new MutationObserver(() => this.updateTabIndices());
this.observer.observe(this.listbox, { childList: true, subtree: true });
this.updateTabIndices();
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (this.typeAheadTimeoutId !== null) {
clearTimeout(this.typeAheadTimeoutId);
this.typeAheadTimeoutId = null;
}
this.observer?.disconnect();
this.observer = null;
this.listbox?.removeEventListener('keydown', this.handleKeyDown);
this.listbox?.removeEventListener('click', this.handleClick);
this.listbox?.removeEventListener('focusin', this.handleFocus);
this.listbox = null;
}
private get isMultiselectable(): boolean {
return this.dataset.multiselectable === 'true';
}
private get orientation(): string {
return this.dataset.orientation || 'vertical';
}
private get typeAheadTimeout(): number {
return parseInt(this.dataset.typeAheadTimeout || '500', 10);
}
private getOptions(): HTMLLIElement[] {
if (!this.listbox) return [];
return Array.from(this.listbox.querySelectorAll<HTMLLIElement>('[role="option"]'));
}
private getAvailableOptions(): HTMLLIElement[] {
return this.getOptions().filter((opt) => opt.getAttribute('aria-disabled') !== 'true');
}
private updateTabIndices() {
const options = this.getAvailableOptions();
if (options.length === 0) return;
if (this.focusedIndex >= options.length) {
this.focusedIndex = options.length - 1;
}
options.forEach((opt, index) => {
opt.tabIndex = index === this.focusedIndex ? 0 : -1;
});
}
private updateSelection(optionId: string | null, action: 'toggle' | 'set' | 'range' | 'all') {
const options = this.getOptions();
if (action === 'all') {
const availableOptions = this.getAvailableOptions();
this.selectedIds = new Set(
availableOptions.map((opt) => opt.dataset.optionId).filter(Boolean) as string[]
);
} else if (action === 'range' && optionId) {
const availableOptions = this.getAvailableOptions();
const start = Math.min(this.selectionAnchor, this.focusedIndex);
const end = Math.max(this.selectionAnchor, this.focusedIndex);
for (let i = start; i <= end; i++) {
const opt = availableOptions[i];
if (opt?.dataset.optionId) {
this.selectedIds.add(opt.dataset.optionId);
}
}
} else if (optionId) {
if (this.isMultiselectable) {
if (this.selectedIds.has(optionId)) {
this.selectedIds.delete(optionId);
} else {
this.selectedIds.add(optionId);
}
} else {
this.selectedIds = new Set([optionId]);
}
}
// Update aria-selected and classes
options.forEach((opt) => {
const id = opt.dataset.optionId;
const isSelected = id ? this.selectedIds.has(id) : false;
opt.setAttribute('aria-selected', String(isSelected));
opt.classList.toggle('apg-listbox-option--selected', isSelected);
});
// Dispatch custom event
this.dispatchEvent(
new CustomEvent('selectionchange', {
detail: { selectedIds: [...this.selectedIds] },
bubbles: true,
})
);
}
private focusOption(index: number) {
const options = this.getAvailableOptions();
if (index >= 0 && index < options.length) {
this.focusedIndex = index;
this.updateTabIndices();
options[index].focus();
}
}
private handleTypeAhead(char: string) {
const options = this.getAvailableOptions();
// Guard: no options to search
if (options.length === 0) return;
if (this.typeAheadTimeoutId !== null) {
clearTimeout(this.typeAheadTimeoutId);
}
this.typeAheadBuffer += char.toLowerCase();
const buffer = this.typeAheadBuffer;
const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);
let startIndex = this.focusedIndex;
if (isSameChar) {
this.typeAheadBuffer = buffer[0];
startIndex = (this.focusedIndex + 1) % options.length;
}
for (let i = 0; i < options.length; i++) {
const index = (startIndex + i) % options.length;
const option = options[index];
const label = option.textContent?.trim().toLowerCase() || '';
const searchStr = isSameChar ? buffer[0] : this.typeAheadBuffer;
if (label.startsWith(searchStr)) {
this.focusOption(index);
// Update anchor for shift-selection
this.selectionAnchor = index;
if (!this.isMultiselectable) {
const optionId = option.dataset.optionId;
if (optionId) {
this.updateSelection(optionId, 'set');
}
}
break;
}
}
this.typeAheadTimeoutId = window.setTimeout(() => {
this.typeAheadBuffer = '';
this.typeAheadTimeoutId = null;
}, this.typeAheadTimeout);
}
private handleClick = (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;
const options = this.getAvailableOptions();
const index = options.indexOf(option);
if (index === -1) return;
this.focusOption(index);
const optionId = option.dataset.optionId;
if (optionId) {
this.updateSelection(optionId, 'toggle');
this.selectionAnchor = index;
}
};
private handleFocus = (event: FocusEvent) => {
const options = this.getAvailableOptions();
const target = event.target as HTMLElement;
const targetIndex = options.findIndex((opt) => opt === target);
if (targetIndex !== -1 && targetIndex !== this.focusedIndex) {
this.focusedIndex = targetIndex;
this.updateTabIndices();
}
};
private handleKeyDown = (event: KeyboardEvent) => {
const options = this.getAvailableOptions();
if (options.length === 0) return;
const { key, shiftKey, ctrlKey, metaKey } = event;
const nextKey = this.orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
const prevKey = this.orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
const invalidKeys =
this.orientation === 'vertical' ? ['ArrowLeft', 'ArrowRight'] : ['ArrowUp', 'ArrowDown'];
if (invalidKeys.includes(key)) {
return;
}
let newIndex = this.focusedIndex;
let shouldPreventDefault = false;
switch (key) {
case nextKey:
if (this.focusedIndex < options.length - 1) {
newIndex = this.focusedIndex + 1;
}
shouldPreventDefault = true;
if (this.isMultiselectable && shiftKey) {
this.focusOption(newIndex);
const option = options[newIndex];
if (option?.dataset.optionId) {
this.updateSelection(option.dataset.optionId, 'range');
}
event.preventDefault();
return;
}
break;
case prevKey:
if (this.focusedIndex > 0) {
newIndex = this.focusedIndex - 1;
}
shouldPreventDefault = true;
if (this.isMultiselectable && shiftKey) {
this.focusOption(newIndex);
const option = options[newIndex];
if (option?.dataset.optionId) {
this.updateSelection(option.dataset.optionId, 'range');
}
event.preventDefault();
return;
}
break;
case 'Home':
newIndex = 0;
shouldPreventDefault = true;
if (this.isMultiselectable && shiftKey) {
this.focusOption(newIndex);
const option = options[newIndex];
if (option?.dataset.optionId) {
this.updateSelection(option.dataset.optionId, 'range');
}
event.preventDefault();
return;
}
break;
case 'End':
newIndex = options.length - 1;
shouldPreventDefault = true;
if (this.isMultiselectable && shiftKey) {
this.focusOption(newIndex);
const option = options[newIndex];
if (option?.dataset.optionId) {
this.updateSelection(option.dataset.optionId, 'range');
}
event.preventDefault();
return;
}
break;
case ' ':
shouldPreventDefault = true;
if (this.isMultiselectable) {
const option = options[this.focusedIndex];
if (option?.dataset.optionId) {
this.updateSelection(option.dataset.optionId, 'toggle');
this.selectionAnchor = this.focusedIndex;
}
}
event.preventDefault();
return;
case 'Enter':
shouldPreventDefault = true;
event.preventDefault();
return;
case 'a':
case 'A':
if ((ctrlKey || metaKey) && this.isMultiselectable) {
shouldPreventDefault = true;
this.updateSelection(null, 'all');
event.preventDefault();
return;
}
break;
}
if (shouldPreventDefault) {
event.preventDefault();
if (newIndex !== this.focusedIndex) {
this.focusOption(newIndex);
if (!this.isMultiselectable) {
const option = options[newIndex];
if (option?.dataset.optionId) {
this.updateSelection(option.dataset.optionId, 'set');
}
} else {
this.selectionAnchor = newIndex;
}
}
return;
}
// Type-ahead
if (key.length === 1 && !ctrlKey && !metaKey) {
event.preventDefault();
this.handleTypeAhead(key);
}
};
}
if (!customElements.get('apg-listbox')) {
customElements.define('apg-listbox', ApgListbox);
}
</script> Usage
---
import Listbox from '@patterns/listbox/Listbox.astro';
const options = [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry' },
];
---
<!-- Single-select -->
<Listbox
options={options}
aria-label="Choose a fruit"
/>
<!-- Multi-select -->
<Listbox
options={options}
multiselectable
aria-label="Choose fruits"
/>
<!-- Listen for selection changes -->
<script>
document.querySelector('apg-listbox')?.addEventListener('selectionchange', (e) => {
console.log('Selected:', e.detail.selectedIds);
});
</script> API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
options | ListboxOption[] | required | Array of options |
multiselectable | boolean | false | Enable multi-select mode |
orientation | 'vertical' | 'horizontal' | 'vertical' | Listbox orientation |
defaultSelectedIds | string[] | [] | Initially selected option IDs |
Custom Events
| Event | Detail | Description |
|---|---|---|
selectionchange | { selectedIds: string[] } | Fired when selection changes |
Testing
Tests verify APG compliance for ARIA attributes, keyboard interactions, selection behavior, and accessibility requirements. The Listbox 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-selected, aria-multiselectable, etc.)
- Keyboard interaction (Arrow keys, Space, Home/End, etc.)
- Selection behavior (single-select, multi-select)
- 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 (single-select, multi-select, horizontal)
- Mouse interactions (click selection, toggle)
- ARIA structure in live browser
- Focus management with roving tabindex
- Type-ahead character navigation
- axe-core accessibility scanning
- Cross-framework consistency checks
Test Categories
High Priority: APG Keyboard Interaction (Unit + E2E)
Test Description ArrowDown/Up Moves focus between options (vertical orientation) ArrowRight/Left Moves focus between options (horizontal orientation) Home/End Moves focus to first/last option Disabled skip Skips disabled options during navigation Selection follows focus Single-select: arrow keys change selection Space toggle Multi-select: Space toggles option selection Shift+Arrow Multi-select: extends selection range Shift+Home/End Multi-select: selects from anchor to first/last Ctrl+A Multi-select: selects all options Type-ahead Character input focuses matching option Type-ahead cycle Repeated same character cycles through matches
High Priority: APG ARIA Attributes (Unit + E2E)
Test Description role="listbox" Container has listbox role role="option" Each option has option role aria-selected Selected options have `aria-selected="true"` aria-multiselectable Listbox has attribute when multi-select enabled aria-orientation Reflects horizontal/vertical orientation aria-disabled Disabled options have `aria-disabled="true"` aria-label/labelledby Listbox has accessible name
High Priority: Focus Management - Roving Tabindex (Unit + E2E)
Test Description tabIndex=0 Focused option has tabIndex=0 tabIndex=-1 Non-focused options have tabIndex=-1 Disabled tabIndex Disabled options have tabIndex=-1 Focus restoration Focus returns to correct option on re-entry
Medium Priority: Accessibility (Unit + E2E)
Test Description axe violations No WCAG 2.1 AA violations (via jest-axe/axe-core)
Medium Priority: Mouse Interaction (E2E)
Test Description Click option Selects option on click (single-select) Click toggle Toggles selection on click (multi-select) Click disabled Disabled options cannot be selected
Low Priority: Cross-framework Consistency (E2E)
Test Description All frameworks have listbox React, Vue, Svelte, Astro all render listbox elements Consistent ARIA All frameworks have consistent ARIA structure Select on click All frameworks select correctly on click Keyboard navigation All frameworks respond to keyboard navigation consistently
Example Test Code
The following is the actual E2E test file (e2e/listbox.spec.ts).
e2e/listbox.spec.ts import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
/**
* E2E Tests for Listbox Pattern
*
* A widget that allows the user to select one or more items from a list of choices.
* Supports single-select (selection follows focus) and multi-select modes.
*
* APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/listbox/
*/
const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;
// Helper to get all listboxes
const getListboxes = (page: import('@playwright/test').Page) => {
return page.locator('[role="listbox"]');
};
// Helper to get listbox by index (0=single-select, 1=multi-select, 2=horizontal)
const getListboxByIndex = (page: import('@playwright/test').Page, index: number) => {
return page.locator('[role="listbox"]').nth(index);
};
// Helper to get available (non-disabled) options in a listbox
const getAvailableOptions = (listbox: import('@playwright/test').Locator) => {
return listbox.locator('[role="option"]:not([aria-disabled="true"])');
};
// Helper to get selected options in a listbox
const getSelectedOptions = (listbox: import('@playwright/test').Locator) => {
return listbox.locator('[role="option"][aria-selected="true"]');
};
for (const framework of frameworks) {
test.describe(`Listbox (${framework})`, () => {
test.beforeEach(async ({ page }) => {
await page.goto(`patterns/listbox/${framework}/demo/`);
await page.waitForLoadState('networkidle');
});
// =========================================================================
// High Priority: ARIA Structure
// =========================================================================
test.describe('APG: ARIA Structure', () => {
test('has role="listbox" on container', async ({ page }) => {
const listboxes = getListboxes(page);
const count = await listboxes.count();
expect(count).toBe(3); // single-select, multi-select, horizontal
for (let i = 0; i < count; i++) {
await expect(listboxes.nth(i)).toHaveAttribute('role', 'listbox');
}
});
test('options have role="option"', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const options = listbox.locator('[role="option"]');
const count = await options.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
await expect(options.nth(i)).toHaveAttribute('role', 'option');
}
});
test('has accessible name via aria-labelledby', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const ariaLabelledby = await listbox.getAttribute('aria-labelledby');
expect(ariaLabelledby).toBeTruthy();
const label = page.locator(`#${ariaLabelledby}`);
const labelText = await label.textContent();
expect(labelText?.trim().length).toBeGreaterThan(0);
});
test('single-select listbox does not have aria-multiselectable', async ({ page }) => {
const singleSelectListbox = getListboxByIndex(page, 0);
const ariaMultiselectable = await singleSelectListbox.getAttribute('aria-multiselectable');
expect(ariaMultiselectable).toBeFalsy();
});
test('multi-select listbox has aria-multiselectable="true"', async ({ page }) => {
const multiSelectListbox = getListboxByIndex(page, 1);
await expect(multiSelectListbox).toHaveAttribute('aria-multiselectable', 'true');
});
test('horizontal listbox has aria-orientation="horizontal"', async ({ page }) => {
const horizontalListbox = getListboxByIndex(page, 2);
await expect(horizontalListbox).toHaveAttribute('aria-orientation', 'horizontal');
});
test('selected options have aria-selected="true"', async ({ page }) => {
const singleSelectListbox = getListboxByIndex(page, 0);
const selectedOptions = getSelectedOptions(singleSelectListbox);
const count = await selectedOptions.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
await expect(selectedOptions.nth(i)).toHaveAttribute('aria-selected', 'true');
}
});
test('disabled options have aria-disabled="true"', async ({ page }) => {
const multiSelectListbox = getListboxByIndex(page, 1);
const disabledOptions = multiSelectListbox.locator('[role="option"][aria-disabled="true"]');
const count = await disabledOptions.count();
expect(count).toBeGreaterThan(0);
});
});
// =========================================================================
// High Priority: Single-Select Keyboard Navigation
// =========================================================================
test.describe('APG: Single-Select Keyboard Navigation', () => {
test('ArrowDown moves focus and selection to next option', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const secondOption = options.nth(1);
await firstOption.focus();
await expect(firstOption).toBeFocused();
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
await firstOption.press('ArrowDown');
await expect(secondOption).toHaveAttribute('tabindex', '0');
await expect(secondOption).toHaveAttribute('aria-selected', 'true');
await expect(firstOption).toHaveAttribute('aria-selected', 'false');
});
test('ArrowUp moves focus and selection to previous option', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const secondOption = options.nth(1);
// Click to set initial state, then navigate down to second option
await firstOption.click();
await expect(firstOption).toBeFocused();
await firstOption.press('ArrowDown');
await expect(secondOption).toHaveAttribute('tabindex', '0');
await expect(secondOption).toHaveAttribute('aria-selected', 'true');
// Now navigate up
await expect(secondOption).toBeFocused();
await secondOption.press('ArrowUp');
await expect(firstOption).toHaveAttribute('tabindex', '0');
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
});
test('Home moves focus and selection to first option', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('ArrowDown');
const secondOption = options.nth(1);
await expect(secondOption).toBeFocused();
await secondOption.press('ArrowDown');
const thirdOption = options.nth(2);
await expect(thirdOption).toBeFocused();
await thirdOption.press('Home');
await expect(firstOption).toHaveAttribute('tabindex', '0');
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
});
test('End moves focus and selection to last option', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const lastOption = options.last();
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('End');
await expect(lastOption).toHaveAttribute('tabindex', '0');
await expect(lastOption).toHaveAttribute('aria-selected', 'true');
});
test('focus does not wrap at boundaries', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const options = getAvailableOptions(listbox);
const lastOption = options.last();
await lastOption.focus();
await expect(lastOption).toBeFocused();
await lastOption.press('End'); // Ensure we're at the end
await expect(lastOption).toBeFocused();
await lastOption.press('ArrowDown');
// Should still be on last option
await expect(lastOption).toHaveAttribute('tabindex', '0');
});
// Note: disabled option skip test is in Multi-Select section since the multi-select
// listbox has disabled options (Green) while single-select doesn't
});
// =========================================================================
// High Priority: Multi-Select Keyboard Navigation
// =========================================================================
test.describe('APG: Multi-Select Keyboard Navigation', () => {
test('ArrowDown moves focus only (no selection change)', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const secondOption = options.nth(1);
await firstOption.focus();
await expect(firstOption).toBeFocused();
// Initially no selection in multi-select
const initialSelected = await getSelectedOptions(listbox).count();
await firstOption.press('ArrowDown');
await expect(secondOption).toHaveAttribute('tabindex', '0');
// Selection should not have changed
const afterSelected = await getSelectedOptions(listbox).count();
expect(afterSelected).toBe(initialSelected);
});
test('ArrowUp moves focus only (no selection change)', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const secondOption = options.nth(1);
// Click to set initial state, then navigate down to second option
await firstOption.click();
await expect(firstOption).toBeFocused();
await firstOption.press('ArrowDown');
await expect(secondOption).toHaveAttribute('tabindex', '0');
// Navigate up should move focus but not change selection
await expect(secondOption).toBeFocused();
await secondOption.press('ArrowUp');
await expect(firstOption).toHaveAttribute('tabindex', '0');
});
test('Space toggles selection of focused option (select)', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const firstOption = getAvailableOptions(listbox).first();
await firstOption.focus();
await expect(firstOption).toBeFocused();
await expect(firstOption).not.toHaveAttribute('aria-selected', 'true');
await firstOption.press('Space');
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
});
test('Space toggles selection of focused option (deselect)', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const firstOption = getAvailableOptions(listbox).first();
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('Space'); // Select
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
await expect(firstOption).toBeFocused();
await firstOption.press('Space'); // Deselect
await expect(firstOption).toHaveAttribute('aria-selected', 'false');
});
test('Shift+ArrowDown extends selection range', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const secondOption = options.nth(1);
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('Space'); // Select first as anchor
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
await expect(firstOption).toBeFocused();
await firstOption.press('Shift+ArrowDown');
await expect(secondOption).toHaveAttribute('aria-selected', 'true');
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
});
test('Shift+ArrowUp extends selection range', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const secondOption = options.nth(1);
// Click second option to set it as anchor (click toggles selection and sets anchor)
await secondOption.click();
await expect(secondOption).toBeFocused();
await expect(secondOption).toHaveAttribute('aria-selected', 'true');
await secondOption.press('Shift+ArrowUp');
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
await expect(secondOption).toHaveAttribute('aria-selected', 'true');
});
test('Shift+Home selects from anchor to first option', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const thirdOption = options.nth(2);
// Focus third option, select it as anchor
await thirdOption.focus();
await expect(thirdOption).toBeFocused();
await thirdOption.press('Space'); // Select third as anchor
await expect(thirdOption).toBeFocused();
await thirdOption.press('Shift+Home');
// All options from first to anchor should be selected
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
});
test('Shift+End selects from anchor to last option', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const lastOption = options.last();
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('Space'); // Select first as anchor
await expect(firstOption).toBeFocused();
await firstOption.press('Shift+End');
// All options from anchor to last should be selected
await expect(lastOption).toHaveAttribute('aria-selected', 'true');
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
});
test('Ctrl+A selects all available options', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const availableOptions = getAvailableOptions(listbox);
const firstOption = availableOptions.first();
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('Control+a');
const count = await availableOptions.count();
for (let i = 0; i < count; i++) {
await expect(availableOptions.nth(i)).toHaveAttribute('aria-selected', 'true');
}
});
test('disabled options are skipped during navigation', async ({ page }) => {
// Multi-select listbox has disabled options (Green at index 3)
const listbox = getListboxByIndex(page, 1);
const availableOptions = getAvailableOptions(listbox);
// Get Yellow (index 2 in available options) and Blue (index 3 after skip)
const yellowOption = availableOptions.nth(2); // Red, Orange, Yellow
const blueOption = availableOptions.nth(3); // Blue (Green is skipped)
// Click to focus Yellow first (ensures proper component state)
await yellowOption.click();
await expect(yellowOption).toBeFocused();
await yellowOption.press('ArrowDown');
// Should skip Green and land on Blue
await expect(blueOption).toHaveAttribute('tabindex', '0');
});
});
// =========================================================================
// High Priority: Horizontal Listbox
// =========================================================================
test.describe('APG: Horizontal Listbox', () => {
test('ArrowRight moves to next option', async ({ page }) => {
const listbox = getListboxByIndex(page, 2);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const secondOption = options.nth(1);
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('ArrowRight');
await expect(secondOption).toHaveAttribute('tabindex', '0');
await expect(secondOption).toHaveAttribute('aria-selected', 'true');
});
test('ArrowLeft moves to previous option', async ({ page }) => {
const listbox = getListboxByIndex(page, 2);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const secondOption = options.nth(1);
// Click to set initial state, then navigate right to second option
await firstOption.click();
await expect(firstOption).toBeFocused();
await firstOption.press('ArrowRight');
await expect(secondOption).toHaveAttribute('tabindex', '0');
// Now navigate left
await expect(secondOption).toBeFocused();
await secondOption.press('ArrowLeft');
await expect(firstOption).toHaveAttribute('tabindex', '0');
});
test('ArrowUp/ArrowDown are ignored in horizontal mode', async ({ page }) => {
const listbox = getListboxByIndex(page, 2);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('ArrowDown');
// Should still be on first option
await expect(firstOption).toHaveAttribute('tabindex', '0');
await expect(firstOption).toBeFocused();
await firstOption.press('ArrowUp');
await expect(firstOption).toHaveAttribute('tabindex', '0');
});
test('Home moves to first option', async ({ page }) => {
const listbox = getListboxByIndex(page, 2);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('ArrowRight');
const secondOption = options.nth(1);
await expect(secondOption).toBeFocused();
await secondOption.press('ArrowRight');
const thirdOption = options.nth(2);
await expect(thirdOption).toBeFocused();
await thirdOption.press('Home');
await expect(firstOption).toHaveAttribute('tabindex', '0');
});
test('End moves to last option', async ({ page }) => {
const listbox = getListboxByIndex(page, 2);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const lastOption = options.last();
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('End');
await expect(lastOption).toHaveAttribute('tabindex', '0');
});
});
// =========================================================================
// High Priority: Focus Management (Roving Tabindex)
// =========================================================================
test.describe('APG: Focus Management', () => {
test('focused option has tabindex="0"', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const focusedOption = listbox.locator('[role="option"][tabindex="0"]');
const count = await focusedOption.count();
expect(count).toBe(1);
});
test('other options have tabindex="-1"', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const allOptions = listbox.locator('[role="option"]');
const count = await allOptions.count();
let tabindexZeroCount = 0;
for (let i = 0; i < count; i++) {
const tabindex = await allOptions.nth(i).getAttribute('tabindex');
if (tabindex === '0') tabindexZeroCount++;
}
expect(tabindexZeroCount).toBe(1);
});
test('tabindex updates on navigation', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const secondOption = options.nth(1);
await firstOption.focus();
await expect(firstOption).toBeFocused();
await expect(firstOption).toHaveAttribute('tabindex', '0');
await expect(secondOption).toHaveAttribute('tabindex', '-1');
await firstOption.press('ArrowDown');
await expect(firstOption).toHaveAttribute('tabindex', '-1');
await expect(secondOption).toHaveAttribute('tabindex', '0');
});
test('Tab exits listbox', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const firstOption = listbox.locator('[role="option"][tabindex="0"]');
await firstOption.focus();
await page.keyboard.press('Tab');
// Focus should have moved out of listbox
const focusedElement = page.locator(':focus');
const isInListbox = await focusedElement.evaluate(
(el, listboxEl) => listboxEl?.contains(el),
await listbox.elementHandle()
);
expect(isInListbox).toBeFalsy();
});
test('focus returns to last focused option on re-entry', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const thirdOption = options.nth(2);
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('ArrowDown');
const secondOption = options.nth(1);
await expect(secondOption).toBeFocused();
await secondOption.press('ArrowDown');
await expect(thirdOption).toHaveAttribute('tabindex', '0');
// Tab out and back (page-level navigation)
await page.keyboard.press('Tab');
await page.keyboard.press('Shift+Tab');
// Should return to the third option
await expect(thirdOption).toHaveAttribute('tabindex', '0');
});
});
// =========================================================================
// High Priority: Type-ahead
// =========================================================================
test.describe('APG: Type-ahead', () => {
test('single character focuses matching option', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const grapeOption = listbox.locator('[role="option"]', { hasText: 'Grape' });
const firstOption = listbox.locator('[role="option"][tabindex="0"]');
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('g');
await expect(grapeOption).toHaveAttribute('tabindex', '0');
});
test('multiple characters match prefix', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const cherryOption = listbox.locator('[role="option"]', { hasText: 'Cherry' });
const firstOption = listbox.locator('[role="option"][tabindex="0"]');
await firstOption.focus();
await page.keyboard.type('ch', { delay: 50 });
await expect(cherryOption).toHaveAttribute('tabindex', '0');
});
test('repeated same character cycles through matches', async ({ page }) => {
// With fruit options: Apple, Apricot, Banana, Cherry, Date, Elderberry, Fig, Grape
// Apple and Apricot both start with 'a', so we can test cycling
const listbox = getListboxByIndex(page, 0);
const firstOption = listbox.locator('[role="option"][tabindex="0"]');
await firstOption.click();
await expect(firstOption).toBeFocused();
// Use id attribute pattern (works across frameworks: id ends with -option-{id} or data-option-id)
const appleOption = listbox.locator(
'[role="option"][id$="-option-apple"], [role="option"][data-option-id="apple"]'
);
const apricotOption = listbox.locator(
'[role="option"][id$="-option-apricot"], [role="option"][data-option-id="apricot"]'
);
// Press 'a' - should stay on Apple (first match)
await firstOption.press('a');
await expect(appleOption).toHaveAttribute('tabindex', '0');
// Press 'a' again - should cycle to Apricot (next match)
await expect(appleOption).toBeFocused();
await appleOption.press('a');
await expect(apricotOption).toHaveAttribute('tabindex', '0');
// Press 'a' again - should cycle back to Apple
await expect(apricotOption).toBeFocused();
await apricotOption.press('a');
await expect(appleOption).toHaveAttribute('tabindex', '0');
});
test('type-ahead buffer clears after timeout', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const firstOption = listbox.locator('[role="option"][tabindex="0"]');
const cherryOption = listbox.locator('[role="option"]', { hasText: 'Cherry' });
const dateOption = listbox.locator('[role="option"]', { hasText: 'Date' });
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('c'); // Focus Cherry
await expect(cherryOption).toHaveAttribute('tabindex', '0');
// Wait for buffer to clear (default 500ms + margin)
await page.waitForTimeout(600);
await expect(cherryOption).toBeFocused();
await cherryOption.press('d'); // Should focus Date, not search for "cd"
await expect(dateOption).toHaveAttribute('tabindex', '0');
});
test('type-ahead updates selection in single-select', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const grapeOption = listbox.locator('[role="option"]', { hasText: 'Grape' });
const firstOption = listbox.locator('[role="option"][tabindex="0"]');
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('g');
// In single-select, selection follows focus
await expect(grapeOption).toHaveAttribute('aria-selected', 'true');
});
});
// =========================================================================
// Medium Priority: Mouse Interaction
// =========================================================================
test.describe('Mouse Interaction', () => {
test('clicking option selects it (single-select)', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const secondOption = listbox.locator('[role="option"]').nth(1);
await secondOption.click();
await expect(secondOption).toHaveAttribute('aria-selected', 'true');
await expect(secondOption).toHaveAttribute('tabindex', '0');
});
test('clicking option toggles selection (multi-select)', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const firstOption = getAvailableOptions(listbox).first();
// First click - select
await firstOption.click();
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
// Second click - deselect
await firstOption.click();
await expect(firstOption).toHaveAttribute('aria-selected', 'false');
});
test('clicking disabled option does nothing', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const disabledOption = listbox.locator('[role="option"][aria-disabled="true"]').first();
const selectedCountBefore = await getSelectedOptions(listbox).count();
await disabledOption.click({ force: true });
const selectedCountAfter = await getSelectedOptions(listbox).count();
expect(selectedCountAfter).toBe(selectedCountBefore);
});
});
// =========================================================================
// Medium Priority: Accessibility
// =========================================================================
test.describe('Accessibility', () => {
test('has no axe-core violations', async ({ page }) => {
const results = await new AxeBuilder({ page }).include('[role="listbox"]').analyze();
expect(results.violations).toEqual([]);
});
});
});
}
// =============================================================================
// Cross-framework Consistency Tests
// =============================================================================
test.describe('Listbox - Cross-framework Consistency', () => {
test('all frameworks have listbox elements', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/listbox/${framework}/demo/`);
await page.waitForLoadState('networkidle');
const listboxes = page.locator('[role="listbox"]');
const count = await listboxes.count();
expect(count).toBe(3); // single-select, multi-select, horizontal
}
});
test('all frameworks have consistent ARIA structure', async ({ page }) => {
const ariaStructures: Record<
string,
{
hasAriaLabelledby: boolean;
ariaMultiselectable: string | null;
ariaOrientation: string | null;
optionCount: number;
}[]
> = {};
for (const framework of frameworks) {
await page.goto(`patterns/listbox/${framework}/demo/`);
await page.waitForLoadState('networkidle');
ariaStructures[framework] = await page.evaluate(() => {
const listboxes = document.querySelectorAll('[role="listbox"]');
return Array.from(listboxes).map((listbox) => ({
hasAriaLabelledby: listbox.hasAttribute('aria-labelledby'),
ariaMultiselectable: listbox.getAttribute('aria-multiselectable'),
ariaOrientation: listbox.getAttribute('aria-orientation'),
optionCount: listbox.querySelectorAll('[role="option"]').length,
}));
});
}
// All frameworks should have the same structure
const reactStructure = ariaStructures['react'];
for (const framework of frameworks) {
expect(ariaStructures[framework].length).toBe(reactStructure.length);
for (let i = 0; i < reactStructure.length; i++) {
expect(ariaStructures[framework][i].hasAriaLabelledby).toBe(
reactStructure[i].hasAriaLabelledby
);
expect(ariaStructures[framework][i].ariaMultiselectable).toBe(
reactStructure[i].ariaMultiselectable
);
expect(ariaStructures[framework][i].ariaOrientation).toBe(
reactStructure[i].ariaOrientation
);
expect(ariaStructures[framework][i].optionCount).toBe(reactStructure[i].optionCount);
}
}
});
test('all frameworks select correctly on click', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/listbox/${framework}/demo/`);
await page.waitForLoadState('networkidle');
// Test single-select listbox
const singleSelectListbox = page.locator('[role="listbox"]').first();
const secondOption = singleSelectListbox.locator('[role="option"]').nth(1);
await secondOption.click();
await expect(secondOption).toHaveAttribute('aria-selected', 'true');
}
});
test('all frameworks handle keyboard navigation consistently', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/listbox/${framework}/demo/`);
await page.waitForLoadState('networkidle');
const listbox = page.locator('[role="listbox"]').first();
const options = listbox.locator('[role="option"]:not([aria-disabled="true"])');
const firstOption = options.first();
const secondOption = options.nth(1);
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('ArrowDown');
// Second option should now be focused and selected
await expect(secondOption).toHaveAttribute('tabindex', '0');
await expect(secondOption).toHaveAttribute('aria-selected', 'true');
}
});
});
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: Listbox Pattern
(opens in new tab)
-
AI Implementation Guide (llm.md)
(opens in new tab) - ARIA specs, keyboard support, test checklist