APG Patterns
English
English

Combobox

リストオートコンプリート機能を持つ編集可能なコンボボックス。ユーザーは入力してオプションをフィルタリングしたり、キーボードやマウスでポップアップリストボックスから選択したりできます。

デモ

デモのみを開く →

ネイティブ 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 のオプションのフォントサイズはページをズームしても拡大されず、テキスト拡大に依存するユーザーに問題を引き起こします。
  • CSS スタイリングの制限: オプションをハイコントラストモード向けにスタイリングできないため、視覚障害のあるユーザーへの対応が困難です。
  • スクリーンリーダーの互換性: 一部のスクリーンリーダーとブラウザの組み合わせ(例:NVDA と Firefox)では、オートサジェストポップアップの内容が読み上げられません。

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

アクセシビリティ

WAI-ARIA ロール

ロール 対象要素 説明
combobox Input (`<input>`) ユーザーが入力するテキスト入力要素
listbox Popup (`<ul>`) 選択可能なオプションを含むポップアップ
option Each item (`<li>`) 個々の選択可能なオプション

WAI-ARIA combobox role (opens in new tab)

WAI-ARIA プロパティ(Input)

属性 必須 説明
role="combobox" - はい 入力をコンボボックスとして識別
aria-controls ID reference はい リストボックスポップアップを参照(閉じている時も)
aria-expanded `true` | `false` はい ポップアップが開いているかどうかを示す
aria-autocomplete `list` | `none` | `both` はい オートコンプリートの動作を説明
aria-activedescendant ID reference | empty はい ポップアップ内で現在フォーカスされているオプションを参照

WAI-ARIA プロパティ(Listbox & Options)

属性 対象 必須 説明
aria-labelledby input、listbox ID reference はい* ラベル要素を参照
aria-selected option `true` | `false` はい 現在フォーカスされているオプションを示す
aria-disabled option `true` いいえ オプションが無効であることを示す

キーボードサポート

ポップアップ閉時

キー アクション
Down Arrow ポップアップを開き、最初のオプションにフォーカス
Up Arrow ポップアップを開き、最後のオプションにフォーカス
Alt + Down Arrow フォーカス位置を変更せずにポップアップを開く
文字入力 オプションをフィルタリングしてポップアップを開く

ポップアップ開時

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

フォーカス管理

このコンポーネントは仮想フォーカス管理に aria-activedescendant を使用します:

  • DOMフォーカスはinputに留まり、aria-activedescendantが視覚的にフォーカスされているオプションを参照
  • aria-activedescendantがクリアされる
  • ナビゲーション中に無効なオプションはスキップされる

オートコンプリートモード

モード 動作
list オプションは入力値に基づいてフィルタリングされる(デフォルト)
none 入力値に関係なくすべてのオプションを表示
both オプションをフィルタリングし、最初の一致を入力にオートコンプリート

非表示状態

閉じている時、リストボックスは hidden 属性を使用して:

  • ポップアップを視覚的に非表示にする
  • ポップアップをアクセシビリティツリーから削除する
  • リストボックス要素は DOM に残り、aria-controls の参照が有効なまま

ソースコード

Combobox.svelte
<script lang="ts">
  import { cn } from '@/lib/utils';
  import { onDestroy, untrack } 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 - use untrack for initial values
  let isOpen = $state(false);
  let activeIndex = $state(-1);
  let isComposing = $state(false);
  let valueBeforeOpen = '';
  let internalInputValue = $state(untrack(() => getInitialInputValue()));
  let internalSelectedId = $state<string | undefined>(untrack(() => 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>
  <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 (option.id)}
      {@const isActive = index === activeIndex}
      {@const isSelected = option.id === currentSelectedId}
      <!-- svelte-ignore a11y_click_events_have_key_events -->
      <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が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 選択せずにポップアップを閉じる

テストツール

詳細なドキュメントは 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();
    });
  });
});

リソース