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.tsx
import { cn } from '@/lib/utils';
import type { HTMLAttributes, KeyboardEvent, ReactElement } from 'react';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';

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

export interface ComboboxProps extends Omit<
  HTMLAttributes<HTMLDivElement>,
  'onChange' | 'onSelect'
> {
  /** List of options */
  options: ComboboxOption[];
  /** Selected option ID (controlled) */
  selectedOptionId?: string;
  /** Default selected option ID */
  defaultSelectedOptionId?: string;
  /** Input value (controlled) */
  inputValue?: string;
  /** Default input value */
  defaultInputValue?: string;
  /** Label text */
  label: string;
  /** Placeholder */
  placeholder?: string;
  /** Disabled state */
  disabled?: boolean;
  /** Autocomplete type */
  autocomplete?: 'none' | 'list' | 'both';
  /** Message shown when no results found */
  noResultsMessage?: string;

  /** Selection callback */
  onSelect?: (option: ComboboxOption) => void;

  /** Input change callback */
  onInputChange?: (value: string) => void;

  /** Popup open/close callback */
  onOpenChange?: (isOpen: boolean) => void;
}

export function Combobox({
  options,
  selectedOptionId: controlledSelectedId,
  defaultSelectedOptionId,
  inputValue: controlledInputValue,
  defaultInputValue = '',
  label,
  placeholder,
  disabled = false,
  autocomplete = 'list',
  noResultsMessage = 'No results found',
  onSelect,
  onInputChange,
  onOpenChange,
  className = '',
  ...restProps
}: ComboboxProps): ReactElement {
  const instanceId = useId();
  const inputId = `${instanceId}-input`;
  const labelId = `${instanceId}-label`;
  const listboxId = `${instanceId}-listbox`;

  // Internal state
  const [isOpen, setIsOpen] = useState(false);
  const [internalInputValue, setInternalInputValue] = useState(() => {
    if (!defaultSelectedOptionId) {
      return defaultInputValue;
    }

    const option = options.find(({ id }) => id === defaultSelectedOptionId);

    if (option === undefined) {
      return defaultInputValue;
    }

    return option.label;
  });
  const [internalSelectedId, setInternalSelectedId] = useState<string | undefined>(
    defaultSelectedOptionId
  );
  const [activeIndex, setActiveIndex] = useState(-1);
  const [isSearching, setIsSearching] = useState(false);

  // Track value before opening for Escape restoration
  const valueBeforeOpen = useRef<string>('');
  const isComposing = useRef(false);

  const containerRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);

  // Determine controlled vs uncontrolled
  const inputValue = controlledInputValue ?? internalInputValue;
  const selectedId = controlledSelectedId ?? internalSelectedId;

  // Get selected option's label
  const selectedLabel = useMemo(() => {
    if (!selectedId) {
      return '';
    }
    const option = options.find(({ id }) => id === selectedId);
    return option?.label ?? '';
  }, [options, selectedId]);

  // Filter options based on input value and search mode
  const filteredOptions = useMemo(() => {
    // Don't filter if autocomplete is none
    if (autocomplete === 'none') {
      return options;
    }

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

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

    const lowerInputValue = inputValue.toLowerCase();

    return options.filter(({ label }) => label.toLowerCase().includes(lowerInputValue));
  }, [options, inputValue, autocomplete, isSearching, selectedLabel]);

  // Get enabled options from filtered list
  const enabledOptions = useMemo(
    () => filteredOptions.filter(({ disabled }) => !disabled),
    [filteredOptions]
  );

  // Generate option IDs
  const getOptionId = useCallback(
    (optionId: string) => `${instanceId}-option-${optionId}`,
    [instanceId]
  );

  // Get active descendant ID
  const activeDescendantId = useMemo(() => {
    if (activeIndex < 0 || activeIndex >= filteredOptions.length) {
      return undefined;
    }

    const option = filteredOptions[activeIndex];

    if (option === undefined) {
      return undefined;
    }

    return getOptionId(option.id);
  }, [activeIndex, filteredOptions, getOptionId]);

  // Update input value
  const updateInputValue = useCallback(
    (value: string) => {
      if (controlledInputValue === undefined) {
        setInternalInputValue(value);
      }
      onInputChange?.(value);
    },
    [controlledInputValue, onInputChange]
  );

  // Open popup
  const openPopup = useCallback(
    (focusPosition?: 'first' | 'last') => {
      if (isOpen) {
        return;
      }

      valueBeforeOpen.current = inputValue;
      setIsOpen(true);
      onOpenChange?.(true);

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

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

      setActiveIndex(targetIndex);
    },
    [isOpen, inputValue, enabledOptions, filteredOptions, onOpenChange]
  );

  // Close popup
  const closePopup = useCallback(
    (restore = false) => {
      setIsOpen(false);
      setActiveIndex(-1);
      setIsSearching(false);
      onOpenChange?.(false);

      if (restore) {
        updateInputValue(valueBeforeOpen.current);
      }
    },
    [onOpenChange, updateInputValue]
  );

  // Select option
  const selectOption = useCallback(
    ({ id, label, disabled }: ComboboxOption) => {
      if (disabled) {
        return;
      }

      if (controlledSelectedId === undefined) {
        setInternalSelectedId(id);
      }

      setIsSearching(false);
      updateInputValue(label);
      onSelect?.({ id, label, disabled });
      closePopup();
    },
    [controlledSelectedId, updateInputValue, onSelect, closePopup]
  );

  // Find next/previous enabled option index
  const findEnabledIndex = useCallback(
    (startIndex: number, direction: 'next' | 'prev' | 'first' | 'last'): number => {
      if (enabledOptions.length === 0) {
        return -1;
      }

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

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

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

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

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

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

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

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

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

  // Handle input keydown
  const handleInputKeyDown = useCallback(
    (event: KeyboardEvent<HTMLInputElement>) => {
      if (isComposing.current) {
        return;
      }

      const { key, altKey } = event;

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

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

            valueBeforeOpen.current = inputValue;
            setIsOpen(true);
            onOpenChange?.(true);
            return;
          }

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

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

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

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

            const option = filteredOptions[activeIndex];

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

            selectOption(option);
            return;
          }

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

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

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

          event.preventDefault();

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

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

          event.preventDefault();

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

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

          event.preventDefault();

          const option = filteredOptions[activeIndex];

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

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

          event.preventDefault();
          closePopup(true);
          break;
        }
        case 'Tab': {
          if (isOpen) {
            closePopup();
          }
          break;
        }
      }
    },
    [
      isOpen,
      inputValue,
      activeIndex,
      filteredOptions,
      openPopup,
      closePopup,
      selectOption,
      findEnabledIndex,
      onOpenChange,
    ]
  );

  // Handle input change
  const handleInputChange = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      const { value } = event.target;
      setIsSearching(true);
      updateInputValue(value);

      if (!isOpen && !isComposing.current) {
        valueBeforeOpen.current = inputValue;
        setIsOpen(true);
        onOpenChange?.(true);
      }

      setActiveIndex(-1);
    },
    [isOpen, inputValue, updateInputValue, onOpenChange]
  );

  // Handle option click
  const handleOptionClick = useCallback(
    (option: ComboboxOption) => {
      if (option.disabled) {
        return;
      }

      selectOption(option);
    },
    [selectOption]
  );

  // Handle option hover
  const handleOptionHover = useCallback(
    ({ id }: ComboboxOption) => {
      const index = filteredOptions.findIndex((option) => option.id === id);

      if (index < 0) {
        return;
      }

      setActiveIndex(index);
    },
    [filteredOptions]
  );

  // Handle IME composition
  const handleCompositionStart = useCallback(() => {
    isComposing.current = true;
  }, []);

  const handleCompositionEnd = useCallback(() => {
    isComposing.current = false;
  }, []);

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

    openPopup();
  }, [isOpen, disabled, openPopup]);

  // Click outside to close
  useEffect(() => {
    if (!isOpen) {
      return;
    }

    const handleClickOutside = (event: MouseEvent) => {
      const { current: container } = containerRef;

      if (container === null) {
        return;
      }

      if (event.target instanceof Node && !container.contains(event.target)) {
        closePopup();
      }
    };

    document.addEventListener('mousedown', handleClickOutside);

    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, [isOpen, closePopup]);

  // Clear active index when filtered options change and no match exists
  useEffect(() => {
    if (activeIndex >= 0 && activeIndex >= filteredOptions.length) {
      setActiveIndex(-1);
    }
  }, [activeIndex, filteredOptions.length]);

  // Reset search mode when input value matches selected label or becomes empty
  useEffect(() => {
    if (inputValue === '' || inputValue === selectedLabel) {
      setIsSearching(false);
    }
  }, [inputValue, selectedLabel]);

  return (
    <div ref={containerRef} className={cn('apg-combobox', className)} {...restProps}>
      <label id={labelId} htmlFor={inputId} className="apg-combobox-label">
        {label}
      </label>
      <div className="apg-combobox-input-wrapper">
        <input
          ref={inputRef}
          id={inputId}
          type="text"
          role="combobox"
          className="apg-combobox-input"
          aria-autocomplete={autocomplete}
          aria-expanded={isOpen}
          aria-controls={listboxId}
          aria-labelledby={labelId}
          aria-activedescendant={activeDescendantId || undefined}
          value={inputValue}
          placeholder={placeholder}
          disabled={disabled}
          onChange={handleInputChange}
          onKeyDown={handleInputKeyDown}
          onFocus={handleFocus}
          onCompositionStart={handleCompositionStart}
          onCompositionEnd={handleCompositionEnd}
        />
        <span className="apg-combobox-caret" aria-hidden="true">
          <svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
            <path
              fillRule="evenodd"
              d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
              clipRule="evenodd"
            />
          </svg>
        </span>
      </div>
      <ul
        id={listboxId}
        role="listbox"
        aria-labelledby={labelId}
        className="apg-combobox-listbox"
        hidden={!isOpen || undefined}
      >
        {filteredOptions.length === 0 && (
          <li className="apg-combobox-no-results" role="status">
            {noResultsMessage}
          </li>
        )}
        {filteredOptions.map(({ id, label: optionLabel, disabled: optionDisabled }, index) => {
          const isActive = index === activeIndex;
          const isSelected = id === selectedId;

          return (
            <li
              key={id}
              id={getOptionId(id)}
              role="option"
              className="apg-combobox-option"
              aria-selected={isActive}
              aria-disabled={optionDisabled || undefined}
              onClick={() =>
                handleOptionClick({ id, label: optionLabel, disabled: optionDisabled })
              }
              onMouseEnter={() =>
                handleOptionHover({ id, label: optionLabel, disabled: optionDisabled })
              }
              data-selected={isSelected || undefined}
            >
              <span className="apg-combobox-option-icon" aria-hidden="true">
                <svg viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
                  <path d="M10.28 2.28a.75.75 0 00-1.06-1.06L4.5 5.94 2.78 4.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.06 0l5.25-5.25z" />
                </svg>
              </span>
              {optionLabel}
            </li>
          );
        })}
      </ul>
    </div>
  );
}

export default Combobox;

使い方

Example
import { Combobox } from './Combobox';

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

function App() {
  return (
    <div>
      {/* 基本的な使い方 */}
      <Combobox
        options={options}
        label="お気に入りのフルーツ"
        placeholder="検索..."
      />

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

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

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

      {/* コールバック付き */}
      <Combobox
        options={options}
        label="フルーツ"
        onSelect={(option) => console.log('選択:', option)}
        onInputChange={(value) => console.log('入力:', value)}
        onOpenChange={(isOpen) => console.log('開閉:', isOpen)}
      />
    </div>
  );
}

API

プロパティ デフォルト 説明
options ComboboxOption[] 必須 id、label、オプションの disabled を持つオプションの配列
label string 必須 表示されるラベルテキスト
placeholder string - 入力のプレースホルダーテキスト
defaultInputValue string "" デフォルトの入力値
defaultSelectedOptionId string - 初期選択されるオプションの ID
inputValue string - 制御された入力値
selectedOptionId string - 制御された選択オプション ID
autocomplete "none" | "list" | "both" "list" オートコンプリートの動作
disabled boolean false コンボボックスを無効にするかどうか
onSelect (option: ComboboxOption) => void - オプション選択時のコールバック
onInputChange (value: string) => void - 入力値変更時のコールバック
onOpenChange (isOpen: boolean) => void - ポップアップ開閉時のコールバック

テスト

テストは ARIA 属性、キーボード操作、フィルタリング動作、アクセシビリティ要件に対する APG 準拠を検証します。

テストカテゴリ

高優先度: ARIA 属性

テスト 説明
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 無効なオプションを示す

高優先度: アクセシブル名

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

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

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

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

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

高優先度: フォーカス管理

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

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

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

中優先度: マウス操作

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

中優先度: IME 入力

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

中優先度: コールバック

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

低優先度: HTML 属性継承

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

テストツール

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    it('clears aria-activedescendant when list is empty after filtering', async () => {
      const user = userEvent.setup();
      render(<Combobox options={defaultOptions} label="Fruit" />);

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

      // Type something that matches no options
      await user.clear(input);
      await user.type(input, 'xyz');
      expect(input.getAttribute('aria-activedescendant')).toBeFalsy();
    });

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

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

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

    it('listbox is hidden when closed', () => {
      render(<Combobox options={defaultOptions} label="Fruit" />);
      const input = screen.getByRole('combobox');
      const listboxId = input.getAttribute('aria-controls');
      const listbox = document.getElementById(listboxId!);

      expect(listbox).toHaveAttribute('hidden');
    });

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

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

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

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

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

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

    it('non-focused options have aria-selected="false"', async () => {
      const user = userEvent.setup();
      render(<Combobox options={defaultOptions} label="Fruit" />);

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

      const secondOption = screen.getByRole('option', { name: 'Banana' });
      const thirdOption = screen.getByRole('option', { name: 'Cherry' });
      expect(secondOption).toHaveAttribute('aria-selected', 'false');
      expect(thirdOption).toHaveAttribute('aria-selected', 'false');
    });

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    it('restores input value on Escape', async () => {
      const user = userEvent.setup();
      render(<Combobox options={defaultOptions} label="Fruit" defaultInputValue="App" />);

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

      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{ArrowDown}');
      // After navigation, input might show preview of Banana
      await user.keyboard('{Escape}');

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

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

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

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

    it('closes popup on Tab', async () => {
      const user = userEvent.setup();
      render(
        <div>
          <Combobox options={defaultOptions} label="Fruit" />
          <button>Next</button>
        </div>
      );

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

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

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

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

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

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

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

      expect(input).toHaveAttribute('aria-expanded', 'true');
      // aria-activedescendant should not be set
      expect(input.getAttribute('aria-activedescendant')).toBeFalsy();
    });

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

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

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

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

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

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

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

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

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

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

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

      await user.keyboard('{ArrowDown}');
      // Should skip Banana (disabled) and go to Cherry
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Cherry');
    });

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

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

      await user.keyboard('{ArrowUp}');
      // Should skip Banana (disabled) and go to Apple
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Apple');
    });

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

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

      await user.keyboard('{Home}');
      // Should skip Apple (disabled) and go to Banana
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Banana');
    });

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

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

      await user.keyboard('{End}');
      // Should skip Cherry (disabled) and go to Banana
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Banana');
    });

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

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

      await user.keyboard('{ArrowDown}');
      // Should stay at Cherry, no wrap
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Cherry');
    });

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

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

      await user.keyboard('{ArrowUp}');
      // Should stay at Apple, no wrap
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Apple');
    });
  });

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

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

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

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

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

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

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

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

    it('aria-activedescendant references existing element', async () => {
      const user = userEvent.setup();
      render(<Combobox options={defaultOptions} label="Fruit" />);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    it('case-insensitive filtering', async () => {
      const user = userEvent.setup();
      render(<Combobox options={defaultOptions} label="Fruit" />);

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

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

    it('shows no options message when filter results are empty', async () => {
      const user = userEvent.setup();
      render(<Combobox options={defaultOptions} label="Fruit" />);

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

      expect(screen.queryAllByRole('option')).toHaveLength(0);
    });
  });

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

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      // First enabled option is Banana
      await user.keyboard('{ArrowUp}');
      // Try to go to Apple (disabled) - should stay at Banana
      await user.keyboard('{Enter}');

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

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

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

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

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

    it('shows disabled options in filtered results', async () => {
      const user = userEvent.setup();
      render(<Combobox options={optionsWithDisabled} label="Fruit" />);

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

      const options = screen.getAllByRole('option');
      expect(options).toHaveLength(1);
      expect(options[0]).toHaveTextContent('Banana');
      expect(options[0]).toHaveAttribute('aria-disabled', 'true');
    });
  });

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

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

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

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

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

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

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

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

    it('closes popup on outside click', async () => {
      const user = userEvent.setup();
      render(
        <div>
          <Combobox options={defaultOptions} label="Fruit" />
          <button>Outside</button>
        </div>
      );

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

      await user.click(screen.getByRole('button', { name: 'Outside' }));
      expect(input).toHaveAttribute('aria-expanded', 'false');
    });

    it('does not select on outside click', async () => {
      const user = userEvent.setup();
      const onSelect = vi.fn();
      render(
        <div>
          <Combobox options={defaultOptions} label="Fruit" onSelect={onSelect} />
          <button>Outside</button>
        </div>
      );

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

      await user.click(screen.getByRole('button', { name: 'Outside' }));
      expect(onSelect).not.toHaveBeenCalled();
    });

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

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

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

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

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

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

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

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

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

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

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

    it('has no axe violations with disabled options', async () => {
      const user = userEvent.setup();
      const { container } = render(<Combobox options={optionsWithDisabled} label="Fruit" />);

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

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

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

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

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

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

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

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

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

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

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

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

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

    it('supports disabled state on combobox', () => {
      render(<Combobox options={defaultOptions} label="Fruit" disabled />);

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

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

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

    it('supports defaultInputValue', () => {
      render(<Combobox options={defaultOptions} label="Fruit" defaultInputValue="Ban" />);

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

    it('supports defaultSelectedOptionId', async () => {
      const user = userEvent.setup();
      render(<Combobox options={defaultOptions} label="Fruit" defaultSelectedOptionId="banana" />);

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

      // Open popup - should show all options (not filtered) since input matches selected label
      await user.click(input);

      // All options should be visible (defaultOptions has 3 items)
      const options = screen.getAllByRole('option');
      expect(options).toHaveLength(3);

      // Banana should have data-selected (visually selected state)
      const bananaOption = screen.getByRole('option', { name: 'Banana' });
      expect(bananaOption).toHaveAttribute('data-selected', 'true');

      // Navigate with ArrowDown - focuses first option (Apple)
      await user.keyboard('{ArrowDown}');
      const appleOption = screen.getByRole('option', { name: 'Apple' });
      expect(appleOption).toHaveAttribute('aria-selected', 'true');
    });

    it('IDs do not conflict with multiple instances', () => {
      render(
        <>
          <Combobox options={defaultOptions} label="Fruit 1" />
          <Combobox options={defaultOptions} label="Fruit 2" />
        </>
      );

      const inputs = screen.getAllByRole('combobox');
      const listboxId1 = inputs[0].getAttribute('aria-controls');
      const listboxId2 = inputs[1].getAttribute('aria-controls');

      expect(listboxId1).not.toBe(listboxId2);
    });
  });

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

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

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

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

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

    it('handles rapid typing without errors', async () => {
      const user = userEvent.setup();
      render(<Combobox options={defaultOptions} label="Fruit" />);

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.type(input, 'applebananacherry', { delay: 10 });

      // Should not throw and should handle gracefully
      expect(input).toHaveValue('applebananacherry');
    });
  });
});

リソース