フォーム・入力

コンボボックス

解説あり

Combobox

入力+候補リスト(オートコンプリート)。APG でも最難関の1つ。

仕様が更新されました

コンボボックスとは?

コンボボックスは、テキスト入力欄と、入力に応じて出てくる候補リスト (ポップアップ)を組み合わせた UI です。検索ボックスのオートコンプリート、 住所・都道府県の入力補助、タグ付けなどでおなじみです。

「自由に文字を打てる」入力欄と「一覧から選べる」リストボックスの合体。 そのぶん、状態(開いているか・どの候補を指しているか)の伝え方が複雑で、 APG の中でも実装が難しいパターンです。

なぜアクセシビリティが大事なの?

これを実現するのが、入力欄の role="combobox" + aria-expanded +aria-controls、ポップアップの role="listbox"、そしてハイライトを伝えるaria-activedescendant です。

ライブデモ(推奨実装)

都道府県を検索するコンボボックスです。文字を打つと候補が絞り込まれます。マウスを使わず、矢印キー・Enter・Esc で操作してみてください。

アクセシブルなコンボボックス(オートコンプリート)

試してみよう:文字を入力すると候補が出る → ↓ で候補へ → ↑ ↓ で移動 → Enter で確定 → Esc で閉じる。「東」「京」「大」などで絞り込めます。

ポイント

フォーカスは入力欄に置いたまま動かしません。代わりにaria-activedescendant で「いまどの候補をハイライトしているか」だけを伝えます。 こうすると、入力を続けながら候補を選べます。確定や開閉の状態はaria-expandedaria-live の件数読み上げで補います。

キーボード操作

キー動作必須/任意
文字入力候補を絞り込み、ポップアップを開く必須
ポップアップを開く / 次の候補へハイライト移動必須
前の候補へハイライト移動必須
Enterハイライト中の候補を確定して閉じる必須
Escポップアップを閉じる必須
Esc(ポップアップが閉じている場合)入力をクリアする任意
Home / End入力欄内のカーソル移動(テキスト操作はそのまま)任意

必要な WAI-ARIA / ロール

付ける場所属性 / ロール意味
入力欄role="combobox"テキスト入力+ポップアップを持つ複合ウィジェットだと示す。
入力欄aria-expanded="true | false"ポップアップが開いているか。開閉に合わせて必ず更新する。
入力欄aria-controls="リストのid"どのポップアップ(listbox)を制御するかを示す。
入力欄aria-activedescendant="候補のid"いまハイライト中の option を指す。移動のたびに更新、閉じたら外す。
入力欄aria-autocomplete="list"入力に応じてリストで補完されることを示す。
ポップアップrole="listbox" + 名前候補の集まり。aria-labelledby 等で名前を付ける。
各候補role="option" + 一意の id + aria-selected1つの候補。ハイライト中は aria-selected="true"。
ライブ領域aria-live="polite"「○件の候補」など件数の変化を読み上げる(任意・推奨)。

実装:推奨パターン(Good)

良い例 / 推奨

入力欄を role="combobox" にして aria-expanded/aria-controls/aria-activedescendant を管理し、 ポップアップは role="listbox" > role="option" にします。

マークアップ:

<label id="cmb-label" for="cmb-input">都道府県を検索</label>

<input
  id="cmb-input"
  type="text"
  role="combobox"
  aria-expanded="false"
  aria-controls="cmb-list"
  aria-autocomplete="list"
  autocomplete="off"
>

<ul id="cmb-list" role="listbox" aria-labelledby="cmb-label" hidden>
  <li role="option" id="cmb-opt-0">北海道</li>
  <li role="option" id="cmb-opt-1">青森県</li>
  <!-- ... -->
</ul>

<!-- 候補件数を読み上げるためのライブ領域 -->
<p class="sr-only" aria-live="polite"></p>

絞り込み・開閉・ハイライト・確定のスクリプト:

const input = document.getElementById('cmb-input');
const listbox = document.getElementById('cmb-list');
const options = [...listbox.querySelectorAll('[role="option"]')];
let activeIndex = -1;

const visible = () => options.filter((o) => !o.hidden);

const open = () => {
  input.setAttribute('aria-expanded', 'true');
  listbox.hidden = false;
};
const close = () => {
  input.setAttribute('aria-expanded', 'false');
  listbox.hidden = true;
  activeIndex = -1;
  input.removeAttribute('aria-activedescendant');
  options.forEach((o) => o.setAttribute('aria-selected', 'false'));
};

// 現在ハイライト中の候補を aria-activedescendant で伝える
const setActive = (i) => {
  const vis = visible();
  options.forEach((o) => o.setAttribute('aria-selected', 'false'));
  if (i < 0 || i >= vis.length) {
    activeIndex = -1;
    input.removeAttribute('aria-activedescendant');
    return;
  }
  activeIndex = i;
  vis[i].setAttribute('aria-selected', 'true');
  input.setAttribute('aria-activedescendant', vis[i].id);
  vis[i].scrollIntoView({ block: 'nearest' });
};

// 入力で候補を絞り込み、件数を読み上げる
const filter = () => {
  const q = input.value.trim();
  options.forEach((o) => {
    o.hidden = q !== '' && !o.textContent.includes(q);
  });
  return visible().length;
};

input.addEventListener('input', () => {
  filter() > 0 ? open() : close();
  setActive(-1);
});

input.addEventListener('keydown', (e) => {
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
    e.preventDefault();
    if (input.getAttribute('aria-expanded') !== 'true' && filter() > 0) open();
    const n = visible().length;
    if (!n) return;
    setActive(e.key === 'ArrowDown'
      ? (activeIndex + 1) % n
      : (activeIndex - 1 + n) % n);
  } else if (e.key === 'Enter' && activeIndex >= 0) {
    e.preventDefault();
    input.value = visible()[activeIndex].textContent;
    close();
  } else if (e.key === 'Escape') {
    input.getAttribute('aria-expanded') === 'true' ? close() : (input.value = '');
  }
});

options.forEach((opt) => opt.addEventListener('click', () => {
  input.value = opt.textContent;
  close();
  input.focus();
}));

補足

ポイントは「フォーカスは入力欄から動かさない」こと。候補の選択はaria-activedescendant で仮想的に行い、aria-expandedaria-live の件数読み上げで状態を補います。これが入力と選択を両立させるコツです。

アンチパターン(Bad)

下は、ただの <input><div> の候補を並べただけのものです。マウスでは候補をクリックできますが、キーボードでは候補に降りられず、 読み上げでは候補の存在も件数も選択も伝わりません。

input + div で作った壊れたコンボボックス

試してみよう:文字を打つと候補は出ますが、↓ キーで候補へ移動できず、Enter でも選べません。スクリーンリーダーには候補が出たことすら伝わりません。

<!-- ❌ アンチパターン:ただの input + div の候補 -->
<input type="text" placeholder="都道府県を入力">

<div class="suggestions">
  <!-- role も aria も無い。キーボードで候補を選べない -->
  <div class="item" onclick="pick(this)">東京都</div>
  <div class="item" onclick="pick(this)">大阪府</div>
</div>

悪い例 / 避ける

この実装の問題点:

  • キーボードで候補を選べない — 候補が div で、矢印キーやフォーカスが効かない。
  • ロール・状態が無いrole="combobox"/aria-expanded/aria-controls がなく、開閉も関係も伝わらない。
  • ハイライトが伝わらないaria-activedescendant/aria-selected がなく、どれを指しているか分からない。
  • 件数が伝わらないaria-live がなく、「○件の候補」が読み上げられない。

実装チェックリスト

現場の実例 — APG と実装の違い

aria-selected はハイライト追随ではなく「確定した選択」だけに使う

APG では
APG のコンボボックス実装例では、矢印キーで候補をハイライトするたびに aria-activedescendant と同期させて、その option の aria-selected="true" を移動させる。
現場では
React Aria や Base UI は、aria-selected を「確定済みの選択値」にのみ付ける。キーボード操作中のハイライトは aria-activedescendant(と視覚スタイル)だけで伝え、Enter などで確定した瞬間に初めて aria-selected="true" になる。
なぜ
「フォーカス(いま指している候補)」と「選択(確定した値)」は別の概念で、ハイライトのたびに aria-selected を動かすと、スクリーンリーダーが移動のたびに「選択済み」と読み上げ、値が確定したとユーザーが誤解しうる。複数選択 UI では「選択済み」の意味が二重になり整合しない。activedescendant がフォーカス位置を伝える以上、selected は本来の意味(選択状態)に限定するのが現場の解釈になっている。

採用:React AriaBase UI

出典:React Aria — useComboBox(新しいタブで開きます)Base UI — Combobox(新しいタブで開きます)

モバイルでは role="combobox" を捨て、トレイ内の role="searchbox" に切り替える

APG では
入力欄に role="combobox" を付け、aria-expanded / aria-controls でリストボックスと関連付け、矢印キーで候補間を移動することを主要な操作方法とする。
現場では
React Aria の ComboBox はモバイルでは外側の入力を廃止してボタン化し、タップで開く全画面トレイの中に検索入力を置く。その入力のロールは combobox ではなく searchbox にしている。
なぜ
実機テストで、トレイ内の入力に role="combobox" を残すとスクリーンリーダーが「ダブルタップで閉じる」という誤ったアナウンスをする不具合が判明した。また、タッチのスクリーンリーダー利用者は物理キーボードを持たず、APG が前提とする矢印キーでの候補移動ができない。外側とトレイ内で入力が二重になる状態管理の複雑さも重なり、「モバイルは別の UI として設計する」判断に至った。APG の型を守ることより、入力手段ごとの実際の操作性を優先した代表例。

採用:React AriaReact Spectrum

出典:React Aria Blog — Creating an accessible autocomplete experience(新しいタブで開きます)

listbox は「隣接要素」に置けない — ポータル + aria-hidden の動的制御で補う

APG では
APG の実装例では listbox は combobox の兄弟要素として DOM に置かれ、スクリーンリーダーが入力と候補リストの間を素直に行き来できることを前提にしている。
現場では
React Aria は親要素の overflow: hidden / scroll によるクリッピングを避けるため listbox を document 末尾へポータルする。崩れた前提を補うため、TreeWalker で DOM 全体を走査して入力と listbox 以外へ aria-hidden を付与し、開いている間は MutationObserver で DOM の変化を監視し続ける。
なぜ
実アプリでは combobox がスクロールコンテナやオーバーフロー制御の中に置かれるため、候補リストを APG どおり隣に置くと欠けて表示される。ポータルは視覚問題を解決するが、DOM 上の距離が生まれてスクリーンリーダーの読み上げ順が壊れる。そこで「入力と listbox 以外を一時的に支援技術から隠す」ことで、APG が想定していたスクリーンリーダー体験そのものを別の手段で再現した。教科書の DOM 構造ではなく、教科書が実現したかった体験を移植した実例。

採用:React AriaReact Spectrum

出典:React Aria Blog — Creating an accessible autocomplete experience(新しいタブで開きます)


原文(英語):Combobox Pattern — W3C APG(新しいタブで開きます)