コンボボックス
リストオートコンプリート機能を持つ編集可能なコンボボックス。ユーザーは入力してオプションをフィルタリングしたり、キーボードやマウスでポップアップリストボックスから選択したりできます。
デモ
- No results found
- りんご
- バナナ
- さくらんぼ
- デーツ
- エルダーベリー
- いちじく
- ぶどう
- No results found
- りんご
- バナナ
- さくらんぼ
- デーツ
- エルダーベリー
- いちじく
- ぶどう
- No results found
- 日本
- アメリカ
- イギリス
- ドイツ
- フランス
- イタリア
- スペイン
- オーストラリア
- No results found
- りんご
- バナナ
- さくらんぼ
- デーツ
- エルダーベリー
- いちじく
- ぶどう
- No results found
- りんご
- バナナ
- さくらんぼ
- デーツ
- エルダーベリー
- いちじく
- ぶどう
ネイティブ 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がクリアされる |
| 無効なオプションに遭遇 | ナビゲーション中に無効なオプションはスキップされる |
参考資料
ソースコード
---
/**
* APG Combobox Pattern - Astro Implementation
*
* An editable combobox with list autocomplete.
* Uses Web Components for enhanced control and proper focus management.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
*/
import { cn } from '@/lib/utils';
export interface ComboboxOption {
id: string;
label: string;
disabled?: boolean;
}
export interface Props {
/** Array of options */
options: ComboboxOption[];
/** Label text */
label: string;
/** Placeholder text */
placeholder?: string;
/** Default input value */
defaultInputValue?: string;
/** Default selected option ID */
defaultSelectedOptionId?: string;
/** Autocomplete type */
autocomplete?: 'none' | 'list' | 'both';
/** Disabled state */
disabled?: boolean;
/** Message shown when no results found */
noResultsMessage?: string;
/** Additional CSS class */
class?: string;
}
const {
options = [],
label,
placeholder = '',
defaultInputValue = '',
defaultSelectedOptionId,
autocomplete = 'list',
disabled = false,
noResultsMessage = 'No results found',
class: className = '',
} = Astro.props;
// Generate unique ID for this instance
const instanceId = `combobox-${Math.random().toString(36).slice(2, 11)}`;
const inputId = `${instanceId}-input`;
const labelId = `${instanceId}-label`;
const listboxId = `${instanceId}-listbox`;
// Calculate initial input value
const initialInputValue = defaultSelectedOptionId
? (options.find((o) => o.id === defaultSelectedOptionId)?.label ?? defaultInputValue)
: defaultInputValue;
---
<apg-combobox
data-autocomplete={autocomplete}
data-default-input-value={initialInputValue}
data-default-selected-id={defaultSelectedOptionId || ''}
>
<div class={cn('apg-combobox', className)}>
<label id={labelId} for={inputId} class="apg-combobox-label">
{label}
</label>
<div class="apg-combobox-input-wrapper">
<input
id={inputId}
type="text"
role="combobox"
class="apg-combobox-input"
aria-autocomplete={autocomplete}
aria-expanded="false"
aria-controls={listboxId}
aria-labelledby={labelId}
value={initialInputValue}
placeholder={placeholder}
disabled={disabled}
data-combobox-input
/>
<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"></path>
</svg>
</span>
</div>
<ul
id={listboxId}
role="listbox"
aria-labelledby={labelId}
class="apg-combobox-listbox"
hidden
data-combobox-listbox
>
<li class="apg-combobox-no-results" role="status" hidden data-no-results>
{noResultsMessage}
</li>
{
options.map((option) => (
<li
id={`${instanceId}-option-${option.id}`}
role="option"
class="apg-combobox-option"
aria-selected="false"
aria-disabled={option.disabled || undefined}
data-option-id={option.id}
data-option-label={option.label}
data-selected={option.id === defaultSelectedOptionId || undefined}
>
<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>
))
}
</ul>
</div>
</apg-combobox>
<script>
class ApgCombobox extends HTMLElement {
private container: HTMLDivElement | null = null;
private input: HTMLInputElement | null = null;
private listbox: HTMLUListElement | null = null;
private rafId: number | null = null;
private isOpen = false;
private activeIndex = -1;
private isComposing = false;
private valueBeforeOpen = '';
private autocomplete: 'none' | 'list' | 'both' = 'list';
private allOptions: HTMLLIElement[] = [];
private noResultsElement: HTMLLIElement | null = null;
private isSearching = false;
private selectedId: string | null = null;
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.container = this.querySelector('.apg-combobox');
this.input = this.querySelector('[data-combobox-input]');
this.listbox = this.querySelector('[data-combobox-listbox]');
if (!this.input || !this.listbox) {
console.warn('apg-combobox: required elements not found');
return;
}
// Initialize state from data attributes
this.autocomplete = (this.dataset.autocomplete as 'none' | 'list' | 'both') || 'list';
this.allOptions = Array.from(this.listbox.querySelectorAll<HTMLLIElement>('[role="option"]'));
this.noResultsElement = this.listbox.querySelector<HTMLLIElement>('[data-no-results]');
this.selectedId = this.dataset.defaultSelectedId || null;
// Attach event listeners
this.input.addEventListener('input', this.handleInput);
this.input.addEventListener('keydown', this.handleKeyDown);
this.input.addEventListener('focus', this.handleFocus);
this.input.addEventListener('compositionstart', this.handleCompositionStart);
this.input.addEventListener('compositionend', this.handleCompositionEnd);
this.listbox.addEventListener('click', this.handleListboxClick);
this.listbox.addEventListener('mouseenter', this.handleListboxMouseEnter, true);
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
document.removeEventListener('pointerdown', this.handleClickOutside);
this.input?.removeEventListener('input', this.handleInput);
this.input?.removeEventListener('keydown', this.handleKeyDown);
this.input?.removeEventListener('focus', this.handleFocus);
this.input?.removeEventListener('compositionstart', this.handleCompositionStart);
this.input?.removeEventListener('compositionend', this.handleCompositionEnd);
this.listbox?.removeEventListener('click', this.handleListboxClick);
this.listbox?.removeEventListener('mouseenter', this.handleListboxMouseEnter, true);
}
private getSelectedLabel(): string {
if (!this.selectedId) {
return '';
}
const selectedOption = this.allOptions.find(
(option) => option.dataset.optionId === this.selectedId
);
return selectedOption?.dataset.optionLabel ?? '';
}
private getFilteredOptions(): HTMLLIElement[] {
if (!this.input) {
return [];
}
const inputValue = this.input.value;
const selectedLabel = this.getSelectedLabel();
// Don't filter if autocomplete is none
if (this.autocomplete === 'none') {
return this.allOptions;
}
// Don't filter if input is empty
if (!inputValue) {
return this.allOptions;
}
// Don't filter if not in search mode AND input matches selected label
if (!this.isSearching && inputValue === selectedLabel) {
return this.allOptions;
}
const lowerInputValue = inputValue.toLowerCase();
return this.allOptions.filter((option) => {
const { optionLabel } = option.dataset;
const label = optionLabel?.toLowerCase() ?? '';
return label.includes(lowerInputValue);
});
}
private getEnabledOptions(): HTMLLIElement[] {
return this.getFilteredOptions().filter(
(option) => option.getAttribute('aria-disabled') !== 'true'
);
}
private updateListboxVisibility() {
if (!this.listbox) return;
const filteredOptions = this.getFilteredOptions();
// Hide all options first
this.allOptions.forEach((option) => {
option.hidden = true;
});
// Show filtered options
filteredOptions.forEach((option) => {
option.hidden = false;
});
// Show/hide no results message
if (this.noResultsElement) {
this.noResultsElement.hidden = filteredOptions.length > 0;
}
}
private openPopup(focusPosition?: 'first' | 'last') {
if (!this.input || !this.listbox || this.isOpen) {
return;
}
this.valueBeforeOpen = this.input.value;
this.isOpen = true;
this.input.setAttribute('aria-expanded', 'true');
this.listbox.removeAttribute('hidden');
this.updateListboxVisibility();
document.addEventListener('pointerdown', this.handleClickOutside);
if (!focusPosition) {
return;
}
const enabledOptions = this.getEnabledOptions();
if (enabledOptions.length === 0) {
return;
}
const targetOption =
focusPosition === 'first' ? enabledOptions[0] : enabledOptions[enabledOptions.length - 1];
const filteredOptions = this.getFilteredOptions();
this.activeIndex = filteredOptions.indexOf(targetOption);
this.updateActiveDescendant();
}
private closePopup(restore = false) {
if (!this.input || !this.listbox) {
return;
}
this.isOpen = false;
this.activeIndex = -1;
this.isSearching = false;
this.input.setAttribute('aria-expanded', 'false');
this.input.removeAttribute('aria-activedescendant');
this.listbox.setAttribute('hidden', '');
// Reset aria-selected
this.allOptions.forEach((option) => {
option.setAttribute('aria-selected', 'false');
});
document.removeEventListener('pointerdown', this.handleClickOutside);
if (restore && this.input) {
this.input.value = this.valueBeforeOpen;
}
}
private updateSelectedState() {
this.allOptions.forEach((option) => {
const { optionId } = option.dataset;
if (optionId === this.selectedId) {
option.dataset.selected = 'true';
} else {
delete option.dataset.selected;
}
});
}
private selectOption(option: HTMLLIElement) {
if (!this.input || option.getAttribute('aria-disabled') === 'true') {
return;
}
const { optionLabel, optionId } = option.dataset;
const label = optionLabel ?? option.textContent?.trim() ?? '';
const id = optionId ?? '';
this.selectedId = id;
this.isSearching = false;
this.input.value = label;
this.updateSelectedState();
this.dispatchEvent(
new CustomEvent('select', {
detail: { id, label },
bubbles: true,
})
);
this.closePopup();
}
private updateActiveDescendant() {
if (!this.input) {
return;
}
const filteredOptions = this.getFilteredOptions();
// Reset all aria-selected
this.allOptions.forEach((option) => {
option.setAttribute('aria-selected', 'false');
});
if (this.activeIndex < 0 || this.activeIndex >= filteredOptions.length) {
this.input.removeAttribute('aria-activedescendant');
return;
}
const activeOption = filteredOptions[this.activeIndex];
this.input.setAttribute('aria-activedescendant', activeOption.id);
activeOption.setAttribute('aria-selected', 'true');
}
private findEnabledIndex(
startIndex: number,
direction: 'next' | 'prev' | 'first' | 'last'
): number {
const enabledOptions = this.getEnabledOptions();
const filteredOptions = this.getFilteredOptions();
if (enabledOptions.length === 0) {
return -1;
}
if (direction === 'first') {
return filteredOptions.indexOf(enabledOptions[0]);
}
if (direction === 'last') {
return filteredOptions.indexOf(enabledOptions[enabledOptions.length - 1]);
}
const currentOption = filteredOptions[startIndex];
const currentEnabledIndex = currentOption ? enabledOptions.indexOf(currentOption) : -1;
if (direction === 'next') {
if (currentEnabledIndex < 0) {
return filteredOptions.indexOf(enabledOptions[0]);
}
if (currentEnabledIndex >= enabledOptions.length - 1) {
return startIndex;
}
return filteredOptions.indexOf(enabledOptions[currentEnabledIndex + 1]);
}
// direction === 'prev'
if (currentEnabledIndex < 0) {
return filteredOptions.indexOf(enabledOptions[enabledOptions.length - 1]);
}
if (currentEnabledIndex <= 0) {
return startIndex;
}
return filteredOptions.indexOf(enabledOptions[currentEnabledIndex - 1]);
}
private handleInput = () => {
if (!this.input) {
return;
}
this.isSearching = true;
if (!this.isOpen && !this.isComposing) {
this.valueBeforeOpen = this.input.value;
this.openPopup();
}
this.updateListboxVisibility();
this.activeIndex = -1;
this.updateActiveDescendant();
// Reset search mode if input matches selected label or is empty
const selectedLabel = this.getSelectedLabel();
if (this.input.value === '' || this.input.value === selectedLabel) {
this.isSearching = false;
}
this.dispatchEvent(
new CustomEvent('inputchange', {
detail: { value: this.input.value },
bubbles: true,
})
);
};
private handleKeyDown = (event: KeyboardEvent) => {
if (this.isComposing) {
return;
}
const { key, altKey } = event;
switch (key) {
case 'ArrowDown': {
event.preventDefault();
if (altKey) {
if (this.isOpen) {
return;
}
this.openPopup();
return;
}
if (!this.isOpen) {
this.openPopup('first');
return;
}
const nextIndex = this.findEnabledIndex(this.activeIndex, 'next');
if (nextIndex >= 0) {
this.activeIndex = nextIndex;
this.updateActiveDescendant();
}
break;
}
case 'ArrowUp': {
event.preventDefault();
if (altKey) {
if (!this.isOpen || this.activeIndex < 0) {
return;
}
const filteredOptions = this.getFilteredOptions();
const option = filteredOptions[this.activeIndex];
if (!option || option.getAttribute('aria-disabled') === 'true') {
return;
}
this.selectOption(option);
return;
}
if (!this.isOpen) {
this.openPopup('last');
return;
}
const prevIndex = this.findEnabledIndex(this.activeIndex, 'prev');
if (prevIndex >= 0) {
this.activeIndex = prevIndex;
this.updateActiveDescendant();
}
break;
}
case 'Home': {
if (!this.isOpen) {
return;
}
event.preventDefault();
const firstIndex = this.findEnabledIndex(0, 'first');
if (firstIndex >= 0) {
this.activeIndex = firstIndex;
this.updateActiveDescendant();
}
break;
}
case 'End': {
if (!this.isOpen) {
return;
}
event.preventDefault();
const lastIndex = this.findEnabledIndex(0, 'last');
if (lastIndex >= 0) {
this.activeIndex = lastIndex;
this.updateActiveDescendant();
}
break;
}
case 'Enter': {
if (!this.isOpen || this.activeIndex < 0) {
return;
}
event.preventDefault();
const filteredOptions = this.getFilteredOptions();
const option = filteredOptions[this.activeIndex];
if (!option || option.getAttribute('aria-disabled') === 'true') {
return;
}
this.selectOption(option);
break;
}
case 'Escape': {
if (!this.isOpen) {
return;
}
event.preventDefault();
this.closePopup(true);
break;
}
case 'Tab': {
if (this.isOpen) {
this.closePopup();
}
break;
}
}
};
private handleListboxClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const option = target.closest('[role="option"]') as HTMLLIElement | null;
if (!option || option.getAttribute('aria-disabled') === 'true') {
return;
}
this.selectOption(option);
};
private handleListboxMouseEnter = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const option = target.closest('[role="option"]') as HTMLLIElement | null;
if (!option) {
return;
}
const filteredOptions = this.getFilteredOptions();
const index = filteredOptions.indexOf(option);
if (index < 0) {
return;
}
this.activeIndex = index;
this.updateActiveDescendant();
};
private handleCompositionStart = () => {
this.isComposing = true;
};
private handleCompositionEnd = () => {
this.isComposing = false;
};
// Handle focus - open popup when input receives focus
private handleFocus = () => {
if (this.isOpen || !this.input || this.input.disabled) {
return;
}
this.openPopup();
};
private handleClickOutside = (event: PointerEvent) => {
if (!this.container) {
return;
}
if (!this.container.contains(event.target as Node)) {
this.closePopup();
}
};
}
if (!customElements.get('apg-combobox')) {
customElements.define('apg-combobox', ApgCombobox);
}
</script> 使い方
---
import Combobox from './Combobox.astro';
const options = [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry' },
];
---
<!-- 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"
/>
<!-- Listen to selection events (Web Component event) -->
<Combobox id="my-combobox" options={options} label="Fruit" />
<script>
const combobox = document.querySelector('#my-combobox');
combobox?.addEventListener('select', (e) => {
console.log('Selected:', e.detail);
});
combobox?.addEventListener('inputchange', (e) => {
console.log('Input:', e.detail.value);
});
</script> API
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
options | ComboboxOption[] | Required | id、label、オプションの disabled を持つオプションの配列 |
label | string | Required | 表示されるラベルテキスト |
placeholder | string | - | 入力のプレースホルダーテキスト |
defaultInputValue | string | "" | デフォルトの入力値 |
defaultSelectedOptionId | string | - | 初期選択されるオプションの ID |
autocomplete | "none" | "list" | "both" | "list" | オートコンプリートの動作 |
disabled | boolean | false | コンボボックスを無効にするかどうか |
Custom Events
| イベント | Detail | 説明 |
|---|---|---|
select | {id: string, label: string} | オプション選択時に発火 |
inputchange | {value: string} | 入力値変更時に発火 |
テスト
テストは 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) を参照してください。
リソース
- 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