# 選択が読まれず、チップをキーボードで消せない複数選択

Source: https://easeweb.dev/learn/multi-select
Topics: カスタム入力 > 複数選択やタグ入力で選んだ項目がわかり、キーボードで削除できる (WCAG 2.1.1, 4.1.2)
The fix: 各チップの×を「デザインを削除」のようにチップの名前を持つボタンにし、チップを外したらフォーカスを次のチップへ移します。そのうえで、選択肢の作り方を三つから選びます。欄のラベルで名前を付けたグループの中のチェックボックス、aria-multiselectable="true"とすべての選択肢のaria-selectedを付けてチェックマークをその属性から描くリストボックス、multiple付きの標準のselectです。selectでは、選択に追加するためのキー操作が一覧に表示されないことを承知しておきます。
Test with: keyboard, screen reader, forced colors
References: https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F15 https://www.w3.org/WAI/ARIA/apg/patterns/listbox/ https://www.w3.org/TR/wai-aria-1.2/#aria-multiselectable https://www.w3.org/TR/wai-aria-1.2/#aria-selected https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/select

よくある問題を再現した学習用のサンプルです。実在のクライアントの診断結果ではありません。コードは出発点となる実装例のため、実際の製品でも確認してください。ここでは支援技術でのテスト結果は報告していません。

## 場面

ある求人サイトのプロフィール入力フォームに、「スキル」欄があります。多くのUIライブラリーにある、複数選択のドロップダウンの形です。角の丸い一つの枠の中に、これまでに選んだデザインとリサーチがチップとして並び、それぞれに外すための小さな×が付いています。枠の端の山形の印を押すと、その下にスキルの一覧が開きます。マウスなら問題なく使えます。枠をクリックすれば一覧が開き、スキルをクリックすれば選べ、×をクリックすればチップが外れます。多くのライブラリーでは枠に文字を入力して一覧を絞り込めますが、ここではそれを省いています。入力による絞り込みは「[候補を読み上げず、キーボードで選べないオートコンプリート](/ja/learn/combobox)」で扱っています。この欄は、よく次の二つのどちらかの作りになっています。

一つ目では、多くの複数選択のコンポーネントの初期設定と同じく、選んだスキルは一覧から消え、チップとしてだけ表示されます。Tabキーを押すと、二つのチップを飛ばして山形の印のボタンへ移り、Enterキーで一覧が開きます。矢印キーで強調が動き、Spaceキーで強調中のスキルを選べるので、スキルを足すことはできます。しかし、どちらの×にもフォーカスは当たらず、選んだスキルはもう一覧にありません。キーボードで操作する人が間違えてライティングを選ぶと、プロフィールから外す方法がありません。

二つ目では、すべてのスキルが一覧に残り、選んだものにチェックマークが付きます。今度はキーボードでも、一覧を開いてリサーチのチェックを外せます。スクリーンリーダーは「スキル、リストボックス」と読んだあと、強調が動くたびにスキルを読みます。ところが、チェックが付いているのがデザインとリサーチだとは伝わりません。一覧は複数選べることを示さず、どの選択肢も選択状態を持たないため、Chromiumのように、強調中のスキルを「選択」と伝えるブラウザーさえあります。ライティングでSpaceキーを押すと、画面にはチェックマークとチップが増えますが、何も読まれません。×は、この作りでもTabキーで飛ばされます。

## 確認できる問題

達成基準2.1.1は、マウスでできることをキーボードでもできるよう求めています。どの×も、クリック処理を付けた `svg` です。svgは操作部品ではないためフォーカスを受け取れず、Tabキーはそこに止まらず、どのキーでも押せません。一つ目の作りでは、選んだスキルを外す方法は×だけなので、キーボードではスキルを足せても、外すことはできません。二つ目の作りでは、一覧でチェックを外すという別の方法がキーボードにもあるため、もう一つの問題がはっきり見えてきます。

達成基準4.1.2は、利用者が変えられる操作部品の状態を支援技術が取得できるよう求めています。スキルを選んでいるかどうかは、まさにその状態です。二つ目の作りでは、チェックマークはクラスをもとにCSSで描かれ、選択肢には `aria-selected` が、一覧には `aria-multiselectable` がありません。スクリーンリーダーが読む内容はブラウザーがマークアップから組み立てますが、クラスはブラウザーにとって意味を持たないため、伝えるべき選択状態も、複数選べる一覧であることも伝わりません。W3Cはこれを失敗例F15として挙げています。アクセシビリティAPIを不完全にしか使わないカスタムの操作部品で、ここでは役割はあっても状態がありません。

## 利用者への影響

複数選択の欄には、スキル、興味のある分野、タグ、宛先など、プロフィールや絞り込みや投稿の中身を決めるものがよく入ります。取り消せない間違いや、確かめられない選択は、そのままフォームと一緒に送られます。

## 自動チェックとAIの限界

一般的なスキャンには、どちらの作りも問題なく見えます。山形の印は名前の付いたボタンで、リストボックスと選択肢の役割は正しく、役割のない `svg` は名前の要らない画像なので、スキャンのどの規則にも引っかかりません。その画像がクリックを受け付けることも、クラスがチェックマークを描いていることも、スキャンには見えません。EaseWebの診断は、チップの中にあってTabキーが届かない削除の×を、`ew:chip-remove-unreachable` として報告します。リストボックスの選択肢が `aria-selected` を持たず、クラスだけで選択済みに描かれていれば、`ew:option-selection-not-exposed` として報告します。`aria-selected="true"` の選択肢が二つ以上あるのに `aria-multiselectable` のないリストボックスは、確認が必要な項目として挙げます。AIに「チップを外せる複数選択」を頼むと、×をonClick付きのsvgやspanで書いたり、`tabindex` もキー操作もないまま `role="button"` だけを付けたり、チェックマークを `selected` クラスから描いたりすることがあります。使えるかどうかは、Tabキーで移動し、キーを押し、読み上げを聞いて初めて分かります。

## 修正前：マウスでしか外せない選んだスキル

```html
<span id="skills-label">スキル</span>
<div class="skills-field">
  <span class="chips">
    <span class="chip">
      デザイン
      <svg class="chip-remove" data-for="skill-design" viewBox="0 0 16 16">…</svg>
    </span>
    <span class="chip">
      リサーチ
      <svg class="chip-remove" data-for="skill-research" viewBox="0 0 16 16">…</svg>
    </span>
  </span>
  <button type="button" class="skills-toggle" aria-labelledby="skills-label"
    aria-haspopup="listbox" aria-expanded="false" aria-controls="skills-list">
    <span class="placeholder">スキルを追加</span>
    <svg class="chevron" viewBox="0 0 16 16" aria-hidden="true">…</svg>
  </button>
</div>
<ul id="skills-list" class="skills" role="listbox" tabindex="-1" aria-labelledby="skills-label" hidden>
  <li id="skill-design" role="option" hidden>デザイン</li>
  <li id="skill-writing" role="option">ライティング</li>
  <li id="skill-research" role="option" hidden>リサーチ</li>
  <li id="skill-coding" role="option">コーディング</li>
  <li id="skill-video" role="option">動画編集</li>
</ul>
```

```js
const CROSS = '<path d="M4 4l8 8m0-8l-8 8"/>';
const options = [...list.querySelectorAll('[role="option"]')];
let active = null;
function move(step) {
  const shown = options.filter((option) => !option.hidden);
  if (!shown.length) return;
  const index = Math.max(0, Math.min(shown.indexOf(active) + step, shown.length - 1));
  active?.classList.remove('active');
  active = shown[index];
  active.classList.add('active');
  list.setAttribute('aria-activedescendant', active.id);
}
function setOpen(open) {
  list.hidden = !open;
  toggle.setAttribute('aria-expanded', String(open));
  if (open) {
    list.focus();
    move(0);
  }
}
// 選んだスキルは一覧から消え、チップとして表示される。
function choose(option) {
  option.hidden = !option.hidden;
  showChips();
  move(0);
}
function showChips() {
  chips.innerHTML = options
    .filter((option) => option.hidden)
    .map((option) => `
      <span class="chip">
        ${option.textContent}
        <svg class="chip-remove" data-for="${option.id}" viewBox="0 0 16 16">${CROSS}</svg>
      </span>`)
    .join('');
  placeholder.textContent = chips.children.length ? 'スキルを追加' : 'スキルを選択';
}
toggle.addEventListener('click', () => setOpen(list.hidden));
field.addEventListener('click', (event) => {
  if (!event.target.closest('.chip-remove, .skills-toggle')) setOpen(true);
});
document.addEventListener('click', (event) => {
  if (!field.contains(event.target) && !list.contains(event.target)) setOpen(false);
});
list.addEventListener('focusout', (event) => {
  if (!field.contains(event.relatedTarget) && !list.contains(event.relatedTarget)) setOpen(false);
});
list.addEventListener('keydown', (event) => {
  if (event.key === 'ArrowDown') move(1);
  else if (event.key === 'ArrowUp') move(-1);
  else if (event.key === ' ' && active) choose(active);
  else if (event.key === 'Escape') {
    setOpen(false);
    toggle.focus();
  } else return;
  event.preventDefault();
});
list.addEventListener('click', (event) => {
  const option = event.target.closest('[role="option"]');
  if (option) choose(option);
});
chips.addEventListener('click', (event) => {
  const cross = event.target.closest('.chip-remove');
  if (cross) choose(document.getElementById(cross.dataset.for));
});
```

```css
.chip-remove {
  cursor: pointer;
}
.skills [role="option"] {
  display: flex;
  justify-content: space-between;
}
.skills .active {
  background: var(--highlight);
}
```

山形の印はボタンで、一覧はキーボードで操作できるため、スキルを足すことはできます。選んだスキルは一覧から消え、戻す方法はその×だけですが、×はフォーカスを受け取れない `svg` です。Tabキーは×を飛ばし、どのキーでもチップを外せません。

## 修正前：目でしか分からないチェックマーク

```html
<span id="skills-label">スキル</span>
<div class="skills-field">
  <span class="chips">
    <span class="chip">
      デザイン
      <svg class="chip-remove" data-for="skill-design" viewBox="0 0 16 16">…</svg>
    </span>
    <span class="chip">
      リサーチ
      <svg class="chip-remove" data-for="skill-research" viewBox="0 0 16 16">…</svg>
    </span>
  </span>
  <button type="button" class="skills-toggle" aria-labelledby="skills-label"
    aria-haspopup="listbox" aria-expanded="false" aria-controls="skills-list">
    <span class="placeholder">スキルを追加</span>
    <svg class="chevron" viewBox="0 0 16 16" aria-hidden="true">…</svg>
  </button>
</div>
<ul id="skills-list" class="skills" role="listbox" tabindex="-1" aria-labelledby="skills-label" hidden>
  <li id="skill-design" role="option" class="ticked">デザイン</li>
  <li id="skill-writing" role="option">ライティング</li>
  <li id="skill-research" role="option" class="ticked">リサーチ</li>
  <li id="skill-coding" role="option">コーディング</li>
  <li id="skill-video" role="option">動画編集</li>
</ul>
```

```js
const CROSS = '<path d="M4 4l8 8m0-8l-8 8"/>';
const options = [...list.querySelectorAll('[role="option"]')];
let active = null;
function move(step) {
  const shown = options.filter((option) => !option.hidden);
  if (!shown.length) return;
  const index = Math.max(0, Math.min(shown.indexOf(active) + step, shown.length - 1));
  active?.classList.remove('active');
  active = shown[index];
  active.classList.add('active');
  list.setAttribute('aria-activedescendant', active.id);
}
function setOpen(open) {
  list.hidden = !open;
  toggle.setAttribute('aria-expanded', String(open));
  if (open) {
    list.focus();
    move(0);
  }
}
// 選んだスキルにはチェックマークが付き、チップとして表示される。
function choose(option) {
  option.classList.toggle('ticked');
  showChips();
  move(0);
}
function showChips() {
  chips.innerHTML = options
    .filter((option) => option.classList.contains('ticked'))
    .map((option) => `
      <span class="chip">
        ${option.textContent}
        <svg class="chip-remove" data-for="${option.id}" viewBox="0 0 16 16">${CROSS}</svg>
      </span>`)
    .join('');
  placeholder.textContent = chips.children.length ? 'スキルを追加' : 'スキルを選択';
}
toggle.addEventListener('click', () => setOpen(list.hidden));
field.addEventListener('click', (event) => {
  if (!event.target.closest('.chip-remove, .skills-toggle')) setOpen(true);
});
document.addEventListener('click', (event) => {
  if (!field.contains(event.target) && !list.contains(event.target)) setOpen(false);
});
list.addEventListener('focusout', (event) => {
  if (!field.contains(event.relatedTarget) && !list.contains(event.relatedTarget)) setOpen(false);
});
list.addEventListener('keydown', (event) => {
  if (event.key === 'ArrowDown') move(1);
  else if (event.key === 'ArrowUp') move(-1);
  else if (event.key === ' ' && active) choose(active);
  else if (event.key === 'Escape') {
    setOpen(false);
    toggle.focus();
  } else return;
  event.preventDefault();
});
list.addEventListener('click', (event) => {
  const option = event.target.closest('[role="option"]');
  if (option) choose(option);
});
chips.addEventListener('click', (event) => {
  const cross = event.target.closest('.chip-remove');
  if (cross) choose(document.getElementById(cross.dataset.for));
});
```

```css
.chip-remove {
  cursor: pointer;
}
.skills [role="option"] {
  display: flex;
  justify-content: space-between;
}
.skills .active {
  background: var(--highlight);
}
/* チェックマーク：回転させた箱の二辺の枠線。クラスから描く */
.skills .ticked::after {
  content: "";
  inline-size: 0.375rem;
  block-size: 0.75rem;
  border: solid currentColor;
  border-width: 0 2px 2px 0;
  rotate: 45deg;
}
```

一つ目と同じ欄で、変わったのは一点だけです。選んだスキルが、チェックマーク付きで一覧に残ります。これでキーボードでも一覧でチェックを外せますが、チェックマークは `ticked` クラスから描かれ、どの選択肢を選んでいるかも、複数選べる一覧であることも、マークアップのどこにも書かれていません。そのためスクリーンリーダーは、どのスキルも同じように読みます。×は相変わらず、Tabキーの届かないsvgです。

## 修正後：ドロップダウンの中のチェックボックスと、各チップのボタン

```html
<span id="skills-label">スキル</span>
<div class="skills-field">
  <span class="chips">
    <span class="chip">
      デザイン
      <button type="button" class="chip-remove" data-for="skill-design" aria-label="デザインを削除">
        <svg viewBox="0 0 16 16" aria-hidden="true">…</svg>
      </button>
    </span>
    <span class="chip">
      リサーチ
      <button type="button" class="chip-remove" data-for="skill-research" aria-label="リサーチを削除">
        <svg viewBox="0 0 16 16" aria-hidden="true">…</svg>
      </button>
    </span>
  </span>
  <button type="button" class="skills-toggle" aria-labelledby="skills-label"
    aria-expanded="false" aria-controls="skills-list">
    <span class="placeholder">スキルを追加</span>
    <svg class="chevron" viewBox="0 0 16 16" aria-hidden="true">…</svg>
  </button>
</div>
<div id="skills-list" class="skills" role="group" aria-labelledby="skills-label" hidden>
  <label class="skill">
    デザイン
    <input type="checkbox" id="skill-design" name="skills" value="design" checked>
  </label>
  <label class="skill">
    ライティング
    <input type="checkbox" id="skill-writing" name="skills" value="writing">
  </label>
  <label class="skill">
    リサーチ
    <input type="checkbox" id="skill-research" name="skills" value="research" checked>
  </label>
  <label class="skill">
    コーディング
    <input type="checkbox" id="skill-coding" name="skills" value="coding">
  </label>
  <label class="skill">
    動画編集
    <input type="checkbox" id="skill-video" name="skills" value="video">
  </label>
</div>
```

```js
const CROSS = '<path d="M4 4l8 8m0-8l-8 8"/>';
const boxes = [...list.querySelectorAll('input[type="checkbox"]')];
const nameOf = (box) => box.labels[0].textContent.trim();
function setOpen(open) {
  list.hidden = !open;
  toggle.setAttribute('aria-expanded', String(open));
  if (open) boxes[0].focus();
}
function showChips() {
  chips.innerHTML = boxes
    .filter((box) => box.checked)
    .map((box) => `
      <span class="chip">
        ${nameOf(box)}
        <button type="button" class="chip-remove" data-for="${box.id}" aria-label="${nameOf(box)}を削除">
          <svg viewBox="0 0 16 16" aria-hidden="true">${CROSS}</svg>
        </button>
      </span>`)
    .join('');
  placeholder.textContent = chips.children.length ? 'スキルを追加' : 'スキルを選択';
}
toggle.addEventListener('click', () => setOpen(list.hidden));
field.addEventListener('click', (event) => {
  if (!event.target.closest('.chip-remove, .skills-toggle')) setOpen(true);
});
document.addEventListener('click', (event) => {
  if (!field.contains(event.target) && !list.contains(event.target)) setOpen(false);
});
list.addEventListener('focusout', (event) => {
  if (!field.contains(event.relatedTarget) && !list.contains(event.relatedTarget)) setOpen(false);
});
list.addEventListener('keydown', (event) => {
  if (event.key !== 'Escape') return;
  setOpen(false);
  toggle.focus();
});
list.addEventListener('change', showChips);
chips.addEventListener('click', (event) => {
  const button = event.target.closest('.chip-remove');
  if (!button) return;
  const index = [...chips.querySelectorAll('.chip-remove')].indexOf(button);
  document.getElementById(button.dataset.for).checked = false;
  showChips();
  // 押したボタンは消えたので、次のボタン、前のボタン、欄のボタンの順にフォーカスする。
  const left = chips.querySelectorAll('.chip-remove');
  (left[index] ?? left[index - 1] ?? toggle).focus();
});
```

```css
.chip-remove {
  cursor: pointer;
}
.skill {
  display: flex;
  justify-content: space-between;
}
/* ボックスは、チェックボックスそのものに描く */
.skill input {
  appearance: none;
  display: grid;
  place-content: center;
  inline-size: 1.25rem;
  block-size: 1.25rem;
  margin: 0;
  border: 2px solid var(--edge);
  border-radius: 0.25rem;
}
/* チェックマークは、チェックボックス自身の状態から描く */
.skill input:checked::before {
  content: "";
  inline-size: 0.375rem;
  block-size: 0.75rem;
  border: solid currentColor;
  border-width: 0 2px 2px 0;
  rotate: 45deg;
}
```

ドロップダウンの見た目はそのままで、中の一覧が本物のチェックボックスになります。各ボックスにはTabキーで移動でき、Spaceキーでチェックを付け外しできます。名前とチェックの有無も、ブラウザーがスクリーンリーダーに伝えます。「ライティング、チェックボックス、チェックなし」と読まれ、Spaceキーを押すと「チェック」と読まれます。山形の印のボタンで一覧が開き、フォーカスは最初のボックスへ移ります。Escapeキーで閉じるとフォーカスはボタンに戻り、フォーカスが外へ出たときや外側をクリックしたときも閉じます。グループの名前は「スキル」のラベルから付きます。各×は、svgを包む `button` になったので、Tabキーで届き、EnterキーかSpaceキーで押せます。ボタンの中身は画像だけで、そのままでは名前がないため、`aria-label` でどのチップを外すかを示します。チップを外すとボタンも一緒に消えるので、スクリプトでフォーカスを次のチップのボタンへ移します。次がなければ前のチップのボタンへ、チップが残っていなければ山形の印のボタンへ移します。これがないと、フォーカスのあったボタンが消え、フォーカスの行き場がなくなります。選んだスキルはチェック付きで一覧に残るため、どのスキルにも外す方法が二つあります。見た目は少し変わり、一覧の各行には、チェックを付けるまで空のボックスが表示されます。

## 修正後：ドロップダウンの中の複数選択のリストボックスと、各チップのボタン

```html
<span id="skills-label">スキル</span>
<div class="skills-field">
  <span class="chips">
    <span class="chip">
      デザイン
      <button type="button" class="chip-remove" data-for="skill-design" aria-label="デザインを削除">
        <svg viewBox="0 0 16 16" aria-hidden="true">…</svg>
      </button>
    </span>
    <span class="chip">
      リサーチ
      <button type="button" class="chip-remove" data-for="skill-research" aria-label="リサーチを削除">
        <svg viewBox="0 0 16 16" aria-hidden="true">…</svg>
      </button>
    </span>
  </span>
  <button type="button" class="skills-toggle" aria-labelledby="skills-label"
    aria-haspopup="listbox" aria-expanded="false" aria-controls="skills-list">
    <span class="placeholder">スキルを追加</span>
    <svg class="chevron" viewBox="0 0 16 16" aria-hidden="true">…</svg>
  </button>
</div>
<ul id="skills-list" class="skills" role="listbox" tabindex="-1" aria-labelledby="skills-label"
  aria-multiselectable="true" hidden>
  <li id="skill-design" role="option" aria-selected="true">デザイン</li>
  <li id="skill-writing" role="option" aria-selected="false">ライティング</li>
  <li id="skill-research" role="option" aria-selected="true">リサーチ</li>
  <li id="skill-coding" role="option" aria-selected="false">コーディング</li>
  <li id="skill-video" role="option" aria-selected="false">動画編集</li>
</ul>
```

```js
const CROSS = '<path d="M4 4l8 8m0-8l-8 8"/>';
const options = [...list.querySelectorAll('[role="option"]')];
let active = null;
function move(step) {
  const shown = options.filter((option) => !option.hidden);
  if (!shown.length) return;
  const index = Math.max(0, Math.min(shown.indexOf(active) + step, shown.length - 1));
  active?.classList.remove('active');
  active = shown[index];
  active.classList.add('active');
  list.setAttribute('aria-activedescendant', active.id);
}
function setOpen(open) {
  list.hidden = !open;
  toggle.setAttribute('aria-expanded', String(open));
  if (open) {
    list.focus();
    move(0);
  }
}
const isChosen = (option) => option.getAttribute('aria-selected') === 'true';
// 選んだスキルは選択済みのまま一覧に残り、チップとして表示される。
function choose(option) {
  option.setAttribute('aria-selected', String(!isChosen(option)));
  showChips();
}
function showChips() {
  chips.innerHTML = options
    .filter(isChosen)
    .map((option) => `
      <span class="chip">
        ${option.textContent}
        <button type="button" class="chip-remove" data-for="${option.id}" aria-label="${option.textContent}を削除">
          <svg viewBox="0 0 16 16" aria-hidden="true">${CROSS}</svg>
        </button>
      </span>`)
    .join('');
  placeholder.textContent = chips.children.length ? 'スキルを追加' : 'スキルを選択';
}
toggle.addEventListener('click', () => setOpen(list.hidden));
field.addEventListener('click', (event) => {
  if (!event.target.closest('.chip-remove, .skills-toggle')) setOpen(true);
});
document.addEventListener('click', (event) => {
  if (!field.contains(event.target) && !list.contains(event.target)) setOpen(false);
});
list.addEventListener('focusout', (event) => {
  if (!field.contains(event.relatedTarget) && !list.contains(event.relatedTarget)) setOpen(false);
});
list.addEventListener('keydown', (event) => {
  if (event.key === 'ArrowDown') move(1);
  else if (event.key === 'ArrowUp') move(-1);
  else if (event.key === ' ' && active) choose(active);
  else if (event.key === 'Escape') {
    setOpen(false);
    toggle.focus();
  } else return;
  event.preventDefault();
});
list.addEventListener('click', (event) => {
  const option = event.target.closest('[role="option"]');
  if (option) choose(option);
});
chips.addEventListener('click', (event) => {
  const button = event.target.closest('.chip-remove');
  if (!button) return;
  const index = [...chips.querySelectorAll('.chip-remove')].indexOf(button);
  choose(document.getElementById(button.dataset.for));
  // 押したボタンは消えたので、次のボタン、前のボタン、欄のボタンの順にフォーカスする。
  const left = chips.querySelectorAll('.chip-remove');
  (left[index] ?? left[index - 1] ?? toggle).focus();
});
```

```css
.chip-remove {
  cursor: pointer;
}
.skills [role="option"] {
  display: flex;
  justify-content: space-between;
}
.skills .active {
  background: var(--highlight);
}
/* チェックマークは、スクリーンリーダーが読む状態から描く。専用のクラスは使わない。 */
.skills [aria-selected="true"]::after {
  content: "";
  inline-size: 0.375rem;
  block-size: 0.75rem;
  border: solid currentColor;
  border-width: 0 2px 2px 0;
  rotate: 45deg;
}
```

修正前からの変更がいちばん少ない方法です。`aria-multiselectable="true"` で、複数選べる一覧であることがスクリーンリーダーに伝わります。すべての選択肢に `"true"` か `"false"` の `aria-selected` があるため、強調が届くたびに各スキルが状態と一緒に読まれ、Spaceキーで状態が変わったことも聞こえます。チェックマークは `aria-selected` から描くので、コードにない選択が画面に出ることはありません。選んだスキルは一覧に残り、チップにはチェックボックスの方法と同じボタンとフォーカスの扱いを付けます。

この一覧の動作は、自分で保守し続けることになります。キー操作、強調を示す `aria-activedescendant`、すべての選択肢の `aria-selected` を保ちます。「選択なし」も読まれるよう、`"false"` も省きません。リストボックスのパターンには、任意のキー操作も挙がっています。HomeキーとEndキー（選択肢が5つを超える一覧では強く推奨）と、範囲をまとめて選ぶShiftキーと矢印キーです。一覧が長いなら追加してください。対応するすべてのブラウザーとスクリーンリーダーで確認してください。

## 修正後：multiple付きの標準のselect

```html
<span id="skills-label">スキル</span>
<div class="skills-field">
  <span class="chips">
    <span class="chip">
      デザイン
      <button type="button" class="chip-remove" data-for="skill-design" aria-label="デザインを削除">
        <svg viewBox="0 0 16 16" aria-hidden="true">…</svg>
      </button>
    </span>
    <span class="chip">
      リサーチ
      <button type="button" class="chip-remove" data-for="skill-research" aria-label="リサーチを削除">
        <svg viewBox="0 0 16 16" aria-hidden="true">…</svg>
      </button>
    </span>
  </span>
</div>
<select id="skills-list" name="skills" multiple size="5" aria-labelledby="skills-label">
  <option id="skill-design" value="design" selected>デザイン</option>
  <option id="skill-writing" value="writing">ライティング</option>
  <option id="skill-research" value="research" selected>リサーチ</option>
  <option id="skill-coding" value="coding">コーディング</option>
  <option id="skill-video" value="video">動画編集</option>
</select>
```

```js
const CROSS = '<path d="M4 4l8 8m0-8l-8 8"/>';
const options = [...list.options];
function showChips() {
  chips.innerHTML = options
    .filter((option) => option.selected)
    .map((option) => `
      <span class="chip">
        ${option.textContent}
        <button type="button" class="chip-remove" data-for="${option.id}" aria-label="${option.textContent}を削除">
          <svg viewBox="0 0 16 16" aria-hidden="true">${CROSS}</svg>
        </button>
      </span>`)
    .join('');
}
list.addEventListener('change', showChips);
chips.addEventListener('click', (event) => {
  const button = event.target.closest('.chip-remove');
  if (!button) return;
  const index = [...chips.querySelectorAll('.chip-remove')].indexOf(button);
  document.getElementById(button.dataset.for).selected = false;
  showChips();
  // 押したボタンは消えたので、次のボタン、前のボタン、一覧の順にフォーカスする。
  const left = chips.querySelectorAll('.chip-remove');
  (left[index] ?? left[index - 1] ?? list).focus();
});
```

```css
.chip-remove {
  cursor: pointer;
}
```

`multiple` 付きのselectを、ブラウザーは常に開いた一覧として描きます。枠から下に開く形にするブラウザーは一部だけで、それも `size="1"` を指定したときに限られます。そのため、ここでは枠にはチップだけを置き、その下に一覧を置きます。一覧にはTabキーで移動でき、複数選べることと各選択肢を選んでいるかどうかはブラウザーがスクリーンリーダーに伝え、選んだスキルはすべてフォームと一緒に送られます。チップには、ほかの方法と同じボタンを付けます。

二つの達成基準は満たしますが、これだけでは十分ではありません。ドロップダウンとチェックマークはなくなり、選んだ選択肢はブラウザー独自の強調で示され、見た目はわずかしか変えられません。また、複数を選ぶ方法を一覧は示しません。Chromeでは、矢印キーだけを押すと選択が強調中のスキルに置き換わります。選択に足すには、Shiftキーを押しながら矢印キーを使うか、Ctrlキー（MacではCmdキー）を押しながら矢印キーで移動してSpaceキーを押します。マウスではCtrlキーかCmdキーを押しながらクリックします。これを知らない人は、ライティングをクリックした瞬間にデザインとリサーチの選択を失います。

## どの方法を選ぶか

三つとも2.1.1と4.1.2を満たし、どれにも同じチップのボタンが必要です。チェックボックスを使ってください。チェックボックスはどのブラウザーとスクリーンリーダーにも対応し、状態、キー操作、タッチ操作をスクリプトなしでブラウザーが用意します。チェックマークの一覧という見た目も保てます。ほぼすべての複数選択にチェックボックスを勧めるのは、このためです。ARIAのリストボックスを残すのは、一覧が長くTabキーの止まる場所を一つにまとめたい場合か、コンポーネントがすでにそう作られている場合です。そのときは、キー操作と状態の確認・保守を自分で引き受けてください。multiple付きの標準のselectは、短い一覧に限って使います。管理画面のように、使う人がそのキー操作を知っていて、ドロップダウンでなく開いた一覧でも構わない場合です。複数を選ぶ方法は横に書いてください。

## 実装の判断

各チップのボタンには、それぞれのフォーカスの枠と、押しやすい大きさが必要です。12ピクセルで描いた×でも、24×24ピクセルのボタンの中に置けます。名前は「削除」や「閉じる」ではなく「デザインを削除」とチップの名前を入れます。ボタンの一覧を前後の文脈なしで読み上げても、どのスキルを外すボタンかが分かるようにするためです。

チップを外したら、フォーカスは外したあとも残っている要素へ移す必要があります。上の方法では、次のチップのボタン、前のボタン、ドロップダウンの山形の印のボタン（標準のselectでは一覧）の順に移します。フォーカスを移しても、デザインが外れたことは伝わりません。スクリーンリーダーは次のボタンを読むだけです。外れたことを伝える必要があるなら、それは操作結果の確認の問題で、「[バッジしか変わらない「カートに入れる」ボタン](/ja/learn/action-confirmations)」で扱っています。

新しいタグを入力してEnterキーで追加するタグ入力欄では、空の欄でBackspaceキーを押すと最後のチップが消えることがよくあります。これはショートカットであり、ボタンの代わりにはなりません。並びの途中のチップは外せないからです。入力に合わせてタグの候補を出す欄なら、候補の作り方は「[候補を読み上げず、キーボードで選べないオートコンプリート](/ja/learn/combobox)」を参照してください。「+3」のように件数でチップの並びを省略するなら、その件数を、隠れたチップを表示するボタンにしてください。そうしないと、隠れたチップにはまったく届きません。

チェックマークは背景の塗りではなく、`currentColor` の枠線かSVGで描きます。Windowsのコントラスト テーマでも残るようにするためです。強調中の選択肢にも、背景色に加えてアウトラインを付けます。`content: "✓"` のようにCSSの文字としてチェックマークを描くのも避けてください。生成された文字は、選択肢の名前の一部として読まれます。UIライブラリーはさまざまです。選んでいない選択肢の `aria-selected` を省くもの、×をspanで描くもの、選んだ選択肢を一覧から消すものがあります。ドキュメントではなく、出力されたマークアップを確認してください。値を一つだけ持つドロップダウンは「[キーボードで開けず、読み上げられないカスタムのドロップダウン](/ja/learn/custom-select)」で、一部の選択肢を選んだときの「すべて選択」のチェックボックスは `mixed-checkbox` のトピックで扱います。

## 修正の確認

1. マークアップを確認する。各チップの×が、チップの名前を持つ `button` で、各選択肢が、チェックボックス、`multiple` 付きselectの `option`、`aria-multiselectable="true"` を持つリストボックスの中の `aria-selected` 付きの `role="option"` のいずれかである。
2. マウスを使わず、Tabキーで欄の中を移動する。どの×にもフォーカスが当たって枠が見え、EnterキーかSpaceキーでチップが外れ、フォーカスは行き場を失わず、次のチップか欄へ移る。
3. キーボードだけでドロップダウンを開き、スキルを選んだり外したりし、Escapeキーで閉じ、最後のチップと途中のチップの両方を外す。
4. スクリーンリーダーで選択肢とチップを移動する。各選択肢が選択の有無とともに読まれ、変更するたびにその変化が読まれ、各ボタンがどのスキルを外すかを伝える。
5. Windowsのコントラスト テーマを有効にするか、強制配色をエミュレートして、チェックマーク、強調中の選択肢、各×、フォーカスの枠が見えることを確認する。

OS、ブラウザーと支援技術のバージョン、ビルド、実施日、各手順の実際の結果を記録してください。共通の複数選択のコンポーネントを変更した際は、再度確認します。

## 制限

これは、決まった選択肢の短い一覧のための出発点となる実装例です。グループ分けした選択肢、スクロールに合わせて読み込む選択肢、チップの並べ替えには当てはまりません。実際の選択肢を使い、対応するブラウザーと支援技術で操作全体を確認してください。

---

このガイドをコーディングエージェントで使う: MCP https://easeweb.dev/mcp（設定は https://easeweb.dev/ja/services/developers）
