APG Patterns
English GitHub
English GitHub

Listbox

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

🤖 AI Implementation Guide

デモ

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

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

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

Selected: None

マルチ選択

フォーカスと選択は独立しています。スペースキーで切り替え、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: None

アクセシビリティ

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.svelte
<script lang="ts">
  import { onMount } from 'svelte';

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

  interface ListboxProps {
    options: ListboxOption[];
    multiselectable?: boolean;
    orientation?: 'vertical' | 'horizontal';
    defaultSelectedIds?: string[];
    ariaLabel?: string;
    ariaLabelledby?: string;
    typeAheadTimeout?: number;
    onSelectionChange?: (selectedIds: string[]) => void;
    class?: string;
  }

  let {
    options = [],
    multiselectable = false,
    orientation = 'vertical',
    defaultSelectedIds = [],
    ariaLabel = undefined,
    ariaLabelledby = undefined,
    typeAheadTimeout = 500,
    onSelectionChange = () => {},
    class: className = '',
  }: ListboxProps = $props();

  let selectedIds = $state<Set<string>>(new Set());
  let focusedIndex = $state(0);
  let selectionAnchor = $state(0);
  let listboxElement: HTMLElement;
  let optionRefs = new Map<string, HTMLLIElement>();
  let instanceId = $state('');
  let typeAheadBuffer = $state('');
  let typeAheadTimeoutId: number | null = null;

  onMount(() => {
    instanceId = `listbox-${Math.random().toString(36).slice(2, 11)}`;
  });

  // Action to track option element references
  function trackOptionRef(node: HTMLLIElement, optionId: string) {
    optionRefs.set(optionId, node);
    return {
      destroy() {
        optionRefs.delete(optionId);
      },
    };
  }

  // Initialize selection
  $effect(() => {
    if (options.length > 0 && selectedIds.size === 0) {
      if (defaultSelectedIds.length > 0) {
        selectedIds = new Set(defaultSelectedIds);
      } else if (availableOptions.length > 0) {
        // Single-select mode: select first available option by default
        if (!multiselectable) {
          selectedIds = new Set([availableOptions[0].id]);
        }
      }

      // Initialize focused index and sync anchor
      const firstSelectedId = [...selectedIds][0];
      if (firstSelectedId) {
        const index = availableOptions.findIndex((opt) => opt.id === firstSelectedId);
        if (index >= 0) {
          focusedIndex = index;
          selectionAnchor = index;
        }
      }
    }
  });

  // Derived values
  let availableOptions = $derived(options.filter((opt) => !opt.disabled));

  // Map of option id to index in availableOptions for O(1) lookup
  let availableIndexMap = $derived.by(() => {
    const map = new Map<string, number>();
    availableOptions.forEach(({ id }, index) => map.set(id, index));
    return map;
  });

  // If no available options, listbox itself needs tabIndex for keyboard access
  let listboxTabIndex = $derived(availableOptions.length === 0 ? 0 : undefined);

  let containerClass = $derived(
    `apg-listbox ${orientation === 'horizontal' ? 'apg-listbox--horizontal' : ''} ${className}`.trim()
  );

  function getOptionClass(option: ListboxOption): string {
    const classes = ['apg-listbox-option'];
    if (selectedIds.has(option.id)) {
      classes.push('apg-listbox-option--selected');
    }
    if (option.disabled) {
      classes.push('apg-listbox-option--disabled');
    }
    return classes.join(' ');
  }

  function getTabIndex(option: ListboxOption): number {
    if (option.disabled) return -1;
    const availableIndex = availableIndexMap.get(option.id) ?? -1;
    return availableIndex === focusedIndex ? 0 : -1;
  }

  function updateSelection(newSelectedIds: Set<string>) {
    selectedIds = newSelectedIds;
    onSelectionChange([...newSelectedIds]);
  }

  function focusOption(index: number) {
    const option = availableOptions[index];
    if (option) {
      focusedIndex = index;
      optionRefs.get(option.id)?.focus();
    }
  }

  function selectOption(optionId: string) {
    if (multiselectable) {
      const newSelected = new Set(selectedIds);
      if (newSelected.has(optionId)) {
        newSelected.delete(optionId);
      } else {
        newSelected.add(optionId);
      }
      updateSelection(newSelected);
    } else {
      updateSelection(new Set([optionId]));
    }
  }

  function selectRange(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);
  }

  function selectAll() {
    const allIds = new Set(availableOptions.map((opt) => opt.id));
    updateSelection(allIds);
  }

  function handleTypeAhead(char: string) {
    // Guard: no options to search
    if (availableOptions.length === 0) return;

    if (typeAheadTimeoutId !== null) {
      clearTimeout(typeAheadTimeoutId);
    }

    typeAheadBuffer += char.toLowerCase();

    const buffer = typeAheadBuffer;
    const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);

    let startIndex = focusedIndex;

    if (isSameChar) {
      typeAheadBuffer = buffer[0];
      startIndex = (focusedIndex + 1) % availableOptions.length;
    }

    for (let i = 0; i < availableOptions.length; i++) {
      const index = (startIndex + i) % availableOptions.length;
      const option = availableOptions[index];
      const searchStr = isSameChar ? buffer[0] : typeAheadBuffer;
      if (option.label.toLowerCase().startsWith(searchStr)) {
        focusOption(index);
        // Update anchor for shift-selection
        selectionAnchor = index;
        if (!multiselectable) {
          updateSelection(new Set([option.id]));
        }
        break;
      }
    }

    typeAheadTimeoutId = window.setTimeout(() => {
      typeAheadBuffer = '';
      typeAheadTimeoutId = null;
    }, typeAheadTimeout);
  }

  function handleOptionClick(optionId: string) {
    const index = availableIndexMap.get(optionId) ?? -1;
    focusOption(index);
    selectOption(optionId);
    selectionAnchor = index;
  }

  function handleKeyDown(event: KeyboardEvent) {
    // Guard: no options to navigate
    if (availableOptions.length === 0) return;

    const { key, shiftKey, ctrlKey, metaKey } = event;

    const nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
    const prevKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';

    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) {
          focusOption(newIndex);
          selectRange(selectionAnchor, newIndex);
          event.preventDefault();
          return;
        }
        break;

      case prevKey:
        if (focusedIndex > 0) {
          newIndex = focusedIndex - 1;
        }
        shouldPreventDefault = true;

        if (multiselectable && shiftKey) {
          focusOption(newIndex);
          selectRange(selectionAnchor, newIndex);
          event.preventDefault();
          return;
        }
        break;

      case 'Home':
        newIndex = 0;
        shouldPreventDefault = true;

        if (multiselectable && shiftKey) {
          focusOption(newIndex);
          selectRange(selectionAnchor, newIndex);
          event.preventDefault();
          return;
        }
        break;

      case 'End':
        newIndex = availableOptions.length - 1;
        shouldPreventDefault = true;

        if (multiselectable && shiftKey) {
          focusOption(newIndex);
          selectRange(selectionAnchor, newIndex);
          event.preventDefault();
          return;
        }
        break;

      case ' ':
        shouldPreventDefault = true;
        if (multiselectable) {
          const focusedOption = availableOptions[focusedIndex];
          if (focusedOption) {
            selectOption(focusedOption.id);
            selectionAnchor = focusedIndex;
          }
        }
        event.preventDefault();
        return;

      case 'Enter':
        shouldPreventDefault = true;
        event.preventDefault();
        return;

      case 'a':
      case 'A':
        if ((ctrlKey || metaKey) && multiselectable) {
          shouldPreventDefault = true;
          selectAll();
          event.preventDefault();
          return;
        }
        break;
    }

    if (shouldPreventDefault) {
      event.preventDefault();

      if (newIndex !== focusedIndex) {
        focusOption(newIndex);

        if (!multiselectable) {
          const newOption = availableOptions[newIndex];
          if (newOption) {
            updateSelection(new Set([newOption.id]));
          }
        } else {
          selectionAnchor = newIndex;
        }
      }
      return;
    }

    if (key.length === 1 && !ctrlKey && !metaKey) {
      event.preventDefault();
      handleTypeAhead(key);
    }
  }
</script>

<ul
  bind:this={listboxElement}
  role="listbox"
  aria-multiselectable={multiselectable || undefined}
  aria-orientation={orientation}
  aria-label={ariaLabel}
  aria-labelledby={ariaLabelledby}
  tabindex={listboxTabIndex}
  class={containerClass}
  onkeydown={handleKeyDown}
>
  {#each options as option}
    {@const isSelected = selectedIds.has(option.id)}

    <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
    <li
      use:trackOptionRef={option.id}
      role="option"
      id="{instanceId}-option-{option.id}"
      aria-selected={isSelected}
      aria-disabled={option.disabled || undefined}
      tabindex={getTabIndex(option)}
      class={getOptionClass(option)}
      onclick={() => !option.disabled && handleOptionClick(option.id)}
    >
      <span class="apg-listbox-option-icon" aria-hidden="true">
        <svg viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
          <path
            d="M10.28 2.28a.75.75 0 00-1.06-1.06L4.5 5.94 2.78 4.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.06 0l5.25-5.25z"
            fill="currentColor"
          />
        </svg>
      </span>
      {option.label}
    </li>
  {/each}
</ul>

使い方

使用例
<script>
  import Listbox from './Listbox.svelte';

  const options = [
    { id: 'apple', label: 'Apple' },
    { id: 'banana', label: 'Banana' },
    { id: 'cherry', label: 'Cherry' },
  ];

  function handleSelectionChange(ids) {
    console.log('Selected:', ids);
  }
</script>

<!-- シングル選択 -->
<Listbox
  {options}
  ariaLabel="フルーツを選択"
  onSelectionChange={handleSelectionChange}
/>

<!-- マルチ選択 -->
<Listbox
  {options}
  multiselectable
  ariaLabel="フルーツを選択"
  onSelectionChange={handleSelectionChange}
/>

API

プロパティ

プロパティ デフォルト 説明
options ListboxOption[] 必須 オプションの配列
multiselectable boolean false マルチ選択モードを有効化
orientation 'vertical' | 'horizontal' 'vertical' Listboxの方向
defaultSelectedIds string[] [] 初期選択されるオプションのID
onSelectionChange (ids: string[]) => void - 選択変更時のコールバック

テスト

テストは、キーボード操作、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) を参照してください。

リソース