APG Patterns
English GitHub
English GitHub

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)では、オートサジェストポップアップの内容が読み上げられません。

出典: MDN Web Docs - <datalist>: Accessibility

アクセシビリティ

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 の参照が有効なまま

ソースコード

Combobox.astro
---
/**
 * 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>

使い方

Example
---
import Combobox from './Combobox.astro';

const options = [
  { id: 'apple', label: 'りんご' },
  { id: 'banana', label: 'バナナ' },
  { id: 'cherry', label: 'さくらんぼ' },
];
---

<!-- 基本的な使い方 -->
<Combobox
  options={options}
  label="お気に入りのフルーツ"
  placeholder="検索..."
/>

<!-- デフォルト値あり -->
<Combobox
  options={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={options}
  label="選択"
  autocomplete="none"
/>

<!-- イベントリスナー(JavaScript で設定) -->
<Combobox
  options={options}
  label="フルーツ"
  id="my-combobox"
/>

<script>
  const combobox = document.getElementById('my-combobox');
  combobox?.addEventListener('combobox:select', (e) => {
    console.log('選択:', e.detail);
  });
  combobox?.addEventListener('combobox:inputchange', (e) => {
    console.log('入力:', e.detail);
  });
  combobox?.addEventListener('combobox:openchange', (e) => {
    console.log('開閉:', e.detail);
  });
</script>

API

プロパティ デフォルト 説明
options ComboboxOption[] 必須 id、label、オプションの disabled を持つオプションの配列
label string 必須 表示されるラベルテキスト
placeholder string - 入力のプレースホルダーテキスト
defaultInputValue string "" デフォルトの入力値
defaultSelectedOptionId string - 初期選択されるオプションの ID
autocomplete "none" | "list" | "both" "list" オートコンプリートの動作
disabled boolean false コンボボックスを無効にするかどうか

カスタムイベント

イベント 詳細の型 説明
combobox:select ComboboxOption オプション選択時に発火
combobox:inputchange string 入力値変更時に発火
combobox:openchange 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 構造を持つ

テストツール

詳細なドキュメントは testing-strategy.md (opens in new tab) を参照してください。

注: Astro 実装は Web Components を使用しており、他のフレームワーク実装と同様に Vitest と JSDOM でテストできます。

リソース