APG Patterns
日本語
日本語

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.

Choose a fruit:
  • Apple
  • Apricot
  • Banana
  • Cherry
  • Date
  • Elderberry
  • Fig
  • Grape

Selected: apple

Multi-Select

Focus and selection are independent. Use Space to toggle, Shift+Arrow to extend selection.

Choose colors (multiple):
  • 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.

Choose a fruit (horizontal):
  • Apple
  • Apricot
  • Banana
  • Cherry
  • Date

Selected: apple

Open demo only →

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

    Listbox.tsx
    import { useCallback, useId, useRef, useState, useMemo } from 'react';
    
    export interface ListboxOption {
      id: string;
      label: string;
      disabled?: boolean;
    }
    
    export interface ListboxProps {
      /** Array of options */
      options: ListboxOption[];
      /** Enable multi-select mode */
      multiselectable?: boolean;
      /** Orientation of the listbox */
      orientation?: 'vertical' | 'horizontal';
      /** Initially selected option ID(s) */
      defaultSelectedIds?: string[];
      /** Callback when selection changes */
      onSelectionChange?: (selectedIds: string[]) => void;
      /** Type-ahead search timeout in ms */
      typeAheadTimeout?: number;
      /** Accessible label */
      'aria-label'?: string;
      /** ID of labeling element */
      'aria-labelledby'?: string;
      /** Additional CSS class */
      className?: string;
    }
    
    export function Listbox({
      options,
      multiselectable = false,
      orientation = 'vertical',
      defaultSelectedIds = [],
      onSelectionChange,
      typeAheadTimeout = 500,
      'aria-label': ariaLabel,
      'aria-labelledby': ariaLabelledby,
      className = '',
    }: ListboxProps): React.ReactElement {
      const availableOptions = useMemo(() => options.filter((opt) => !opt.disabled), [options]);
    
      // Map of option id to index in availableOptions for O(1) lookup
      const availableIndexMap = useMemo(() => {
        const map = new Map<string, number>();
        availableOptions.forEach(({ id }, index) => map.set(id, index));
        return map;
      }, [availableOptions]);
    
      const initialSelectedIds = useMemo(() => {
        if (defaultSelectedIds.length > 0) {
          return new Set(defaultSelectedIds);
        }
        if (availableOptions.length > 0) {
          // Single-select mode: select first available option by default
          if (!multiselectable) {
            return new Set([availableOptions[0].id]);
          }
        }
        return new Set<string>();
      }, [defaultSelectedIds, multiselectable, availableOptions]);
    
      // Compute initial focus index
      const initialFocusIndex = useMemo(() => {
        if (availableOptions.length === 0) return 0;
        const firstSelectedId = [...initialSelectedIds][0];
        const index = availableOptions.findIndex((opt) => opt.id === firstSelectedId);
        return index >= 0 ? index : 0;
      }, [initialSelectedIds, availableOptions]);
    
      const [selectedIds, setSelectedIds] = useState<Set<string>>(initialSelectedIds);
      const [focusedIndex, setFocusedIndex] = useState(initialFocusIndex);
    
      const listboxRef = useRef<HTMLUListElement>(null);
      const optionRefs = useRef<Map<string, HTMLLIElement>>(new Map());
      const typeAheadBuffer = useRef<string>('');
      const typeAheadTimeoutId = useRef<number | null>(null);
      // Track anchor for shift-selection range (synced with initial focus)
      const selectionAnchor = useRef<number>(initialFocusIndex);
    
      const instanceId = useId();
    
      const getOptionId = useCallback(
        (optionId: string) => `${instanceId}-option-${optionId}`,
        [instanceId]
      );
    
      const updateSelection = useCallback(
        (newSelectedIds: Set<string>) => {
          setSelectedIds(newSelectedIds);
          onSelectionChange?.([...newSelectedIds]);
        },
        [onSelectionChange]
      );
    
      const focusOption = useCallback(
        (index: number) => {
          const option = availableOptions[index];
          if (option) {
            setFocusedIndex(index);
            optionRefs.current.get(option.id)?.focus();
          }
        },
        [availableOptions]
      );
    
      const selectOption = useCallback(
        (optionId: string) => {
          if (multiselectable) {
            // Toggle selection
            const newSelected = new Set(selectedIds);
            if (newSelected.has(optionId)) {
              newSelected.delete(optionId);
            } else {
              newSelected.add(optionId);
            }
            updateSelection(newSelected);
          } else {
            // Single-select: replace selection
            updateSelection(new Set([optionId]));
          }
        },
        [multiselectable, selectedIds, updateSelection]
      );
    
      const selectRange = useCallback(
        (fromIndex: number, toIndex: number) => {
          const start = Math.min(fromIndex, toIndex);
          const end = Math.max(fromIndex, toIndex);
          const newSelected = new Set(selectedIds);
    
          for (let i = start; i <= end; i++) {
            const option = availableOptions[i];
            if (option) {
              newSelected.add(option.id);
            }
          }
    
          updateSelection(newSelected);
        },
        [availableOptions, selectedIds, updateSelection]
      );
    
      const selectAll = useCallback(() => {
        const allIds = new Set(availableOptions.map((opt) => opt.id));
        updateSelection(allIds);
      }, [availableOptions, updateSelection]);
    
      const handleTypeAhead = useCallback(
        (char: string) => {
          // Guard: no options to search
          if (availableOptions.length === 0) return;
    
          // Clear existing timeout
          if (typeAheadTimeoutId.current !== null) {
            clearTimeout(typeAheadTimeoutId.current);
          }
    
          // Add character to buffer
          typeAheadBuffer.current += char.toLowerCase();
    
          // Find matching option starting from current focus (or next if same char repeated)
          const buffer = typeAheadBuffer.current;
          const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);
    
          const searchOptions = availableOptions;
          let startIndex = focusedIndex;
    
          // If repeating same character, cycle through matches starting from next
          if (isSameChar) {
            typeAheadBuffer.current = buffer[0]; // Reset buffer to single char
            startIndex = (focusedIndex + 1) % availableOptions.length;
          }
    
          // Search from start index, wrapping around
          for (let i = 0; i < searchOptions.length; i++) {
            const index = (startIndex + i) % searchOptions.length;
            const option = searchOptions[index];
            const searchStr = isSameChar ? buffer[0] : typeAheadBuffer.current;
            if (option.label.toLowerCase().startsWith(searchStr)) {
              focusOption(index);
              // Update anchor for shift-selection
              selectionAnchor.current = index;
              // Single-select: also select the option
              if (!multiselectable) {
                updateSelection(new Set([option.id]));
              }
              break;
            }
          }
    
          // Set timeout to clear buffer
          typeAheadTimeoutId.current = window.setTimeout(() => {
            typeAheadBuffer.current = '';
            typeAheadTimeoutId.current = null;
          }, typeAheadTimeout);
        },
        [
          availableOptions,
          focusedIndex,
          focusOption,
          multiselectable,
          typeAheadTimeout,
          updateSelection,
        ]
      );
    
      const handleKeyDown = useCallback(
        (event: React.KeyboardEvent) => {
          // Guard: no options to navigate
          if (availableOptions.length === 0) return;
    
          const { key, shiftKey, ctrlKey, metaKey } = event;
    
          // Determine navigation keys based on orientation
          const nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
          const prevKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
    
          // Ignore navigation keys for wrong orientation
          if (orientation === 'vertical' && (key === 'ArrowLeft' || key === 'ArrowRight')) {
            return;
          }
          if (orientation === 'horizontal' && (key === 'ArrowUp' || key === 'ArrowDown')) {
            return;
          }
    
          let newIndex = focusedIndex;
          let shouldPreventDefault = false;
    
          switch (key) {
            case nextKey:
              if (focusedIndex < availableOptions.length - 1) {
                newIndex = focusedIndex + 1;
              }
              shouldPreventDefault = true;
    
              if (multiselectable && shiftKey) {
                // Extend selection
                focusOption(newIndex);
                selectRange(selectionAnchor.current, newIndex);
                return;
              }
              break;
    
            case prevKey:
              if (focusedIndex > 0) {
                newIndex = focusedIndex - 1;
              }
              shouldPreventDefault = true;
    
              if (multiselectable && shiftKey) {
                // Extend selection
                focusOption(newIndex);
                selectRange(selectionAnchor.current, newIndex);
                return;
              }
              break;
    
            case 'Home':
              newIndex = 0;
              shouldPreventDefault = true;
    
              if (multiselectable && shiftKey) {
                // Select from anchor to start
                focusOption(newIndex);
                selectRange(selectionAnchor.current, newIndex);
                return;
              }
              break;
    
            case 'End':
              newIndex = availableOptions.length - 1;
              shouldPreventDefault = true;
    
              if (multiselectable && shiftKey) {
                // Select from anchor to end
                focusOption(newIndex);
                selectRange(selectionAnchor.current, newIndex);
                return;
              }
              break;
    
            case ' ':
              shouldPreventDefault = true;
              if (multiselectable) {
                // Toggle selection of focused option
                const focusedOption = availableOptions[focusedIndex];
                if (focusedOption) {
                  selectOption(focusedOption.id);
                  // Update anchor
                  selectionAnchor.current = focusedIndex;
                }
              }
              // Single-select: Space/Enter just confirms (selection already follows focus)
              return;
    
            case 'Enter':
              shouldPreventDefault = true;
              // Confirm current selection (useful for form submission scenarios)
              return;
    
            case 'a':
            case 'A':
              if ((ctrlKey || metaKey) && multiselectable) {
                shouldPreventDefault = true;
                selectAll();
                return;
              }
              // Fall through to type-ahead
              break;
          }
    
          if (shouldPreventDefault) {
            event.preventDefault();
    
            if (newIndex !== focusedIndex) {
              focusOption(newIndex);
    
              // Single-select: selection follows focus
              if (!multiselectable) {
                const newOption = availableOptions[newIndex];
                if (newOption) {
                  updateSelection(new Set([newOption.id]));
                }
              } else {
                // Multi-select without shift: just move focus, update anchor
                selectionAnchor.current = newIndex;
              }
            }
            return;
          }
    
          // Type-ahead: single printable character
          if (key.length === 1 && !ctrlKey && !metaKey) {
            event.preventDefault();
            handleTypeAhead(key);
          }
        },
        [
          orientation,
          focusedIndex,
          availableOptions,
          multiselectable,
          focusOption,
          selectRange,
          selectOption,
          selectAll,
          updateSelection,
          handleTypeAhead,
        ]
      );
    
      const handleOptionClick = useCallback(
        (optionId: string, index: number) => {
          focusOption(index);
          selectOption(optionId);
          selectionAnchor.current = index;
        },
        [focusOption, selectOption]
      );
    
      const containerClass = `apg-listbox ${
        orientation === 'horizontal' ? 'apg-listbox--horizontal' : ''
      } ${className}`.trim();
    
      // If no available options, listbox itself needs tabIndex for keyboard access
      const listboxTabIndex = availableOptions.length === 0 ? 0 : undefined;
    
      return (
        <ul
          ref={listboxRef}
          role="listbox"
          aria-multiselectable={multiselectable || undefined}
          aria-orientation={orientation}
          aria-label={ariaLabel}
          aria-labelledby={ariaLabelledby}
          tabIndex={listboxTabIndex}
          className={containerClass}
          onKeyDown={handleKeyDown}
        >
          {options.map((option) => {
            const isSelected = selectedIds.has(option.id);
            const availableIndex = availableIndexMap.get(option.id) ?? -1;
            const isFocusTarget = availableIndex === focusedIndex;
            const tabIndex = option.disabled ? -1 : isFocusTarget ? 0 : -1;
    
            const optionClass = `apg-listbox-option ${
              isSelected ? 'apg-listbox-option--selected' : ''
            } ${option.disabled ? 'apg-listbox-option--disabled' : ''}`.trim();
    
            return (
              // role option keyboard events handled by aria-activedescendant on the listbox
              // eslint-disable-next-line jsx-a11y/click-events-have-key-events
              <li
                key={option.id}
                ref={(el) => {
                  if (el) {
                    optionRefs.current.set(option.id, el);
                  } else {
                    optionRefs.current.delete(option.id);
                  }
                }}
                role="option"
                id={getOptionId(option.id)}
                aria-selected={isSelected}
                aria-disabled={option.disabled || undefined}
                tabIndex={tabIndex}
                className={optionClass}
                onClick={() => !option.disabled && handleOptionClick(option.id, availableIndex)}
              >
                <span className="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>
      );
    }
    
    export default Listbox;

    Usage

    Example
    import { Listbox } from './Listbox';
    
    const options = [
      { id: 'apple', label: 'Apple' },
      { id: 'banana', label: 'Banana' },
      { id: 'cherry', label: 'Cherry' },
      { id: 'date', label: 'Date', disabled: true },
    ];
    
    // Single-select (selection follows focus)
    <Listbox
      options={options}
      aria-label="Choose a fruit"
      onSelectionChange={(ids) => console.log('Selected:', ids)}
    />
    
    // Multi-select
    <Listbox
      options={options}
      multiselectable
      aria-label="Choose fruits"
      onSelectionChange={(ids) => console.log('Selected:', ids)}
    />
    
    // Horizontal orientation
    <Listbox
      options={options}
      orientation="horizontal"
      aria-label="Choose a fruit"
    />

    API

    Listbox 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
    onSelectionChange (ids: string[]) => void - Callback when selection changes
    typeAheadTimeout number 500 Type-ahead timeout in milliseconds

    ListboxOption Interface

    Types
    interface ListboxOption {
      id: string;
      label: string;
      disabled?: boolean;
    }

    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).toHaveAttribute('aria-selected', 'true');
    
            await page.keyboard.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 page.keyboard.press('ArrowDown');
            await expect(secondOption).toHaveAttribute('tabindex', '0');
            await expect(secondOption).toHaveAttribute('aria-selected', 'true');
    
            // Now navigate up
            await page.keyboard.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 page.keyboard.press('ArrowDown');
            await page.keyboard.press('ArrowDown');
    
            await page.keyboard.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 page.keyboard.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 page.keyboard.press('End'); // Ensure we're at the end
    
            await page.keyboard.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();
            // Initially no selection in multi-select
            const initialSelected = await getSelectedOptions(listbox).count();
    
            await page.keyboard.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 page.keyboard.press('ArrowDown');
            await expect(secondOption).toHaveAttribute('tabindex', '0');
    
            // Navigate up should move focus but not change selection
            await page.keyboard.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).not.toHaveAttribute('aria-selected', 'true');
    
            await page.keyboard.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 page.keyboard.press('Space'); // Select
            await expect(firstOption).toHaveAttribute('aria-selected', 'true');
    
            await page.keyboard.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 page.keyboard.press('Space'); // Select first as anchor
            await expect(firstOption).toHaveAttribute('aria-selected', 'true');
    
            await page.keyboard.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).toHaveAttribute('aria-selected', 'true');
    
            await page.keyboard.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);
    
            await thirdOption.focus();
            await page.keyboard.press('ArrowDown');
            await page.keyboard.press('ArrowDown');
            await page.keyboard.press('Space'); // Select third as anchor
    
            await page.keyboard.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 page.keyboard.press('Space'); // Select first as anchor
    
            await page.keyboard.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 page.keyboard.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 page.keyboard.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 page.keyboard.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 page.keyboard.press('ArrowRight');
            await expect(secondOption).toHaveAttribute('tabindex', '0');
    
            // Now navigate left
            await page.keyboard.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 page.keyboard.press('ArrowDown');
            // Should still be on first option
            await expect(firstOption).toHaveAttribute('tabindex', '0');
    
            await page.keyboard.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 page.keyboard.press('ArrowRight');
            await page.keyboard.press('ArrowRight');
    
            await page.keyboard.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 page.keyboard.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).toHaveAttribute('tabindex', '0');
            await expect(secondOption).toHaveAttribute('tabindex', '-1');
    
            await page.keyboard.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 page.keyboard.press('ArrowDown');
            await page.keyboard.press('ArrowDown');
            await expect(thirdOption).toHaveAttribute('tabindex', '0');
    
            // Tab out and back
            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 page.keyboard.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();
    
            // 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 page.keyboard.press('a');
            await expect(appleOption).toHaveAttribute('tabindex', '0');
    
            // Press 'a' again - should cycle to Apricot (next match)
            await page.keyboard.press('a');
            await expect(apricotOption).toHaveAttribute('tabindex', '0');
    
            // Press 'a' again - should cycle back to Apple
            await page.keyboard.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 page.keyboard.press('c'); // Focus Cherry
            await expect(cherryOption).toHaveAttribute('tabindex', '0');
    
            // Wait for buffer to clear (default 500ms + margin)
            await page.waitForTimeout(600);
    
            await page.keyboard.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 page.keyboard.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 page.keyboard.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

    See testing-strategy.md (opens in new tab) for full documentation.

    Resources