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.svelte
<script lang="ts">
  import { cn } from '@/lib/utils';
  import { onDestroy, tick } from 'svelte';

  export interface ComboboxOption {
    id: string;
    label: string;
    disabled?: boolean;
  }

  interface ComboboxProps {
    options: ComboboxOption[];
    selectedOptionId?: string;
    defaultSelectedOptionId?: string;
    inputValue?: string;
    defaultInputValue?: string;
    label: string;
    placeholder?: string;
    disabled?: boolean;
    autocomplete?: 'none' | 'list' | 'both';
    noResultsMessage?: string;
    onSelect?: (option: ComboboxOption) => void;
    onInputChange?: (value: string) => void;
    onOpenChange?: (isOpen: boolean) => void;
    class?: string;
  }

  let {
    options = [],
    selectedOptionId = undefined,
    defaultSelectedOptionId = undefined,
    inputValue = undefined,
    defaultInputValue = '',
    label,
    placeholder = undefined,
    disabled = false,
    autocomplete = 'list',
    noResultsMessage = 'No results found',
    onSelect = () => {},
    onInputChange = () => {},
    onOpenChange = () => {},
    class: className = '',
    ...restProps
  }: ComboboxProps = $props();

  // Generate ID for SSR-safe aria-controls/aria-labelledby
  const instanceId = `combobox-${Math.random().toString(36).slice(2, 11)}`;

  // Compute initial input value
  const getInitialInputValue = () => {
    if (defaultSelectedOptionId) {
      const option = options.find((o) => o.id === defaultSelectedOptionId);
      return option?.label ?? defaultInputValue;
    }
    return defaultInputValue;
  };

  // State
  let isOpen = $state(false);
  let activeIndex = $state(-1);
  let isComposing = $state(false);
  let valueBeforeOpen = '';
  let internalInputValue = $state(getInitialInputValue());
  let internalSelectedId = $state<string | undefined>(defaultSelectedOptionId);
  let isSearching = $state(false);

  // Refs
  let containerElement: HTMLDivElement;
  let inputElement: HTMLInputElement;

  // Derived values
  let inputId = $derived(`${instanceId}-input`);
  let labelId = $derived(`${instanceId}-label`);
  let listboxId = $derived(`${instanceId}-listbox`);

  let currentInputValue = $derived(inputValue !== undefined ? inputValue : internalInputValue);
  let currentSelectedId = $derived(selectedOptionId ?? internalSelectedId);

  // Get selected option's label
  let selectedLabel = $derived.by(() => {
    if (!currentSelectedId) {
      return '';
    }
    const option = options.find(({ id }) => id === currentSelectedId);
    return option?.label ?? '';
  });

  let filteredOptions = $derived.by(() => {
    // Don't filter if autocomplete is none
    if (autocomplete === 'none') {
      return options;
    }

    // Don't filter if input is empty
    if (!currentInputValue) {
      return options;
    }

    // Don't filter if not in search mode AND input matches selected label
    if (!isSearching && currentInputValue === selectedLabel) {
      return options;
    }

    const lowerInputValue = currentInputValue.toLowerCase();

    return options.filter(({ label }) => label.toLowerCase().includes(lowerInputValue));
  });

  let enabledOptions = $derived(filteredOptions.filter(({ disabled }) => !disabled));

  let activeDescendantId = $derived.by(() => {
    if (activeIndex < 0 || activeIndex >= filteredOptions.length) {
      return undefined;
    }
    const option = filteredOptions[activeIndex];
    return option ? getOptionId(option.id) : undefined;
  });

  onDestroy(() => {
    if (typeof document !== 'undefined') {
      document.removeEventListener('mousedown', handleClickOutside);
    }
  });

  // Click outside effect
  $effect(() => {
    if (typeof document === 'undefined') return;

    if (isOpen) {
      document.addEventListener('mousedown', handleClickOutside);
    } else {
      document.removeEventListener('mousedown', handleClickOutside);
    }
  });

  // Clear active index when filtered options change
  $effect(() => {
    if (activeIndex >= 0 && activeIndex >= filteredOptions.length) {
      activeIndex = -1;
    }
  });

  // Reset search mode when input value matches selected label or becomes empty
  $effect(() => {
    if (currentInputValue === '' || currentInputValue === selectedLabel) {
      isSearching = false;
    }
  });

  function getOptionId(optionId: string): string {
    return `${instanceId}-option-${optionId}`;
  }

  function updateInputValue(value: string) {
    if (inputValue === undefined) {
      internalInputValue = value;
    }
    onInputChange(value);
  }

  function openPopup(focusPosition?: 'first' | 'last') {
    if (isOpen) {
      return;
    }

    valueBeforeOpen = currentInputValue;
    isOpen = true;
    onOpenChange(true);

    if (!focusPosition || enabledOptions.length === 0) {
      return;
    }

    const targetOption =
      focusPosition === 'first' ? enabledOptions[0] : enabledOptions[enabledOptions.length - 1];
    const { id: targetId } = targetOption;
    const targetIndex = filteredOptions.findIndex(({ id }) => id === targetId);
    activeIndex = targetIndex;
  }

  function closePopup(restore = false) {
    isOpen = false;
    activeIndex = -1;
    isSearching = false;
    onOpenChange(false);

    if (restore) {
      updateInputValue(valueBeforeOpen);
    }
  }

  function selectOption({ id, label, disabled }: ComboboxOption) {
    if (disabled) {
      return;
    }

    if (selectedOptionId === undefined) {
      internalSelectedId = id;
    }

    isSearching = false;
    updateInputValue(label);
    onSelect({ id, label, disabled });
    closePopup();
  }

  function findEnabledIndex(
    startIndex: number,
    direction: 'next' | 'prev' | 'first' | 'last'
  ): number {
    if (enabledOptions.length === 0) {
      return -1;
    }

    if (direction === 'first') {
      const { id: firstId } = enabledOptions[0];
      return filteredOptions.findIndex(({ id }) => id === firstId);
    }

    if (direction === 'last') {
      const { id: lastId } = enabledOptions[enabledOptions.length - 1];
      return filteredOptions.findIndex(({ id }) => id === lastId);
    }

    const currentOption = filteredOptions[startIndex];
    const currentEnabledIndex = currentOption
      ? enabledOptions.findIndex(({ id }) => id === currentOption.id)
      : -1;

    if (direction === 'next') {
      if (currentEnabledIndex < 0) {
        const { id: firstId } = enabledOptions[0];
        return filteredOptions.findIndex(({ id }) => id === firstId);
      }

      if (currentEnabledIndex >= enabledOptions.length - 1) {
        return startIndex;
      }

      const { id: nextId } = enabledOptions[currentEnabledIndex + 1];
      return filteredOptions.findIndex(({ id }) => id === nextId);
    }

    // direction === 'prev'
    if (currentEnabledIndex < 0) {
      const { id: lastId } = enabledOptions[enabledOptions.length - 1];
      return filteredOptions.findIndex(({ id }) => id === lastId);
    }

    if (currentEnabledIndex <= 0) {
      return startIndex;
    }

    const { id: prevId } = enabledOptions[currentEnabledIndex - 1];
    return filteredOptions.findIndex(({ id }) => id === prevId);
  }

  function handleClickOutside(event: MouseEvent) {
    if (!containerElement) {
      return;
    }

    if (!containerElement.contains(event.target as Node)) {
      closePopup();
    }
  }

  function handleInput(event: Event) {
    const target = event.target as HTMLInputElement;
    const value = target.value;
    isSearching = true;
    updateInputValue(value);

    if (!isOpen && !isComposing) {
      valueBeforeOpen = currentInputValue;
      isOpen = true;
      onOpenChange(true);
    }

    activeIndex = -1;
  }

  function handleKeyDown(event: KeyboardEvent) {
    if (isComposing) {
      return;
    }

    const { key, altKey } = event;

    switch (key) {
      case 'ArrowDown': {
        event.preventDefault();

        if (altKey) {
          if (isOpen) {
            return;
          }

          valueBeforeOpen = currentInputValue;
          isOpen = true;
          onOpenChange(true);
          return;
        }

        if (!isOpen) {
          openPopup('first');
          return;
        }

        const nextIndex = findEnabledIndex(activeIndex, 'next');

        if (nextIndex >= 0) {
          activeIndex = nextIndex;
        }
        break;
      }
      case 'ArrowUp': {
        event.preventDefault();

        if (altKey) {
          if (!isOpen || activeIndex < 0) {
            return;
          }

          const option = filteredOptions[activeIndex];

          if (option === undefined || option.disabled) {
            return;
          }

          selectOption(option);
          return;
        }

        if (!isOpen) {
          openPopup('last');
          return;
        }

        const prevIndex = findEnabledIndex(activeIndex, 'prev');

        if (prevIndex >= 0) {
          activeIndex = prevIndex;
        }
        break;
      }
      case 'Home': {
        if (!isOpen) {
          return;
        }

        event.preventDefault();

        const firstIndex = findEnabledIndex(0, 'first');

        if (firstIndex >= 0) {
          activeIndex = firstIndex;
        }
        break;
      }
      case 'End': {
        if (!isOpen) {
          return;
        }

        event.preventDefault();

        const lastIndex = findEnabledIndex(0, 'last');

        if (lastIndex >= 0) {
          activeIndex = lastIndex;
        }
        break;
      }
      case 'Enter': {
        if (!isOpen || activeIndex < 0) {
          return;
        }

        event.preventDefault();

        const option = filteredOptions[activeIndex];

        if (option === undefined || option.disabled) {
          return;
        }

        selectOption(option);
        break;
      }
      case 'Escape': {
        if (!isOpen) {
          return;
        }

        event.preventDefault();
        closePopup(true);
        break;
      }
      case 'Tab': {
        if (isOpen) {
          closePopup();
        }
        break;
      }
    }
  }

  function handleOptionClick(option: ComboboxOption) {
    if (option.disabled) {
      return;
    }

    selectOption(option);
  }

  function handleOptionHover({ id }: ComboboxOption) {
    const index = filteredOptions.findIndex((option) => option.id === id);

    if (index < 0) {
      return;
    }

    activeIndex = index;
  }

  function handleCompositionStart() {
    isComposing = true;
  }

  function handleCompositionEnd() {
    isComposing = false;
  }

  // Handle focus - open popup when input receives focus
  function handleFocus() {
    if (isOpen || disabled) {
      return;
    }

    openPopup();
  }
</script>

<div bind:this={containerElement} class={cn('apg-combobox', className)}>
  <label id={labelId} for={inputId} class="apg-combobox-label">
    {label}
  </label>
  <div class="apg-combobox-input-wrapper">
    <input
      bind:this={inputElement}
      id={inputId}
      type="text"
      role="combobox"
      class="apg-combobox-input"
      aria-autocomplete={autocomplete}
      aria-expanded={isOpen}
      aria-controls={listboxId}
      aria-labelledby={labelId}
      aria-activedescendant={activeDescendantId}
      value={currentInputValue}
      {placeholder}
      {disabled}
      oninput={handleInput}
      onkeydown={handleKeyDown}
      onfocus={handleFocus}
      oncompositionstart={handleCompositionStart}
      oncompositionend={handleCompositionEnd}
      {...restProps}
    />
    <span class="apg-combobox-caret" aria-hidden="true">
      <svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
        <path
          fill-rule="evenodd"
          d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
          clip-rule="evenodd"
        />
      </svg>
    </span>
  </div>
  <!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
  <ul
    id={listboxId}
    role="listbox"
    aria-labelledby={labelId}
    class="apg-combobox-listbox"
    hidden={!isOpen ? true : undefined}
  >
    {#if filteredOptions.length === 0}
      <li class="apg-combobox-no-results" role="status">
        {noResultsMessage}
      </li>
    {/if}
    {#each filteredOptions as option, index}
      {@const isActive = index === activeIndex}
      {@const isSelected = option.id === currentSelectedId}
      <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
      <li
        id={getOptionId(option.id)}
        role="option"
        class="apg-combobox-option"
        aria-selected={isActive}
        aria-disabled={option.disabled || undefined}
        data-selected={isSelected || undefined}
        onclick={() => handleOptionClick(option)}
        onmouseenter={() => handleOptionHover(option)}
      >
        <span class="apg-combobox-option-icon" aria-hidden="true">
          <svg viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
            <path
              d="M10.28 2.28a.75.75 0 00-1.06-1.06L4.5 5.94 2.78 4.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.06 0l5.25-5.25z"
            />
          </svg>
        </span>
        {option.label}
      </li>
    {/each}
  </ul>
</div>

使い方

Example
<script lang="ts">
  import Combobox from './Combobox.svelte';

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

  function handleSelect(event: CustomEvent<{ id: string; label: string }>) {
    console.log('選択:', event.detail);
  }

  function handleInputChange(event: CustomEvent<string>) {
    console.log('入力:', event.detail);
  }

  function handleOpenChange(event: CustomEvent<boolean>) {
    console.log('開閉:', event.detail);
  }
</script>

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

<!-- デフォルト値あり -->
<Combobox
  {options}
  label="フルーツ"
  defaultSelectedOptionId="banana"
/>

<!-- 無効なオプションあり -->
<Combobox
  options={[
    { id: 'a', label: 'オプション A' },
    { id: 'b', label: 'オプション B', disabled: true },
    { id: 'c', label: 'オプション C' },
  ]}
  label="オプションを選択"
/>

<!-- フィルタなし (autocomplete="none") -->
<Combobox
  {options}
  label="選択"
  autocomplete="none"
/>

<!-- コールバック付き -->
<Combobox
  {options}
  label="フルーツ"
  onselect={handleSelect}
  oninputchange={handleInputChange}
  onopenchange={handleOpenChange}
/>

API

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

イベント

イベント 詳細の型 説明
onselect ComboboxOption オプション選択時に発火
oninputchange string 入力値変更時に発火
onopenchange boolean ポップアップ開閉時に発火

テスト

テストは ARIA 属性、キーボード操作、フィルタリング動作、アクセシビリティ要件に対する APG 準拠を検証します。Combobox コンポーネントは2層のテスト戦略を採用しています。

テスト戦略

ユニットテスト (Testing Library)

フレームワーク固有のテストライブラリを使用してコンポーネントの出力を検証します。正しい HTML 構造と ARIA 属性を確認します。

  • ARIA 属性(role、aria-controls、aria-expanded など)
  • キーボード操作(矢印キー、Enter、Escape など)
  • フィルタリング動作とオプションのレンダリング
  • jest-axe によるアクセシビリティ検証

E2E テスト (Playwright)

実際のブラウザ環境で全フレームワークのコンポーネント動作を検証します。インタラクションと クロスフレームワークの一貫性をカバーします。

  • キーボードナビゲーションと選択
  • マウス操作(クリック、ホバー)
  • ライブブラウザでの ARIA 構造
  • aria-activedescendant によるフォーカス管理
  • axe-core によるアクセシビリティスキャン
  • クロスフレームワーク一貫性チェック

テストカテゴリ

高優先度: ARIA 属性 (Unit + E2E)

テスト 説明
role="combobox" Input 要素に combobox ロールがある
role="listbox" ポップアップ要素に listbox ロールがある
role="option" 各オプションに option ロールがある
aria-controls Input が listbox ID を参照(常に存在)
aria-expanded ポップアップの開閉状態を反映
aria-autocomplete "list"、"none"、"both" のいずれかに設定
aria-activedescendant 現在フォーカスされているオプションを参照
aria-selected 現在ハイライトされているオプションを示す
aria-disabled 無効なオプションを示す

高優先度: アクセシブル名 (Unit + E2E)

テスト 説明
aria-labelledby Input が可視ラベル要素を参照
aria-labelledby (listbox) Listbox もラベルを参照

高優先度: キーボード操作 - ポップアップ閉時 (Unit + E2E)

テスト 説明
Down Arrow ポップアップを開き、最初のオプションにフォーカス
Up Arrow ポップアップを開き、最後のオプションにフォーカス
Alt + Down Arrow フォーカスを変更せずにポップアップを開く
入力 ポップアップを開き、オプションをフィルタリング

高優先度: キーボード操作 - ポップアップ開時 (Unit + E2E)

テスト 説明
Down Arrow 次の有効なオプションに移動(折り返しなし)
Up Arrow 前の有効なオプションに移動(折り返しなし)
Home 最初の有効なオプションに移動
End 最後の有効なオプションに移動
Enter フォーカス中のオプションを選択しポップアップを閉じる
Escape ポップアップを閉じ、以前の値を復元
Alt + Up Arrow フォーカス中のオプションを選択しポップアップを閉じる
Tab ポップアップを閉じ、次のフォーカス可能な要素に移動

高優先度: フォーカス管理 (Unit + E2E)

テスト 説明
Input に DOM フォーカス DOM フォーカスは常に input に留まる
aria-activedescendant による仮想フォーカス 視覚的フォーカスは aria-activedescendant で制御
閉じた時にクリア ポップアップが閉じると aria-activedescendant がクリア
無効なオプションをスキップ ナビゲーションが無効なオプションをスキップ

中優先度: フィルタリング (Unit)

テスト 説明
入力時にフィルタ ユーザーが入力するとオプションがフィルタリング
大文字小文字を区別しない フィルタリングは大文字小文字を区別しない
フィルタなし (autocomplete="none") 入力に関係なくすべてのオプションを表示
空の結果 一致がない場合 aria-activedescendant がクリア

中優先度: マウス操作 (E2E)

テスト 説明
オプションをクリック オプションを選択しポップアップを閉じる
オプションにホバー ホバー時に aria-activedescendant を更新
無効なオプションをクリック 無効なオプションは選択不可
外側をクリック 選択せずにポップアップを閉じる

中優先度: IME 入力 (Unit)

テスト 説明
変換中 IME 変換中はキーボードナビゲーションをブロック
変換完了時 変換完了後にフィルタリングを更新

中優先度: コールバック (Unit)

テスト 説明
onSelect 選択時にオプションデータと共に呼び出し
onInputChange 入力値の変更時に呼び出し
onOpenChange ポップアップの開閉時に呼び出し

低優先度: HTML 属性継承 (Unit)

テスト 説明
className コンテナにカスタムクラスが適用される
placeholder プレースホルダーテキストが input に表示
disabled 状態 disabled prop が設定されるとコンポーネントが無効化

低優先度: クロスフレームワーク一貫性 (E2E)

テスト 説明
全フレームワークにコンボボックス React、Vue、Svelte、Astro すべてがコンボボックス要素をレンダリング
クリックで選択 すべてのフレームワークがクリックで正しく選択
一貫した ARIA すべてのフレームワークが一貫した ARIA 構造を持つ

テストツール

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

Combobox.test.svelte.ts
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Combobox from './Combobox.svelte';

// Default test options
const defaultOptions = [
  { id: 'apple', label: 'Apple' },
  { id: 'banana', label: 'Banana' },
  { id: 'cherry', label: 'Cherry' },
];

// Options with disabled item
const optionsWithDisabled = [
  { id: 'apple', label: 'Apple' },
  { id: 'banana', label: 'Banana', disabled: true },
  { id: 'cherry', label: 'Cherry' },
];

// Options with first item disabled
const optionsWithFirstDisabled = [
  { id: 'apple', label: 'Apple', disabled: true },
  { id: 'banana', label: 'Banana' },
  { id: 'cherry', label: 'Cherry' },
];

// Options with last item disabled
const optionsWithLastDisabled = [
  { id: 'apple', label: 'Apple' },
  { id: 'banana', label: 'Banana' },
  { id: 'cherry', label: 'Cherry', disabled: true },
];

// All disabled options
const allDisabledOptions = [
  { id: 'apple', label: 'Apple', disabled: true },
  { id: 'banana', label: 'Banana', disabled: true },
  { id: 'cherry', label: 'Cherry', disabled: true },
];

describe('Combobox (Svelte)', () => {
  // 🔴 High Priority: APG ARIA Attributes
  describe('APG: ARIA Attributes', () => {
    it('input has role="combobox"', () => {
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });
      expect(screen.getByRole('combobox')).toBeInTheDocument();
    });

    it('has accessible name via aria-labelledby', () => {
      render(Combobox, {
        props: { options: defaultOptions, label: 'Select a fruit' },
      });
      const input = screen.getByRole('combobox');
      expect(input).toHaveAccessibleName('Select a fruit');
    });

    it('has aria-controls pointing to listbox', () => {
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });
      const input = screen.getByRole('combobox');
      const listboxId = input.getAttribute('aria-controls');

      expect(listboxId).toBeTruthy();
      expect(document.getElementById(listboxId!)).toHaveAttribute('role', 'listbox');
    });

    it('aria-controls points to existing listbox even when closed', () => {
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });
      const input = screen.getByRole('combobox');
      const listboxId = input.getAttribute('aria-controls');

      expect(listboxId).toBeTruthy();
      const listbox = document.getElementById(listboxId!);
      expect(listbox).toBeInTheDocument();
      expect(listbox).toHaveAttribute('hidden');
    });

    it('has aria-expanded="false" when closed', () => {
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });
      expect(screen.getByRole('combobox')).toHaveAttribute('aria-expanded', 'false');
    });

    it('has aria-expanded="true" when opened', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      expect(input).toHaveAttribute('aria-expanded', 'true');
    });

    it('has aria-autocomplete="list"', () => {
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });
      expect(screen.getByRole('combobox')).toHaveAttribute('aria-autocomplete', 'list');
    });

    it('has aria-autocomplete="none" when autocomplete is none', () => {
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', autocomplete: 'none' },
      });
      expect(screen.getByRole('combobox')).toHaveAttribute('aria-autocomplete', 'none');
    });

    it('has aria-activedescendant when option focused', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      expect(input).toHaveAttribute('aria-activedescendant');
      const activeId = input.getAttribute('aria-activedescendant');
      expect(activeId).toBeTruthy();
      expect(document.getElementById(activeId!)).toHaveTextContent('Apple');
    });

    it('clears aria-activedescendant when closed', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      expect(input.getAttribute('aria-activedescendant')).toBeTruthy();

      await user.keyboard('{Escape}');
      expect(input.getAttribute('aria-activedescendant')).toBeFalsy();
    });

    it('listbox has role="listbox"', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      expect(screen.getByRole('listbox')).toBeInTheDocument();
    });

    it('options have role="option"', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      const options = screen.getAllByRole('option');
      expect(options).toHaveLength(3);
    });

    it('focused option has aria-selected="true"', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      const firstOption = screen.getByRole('option', { name: 'Apple' });
      expect(firstOption).toHaveAttribute('aria-selected', 'true');
    });

    it('disabled option has aria-disabled="true"', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: optionsWithDisabled, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      const disabledOption = screen.getByRole('option', { name: 'Banana' });
      expect(disabledOption).toHaveAttribute('aria-disabled', 'true');
    });
  });

  // 🔴 High Priority: APG Keyboard Interaction (Input)
  describe('APG: Keyboard Interaction (Input)', () => {
    it('opens popup and focuses first enabled option on ArrowDown', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      expect(input).toHaveAttribute('aria-expanded', 'true');
      const activeId = input.getAttribute('aria-activedescendant');
      expect(document.getElementById(activeId!)).toHaveTextContent('Apple');
    });

    it('opens popup and focuses last enabled option on ArrowUp', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowUp}');

      expect(input).toHaveAttribute('aria-expanded', 'true');
      const activeId = input.getAttribute('aria-activedescendant');
      expect(document.getElementById(activeId!)).toHaveTextContent('Cherry');
    });

    it('skips disabled first option on ArrowDown', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: optionsWithFirstDisabled, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      const activeId = input.getAttribute('aria-activedescendant');
      expect(document.getElementById(activeId!)).toHaveTextContent('Banana');
    });

    it('skips disabled last option on ArrowUp', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: optionsWithLastDisabled, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowUp}');

      const activeId = input.getAttribute('aria-activedescendant');
      expect(document.getElementById(activeId!)).toHaveTextContent('Banana');
    });

    it('closes popup on Escape', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      expect(input).toHaveAttribute('aria-expanded', 'true');

      await user.keyboard('{Escape}');
      expect(input).toHaveAttribute('aria-expanded', 'false');
    });

    it('selects option and closes popup on Enter', async () => {
      const user = userEvent.setup();
      const onSelect = vi.fn();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', onSelect },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{Enter}');

      expect(onSelect).toHaveBeenCalledWith(defaultOptions[0]);
      expect(input).toHaveAttribute('aria-expanded', 'false');
      expect(input).toHaveValue('Apple');
    });

    it('closes popup on Tab', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      expect(input).toHaveAttribute('aria-expanded', 'true');

      await user.keyboard('{Tab}');
      expect(input).toHaveAttribute('aria-expanded', 'false');
    });

    it('opens popup on typing', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.type(input, 'a');

      expect(input).toHaveAttribute('aria-expanded', 'true');
    });

    it('Alt+ArrowDown opens without changing focus position', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{Alt>}{ArrowDown}{/Alt}');

      expect(input).toHaveAttribute('aria-expanded', 'true');
      expect(input.getAttribute('aria-activedescendant')).toBeFalsy();
    });

    it('Alt+ArrowUp commits selection and closes', async () => {
      const user = userEvent.setup();
      const onSelect = vi.fn();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', onSelect },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{Alt>}{ArrowUp}{/Alt}');

      expect(onSelect).toHaveBeenCalledWith(defaultOptions[1]);
      expect(input).toHaveAttribute('aria-expanded', 'false');
    });
  });

  // 🔴 High Priority: APG Keyboard Interaction (Listbox Navigation)
  describe('APG: Keyboard Interaction (Listbox)', () => {
    it('moves to next enabled option on ArrowDown', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Apple');

      await user.keyboard('{ArrowDown}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Banana');
    });

    it('moves to previous enabled option on ArrowUp', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{ArrowDown}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Banana');

      await user.keyboard('{ArrowUp}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Apple');
    });

    it('skips disabled option on ArrowDown', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: optionsWithDisabled, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Apple');

      await user.keyboard('{ArrowDown}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Cherry');
    });

    it('skips disabled option on ArrowUp', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: optionsWithDisabled, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowUp}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Cherry');

      await user.keyboard('{ArrowUp}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Apple');
    });

    it('moves to first enabled option on Home', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: optionsWithFirstDisabled, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowUp}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Cherry');

      await user.keyboard('{Home}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Banana');
    });

    it('moves to last enabled option on End', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: optionsWithLastDisabled, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Apple');

      await user.keyboard('{End}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Banana');
    });

    it('does not wrap on ArrowDown at last option', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{ArrowDown}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Cherry');

      await user.keyboard('{ArrowDown}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Cherry');
    });

    it('does not wrap on ArrowUp at first option', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Apple');

      await user.keyboard('{ArrowUp}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Apple');
    });
  });

  // 🔴 High Priority: Focus Management
  describe('APG: Focus Management', () => {
    it('keeps DOM focus on input when navigating', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{ArrowDown}');

      expect(input).toHaveFocus();
    });

    it('updates aria-activedescendant on navigation', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      const firstActiveId = input.getAttribute('aria-activedescendant');
      expect(firstActiveId).toBeTruthy();

      await user.keyboard('{ArrowDown}');

      const secondActiveId = input.getAttribute('aria-activedescendant');
      expect(secondActiveId).toBeTruthy();
      expect(secondActiveId).not.toBe(firstActiveId);
    });

    it('maintains focus on input after selection', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{Enter}');

      expect(input).toHaveFocus();
    });
  });

  // 🔴 High Priority: Autocomplete
  describe('Autocomplete', () => {
    it('filters options based on input', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.type(input, 'app');

      const options = screen.getAllByRole('option');
      expect(options).toHaveLength(1);
      expect(options[0]).toHaveTextContent('Apple');
    });

    it('shows all options when input is empty', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      const options = screen.getAllByRole('option');
      expect(options).toHaveLength(3);
    });

    it('updates input value on selection', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{Enter}');

      expect(input).toHaveValue('Banana');
    });

    it('does not filter when autocomplete="none"', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', autocomplete: 'none' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.type(input, 'xyz');

      const options = screen.getAllByRole('option');
      expect(options).toHaveLength(3);
    });
  });

  // 🔴 High Priority: Disabled Options
  describe('Disabled Options', () => {
    it('does not select disabled option on Enter', async () => {
      const user = userEvent.setup();
      const onSelect = vi.fn();
      render(Combobox, {
        props: { options: optionsWithFirstDisabled, label: 'Fruit', onSelect },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{ArrowUp}');
      await user.keyboard('{Enter}');

      expect(onSelect).toHaveBeenCalledWith(optionsWithFirstDisabled[1]);
    });

    it('does not select disabled option on click', async () => {
      const user = userEvent.setup();
      const onSelect = vi.fn();
      render(Combobox, {
        props: { options: optionsWithDisabled, label: 'Fruit', onSelect },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      const disabledOption = screen.getByRole('option', { name: 'Banana' });
      await user.click(disabledOption);

      expect(onSelect).not.toHaveBeenCalled();
      expect(input).toHaveAttribute('aria-expanded', 'true');
    });
  });

  // 🔴 High Priority: Mouse Interaction
  describe('Mouse Interaction', () => {
    it('selects option on click', async () => {
      const user = userEvent.setup();
      const onSelect = vi.fn();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', onSelect },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      const option = screen.getByRole('option', { name: 'Banana' });
      await user.click(option);

      expect(onSelect).toHaveBeenCalledWith(defaultOptions[1]);
      expect(input).toHaveValue('Banana');
    });

    it('closes popup on option click', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      expect(input).toHaveAttribute('aria-expanded', 'true');

      const option = screen.getByRole('option', { name: 'Banana' });
      await user.click(option);

      expect(input).toHaveAttribute('aria-expanded', 'false');
    });

    it('closes popup on outside click', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      expect(input).toHaveAttribute('aria-expanded', 'true');

      await user.click(document.body);
      expect(input).toHaveAttribute('aria-expanded', 'false');
    });

    it('updates aria-selected on hover', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      const bananaOption = screen.getByRole('option', { name: 'Banana' });
      await user.hover(bananaOption);

      expect(bananaOption).toHaveAttribute('aria-selected', 'true');
      expect(screen.getByRole('option', { name: 'Apple' })).toHaveAttribute(
        'aria-selected',
        'false'
      );
    });
  });

  // 🟡 Medium Priority: Accessibility Validation
  describe('Accessibility', () => {
    it('has no axe violations when closed', async () => {
      const { container } = render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when open', async () => {
      const user = userEvent.setup();
      const { container } = render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with selection', async () => {
      const user = userEvent.setup();
      const { container } = render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{Enter}');

      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟢 Low Priority: Props & Behavior
  describe('Props & Behavior', () => {
    it('calls onSelect when option selected', async () => {
      const user = userEvent.setup();
      const onSelect = vi.fn();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', onSelect },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{Enter}');

      expect(onSelect).toHaveBeenCalledWith(defaultOptions[0]);
      expect(onSelect).toHaveBeenCalledTimes(1);
    });

    it('calls onInputChange when typing', async () => {
      const user = userEvent.setup();
      const onInputChange = vi.fn();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', onInputChange },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.type(input, 'app');

      expect(onInputChange).toHaveBeenCalledWith('a');
      expect(onInputChange).toHaveBeenCalledWith('ap');
      expect(onInputChange).toHaveBeenCalledWith('app');
    });

    it('calls onOpenChange when popup toggles', async () => {
      const user = userEvent.setup();
      const onOpenChange = vi.fn();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', onOpenChange },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      expect(onOpenChange).toHaveBeenCalledWith(true);

      await user.keyboard('{Escape}');
      expect(onOpenChange).toHaveBeenCalledWith(false);
    });

    it('applies className to container', () => {
      const { container } = render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', class: 'custom-class' },
      });

      expect(container.querySelector('.apg-combobox')).toHaveClass('custom-class');
    });

    it('supports disabled state on combobox', () => {
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', disabled: true },
      });

      const input = screen.getByRole('combobox');
      expect(input).toBeDisabled();
    });

    it('supports placeholder', () => {
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', placeholder: 'Choose a fruit...' },
      });

      const input = screen.getByRole('combobox');
      expect(input).toHaveAttribute('placeholder', 'Choose a fruit...');
    });

    it('supports defaultInputValue', () => {
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', defaultInputValue: 'Ban' },
      });

      const input = screen.getByRole('combobox');
      expect(input).toHaveValue('Ban');
    });
  });

  // Edge Cases
  describe('Edge Cases', () => {
    it('handles empty options array', () => {
      expect(() => {
        render(Combobox, {
          props: { options: [], label: 'Fruit' },
        });
      }).not.toThrow();

      expect(screen.getByRole('combobox')).toBeInTheDocument();
    });

    it('when all options are disabled, popup opens but no focus set', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: allDisabledOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      expect(input).toHaveAttribute('aria-expanded', 'true');
      expect(input.getAttribute('aria-activedescendant')).toBeFalsy();
    });
  });
});

リソース