APG Patterns
日本語 GitHub
日本語 GitHub

Listbox

A widget that allows the user to select one or more items from a list of choices.

🤖 AI Implementation Guide

Demo

Single-Select (Default)

Selection follows focus. Use arrow keys to navigate and select.

  • Apple
  • Banana
  • Cherry
  • Date
  • Elderberry
  • Fig
  • Grape

Selected: apple

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: apple

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
listbox Container (<ul>) Widget for selecting one or more items from a list
option Each item (<li>) 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)
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
  • 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, useMemo, useRef, useState } 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]);

      let 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 (
          <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 across keyboard interaction, ARIA attributes, and accessibility requirements.

Test Categories

High Priority: APG Keyboard Interaction

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

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)

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

Test Description
axe violations No WCAG 2.1 AA violations (via jest-axe)

Testing Tools

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

Resources