コンボボックス
リストオートコンプリート機能を持つ編集可能なコンボボックス。ユーザーは入力してオプションをフィルタリングしたり、キーボードやマウスでポップアップリストボックスから選択したりできます。
デモ
- りんご
- バナナ
- さくらんぼ
- デーツ
- エルダーベリー
- いちじく
- ぶどう
- りんご
- バナナ
- さくらんぼ
- デーツ
- エルダーベリー
- いちじく
- ぶどう
- 日本
- アメリカ
- イギリス
- ドイツ
- フランス
- イタリア
- スペイン
- オーストラリア
- りんご
- バナナ
- さくらんぼ
- デーツ
- エルダーベリー
- いちじく
- ぶどう
- りんご
- バナナ
- さくらんぼ
- デーツ
- エルダーベリー
- いちじく
- ぶどう
ネイティブ 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 | Popup (<ul>) | 選択可能なオプションを含むポップアップ |
option | Each item (<li>) | 個々の選択可能なオプション |
WAI-ARIA プロパティ
role="combobox"
入力をコンボボックスとして識別
- 値
- -
- 必須
- はい
aria-controls
リストボックスポップアップを参照(閉じている時も)
- 値
- ID reference
- 必須
- はい
aria-expanded
ポップアップが開いているかどうかを示す
- 値
true|false- 必須
- はい
aria-autocomplete
オートコンプリートの動作を説明
- 値
list|none|both- 必須
- はい
aria-activedescendant
ポップアップ内で現在フォーカスされているオプションを参照
- 値
- ID reference | empty
- 必須
- はい
aria-labelledby
ラベル要素を参照
- 値
- ID reference
- 必須
- はい*
aria-selected
現在フォーカスされているオプションを示す
- 値
true|false- 必須
- はい
aria-disabled
オプションが無効であることを示す
- 値
true- 必須
- いいえ
キーボードサポート
| キー | アクション |
|---|---|
| Down Arrow | ポップアップを開き、最初のオプションにフォーカス |
| Up Arrow | ポップアップを開き、最後のオプションにフォーカス |
| Alt + Down Arrow | フォーカス位置を変更せずにポップアップを開く |
| 文字入力 | オプションをフィルタリングしてポップアップを開く |
| Down Arrow | 次の有効なオプションにフォーカスを移動(折り返しなし) |
| Up Arrow | 前の有効なオプションにフォーカスを移動(折り返しなし) |
| Home | 最初の有効なオプションにフォーカスを移動 |
| End | 最後の有効なオプションにフォーカスを移動 |
| Enter | フォーカス中のオプションを選択しポップアップを閉じる |
| Escape | ポップアップを閉じ、以前の入力値を復元 |
| Alt + Up Arrow | フォーカス中のオプションを選択しポップアップを閉じる |
| Tab | ポップアップを閉じ、次のフォーカス可能な要素に移動 |
- リストボックスは常にDOMに存在:
aria-controls参照のため、閉じている時もhidden属性を使用してリストボックスをDOMに保持 - IME処理: IME入力中のフィルタリングを防ぐため、変換状態を追跡
- 外側クリック: イベントリスナーを使用して外側クリック時にポップアップを閉じる
- 値の復元: Escapeキーで復元するため、編集前の値を保存
フォーカス管理
| イベント | 振る舞い |
|---|---|
| 矢印キーでナビゲーション | DOMフォーカスはinputに留まり、aria-activedescendantが視覚的にフォーカスされているオプションを参照 |
| ポップアップが閉じるかフィルタ結果が空 | aria-activedescendantがクリアされる |
| 無効なオプションに遭遇 | ナビゲーション中に無効なオプションはスキップされる |
参考資料
ソースコード
import { cn } from '@/lib/utils';
import type { HTMLAttributes, KeyboardEvent, ReactElement } from 'react';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
export interface ComboboxOption {
id: string;
label: string;
disabled?: boolean;
}
export interface ComboboxProps extends Omit<
HTMLAttributes<HTMLDivElement>,
'onChange' | 'onSelect'
> {
/** List of options */
options: ComboboxOption[];
/** Selected option ID (controlled) */
selectedOptionId?: string;
/** Default selected option ID */
defaultSelectedOptionId?: string;
/** Input value (controlled) */
inputValue?: string;
/** Default input value */
defaultInputValue?: string;
/** Label text */
label: string;
/** Placeholder */
placeholder?: string;
/** Disabled state */
disabled?: boolean;
/** Autocomplete type */
autocomplete?: 'none' | 'list' | 'both';
/** Message shown when no results found */
noResultsMessage?: string;
/** Selection callback */
onSelect?: (option: ComboboxOption) => void;
/** Input change callback */
onInputChange?: (value: string) => void;
/** Popup open/close callback */
onOpenChange?: (isOpen: boolean) => void;
}
export function Combobox({
options,
selectedOptionId: controlledSelectedId,
defaultSelectedOptionId,
inputValue: controlledInputValue,
defaultInputValue = '',
label,
placeholder,
disabled = false,
autocomplete = 'list',
noResultsMessage = 'No results found',
onSelect,
onInputChange,
onOpenChange,
className = '',
...restProps
}: ComboboxProps): ReactElement {
const instanceId = useId();
const inputId = `${instanceId}-input`;
const labelId = `${instanceId}-label`;
const listboxId = `${instanceId}-listbox`;
// Internal state
const [isOpen, setIsOpen] = useState(false);
const [internalInputValue, setInternalInputValue] = useState(() => {
if (!defaultSelectedOptionId) {
return defaultInputValue;
}
const option = options.find(({ id }) => id === defaultSelectedOptionId);
if (option === undefined) {
return defaultInputValue;
}
return option.label;
});
const [internalSelectedId, setInternalSelectedId] = useState<string | undefined>(
defaultSelectedOptionId
);
const [activeIndex, setActiveIndex] = useState(-1);
const [isSearching, setIsSearching] = useState(false);
// Track value before opening for Escape restoration
const valueBeforeOpen = useRef<string>('');
const isComposing = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Determine controlled vs uncontrolled
const inputValue = controlledInputValue ?? internalInputValue;
const selectedId = controlledSelectedId ?? internalSelectedId;
// Get selected option's label
const selectedLabel = useMemo(() => {
if (!selectedId) {
return '';
}
const option = options.find(({ id }) => id === selectedId);
return option?.label ?? '';
}, [options, selectedId]);
// Filter options based on input value and search mode
const filteredOptions = useMemo(() => {
// Don't filter if autocomplete is none
if (autocomplete === 'none') {
return options;
}
// Don't filter if input is empty
if (!inputValue) {
return options;
}
// Don't filter if not in search mode AND input matches selected label
if (!isSearching && inputValue === selectedLabel) {
return options;
}
const lowerInputValue = inputValue.toLowerCase();
return options.filter(({ label }) => label.toLowerCase().includes(lowerInputValue));
}, [options, inputValue, autocomplete, isSearching, selectedLabel]);
// Get enabled options from filtered list
const enabledOptions = useMemo(
() => filteredOptions.filter(({ disabled }) => !disabled),
[filteredOptions]
);
// Generate option IDs
const getOptionId = useCallback(
(optionId: string) => `${instanceId}-option-${optionId}`,
[instanceId]
);
// Get active descendant ID
const activeDescendantId = useMemo(() => {
if (activeIndex < 0 || activeIndex >= filteredOptions.length) {
return undefined;
}
const option = filteredOptions[activeIndex];
if (option === undefined) {
return undefined;
}
return getOptionId(option.id);
}, [activeIndex, filteredOptions, getOptionId]);
// Update input value
const updateInputValue = useCallback(
(value: string) => {
if (controlledInputValue === undefined) {
setInternalInputValue(value);
}
onInputChange?.(value);
},
[controlledInputValue, onInputChange]
);
// Open popup
const openPopup = useCallback(
(focusPosition?: 'first' | 'last') => {
if (isOpen) {
return;
}
valueBeforeOpen.current = inputValue;
setIsOpen(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);
setActiveIndex(targetIndex);
},
[isOpen, inputValue, enabledOptions, filteredOptions, onOpenChange]
);
// Close popup
const closePopup = useCallback(
(restore = false) => {
setIsOpen(false);
setActiveIndex(-1);
setIsSearching(false);
onOpenChange?.(false);
if (restore) {
updateInputValue(valueBeforeOpen.current);
}
},
[onOpenChange, updateInputValue]
);
// Select option
const selectOption = useCallback(
({ id, label, disabled }: ComboboxOption) => {
if (disabled) {
return;
}
if (controlledSelectedId === undefined) {
setInternalSelectedId(id);
}
setIsSearching(false);
updateInputValue(label);
onSelect?.({ id, label, disabled });
closePopup();
},
[controlledSelectedId, updateInputValue, onSelect, closePopup]
);
// Find next/previous enabled option index
const findEnabledIndex = useCallback(
(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);
},
[enabledOptions, filteredOptions]
);
// Handle input keydown
const handleInputKeyDown = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
if (isComposing.current) {
return;
}
const { key, altKey } = event;
switch (key) {
case 'ArrowDown': {
event.preventDefault();
if (altKey) {
if (isOpen) {
return;
}
valueBeforeOpen.current = inputValue;
setIsOpen(true);
onOpenChange?.(true);
return;
}
if (!isOpen) {
openPopup('first');
return;
}
const nextIndex = findEnabledIndex(activeIndex, 'next');
if (nextIndex >= 0) {
setActiveIndex(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) {
setActiveIndex(prevIndex);
}
break;
}
case 'Home': {
if (!isOpen) {
return;
}
event.preventDefault();
const firstIndex = findEnabledIndex(0, 'first');
if (firstIndex >= 0) {
setActiveIndex(firstIndex);
}
break;
}
case 'End': {
if (!isOpen) {
return;
}
event.preventDefault();
const lastIndex = findEnabledIndex(0, 'last');
if (lastIndex >= 0) {
setActiveIndex(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;
}
}
},
[
isOpen,
inputValue,
activeIndex,
filteredOptions,
openPopup,
closePopup,
selectOption,
findEnabledIndex,
onOpenChange,
]
);
// Handle input change
const handleInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setIsSearching(true);
updateInputValue(value);
if (!isOpen && !isComposing.current) {
valueBeforeOpen.current = inputValue;
setIsOpen(true);
onOpenChange?.(true);
}
setActiveIndex(-1);
},
[isOpen, inputValue, updateInputValue, onOpenChange]
);
// Handle option click
const handleOptionClick = useCallback(
(option: ComboboxOption) => {
if (option.disabled) {
return;
}
selectOption(option);
},
[selectOption]
);
// Handle option hover
const handleOptionHover = useCallback(
({ id }: ComboboxOption) => {
const index = filteredOptions.findIndex((option) => option.id === id);
if (index < 0) {
return;
}
setActiveIndex(index);
},
[filteredOptions]
);
// Handle IME composition
const handleCompositionStart = useCallback(() => {
isComposing.current = true;
}, []);
const handleCompositionEnd = useCallback(() => {
isComposing.current = false;
}, []);
// Handle focus - open popup when input receives focus
const handleFocus = useCallback(() => {
if (isOpen || disabled) {
return;
}
openPopup();
}, [isOpen, disabled, openPopup]);
// Click outside to close
useEffect(() => {
if (!isOpen) {
return;
}
const handleClickOutside = (event: MouseEvent) => {
const { current: container } = containerRef;
if (container === null) {
return;
}
if (event.target instanceof Node && !container.contains(event.target)) {
closePopup();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, closePopup]);
// Clear active index when filtered options change and no match exists
useEffect(() => {
if (activeIndex >= 0 && activeIndex >= filteredOptions.length) {
setActiveIndex(-1);
}
}, [activeIndex, filteredOptions.length]);
// Reset search mode when input value matches selected label or becomes empty
useEffect(() => {
if (inputValue === '' || inputValue === selectedLabel) {
setIsSearching(false);
}
}, [inputValue, selectedLabel]);
return (
<div ref={containerRef} className={cn('apg-combobox', className)} {...restProps}>
<label id={labelId} htmlFor={inputId} className="apg-combobox-label">
{label}
</label>
<div className="apg-combobox-input-wrapper">
<input
ref={inputRef}
id={inputId}
type="text"
role="combobox"
className="apg-combobox-input"
aria-autocomplete={autocomplete}
aria-expanded={isOpen}
aria-controls={listboxId}
aria-labelledby={labelId}
aria-activedescendant={activeDescendantId || undefined}
value={inputValue}
placeholder={placeholder}
disabled={disabled}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onFocus={handleFocus}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
/>
<span className="apg-combobox-caret" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="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"
clipRule="evenodd"
/>
</svg>
</span>
</div>
<ul
id={listboxId}
role="listbox"
aria-labelledby={labelId}
className="apg-combobox-listbox"
hidden={!isOpen || undefined}
>
{filteredOptions.length === 0 && (
<li className="apg-combobox-no-results" role="status">
{noResultsMessage}
</li>
)}
{filteredOptions.map(({ id, label: optionLabel, disabled: optionDisabled }, index) => {
const isActive = index === activeIndex;
const isSelected = id === selectedId;
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events -- managed on option aria-activedescendant
<li
key={id}
id={getOptionId(id)}
role="option"
className="apg-combobox-option"
aria-selected={isActive}
aria-disabled={optionDisabled || undefined}
onClick={() =>
handleOptionClick({ id, label: optionLabel, disabled: optionDisabled })
}
onMouseEnter={() =>
handleOptionHover({ id, label: optionLabel, disabled: optionDisabled })
}
data-selected={isSelected || undefined}
>
<span className="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>
{optionLabel}
</li>
);
})}
</ul>
</div>
);
}
export default Combobox; 使い方
import { Combobox } from './Combobox';
const options = [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry' },
];
function App() {
return (
<div>
{/* Basic usage */}
<Combobox
options={options}
label="Favorite Fruit"
placeholder="Type to search..."
/>
{/* With default value */}
<Combobox
options={options}
label="Fruit"
defaultSelectedOptionId="banana"
/>
{/* With disabled options */}
<Combobox
options={[
{ id: 'a', label: 'Option A' },
{ id: 'b', label: 'Option B', disabled: true },
{ id: 'c', label: 'Option C' },
]}
label="Select Option"
/>
{/* No filtering (autocomplete="none") */}
<Combobox
options={options}
label="Select"
autocomplete="none"
/>
{/* With callbacks */}
<Combobox
options={options}
label="Fruit"
onSelect={(option) => console.log('Selected:', option)}
onInputChange={(value) => console.log('Input:', value)}
onOpenChange={(isOpen) => console.log('Open:', isOpen)}
/>
</div>
);
} API
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
options | ComboboxOption[] | Required | id、label、オプションの disabled を持つオプションの配列 |
label | string | Required | 表示されるラベルテキスト |
placeholder | string | - | 入力のプレースホルダーテキスト |
defaultInputValue | string | "" | デフォルトの入力値 |
defaultSelectedOptionId | string | - | 初期選択されるオプションの ID |
inputValue | string | - | 制御された入力値 |
selectedOptionId | string | - | 制御された選択オプション ID |
autocomplete | "none" | "list" | "both" | "list" | オートコンプリートの動作 |
disabled | boolean | false | コンボボックスを無効にするかどうか |
onSelect | (option: ComboboxOption) => void | - | オプション選択時のコールバック |
onInputChange | (value: string) => void | - | 入力値変更時のコールバック |
onOpenChange | (isOpen: boolean) => void | - | ポップアップ開閉時のコールバック |
テスト
テストは 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がlistboxIDを参照(常に存在) |
aria-expanded | ポップアップの開閉状態を反映 |
aria-autocomplete | "list"、"none"、"both"のいずれかに設定 |
aria-activedescendant | 現在フォーカスされているオプションを参照 |
aria-selected | 現在ハイライトされているオプションを示す |
aria-disabled | 無効なオプションを示す |
高優先度: キーボード - ポップアップ閉時 (Unit + E2E)
| テスト | 説明 |
|---|---|
Down Arrow | ポップアップを開き、最初のオプションにフォーカス |
Up Arrow | ポップアップを開き、最後のオプションにフォーカス |
Alt + Down Arrow | フォーカスを変更せずにポップアップを開く |
Typing | ポップアップを開き、オプションをフィルタリング |
高優先度: キーボード - ポップアップ開時 (Unit + E2E)
| テスト | 説明 |
|---|---|
Down Arrow | 次の有効なオプションに移動(折り返しなし) |
Up Arrow | 前の有効なオプションに移動(折り返しなし) |
Home | 最初の有効なオプションに移動 |
End | 最後の有効なオプションに移動 |
Enter | フォーカス中のオプションを選択しポップアップを閉じる |
Escape | ポップアップを閉じ、以前の値を復元 |
Alt + Up Arrow | フォーカス中のオプションを選択しポップアップを閉じる |
Tab | ポップアップを閉じ、次のフォーカス可能な要素に移動 |
高優先度: フォーカス管理 (Unit + E2E)
| テスト | 説明 |
|---|---|
DOM focus on input | DOMフォーカスは常にinputに留まる |
Virtual focus via aria-activedescendant | 視覚的フォーカスはaria-activedescendantで制御 |
Clear on close | ポップアップが閉じるとaria-activedescendantがクリア |
Skip disabled options | ナビゲーションが無効なオプションをスキップ |
中優先度: フィルタリング (Unit)
| テスト | 説明 |
|---|---|
Filter on typing | ユーザーが入力するとオプションがフィルタリング |
Case insensitive | フィルタリングは大文字小文字を区別しない |
No filter (autocomplete="none") | 入力に関係なくすべてのオプションを表示 |
Empty results | 一致がない場合aria-activedescendantがクリア |
中優先度: マウス操作 (E2E)
| テスト | 説明 |
|---|---|
Click option | オプションを選択しポップアップを閉じる |
Hover option | ホバー時にaria-activedescendantを更新 |
Click disabled | 無効なオプションは選択不可 |
Click outside | 選択せずにポップアップを閉じる |
テストツール
- 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/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Combobox, type ComboboxOption } from './Combobox';
// Default test options
const defaultOptions: ComboboxOption[] = [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry' },
];
// Options with disabled item
const optionsWithDisabled: ComboboxOption[] = [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana', disabled: true },
{ id: 'cherry', label: 'Cherry' },
];
// Options with first item disabled
const optionsWithFirstDisabled: ComboboxOption[] = [
{ id: 'apple', label: 'Apple', disabled: true },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry' },
];
// Options with last item disabled
const optionsWithLastDisabled: ComboboxOption[] = [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry', disabled: true },
];
// All disabled options
const allDisabledOptions: ComboboxOption[] = [
{ id: 'apple', label: 'Apple', disabled: true },
{ id: 'banana', label: 'Banana', disabled: true },
{ id: 'cherry', label: 'Cherry', disabled: true },
];
describe('Combobox', () => {
// 🔴 High Priority: APG ARIA Attributes
describe('APG: ARIA Attributes', () => {
it('input has role="combobox"', () => {
render(<Combobox options={defaultOptions} label="Fruit" />);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('has accessible name via aria-labelledby', () => {
render(<Combobox 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 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 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 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 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 options={defaultOptions} label="Fruit" />);
expect(screen.getByRole('combobox')).toHaveAttribute('aria-autocomplete', 'list');
});
it('has aria-autocomplete="none" when autocomplete is none', () => {
render(<Combobox 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 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 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('clears aria-activedescendant when list is empty after filtering', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input.getAttribute('aria-activedescendant')).toBeTruthy();
// Type something that matches no options
await user.clear(input);
await user.type(input, 'xyz');
expect(input.getAttribute('aria-activedescendant')).toBeFalsy();
});
it('listbox has role="listbox"', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
it('listbox is hidden when closed', () => {
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
const listboxId = input.getAttribute('aria-controls');
const listbox = document.getElementById(listboxId!);
expect(listbox).toHaveAttribute('hidden');
});
it('options have role="option"', async () => {
const user = userEvent.setup();
render(<Combobox 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 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('non-focused options have aria-selected="false"', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const secondOption = screen.getByRole('option', { name: 'Banana' });
const thirdOption = screen.getByRole('option', { name: 'Cherry' });
expect(secondOption).toHaveAttribute('aria-selected', 'false');
expect(thirdOption).toHaveAttribute('aria-selected', 'false');
});
it('disabled option has aria-disabled="true"', async () => {
const user = userEvent.setup();
render(<Combobox 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 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 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 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 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 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('restores input value on Escape', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" defaultInputValue="App" />);
const input = screen.getByRole('combobox');
expect(input).toHaveValue('App');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
// After navigation, input might show preview of Banana
await user.keyboard('{Escape}');
expect(input).toHaveValue('App');
});
it('selects option and closes popup on Enter', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<Combobox options={defaultOptions} label="Fruit" onSelect={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(
<div>
<Combobox options={defaultOptions} label="Fruit" />
<button>Next</button>
</div>
);
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 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 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');
// aria-activedescendant should not be set
expect(input.getAttribute('aria-activedescendant')).toBeFalsy();
});
it('Alt+ArrowUp commits selection and closes', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<Combobox options={defaultOptions} label="Fruit" onSelect={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 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 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 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}');
// Should skip Banana (disabled) and go to Cherry
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Cherry');
});
it('skips disabled option on ArrowUp', async () => {
const user = userEvent.setup();
render(<Combobox 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}');
// Should skip Banana (disabled) and go to Apple
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Apple');
});
it('moves to first enabled option on Home', async () => {
const user = userEvent.setup();
render(<Combobox 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}');
// Should skip Apple (disabled) and go to Banana
expect(
document.getElementById(input.getAttribute('aria-activedescendant')!)
).toHaveTextContent('Banana');
});
it('moves to last enabled option on End', async () => {
const user = userEvent.setup();
render(<Combobox 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}');
// Should skip Cherry (disabled) and go to Banana
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 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}');
// Should stay at Cherry, no wrap
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 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}');
// Should stay at Apple, no wrap
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 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 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('aria-activedescendant references existing element', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
const activeId = input.getAttribute('aria-activedescendant');
expect(activeId).toBeTruthy();
expect(document.getElementById(activeId!)).toBeInTheDocument();
});
it('maintains focus on input after selection', async () => {
const user = userEvent.setup();
render(<Combobox 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 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 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 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 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);
});
it('case-insensitive filtering', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'APPLE');
const options = screen.getAllByRole('option');
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Apple');
});
it('shows no options message when filter results are empty', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'xyz');
expect(screen.queryAllByRole('option')).toHaveLength(0);
});
});
// 🔴 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 options={optionsWithFirstDisabled} label="Fruit" onSelect={onSelect} />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
// First enabled option is Banana
await user.keyboard('{ArrowUp}');
// Try to go to Apple (disabled) - should stay at Banana
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 options={optionsWithDisabled} label="Fruit" onSelect={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');
});
it('shows disabled options in filtered results', async () => {
const user = userEvent.setup();
render(<Combobox options={optionsWithDisabled} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'ban');
const options = screen.getAllByRole('option');
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Banana');
expect(options[0]).toHaveAttribute('aria-disabled', 'true');
});
});
// 🔴 High Priority: Mouse Interaction
describe('Mouse Interaction', () => {
it('selects option on click', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<Combobox options={defaultOptions} label="Fruit" onSelect={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 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(
<div>
<Combobox options={defaultOptions} label="Fruit" />
<button>Outside</button>
</div>
);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-expanded', 'true');
await user.click(screen.getByRole('button', { name: 'Outside' }));
expect(input).toHaveAttribute('aria-expanded', 'false');
});
it('does not select on outside click', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(
<div>
<Combobox options={defaultOptions} label="Fruit" onSelect={onSelect} />
<button>Outside</button>
</div>
);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
await user.click(screen.getByRole('button', { name: 'Outside' }));
expect(onSelect).not.toHaveBeenCalled();
});
it('updates aria-selected on hover', async () => {
const user = userEvent.setup();
render(<Combobox 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 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 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 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();
});
it('has no axe violations with disabled options', async () => {
const user = userEvent.setup();
const { container } = render(<Combobox options={optionsWithDisabled} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('{ArrowDown}');
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 options={defaultOptions} label="Fruit" onSelect={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 options={defaultOptions} label="Fruit" onInputChange={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 options={defaultOptions} label="Fruit" onOpenChange={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 options={defaultOptions} label="Fruit" className="custom-class" />
);
expect(container.querySelector('.apg-combobox')).toHaveClass('custom-class');
});
it('supports disabled state on combobox', () => {
render(<Combobox options={defaultOptions} label="Fruit" disabled />);
const input = screen.getByRole('combobox');
expect(input).toBeDisabled();
});
it('supports placeholder', () => {
render(<Combobox 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 options={defaultOptions} label="Fruit" defaultInputValue="Ban" />);
const input = screen.getByRole('combobox');
expect(input).toHaveValue('Ban');
});
it('supports defaultSelectedOptionId', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" defaultSelectedOptionId="banana" />);
const input = screen.getByRole('combobox');
expect(input).toHaveValue('Banana');
// Open popup - should show all options (not filtered) since input matches selected label
await user.click(input);
// All options should be visible (defaultOptions has 3 items)
const options = screen.getAllByRole('option');
expect(options).toHaveLength(3);
// Banana should have data-selected (visually selected state)
const bananaOption = screen.getByRole('option', { name: 'Banana' });
expect(bananaOption).toHaveAttribute('data-selected', 'true');
// Navigate with ArrowDown - focuses first option (Apple)
await user.keyboard('{ArrowDown}');
const appleOption = screen.getByRole('option', { name: 'Apple' });
expect(appleOption).toHaveAttribute('aria-selected', 'true');
});
it('IDs do not conflict with multiple instances', () => {
render(
<>
<Combobox options={defaultOptions} label="Fruit 1" />
<Combobox options={defaultOptions} label="Fruit 2" />
</>
);
const inputs = screen.getAllByRole('combobox');
const listboxId1 = inputs[0].getAttribute('aria-controls');
const listboxId2 = inputs[1].getAttribute('aria-controls');
expect(listboxId1).not.toBe(listboxId2);
});
});
// Edge Cases
describe('Edge Cases', () => {
it('handles empty options array', () => {
expect(() => {
render(<Combobox 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 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();
});
it('handles rapid typing without errors', async () => {
const user = userEvent.setup();
render(<Combobox options={defaultOptions} label="Fruit" />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'applebananacherry', { delay: 10 });
// Should not throw and should handle gracefully
expect(input).toHaveValue('applebananacherry');
});
});
}); リソース
- 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