Combobox
リストオートコンプリート機能を持つ編集可能なコンボボックス。ユーザーは入力してオプションをフィルタリングしたり、キーボードやマウスでポップアップリストボックスから選択したりできます。
🤖 AI Implementation Guideデモ
- りんご
- バナナ
- さくらんぼ
- デーツ
- エルダーベリー
- いちじく
- ぶどう
- りんご
- バナナ
- さくらんぼ
- デーツ
- エルダーベリー
- いちじく
- ぶどう
- 日本
- アメリカ
- イギリス
- ドイツ
- フランス
- イタリア
- スペイン
- オーストラリア
- りんご
- バナナ
- さくらんぼ
- デーツ
- エルダーベリー
- いちじく
- ぶどう
- りんご
- バナナ
- さくらんぼ
- デーツ
- エルダーベリー
- いちじく
- ぶどう
ネイティブ HTML
まずネイティブ HTML を検討してください
カスタムコンボボックスを使用する前に、ネイティブ HTML の代替手段を検討してください。 これらは組み込みのセマンティクスを持ち、JavaScript なしで動作し、ブラウザネイティブのサポートがあります。
<!-- シンプルなドロップダウン選択 -->
<label for="fruit">フルーツを選択</label>
<select id="fruit">
<option value="apple">りんご</option>
<option value="banana">バナナ</option>
</select>
<!-- 基本的なオートコンプリート -->
<label for="browser">ブラウザを選択</label>
<input list="browsers" id="browser" name="browser">
<datalist id="browsers">
<option value="Chrome">
<option value="Firefox">
<option value="Safari">
</datalist> カスタムスタイル、複雑なフィルタリングロジック、リッチなオプション表示、またはネイティブ要素でサポートされていない動作が必要な場合にのみカスタムコンボボックスを使用してください。
| ユースケース | ネイティブ HTML | カスタム実装 |
|---|---|---|
| シンプルなドロップダウン選択 | <select> 推奨 | 不要 |
| 基本的なオートコンプリート候補 | <datalist> 推奨 | 不要 |
| JavaScript 無効時のサポート | ネイティブで動作 | フォールバックが必要 |
| カスタムオプション表示(アイコン、説明) | 非対応 | 完全制御可能 |
| カスタムフィルタリングロジック | 基本的な前方一致のみ | カスタムアルゴリズム |
| ブラウザ間で一貫したスタイル | 限定的(特に datalist) | 完全制御可能 |
| キーボードナビゲーションのカスタマイズ | ブラウザデフォルトのみ | カスタマイズ可能 |
| 無効なオプション | <select> のみ | 完全対応 |
ネイティブ <select> 要素は優れたアクセシビリティ、フォーム送信サポート、JavaScript
不要で動作します。<datalist> 要素は基本的なオートコンプリート機能を提供しますが、ブラウザ間で見た目が大きく異なり、無効なオプションやカスタム表示には対応していません。
<datalist> のアクセシビリティ上の問題
<datalist> 要素には、以下のアクセシビリティ上の問題が知られています:
- テキストズーム非対応:datalist のオプションのフォントサイズはページをズームしても拡大されず、テキスト拡大に依存するユーザーに問題を引き起こします。
- CSS スタイリングの制限:オプションをハイコントラストモード向けにスタイリングできないため、視覚障害のあるユーザーへの対応が困難です。
- スクリーンリーダーの互換性:一部のスクリーンリーダーとブラウザの組み合わせ(例:NVDA と Firefox)では、オートサジェストポップアップの内容が読み上げられません。
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
combobox | Input(<input>) | ユーザーが入力するテキスト入力要素 |
listbox | ポップアップ(<ul>) | 選択可能なオプションを含むポップアップ |
option | 各アイテム(<li>) | 個々の選択可能なオプション |
WAI-ARIA combobox role (opens in new tab)
WAI-ARIA プロパティ(Input)
| 属性 | 値 | 必須 | 説明 |
|---|---|---|---|
role="combobox" | - | はい | 入力をコンボボックスとして識別 |
aria-controls | ID参照 | はい | リストボックスポップアップを参照(閉じている時も) |
aria-expanded | true | false | はい | ポップアップが開いているかどうかを示す |
aria-autocomplete | list | none | both | はい | オートコンプリートの動作を説明 |
aria-activedescendant | ID参照 | 空 | はい | ポップアップ内で現在フォーカスされているオプションを参照 |
aria-labelledby | ID参照 | はい* | ラベル要素を参照 |
WAI-ARIA プロパティ(Listbox & Options)
| 属性 | 対象 | 値 | 必須 | 説明 |
|---|---|---|---|---|
aria-labelledby | listbox | ID参照 | はい | ラベル要素を参照 |
aria-selected | option | true | false | はい | 現在フォーカスされているオプションを示す |
aria-disabled | option | true | いいえ | オプションが無効であることを示す |
キーボードサポート
Input(ポップアップ閉時)
| キー | アクション |
|---|---|
| Down Arrow | ポップアップを開き、最初のオプションにフォーカス |
| Up Arrow | ポップアップを開き、最後のオプションにフォーカス |
| Alt + Down Arrow | フォーカス位置を変更せずにポップアップを開く |
| 文字入力 | オプションをフィルタリングしてポップアップを開く |
Input(ポップアップ開時)
| キー | アクション |
|---|---|
| Down Arrow | 次の有効なオプションにフォーカスを移動(折り返しなし) |
| Up Arrow | 前の有効なオプションにフォーカスを移動(折り返しなし) |
| Home | 最初の有効なオプションにフォーカスを移動 |
| End | 最後の有効なオプションにフォーカスを移動 |
| Enter | フォーカス中のオプションを選択しポップアップを閉じる |
| Escape | ポップアップを閉じ、以前の入力値を復元 |
| Alt + Up Arrow | フォーカス中のオプションを選択しポップアップを閉じる |
| Tab | ポップアップを閉じ、次のフォーカス可能な要素に移動 |
フォーカス管理
このコンポーネントは仮想フォーカス管理に aria-activedescendant を使用します:
- DOM フォーカスは常に input に留まる
aria-activedescendantが視覚的にフォーカスされているオプションを参照- 矢印キーが DOM フォーカスを移動せずに
aria-activedescendantを更新 - 無効化されたオプションはナビゲーション中にスキップされる
-
ポップアップが閉じるか、フィルタ結果が空になると
aria-activedescendantがクリア
オートコンプリートモード
| モード | 動作 |
|---|---|
list | オプションは入力値に基づいてフィルタリングされる(デフォルト) |
none | 入力値に関係なくすべてのオプションを表示 |
both | オプションをフィルタリングし、最初の一致を入力にオートコンプリート |
非表示状態
閉じている時、リストボックスは hidden 属性を使用して:
- ポップアップを視覚的に非表示にする
- ポップアップをアクセシビリティツリーから削除する
- リストボックス要素は DOM に残り、
aria-controlsの参照が有効なまま
ソースコード
<script lang="ts">
import { cn } from '@/lib/utils';
import { onDestroy, tick } from 'svelte';
export interface ComboboxOption {
id: string;
label: string;
disabled?: boolean;
}
interface ComboboxProps {
options: ComboboxOption[];
selectedOptionId?: string;
defaultSelectedOptionId?: string;
inputValue?: string;
defaultInputValue?: string;
label: string;
placeholder?: string;
disabled?: boolean;
autocomplete?: 'none' | 'list' | 'both';
noResultsMessage?: string;
onSelect?: (option: ComboboxOption) => void;
onInputChange?: (value: string) => void;
onOpenChange?: (isOpen: boolean) => void;
class?: string;
}
let {
options = [],
selectedOptionId = undefined,
defaultSelectedOptionId = undefined,
inputValue = undefined,
defaultInputValue = '',
label,
placeholder = undefined,
disabled = false,
autocomplete = 'list',
noResultsMessage = 'No results found',
onSelect = () => {},
onInputChange = () => {},
onOpenChange = () => {},
class: className = '',
...restProps
}: ComboboxProps = $props();
// Generate ID for SSR-safe aria-controls/aria-labelledby
const instanceId = `combobox-${Math.random().toString(36).slice(2, 11)}`;
// Compute initial input value
const getInitialInputValue = () => {
if (defaultSelectedOptionId) {
const option = options.find((o) => o.id === defaultSelectedOptionId);
return option?.label ?? defaultInputValue;
}
return defaultInputValue;
};
// State
let isOpen = $state(false);
let activeIndex = $state(-1);
let isComposing = $state(false);
let valueBeforeOpen = '';
let internalInputValue = $state(getInitialInputValue());
let internalSelectedId = $state<string | undefined>(defaultSelectedOptionId);
let isSearching = $state(false);
// Refs
let containerElement: HTMLDivElement;
let inputElement: HTMLInputElement;
// Derived values
let inputId = $derived(`${instanceId}-input`);
let labelId = $derived(`${instanceId}-label`);
let listboxId = $derived(`${instanceId}-listbox`);
let currentInputValue = $derived(inputValue !== undefined ? inputValue : internalInputValue);
let currentSelectedId = $derived(selectedOptionId ?? internalSelectedId);
// Get selected option's label
let selectedLabel = $derived.by(() => {
if (!currentSelectedId) {
return '';
}
const option = options.find(({ id }) => id === currentSelectedId);
return option?.label ?? '';
});
let filteredOptions = $derived.by(() => {
// Don't filter if autocomplete is none
if (autocomplete === 'none') {
return options;
}
// Don't filter if input is empty
if (!currentInputValue) {
return options;
}
// Don't filter if not in search mode AND input matches selected label
if (!isSearching && currentInputValue === selectedLabel) {
return options;
}
const lowerInputValue = currentInputValue.toLowerCase();
return options.filter(({ label }) => label.toLowerCase().includes(lowerInputValue));
});
let enabledOptions = $derived(filteredOptions.filter(({ disabled }) => !disabled));
let activeDescendantId = $derived.by(() => {
if (activeIndex < 0 || activeIndex >= filteredOptions.length) {
return undefined;
}
const option = filteredOptions[activeIndex];
return option ? getOptionId(option.id) : undefined;
});
onDestroy(() => {
if (typeof document !== 'undefined') {
document.removeEventListener('mousedown', handleClickOutside);
}
});
// Click outside effect
$effect(() => {
if (typeof document === 'undefined') return;
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
});
// Clear active index when filtered options change
$effect(() => {
if (activeIndex >= 0 && activeIndex >= filteredOptions.length) {
activeIndex = -1;
}
});
// Reset search mode when input value matches selected label or becomes empty
$effect(() => {
if (currentInputValue === '' || currentInputValue === selectedLabel) {
isSearching = false;
}
});
function getOptionId(optionId: string): string {
return `${instanceId}-option-${optionId}`;
}
function updateInputValue(value: string) {
if (inputValue === undefined) {
internalInputValue = value;
}
onInputChange(value);
}
function openPopup(focusPosition?: 'first' | 'last') {
if (isOpen) {
return;
}
valueBeforeOpen = currentInputValue;
isOpen = true;
onOpenChange(true);
if (!focusPosition || enabledOptions.length === 0) {
return;
}
const targetOption =
focusPosition === 'first' ? enabledOptions[0] : enabledOptions[enabledOptions.length - 1];
const { id: targetId } = targetOption;
const targetIndex = filteredOptions.findIndex(({ id }) => id === targetId);
activeIndex = targetIndex;
}
function closePopup(restore = false) {
isOpen = false;
activeIndex = -1;
isSearching = false;
onOpenChange(false);
if (restore) {
updateInputValue(valueBeforeOpen);
}
}
function selectOption({ id, label, disabled }: ComboboxOption) {
if (disabled) {
return;
}
if (selectedOptionId === undefined) {
internalSelectedId = id;
}
isSearching = false;
updateInputValue(label);
onSelect({ id, label, disabled });
closePopup();
}
function findEnabledIndex(
startIndex: number,
direction: 'next' | 'prev' | 'first' | 'last'
): number {
if (enabledOptions.length === 0) {
return -1;
}
if (direction === 'first') {
const { id: firstId } = enabledOptions[0];
return filteredOptions.findIndex(({ id }) => id === firstId);
}
if (direction === 'last') {
const { id: lastId } = enabledOptions[enabledOptions.length - 1];
return filteredOptions.findIndex(({ id }) => id === lastId);
}
const currentOption = filteredOptions[startIndex];
const currentEnabledIndex = currentOption
? enabledOptions.findIndex(({ id }) => id === currentOption.id)
: -1;
if (direction === 'next') {
if (currentEnabledIndex < 0) {
const { id: firstId } = enabledOptions[0];
return filteredOptions.findIndex(({ id }) => id === firstId);
}
if (currentEnabledIndex >= enabledOptions.length - 1) {
return startIndex;
}
const { id: nextId } = enabledOptions[currentEnabledIndex + 1];
return filteredOptions.findIndex(({ id }) => id === nextId);
}
// direction === 'prev'
if (currentEnabledIndex < 0) {
const { id: lastId } = enabledOptions[enabledOptions.length - 1];
return filteredOptions.findIndex(({ id }) => id === lastId);
}
if (currentEnabledIndex <= 0) {
return startIndex;
}
const { id: prevId } = enabledOptions[currentEnabledIndex - 1];
return filteredOptions.findIndex(({ id }) => id === prevId);
}
function handleClickOutside(event: MouseEvent) {
if (!containerElement) {
return;
}
if (!containerElement.contains(event.target as Node)) {
closePopup();
}
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
const value = target.value;
isSearching = true;
updateInputValue(value);
if (!isOpen && !isComposing) {
valueBeforeOpen = currentInputValue;
isOpen = true;
onOpenChange(true);
}
activeIndex = -1;
}
function handleKeyDown(event: KeyboardEvent) {
if (isComposing) {
return;
}
const { key, altKey } = event;
switch (key) {
case 'ArrowDown': {
event.preventDefault();
if (altKey) {
if (isOpen) {
return;
}
valueBeforeOpen = currentInputValue;
isOpen = true;
onOpenChange(true);
return;
}
if (!isOpen) {
openPopup('first');
return;
}
const nextIndex = findEnabledIndex(activeIndex, 'next');
if (nextIndex >= 0) {
activeIndex = nextIndex;
}
break;
}
case 'ArrowUp': {
event.preventDefault();
if (altKey) {
if (!isOpen || activeIndex < 0) {
return;
}
const option = filteredOptions[activeIndex];
if (option === undefined || option.disabled) {
return;
}
selectOption(option);
return;
}
if (!isOpen) {
openPopup('last');
return;
}
const prevIndex = findEnabledIndex(activeIndex, 'prev');
if (prevIndex >= 0) {
activeIndex = prevIndex;
}
break;
}
case 'Home': {
if (!isOpen) {
return;
}
event.preventDefault();
const firstIndex = findEnabledIndex(0, 'first');
if (firstIndex >= 0) {
activeIndex = firstIndex;
}
break;
}
case 'End': {
if (!isOpen) {
return;
}
event.preventDefault();
const lastIndex = findEnabledIndex(0, 'last');
if (lastIndex >= 0) {
activeIndex = lastIndex;
}
break;
}
case 'Enter': {
if (!isOpen || activeIndex < 0) {
return;
}
event.preventDefault();
const option = filteredOptions[activeIndex];
if (option === undefined || option.disabled) {
return;
}
selectOption(option);
break;
}
case 'Escape': {
if (!isOpen) {
return;
}
event.preventDefault();
closePopup(true);
break;
}
case 'Tab': {
if (isOpen) {
closePopup();
}
break;
}
}
}
function handleOptionClick(option: ComboboxOption) {
if (option.disabled) {
return;
}
selectOption(option);
}
function handleOptionHover({ id }: ComboboxOption) {
const index = filteredOptions.findIndex((option) => option.id === id);
if (index < 0) {
return;
}
activeIndex = index;
}
function handleCompositionStart() {
isComposing = true;
}
function handleCompositionEnd() {
isComposing = false;
}
// Handle focus - open popup when input receives focus
function handleFocus() {
if (isOpen || disabled) {
return;
}
openPopup();
}
</script>
<div bind:this={containerElement} class={cn('apg-combobox', className)}>
<label id={labelId} for={inputId} class="apg-combobox-label">
{label}
</label>
<div class="apg-combobox-input-wrapper">
<input
bind:this={inputElement}
id={inputId}
type="text"
role="combobox"
class="apg-combobox-input"
aria-autocomplete={autocomplete}
aria-expanded={isOpen}
aria-controls={listboxId}
aria-labelledby={labelId}
aria-activedescendant={activeDescendantId}
value={currentInputValue}
{placeholder}
{disabled}
oninput={handleInput}
onkeydown={handleKeyDown}
onfocus={handleFocus}
oncompositionstart={handleCompositionStart}
oncompositionend={handleCompositionEnd}
{...restProps}
/>
<span class="apg-combobox-caret" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd"
/>
</svg>
</span>
</div>
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
<ul
id={listboxId}
role="listbox"
aria-labelledby={labelId}
class="apg-combobox-listbox"
hidden={!isOpen ? true : undefined}
>
{#if filteredOptions.length === 0}
<li class="apg-combobox-no-results" role="status">
{noResultsMessage}
</li>
{/if}
{#each filteredOptions as option, index}
{@const isActive = index === activeIndex}
{@const isSelected = option.id === currentSelectedId}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
<li
id={getOptionId(option.id)}
role="option"
class="apg-combobox-option"
aria-selected={isActive}
aria-disabled={option.disabled || undefined}
data-selected={isSelected || undefined}
onclick={() => handleOptionClick(option)}
onmouseenter={() => handleOptionHover(option)}
>
<span class="apg-combobox-option-icon" aria-hidden="true">
<svg viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<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"
/>
</svg>
</span>
{option.label}
</li>
{/each}
</ul>
</div> 使い方
<script lang="ts">
import Combobox from './Combobox.svelte';
const options = [
{ id: 'apple', label: 'りんご' },
{ id: 'banana', label: 'バナナ' },
{ id: 'cherry', label: 'さくらんぼ' },
];
function handleSelect(event: CustomEvent<{ id: string; label: string }>) {
console.log('選択:', event.detail);
}
function handleInputChange(event: CustomEvent<string>) {
console.log('入力:', event.detail);
}
function handleOpenChange(event: CustomEvent<boolean>) {
console.log('開閉:', event.detail);
}
</script>
<!-- 基本的な使い方 -->
<Combobox
{options}
label="お気に入りのフルーツ"
placeholder="検索..."
/>
<!-- デフォルト値あり -->
<Combobox
{options}
label="フルーツ"
defaultSelectedOptionId="banana"
/>
<!-- 無効なオプションあり -->
<Combobox
options={[
{ id: 'a', label: 'オプション A' },
{ id: 'b', label: 'オプション B', disabled: true },
{ id: 'c', label: 'オプション C' },
]}
label="オプションを選択"
/>
<!-- フィルタなし (autocomplete="none") -->
<Combobox
{options}
label="選択"
autocomplete="none"
/>
<!-- コールバック付き -->
<Combobox
{options}
label="フルーツ"
onselect={handleSelect}
oninputchange={handleInputChange}
onopenchange={handleOpenChange}
/> API
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
options | ComboboxOption[] | 必須 | id、label、オプションの disabled を持つオプションの配列 |
label | string | 必須 | 表示されるラベルテキスト |
placeholder | string | - | 入力のプレースホルダーテキスト |
defaultInputValue | string | "" | デフォルトの入力値 |
defaultSelectedOptionId | string | - | 初期選択されるオプションの ID |
autocomplete | "none" | "list" | "both" | "list" | オートコンプリートの動作 |
disabled | boolean | false | コンボボックスを無効にするかどうか |
イベント
| イベント | 詳細の型 | 説明 |
|---|---|---|
onselect | ComboboxOption | オプション選択時に発火 |
oninputchange | string | 入力値変更時に発火 |
onopenchange | boolean | ポップアップ開閉時に発火 |
テスト
テストは ARIA 属性、キーボード操作、フィルタリング動作、アクセシビリティ要件に対する APG 準拠を検証します。Combobox コンポーネントは2層のテスト戦略を採用しています。
テスト戦略
ユニットテスト (Testing Library)
フレームワーク固有のテストライブラリを使用してコンポーネントの出力を検証します。正しい HTML 構造と ARIA 属性を確認します。
- ARIA 属性(role、aria-controls、aria-expanded など)
- キーボード操作(矢印キー、Enter、Escape など)
- フィルタリング動作とオプションのレンダリング
- jest-axe によるアクセシビリティ検証
E2E テスト (Playwright)
実際のブラウザ環境で全フレームワークのコンポーネント動作を検証します。インタラクションと クロスフレームワークの一貫性をカバーします。
- キーボードナビゲーションと選択
- マウス操作(クリック、ホバー)
- ライブブラウザでの ARIA 構造
- aria-activedescendant によるフォーカス管理
- axe-core によるアクセシビリティスキャン
- クロスフレームワーク一貫性チェック
テストカテゴリ
高優先度: ARIA 属性 (Unit + E2E)
| テスト | 説明 |
|---|---|
role="combobox" | Input 要素に combobox ロールがある |
role="listbox" | ポップアップ要素に listbox ロールがある |
role="option" | 各オプションに option ロールがある |
aria-controls | Input が listbox ID を参照(常に存在) |
aria-expanded | ポップアップの開閉状態を反映 |
aria-autocomplete | "list"、"none"、"both" のいずれかに設定 |
aria-activedescendant | 現在フォーカスされているオプションを参照 |
aria-selected | 現在ハイライトされているオプションを示す |
aria-disabled | 無効なオプションを示す |
高優先度: アクセシブル名 (Unit + E2E)
| テスト | 説明 |
|---|---|
aria-labelledby | Input が可視ラベル要素を参照 |
aria-labelledby (listbox) | Listbox もラベルを参照 |
高優先度: キーボード操作 - ポップアップ閉時 (Unit + E2E)
| テスト | 説明 |
|---|---|
Down Arrow | ポップアップを開き、最初のオプションにフォーカス |
Up Arrow | ポップアップを開き、最後のオプションにフォーカス |
Alt + Down Arrow | フォーカスを変更せずにポップアップを開く |
入力 | ポップアップを開き、オプションをフィルタリング |
高優先度: キーボード操作 - ポップアップ開時 (Unit + E2E)
| テスト | 説明 |
|---|---|
Down Arrow | 次の有効なオプションに移動(折り返しなし) |
Up Arrow | 前の有効なオプションに移動(折り返しなし) |
Home | 最初の有効なオプションに移動 |
End | 最後の有効なオプションに移動 |
Enter | フォーカス中のオプションを選択しポップアップを閉じる |
Escape | ポップアップを閉じ、以前の値を復元 |
Alt + Up Arrow | フォーカス中のオプションを選択しポップアップを閉じる |
Tab | ポップアップを閉じ、次のフォーカス可能な要素に移動 |
高優先度: フォーカス管理 (Unit + E2E)
| テスト | 説明 |
|---|---|
Input に DOM フォーカス | DOM フォーカスは常に input に留まる |
aria-activedescendant による仮想フォーカス | 視覚的フォーカスは aria-activedescendant で制御 |
閉じた時にクリア | ポップアップが閉じると aria-activedescendant がクリア |
無効なオプションをスキップ | ナビゲーションが無効なオプションをスキップ |
中優先度: フィルタリング (Unit)
| テスト | 説明 |
|---|---|
入力時にフィルタ | ユーザーが入力するとオプションがフィルタリング |
大文字小文字を区別しない | フィルタリングは大文字小文字を区別しない |
フィルタなし (autocomplete="none") | 入力に関係なくすべてのオプションを表示 |
空の結果 | 一致がない場合 aria-activedescendant がクリア |
中優先度: マウス操作 (E2E)
| テスト | 説明 |
|---|---|
オプションをクリック | オプションを選択しポップアップを閉じる |
オプションにホバー | ホバー時に aria-activedescendant を更新 |
無効なオプションをクリック | 無効なオプションは選択不可 |
外側をクリック | 選択せずにポップアップを閉じる |
中優先度: IME 入力 (Unit)
| テスト | 説明 |
|---|---|
変換中 | IME 変換中はキーボードナビゲーションをブロック |
変換完了時 | 変換完了後にフィルタリングを更新 |
中優先度: コールバック (Unit)
| テスト | 説明 |
|---|---|
onSelect | 選択時にオプションデータと共に呼び出し |
onInputChange | 入力値の変更時に呼び出し |
onOpenChange | ポップアップの開閉時に呼び出し |
低優先度: HTML 属性継承 (Unit)
| テスト | 説明 |
|---|---|
className | コンテナにカスタムクラスが適用される |
placeholder | プレースホルダーテキストが input に表示 |
disabled 状態 | disabled prop が設定されるとコンポーネントが無効化 |
低優先度: クロスフレームワーク一貫性 (E2E)
| テスト | 説明 |
|---|---|
全フレームワークにコンボボックス | React、Vue、Svelte、Astro すべてがコンボボックス要素をレンダリング |
クリックで選択 | すべてのフレームワークがクリックで正しく選択 |
一貫した ARIA | すべてのフレームワークが一貫した ARIA 構造を持つ |
テストツール
- Vitest (opens in new tab) - ユニットテスト用テストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ(React、Vue、Svelte)
- Playwright (opens in new tab) - E2E テスト用ブラウザ自動化
- axe-core/playwright (opens in new tab) - E2E での自動アクセシビリティテスト
詳細なドキュメントは testing-strategy.md (opens in new tab) を参照してください。
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Combobox from './Combobox.svelte';
// Default test options
const defaultOptions = [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry' },
];
// Options with disabled item
const optionsWithDisabled = [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana', disabled: true },
{ id: 'cherry', label: 'Cherry' },
];
// Options with first item disabled
const optionsWithFirstDisabled = [
{ id: 'apple', label: 'Apple', disabled: true },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry' },
];
// Options with last item disabled
const optionsWithLastDisabled = [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry', disabled: true },
];
// All disabled options
const allDisabledOptions = [
{ id: 'apple', label: 'Apple', disabled: true },
{ id: 'banana', label: 'Banana', disabled: true },
{ id: 'cherry', label: 'Cherry', disabled: true },
];
describe('Combobox (Svelte)', () => {
// 🔴 High Priority: APG ARIA Attributes
describe('APG: ARIA Attributes', () => {
it('input has role="combobox"', () => {
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('has accessible name via aria-labelledby', () => {
render(Combobox, {
props: { options: defaultOptions, label: 'Select a fruit' },
});
const input = screen.getByRole('combobox');
expect(input).toHaveAccessibleName('Select a fruit');
});
it('has aria-controls pointing to listbox', () => {
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
const listboxId = input.getAttribute('aria-controls');
expect(listboxId).toBeTruthy();
expect(document.getElementById(listboxId!)).toHaveAttribute('role', 'listbox');
});
it('aria-controls points to existing listbox even when closed', () => {
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
const listboxId = input.getAttribute('aria-controls');
expect(listboxId).toBeTruthy();
const listbox = document.getElementById(listboxId!);
expect(listbox).toBeInTheDocument();
expect(listbox).toHaveAttribute('hidden');
});
it('has aria-expanded="false" when closed', () => {
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
expect(screen.getByRole('combobox')).toHaveAttribute('aria-expanded', 'false');
});
it('has aria-expanded="true" when opened', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-expanded', 'true');
});
it('has aria-autocomplete="list"', () => {
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
expect(screen.getByRole('combobox')).toHaveAttribute('aria-autocomplete', 'list');
});
it('has aria-autocomplete="none" when autocomplete is none', () => {
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit', autocomplete: 'none' },
});
expect(screen.getByRole('combobox')).toHaveAttribute('aria-autocomplete', 'none');
});
it('has aria-activedescendant when option focused', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-activedescendant');
const activeId = input.getAttribute('aria-activedescendant');
expect(activeId).toBeTruthy();
expect(document.getElementById(activeId!)).toHaveTextContent('Apple');
});
it('clears aria-activedescendant when closed', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input.getAttribute('aria-activedescendant')).toBeTruthy();
await user.keyboard('{Escape}');
expect(input.getAttribute('aria-activedescendant')).toBeFalsy();
});
it('listbox has role="listbox"', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
it('options have role="option"', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const options = screen.getAllByRole('option');
expect(options).toHaveLength(3);
});
it('focused option has aria-selected="true"', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const firstOption = screen.getByRole('option', { name: 'Apple' });
expect(firstOption).toHaveAttribute('aria-selected', 'true');
});
it('disabled option has aria-disabled="true"', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: optionsWithDisabled, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const disabledOption = screen.getByRole('option', { name: 'Banana' });
expect(disabledOption).toHaveAttribute('aria-disabled', 'true');
});
});
// 🔴 High Priority: APG Keyboard Interaction (Input)
describe('APG: Keyboard Interaction (Input)', () => {
it('opens popup and focuses first enabled option on ArrowDown', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-expanded', 'true');
const activeId = input.getAttribute('aria-activedescendant');
expect(document.getElementById(activeId!)).toHaveTextContent('Apple');
});
it('opens popup and focuses last enabled option on ArrowUp', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowUp}');
expect(input).toHaveAttribute('aria-expanded', 'true');
const activeId = input.getAttribute('aria-activedescendant');
expect(document.getElementById(activeId!)).toHaveTextContent('Cherry');
});
it('skips disabled first option on ArrowDown', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: optionsWithFirstDisabled, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const activeId = input.getAttribute('aria-activedescendant');
expect(document.getElementById(activeId!)).toHaveTextContent('Banana');
});
it('skips disabled last option on ArrowUp', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: optionsWithLastDisabled, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowUp}');
const activeId = input.getAttribute('aria-activedescendant');
expect(document.getElementById(activeId!)).toHaveTextContent('Banana');
});
it('closes popup on Escape', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-expanded', 'true');
await user.keyboard('{Escape}');
expect(input).toHaveAttribute('aria-expanded', 'false');
});
it('selects option and closes popup on Enter', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit', onSelect },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
expect(onSelect).toHaveBeenCalledWith(defaultOptions[0]);
expect(input).toHaveAttribute('aria-expanded', 'false');
expect(input).toHaveValue('Apple');
});
it('closes popup on Tab', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-expanded', 'true');
await user.keyboard('{Tab}');
expect(input).toHaveAttribute('aria-expanded', 'false');
});
it('opens popup on typing', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'a');
expect(input).toHaveAttribute('aria-expanded', 'true');
});
it('Alt+ArrowDown opens without changing focus position', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{Alt>}{ArrowDown}{/Alt}');
expect(input).toHaveAttribute('aria-expanded', 'true');
expect(input.getAttribute('aria-activedescendant')).toBeFalsy();
});
it('Alt+ArrowUp commits selection and closes', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit', onSelect },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
await user.keyboard('{Alt>}{ArrowUp}{/Alt}');
expect(onSelect).toHaveBeenCalledWith(defaultOptions[1]);
expect(input).toHaveAttribute('aria-expanded', 'false');
});
});
// 🔴 High Priority: APG Keyboard Interaction (Listbox Navigation)
describe('APG: Keyboard Interaction (Listbox)', () => {
it('moves to next enabled option on ArrowDown', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Apple');
await user.keyboard('{ArrowDown}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Banana');
});
it('moves to previous enabled option on ArrowUp', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Banana');
await user.keyboard('{ArrowUp}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Apple');
});
it('skips disabled option on ArrowDown', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: optionsWithDisabled, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Apple');
await user.keyboard('{ArrowDown}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Cherry');
});
it('skips disabled option on ArrowUp', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: optionsWithDisabled, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowUp}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Cherry');
await user.keyboard('{ArrowUp}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Apple');
});
it('moves to first enabled option on Home', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: optionsWithFirstDisabled, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowUp}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Cherry');
await user.keyboard('{Home}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Banana');
});
it('moves to last enabled option on End', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: optionsWithLastDisabled, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Apple');
await user.keyboard('{End}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Banana');
});
it('does not wrap on ArrowDown at last option', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Cherry');
await user.keyboard('{ArrowDown}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Cherry');
});
it('does not wrap on ArrowUp at first option', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Apple');
await user.keyboard('{ArrowUp}');
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Apple');
});
});
// 🔴 High Priority: Focus Management
describe('APG: Focus Management', () => {
it('keeps DOM focus on input when navigating', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
expect(input).toHaveFocus();
});
it('updates aria-activedescendant on navigation', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const firstActiveId = input.getAttribute('aria-activedescendant');
expect(firstActiveId).toBeTruthy();
await user.keyboard('{ArrowDown}');
const secondActiveId = input.getAttribute('aria-activedescendant');
expect(secondActiveId).toBeTruthy();
expect(secondActiveId).not.toBe(firstActiveId);
});
it('maintains focus on input after selection', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
expect(input).toHaveFocus();
});
});
// 🔴 High Priority: Autocomplete
describe('Autocomplete', () => {
it('filters options based on input', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'app');
const options = screen.getAllByRole('option');
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Apple');
});
it('shows all options when input is empty', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const options = screen.getAllByRole('option');
expect(options).toHaveLength(3);
});
it('updates input value on selection', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
expect(input).toHaveValue('Banana');
});
it('does not filter when autocomplete="none"', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit', autocomplete: 'none' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'xyz');
const options = screen.getAllByRole('option');
expect(options).toHaveLength(3);
});
});
// 🔴 High Priority: Disabled Options
describe('Disabled Options', () => {
it('does not select disabled option on Enter', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(Combobox, {
props: { options: optionsWithFirstDisabled, label: 'Fruit', onSelect },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowUp}');
await user.keyboard('{Enter}');
expect(onSelect).toHaveBeenCalledWith(optionsWithFirstDisabled[1]);
});
it('does not select disabled option on click', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(Combobox, {
props: { options: optionsWithDisabled, label: 'Fruit', onSelect },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const disabledOption = screen.getByRole('option', { name: 'Banana' });
await user.click(disabledOption);
expect(onSelect).not.toHaveBeenCalled();
expect(input).toHaveAttribute('aria-expanded', 'true');
});
});
// 🔴 High Priority: Mouse Interaction
describe('Mouse Interaction', () => {
it('selects option on click', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit', onSelect },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const option = screen.getByRole('option', { name: 'Banana' });
await user.click(option);
expect(onSelect).toHaveBeenCalledWith(defaultOptions[1]);
expect(input).toHaveValue('Banana');
});
it('closes popup on option click', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-expanded', 'true');
const option = screen.getByRole('option', { name: 'Banana' });
await user.click(option);
expect(input).toHaveAttribute('aria-expanded', 'false');
});
it('closes popup on outside click', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-expanded', 'true');
await user.click(document.body);
expect(input).toHaveAttribute('aria-expanded', 'false');
});
it('updates aria-selected on hover', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const bananaOption = screen.getByRole('option', { name: 'Banana' });
await user.hover(bananaOption);
expect(bananaOption).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('option', { name: 'Apple' })).toHaveAttribute(
'aria-selected',
'false'
);
});
});
// 🟡 Medium Priority: Accessibility Validation
describe('Accessibility', () => {
it('has no axe violations when closed', async () => {
const { container } = render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when open', async () => {
const user = userEvent.setup();
const { container } = render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with selection', async () => {
const user = userEvent.setup();
const { container } = render(Combobox, {
props: { options: defaultOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟢 Low Priority: Props & Behavior
describe('Props & Behavior', () => {
it('calls onSelect when option selected', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit', onSelect },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
expect(onSelect).toHaveBeenCalledWith(defaultOptions[0]);
expect(onSelect).toHaveBeenCalledTimes(1);
});
it('calls onInputChange when typing', async () => {
const user = userEvent.setup();
const onInputChange = vi.fn();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit', onInputChange },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'app');
expect(onInputChange).toHaveBeenCalledWith('a');
expect(onInputChange).toHaveBeenCalledWith('ap');
expect(onInputChange).toHaveBeenCalledWith('app');
});
it('calls onOpenChange when popup toggles', async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit', onOpenChange },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(onOpenChange).toHaveBeenCalledWith(true);
await user.keyboard('{Escape}');
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('applies className to container', () => {
const { container } = render(Combobox, {
props: { options: defaultOptions, label: 'Fruit', class: 'custom-class' },
});
expect(container.querySelector('.apg-combobox')).toHaveClass('custom-class');
});
it('supports disabled state on combobox', () => {
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit', disabled: true },
});
const input = screen.getByRole('combobox');
expect(input).toBeDisabled();
});
it('supports placeholder', () => {
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit', placeholder: 'Choose a fruit...' },
});
const input = screen.getByRole('combobox');
expect(input).toHaveAttribute('placeholder', 'Choose a fruit...');
});
it('supports defaultInputValue', () => {
render(Combobox, {
props: { options: defaultOptions, label: 'Fruit', defaultInputValue: 'Ban' },
});
const input = screen.getByRole('combobox');
expect(input).toHaveValue('Ban');
});
});
// Edge Cases
describe('Edge Cases', () => {
it('handles empty options array', () => {
expect(() => {
render(Combobox, {
props: { options: [], label: 'Fruit' },
});
}).not.toThrow();
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('when all options are disabled, popup opens but no focus set', async () => {
const user = userEvent.setup();
render(Combobox, {
props: { options: allDisabledOptions, label: 'Fruit' },
});
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-expanded', 'true');
expect(input.getAttribute('aria-activedescendant')).toBeFalsy();
});
});
}); リソース
- WAI-ARIA APG: Combobox パターン (opens in new tab)
- MDN: <datalist> 要素 (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist