APG Patterns
English GitHub
English GitHub

Listbox

ユーザーが選択肢のリストから1つまたは複数のアイテムを選択できるウィジェット。

🤖 AI 実装ガイド

デモ

シングル選択(デフォルト)

選択はフォーカスに追従します。矢印キーでナビゲートして選択します。

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

Selected: apple

マルチ選択

フォーカスと選択は独立しています。Spaceで選択を切り替え、Shift+矢印で選択範囲を拡張します。

  • Red
  • Orange
  • Yellow
  • Green
  • Blue
  • Indigo
  • Purple

Selected: None

Tip: Use Space to toggle, Shift+Arrow to extend selection, Ctrl+A to select all

水平方向

左右の矢印キーでナビゲートします。

  • Apple
  • Banana
  • Cherry
  • Date
  • Elderberry

Selected: apple

アクセシビリティ

WAI-ARIA ロール

ロール 対象要素 説明
listbox コンテナ(<ul> リストから1つまたは複数のアイテムを選択するためのウィジェット
option 各アイテム(<li> リストボックス内の選択可能なオプション

WAI-ARIA listbox role (opens in new tab)

WAI-ARIA プロパティ

属性 対象 必須 説明
aria-label listbox String はい* リストボックスのアクセシブル名
aria-labelledby listbox ID参照 はい* ラベル要素への参照
aria-multiselectable listbox true いいえ 複数選択モードを有効にする
aria-orientation listbox "vertical" | "horizontal" いいえ ナビゲーションの方向(デフォルト: vertical)

* aria-label または aria-labelledby のいずれかが必須

WAI-ARIA ステート

aria-selected

オプションが選択されているかどうかを示します。

対象 option
true | false
必須 はい
変更トリガー クリック、矢印キー(単一選択)、Space(複数選択)
リファレンス aria-selected (opens in new tab)

aria-disabled

オプションが選択不可であることを示します。

対象 option
true
必須 いいえ(無効時のみ)
リファレンス aria-disabled (opens in new tab)

キーボードサポート

共通ナビゲーション

キー アクション
Down Arrow / Up Arrow フォーカスを移動(垂直方向)
Right Arrow / Left Arrow フォーカスを移動(水平方向)
Home 最初のオプションにフォーカスを移動
End 最後のオプションにフォーカスを移動
文字入力 先行入力: 入力した文字で始まるオプションにフォーカス

単一選択(選択がフォーカスに追従)

キー アクション
矢印キー フォーカスと選択を同時に移動
Space / Enter 現在の選択を確定

複数選択

キー アクション
矢印キー フォーカスのみ移動(選択は変更なし)
Space フォーカス中のオプションの選択をトグル
Shift + 矢印 フォーカスを移動し選択範囲を拡張
Shift + Home アンカーから最初のオプションまで選択
Shift + End アンカーから最後のオプションまで選択
Ctrl + A すべてのオプションを選択

フォーカス管理

このコンポーネントは、フォーカス管理にローヴィングタブインデックスパターンを使用します:

  • 常に1つのオプションのみが tabindex="0" を持つ
  • 他のオプションは tabindex="-1" を持つ
  • 矢印キーでオプション間のフォーカスを移動
  • 無効化されたオプションはナビゲーション中にスキップされる
  • フォーカスは端で折り返さない(端で停止)

選択モデル

  • 単一選択: 選択がフォーカスに追従(矢印キーで選択が変更される)
  • 複数選択: フォーカスと選択は独立(Spaceで選択をトグル)

ソースコード

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;

使い方

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

プロパティ デフォルト 説明
options ListboxOption[] 必須 オプションの配列
multiselectable boolean false マルチ選択モードを有効化
orientation 'vertical' | 'horizontal' 'vertical' リストボックスの方向
defaultSelectedIds string[] [] 初期選択されるオプション ID
onSelectionChange (ids: string[]) => void - 選択変更時のコールバック
typeAheadTimeout number 500 先行入力のタイムアウト(ミリ秒)

ListboxOption Interface

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

テスト

テストは、キーボード操作、ARIA属性、アクセシビリティ要件全般にわたってAPG準拠を検証します。

テストカテゴリ

高優先度: APG キーボード操作

テスト 説明
ArrowDown/Up オプション間でフォーカスを移動(垂直方向)
ArrowRight/Left オプション間でフォーカスを移動(水平方向)
Home/End 最初/最後のオプションにフォーカスを移動
Disabled skip ナビゲーション中に無効化されたオプションをスキップ
Selection follows focus 単一選択: 矢印キーで選択が変更される
Space toggle 複数選択: Spaceでオプションの選択をトグル
Shift+Arrow 複数選択: 選択範囲を拡張
Shift+Home/End 複数選択: アンカーから最初/最後まで選択
Ctrl+A 複数選択: すべてのオプションを選択
Type-ahead 文字入力で一致するオプションにフォーカス
Type-ahead cycle 同じ文字の繰り返し入力で一致項目を巡回

高優先度: APG ARIA 属性

テスト 説明
role="listbox" コンテナがlistboxロールを持つ
role="option" 各オプションがoptionロールを持つ
aria-selected 選択されたオプションが aria-selected="true" を持つ
aria-multiselectable 複数選択が有効な場合にリストボックスが属性を持つ
aria-orientation 水平/垂直方向を反映
aria-disabled 無効化されたオプションが aria-disabled="true" を持つ
aria-label/labelledby リストボックスがアクセシブル名を持つ

高優先度: フォーカス管理(ローヴィングタブインデックス)

テスト 説明
tabIndex=0 フォーカス中のオプションがtabIndex=0を持つ
tabIndex=-1 フォーカスされていないオプションがtabIndex=-1を持つ
Disabled tabIndex 無効化されたオプションがtabIndex=-1を持つ
Focus restoration 再入時に正しいオプションにフォーカスが戻る

中優先度: アクセシビリティ

テスト 説明
axe violations WCAG 2.1 AA違反がないこと(jest-axe経由)

テストツール

完全なドキュメントは testing-strategy.md (opens in new tab) を参照してください。

リソース