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経由) |
テストツール
- Vitest (opens in new tab) - テストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ
- jest-axe (opens in new tab) - 自動アクセシビリティテスト
完全なドキュメントは testing-strategy.md (opens in new tab) を参照してください。
リソース
- WAI-ARIA APG: Listbox パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist