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
Selected: None
Multi-Select
Focus and selection are independent. Use Space to toggle, Shift+Arrow to extend selection.
- Red
- Orange
- Yellow
- Green
- Blue
- Indigo
- Purple
Selected: None
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
Selected: None
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
<template>
<ul
ref="listboxRef"
role="listbox"
:aria-multiselectable="multiselectable || undefined"
:aria-orientation="orientation"
:aria-label="ariaLabel"
:aria-labelledby="ariaLabelledby"
:tabindex="listboxTabIndex"
:class="containerClass"
@keydown="handleKeyDown"
>
<li
v-for="option in options"
:key="option.id"
:ref="(el) => setOptionRef(option.id, el)"
role="option"
:id="getOptionId(option.id)"
:aria-selected="selectedIds.has(option.id)"
:aria-disabled="option.disabled || undefined"
:tabindex="getTabIndex(option)"
:class="getOptionClass(option)"
@click="!option.disabled && handleOptionClick(option.id)"
>
<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>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
export interface ListboxOption {
id: string;
label: string;
disabled?: boolean;
}
export interface ListboxProps {
options: ListboxOption[];
multiselectable?: boolean;
orientation?: 'vertical' | 'horizontal';
defaultSelectedIds?: string[];
ariaLabel?: string;
ariaLabelledby?: string;
typeAheadTimeout?: number;
}
const props = withDefaults(defineProps<ListboxProps>(), {
multiselectable: false,
orientation: 'vertical',
defaultSelectedIds: () => [],
typeAheadTimeout: 500,
});
const emit = defineEmits<{
selectionChange: [selectedIds: string[]];
}>();
const listboxRef = ref<HTMLElement>();
const optionRefs = ref<Record<string, HTMLLIElement>>({});
const instanceId = ref('');
const selectedIds = ref<Set<string>>(new Set());
const focusedIndex = ref(0);
const selectionAnchor = ref(0);
const typeAheadBuffer = ref('');
const typeAheadTimeoutId = ref<number | null>(null);
const availableOptions = computed(() => props.options.filter((opt) => !opt.disabled));
// Map of option id to index in availableOptions for O(1) lookup
const availableIndexMap = computed(() => {
const map = new Map<string, number>();
availableOptions.value.forEach(({ id }, index) => map.set(id, index));
return map;
});
onMounted(() => {
instanceId.value = `listbox-${Math.random().toString(36).slice(2, 11)}`;
// Initialize selection
if (props.defaultSelectedIds.length > 0) {
selectedIds.value = new Set(props.defaultSelectedIds);
} else if (availableOptions.value.length > 0) {
// Single-select mode: select first available option by default
if (!props.multiselectable) {
selectedIds.value = new Set([availableOptions.value[0].id]);
}
}
// Initialize focused index and sync anchor
const firstSelectedId = [...selectedIds.value][0];
if (firstSelectedId) {
const index = availableOptions.value.findIndex((opt) => opt.id === firstSelectedId);
if (index >= 0) {
focusedIndex.value = index;
selectionAnchor.value = index;
}
}
});
const setOptionRef = (id: string, el: unknown) => {
if (el instanceof HTMLLIElement) {
optionRefs.value[id] = el;
} else if (el === null) {
// Clean up ref when element is unmounted
delete optionRefs.value[id];
}
};
// If no available options, listbox itself needs tabIndex for keyboard access
const listboxTabIndex = computed(() => (availableOptions.value.length === 0 ? 0 : undefined));
const getOptionId = (optionId: string) => `${instanceId.value}-option-${optionId}`;
const containerClass = computed(() => {
const classes = ['apg-listbox'];
if (props.orientation === 'horizontal') {
classes.push('apg-listbox--horizontal');
}
return classes.join(' ');
});
const getOptionClass = (option: ListboxOption) => {
const classes = ['apg-listbox-option'];
if (selectedIds.value.has(option.id)) {
classes.push('apg-listbox-option--selected');
}
if (option.disabled) {
classes.push('apg-listbox-option--disabled');
}
return classes.join(' ');
};
const getTabIndex = (option: ListboxOption): number => {
if (option.disabled) return -1;
const availableIndex = availableIndexMap.value.get(option.id) ?? -1;
return availableIndex === focusedIndex.value ? 0 : -1;
};
const updateSelection = (newSelectedIds: Set<string>) => {
selectedIds.value = newSelectedIds;
emit('selectionChange', [...newSelectedIds]);
};
const focusOption = async (index: number) => {
const option = availableOptions.value[index];
if (option) {
focusedIndex.value = index;
await nextTick();
optionRefs.value[option.id]?.focus();
}
};
const selectOption = (optionId: string) => {
if (props.multiselectable) {
const newSelected = new Set(selectedIds.value);
if (newSelected.has(optionId)) {
newSelected.delete(optionId);
} else {
newSelected.add(optionId);
}
updateSelection(newSelected);
} else {
updateSelection(new Set([optionId]));
}
};
const selectRange = (fromIndex: number, toIndex: number) => {
const start = Math.min(fromIndex, toIndex);
const end = Math.max(fromIndex, toIndex);
const newSelected = new Set(selectedIds.value);
for (let i = start; i <= end; i++) {
const option = availableOptions.value[i];
if (option) {
newSelected.add(option.id);
}
}
updateSelection(newSelected);
};
const selectAll = () => {
const allIds = new Set(availableOptions.value.map((opt) => opt.id));
updateSelection(allIds);
};
const handleTypeAhead = (char: string) => {
// Guard: no options to search
if (availableOptions.value.length === 0) return;
if (typeAheadTimeoutId.value !== null) {
clearTimeout(typeAheadTimeoutId.value);
}
typeAheadBuffer.value += char.toLowerCase();
const buffer = typeAheadBuffer.value;
const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);
let startIndex = focusedIndex.value;
if (isSameChar) {
typeAheadBuffer.value = buffer[0];
startIndex = (focusedIndex.value + 1) % availableOptions.value.length;
}
for (let i = 0; i < availableOptions.value.length; i++) {
const index = (startIndex + i) % availableOptions.value.length;
const option = availableOptions.value[index];
const searchStr = isSameChar ? buffer[0] : typeAheadBuffer.value;
if (option.label.toLowerCase().startsWith(searchStr)) {
focusOption(index);
// Update anchor for shift-selection
selectionAnchor.value = index;
if (!props.multiselectable) {
updateSelection(new Set([option.id]));
}
break;
}
}
typeAheadTimeoutId.value = window.setTimeout(() => {
typeAheadBuffer.value = '';
typeAheadTimeoutId.value = null;
}, props.typeAheadTimeout);
};
const handleOptionClick = (optionId: string) => {
const index = availableIndexMap.value.get(optionId) ?? -1;
focusOption(index);
selectOption(optionId);
selectionAnchor.value = index;
};
const handleKeyDown = async (event: KeyboardEvent) => {
// Guard: no options to navigate
if (availableOptions.value.length === 0) return;
const { key, shiftKey, ctrlKey, metaKey } = event;
const nextKey = props.orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
const prevKey = props.orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
if (props.orientation === 'vertical' && (key === 'ArrowLeft' || key === 'ArrowRight')) {
return;
}
if (props.orientation === 'horizontal' && (key === 'ArrowUp' || key === 'ArrowDown')) {
return;
}
let newIndex = focusedIndex.value;
let shouldPreventDefault = false;
switch (key) {
case nextKey:
if (focusedIndex.value < availableOptions.value.length - 1) {
newIndex = focusedIndex.value + 1;
}
shouldPreventDefault = true;
if (props.multiselectable && shiftKey) {
await focusOption(newIndex);
selectRange(selectionAnchor.value, newIndex);
event.preventDefault();
return;
}
break;
case prevKey:
if (focusedIndex.value > 0) {
newIndex = focusedIndex.value - 1;
}
shouldPreventDefault = true;
if (props.multiselectable && shiftKey) {
await focusOption(newIndex);
selectRange(selectionAnchor.value, newIndex);
event.preventDefault();
return;
}
break;
case 'Home':
newIndex = 0;
shouldPreventDefault = true;
if (props.multiselectable && shiftKey) {
await focusOption(newIndex);
selectRange(selectionAnchor.value, newIndex);
event.preventDefault();
return;
}
break;
case 'End':
newIndex = availableOptions.value.length - 1;
shouldPreventDefault = true;
if (props.multiselectable && shiftKey) {
await focusOption(newIndex);
selectRange(selectionAnchor.value, newIndex);
event.preventDefault();
return;
}
break;
case ' ':
shouldPreventDefault = true;
if (props.multiselectable) {
const focusedOption = availableOptions.value[focusedIndex.value];
if (focusedOption) {
selectOption(focusedOption.id);
selectionAnchor.value = focusedIndex.value;
}
}
event.preventDefault();
return;
case 'Enter':
shouldPreventDefault = true;
event.preventDefault();
return;
case 'a':
case 'A':
if ((ctrlKey || metaKey) && props.multiselectable) {
shouldPreventDefault = true;
selectAll();
event.preventDefault();
return;
}
break;
}
if (shouldPreventDefault) {
event.preventDefault();
if (newIndex !== focusedIndex.value) {
await focusOption(newIndex);
if (!props.multiselectable) {
const newOption = availableOptions.value[newIndex];
if (newOption) {
updateSelection(new Set([newOption.id]));
}
} else {
selectionAnchor.value = newIndex;
}
}
return;
}
if (key.length === 1 && !ctrlKey && !metaKey) {
event.preventDefault();
handleTypeAhead(key);
}
};
</script> Usage
<script setup>
import Listbox from './Listbox.vue';
const options = [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry' },
];
const handleSelectionChange = (ids) => {
console.log('Selected:', ids);
};
</script>
<template>
<!-- Single-select -->
<Listbox
:options="options"
aria-label="Choose a fruit"
@selection-change="handleSelectionChange"
/>
<!-- Multi-select -->
<Listbox
:options="options"
multiselectable
aria-label="Choose fruits"
@selection-change="handleSelectionChange"
/>
</template> 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 |
Events
| Event | Payload | Description |
|---|---|---|
selection-change | string[] | Emitted 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).
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