フォーム・入力

コンボボックス

解説あり

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ポップアップを閉じる(開いていなければ入力をクリア)必須
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 がなく、「○件の候補」が読み上げられない。

実装チェックリスト


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