フォーム・入力
コンボボックス
解説ありCombobox
入力+候補リスト(オートコンプリート)。APG でも最難関の1つ。
コンボボックスとは?
コンボボックスは、テキスト入力欄と、入力に応じて出てくる候補リスト (ポップアップ)を組み合わせた 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 | ポップアップを閉じる(開いていなければ入力をクリア) | 必須 |
| 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で読み上げる(推奨) - フォーカスは入力欄に保持し、フォーカスリングを消していない