フォーム・入力
コンボボックス
解説ありCombobox
入力+候補リスト(オートコンプリート)。APG でも最難関の1つ。
仕様が更新されました
- 機能追加#3311(新しいタブで開きます)キーボード実装ガイド: ポインター操作時のフォーカス管理に関する注記を追加
コンボボックスとは?
コンボボックスは、テキスト入力欄と、入力に応じて出てくる候補リスト (ポップアップ)を組み合わせた UI です。検索ボックスのオートコンプリート、 住所・都道府県の入力補助、タグ付けなどでおなじみです。
「自由に文字を打てる」入力欄と「一覧から選べる」リストボックスの合体。 そのぶん、状態(開いているか・どの候補を指しているか)の伝え方が複雑で、 APG の中でも実装が難しいパターンです。
なぜアクセシビリティが大事なの?
- キーボードだけで操作する人。↓ で候補へ降りて↑↓ で移動、Enter で確定、Esc で閉じる、が必要です。 ただの
<div>候補では、マウスがないと候補を選べません。 - スクリーンリーダーを使う人。「都道府県を検索, 編集, コンボボックス, 折りたたみ」 と役割・状態が読まれ、候補を出すと「○件の候補」、移動すると「東京都, 選択済み, 12個中4個目」 のように件数とハイライト位置が伝わる必要があります。
これを実現するのが、入力欄の role="combobox" + aria-expanded +aria-controls、ポップアップの role="listbox"、そしてハイライトを伝えるaria-activedescendant です。
ライブデモ(推奨実装)
都道府県を検索するコンボボックスです。文字を打つと候補が絞り込まれます。マウスを使わず、矢印キー・Enter・Esc で操作してみてください。
- 北海道
- 青森県
- 秋田県
- 東京都
- 神奈川県
- 愛知県
- 大阪府
- 京都府
- 兵庫県
- 広島県
- 福岡県
- 沖縄県
試してみよう:文字を入力すると候補が出る → ↓ で候補へ → ↑ ↓ で移動 → Enter で確定 → Esc で閉じる。「東」「京」「大」などで絞り込めます。
ポイント
フォーカスは入力欄に置いたまま動かしません。代わりにaria-activedescendant で「いまどの候補をハイライトしているか」だけを伝えます。 こうすると、入力を続けながら候補を選べます。確定や開閉の状態はaria-expanded と aria-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-selected | 1つの候補。ハイライト中は 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-expanded とaria-live の件数読み上げで状態を補います。これが入力と選択を両立させるコツです。
アンチパターン(Bad)
下は、ただの <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がなく、「○件の候補」が読み上げられない。
実装チェックリスト
- 入力欄に role="combobox" + aria-expanded + aria-controls がある
- ポップアップは role="listbox" > role="option"(option に一意の id)
- ハイライトは aria-activedescendant + aria-selected で表し、移動のたびに更新
- aria-expanded が開閉と常に一致している
- ↓ で候補へ、↑↓ で移動、Enter 確定、Esc で閉じる
- 入力で候補が絞り込まれ、件数を 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 Aria、Base 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 Aria、React 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 Aria、React Spectrum
出典:React Aria Blog — Creating an accessible autocomplete experience(新しいタブで開きます)