# キーボードで押せないアイコンが並ぶツールバー

Source: https://easeweb.dev/learn/toolbars
Topics: 複雑なウィジェット > ツールバーを矢印キーで操作でき、Tabの移動は1回で済む (WCAG 2.1.1, 4.1.2)
The fix: 各ツールを、働きを表す名前を付けたHTML標準のボタンにします。ボタンをすべてTabの順序に残すか、名前を付けた role="toolbar" にまとめ、Tabの移動先を1つにして、ARIAのツールバーのパターンのとおりに矢印キーでツールの間を移動できるようにします。
Test with: keyboard, screen reader, speech input, forced colors, zoom
References: https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/ https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/examples/toolbar/ https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/ 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/WCAG22/Techniques/failures/F59 https://www.w3.org/TR/wai-aria-1.2/#toolbar

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

## 場面

あるフリマサイトでは、出品者が商品の写真を公開前に整えられます。写真の下には「左に回転」「右に回転」「反転」「拡大」「縮小」「元に戻す」の6つのアイコンが並んでいます。写真が横向きに写ってしまった出品者が「右に回転」をクリックすると、マグカップがまっすぐ立ちます。アイコンはどれもSVGを入れたspanで、spanにクリック処理を付けています。マウスを使わずにTabキーを押すと、フォーカスはページの上の部分から「写真を保存」へ移り、6つのツールはすべて飛ばされます。写真を回すキーはないため、キーボード利用者は横向きのまま保存するしかありません。スクリーンリーダーでツールバーを読むと、文字も役割も名前もないspanが6つあるだけです。ツールがあることも、押せることも、それぞれが何をするかも伝わりません。

## 確認できる問題

達成基準2.1.1「キーボード」は、マウスでできることはすべてキーボードでもできることを求めています。どのツールも、フォーカスを受けないspanのクリックでしか動きません。ブラウザーがEnterキーとSpaceキーをクリックに変えるのはHTML標準のコントロールだけのため、spanに `tabindex` を付けても、どちらのキーにも反応しません。写真の回転、反転、拡大は、キーボードではまったくできません。

達成基準4.1.2「名前（name）・役割（role）及び値（value）」は、どのコントロールも名前と役割をソフトウェアが判別できることを求めています。ツールはどれも、クリックすると写真が変わるコントロールです。spanにはもともと役割がないため、押せるものだとは伝わりません。中のアイコンはスクリーンリーダーから隠したSVGのため、spanに名前を与えるものもありません。W3Cはこれを失敗例として挙げています。スクリプトでspanやdivをコントロールにしながら役割を与えないものが失敗例F59、より広く、アクセシビリティAPIを使わない独自のコントロールが失敗例F15です。

## 利用者への影響

このようなツールバーは、何かをその場で編集する画面ならどこにでもあります。写真やアイコン画像の切り抜き、文書やPDFのビューアー、コメント欄、選んだファイルを操作するファイル一覧などです。アイコンのツールは小さく、間隔を詰めて描かれることが多いため、手の震えがある人にはクリックしにくく、だからこそキーボードを使う人もいます。

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

このツールバーには、axeが確認することがありません。クリック処理を付けたspanには役割がないため、名前を求めるルールは当てはまらず、押すためのものであることはマークアップのどこにも書かれていません。AIが提案することのある修正どおりに `role="button"`、`tabindex="0"`、`aria-label` を足すと、spanはaxeに合格し、Tabキーでも届きますが、EnterキーとSpaceキーでは何も起きません。[Tabキーで到達できないdivのボタン](/ja/learn/native-buttons)で説明しているとおりです。

この診断ツールは、ページをTabキーで進みます。クラス、id、ロールからツールバーと分かる要素の中で、アイコンや文字を含み、それ自体にポインターのカーソルが付いた要素をツールとみなします。そうしたツールが2つ以上並び、その列にフォーカスを受けるものが何もないとき、Tabキーが一度も届かなかったツールを報告します。Tabの移動先がツール1つだけの `role="toolbar"` では、そのツールにフォーカスを置いて矢印キー、Home、Endを押し、フォーカスがまったく動かなければ、ほかのツールに届かないとして報告します。こちらは、修正が途中で終わったもう1つの形です。ツールバーのパターンのマークアップ（最初以外のツールの `tabindex="-1"`）だけを写し、フォーカスを動かすスクリプトを書かなかった場合です。クリックできるアイコンが並んでいても、コンテナーのクラス名、id、ロールからツールバーと分からなければ、ツールバーとしては報告しません。名前がアイコンの働きを表しているかどうかも、どの確認でも判断できません。

## 修正前：クリック処理を付けたspanのアイコン

```html
<div class="photo-editor">
  <img class="photo" src="/uploads/mug.jpg" alt="ホーローのマグカップ、正面">
  <div class="toolbar">
    <span class="tool" data-action="rotate-left">
      <svg aria-hidden="true">…</svg>
    </span>
    <span class="tool" data-action="rotate-right">
      <svg aria-hidden="true">…</svg>
    </span>
    <span class="tool" data-action="flip">
      <svg aria-hidden="true">…</svg>
    </span>
    <span class="tool" data-action="zoom-in">
      <svg aria-hidden="true">…</svg>
    </span>
    <span class="tool" data-action="zoom-out">
      <svg aria-hidden="true">…</svg>
    </span>
    <span class="tool" data-action="reset">
      <svg aria-hidden="true">…</svg>
    </span>
  </div>
  <button type="button">写真を保存</button>
</div>
```

```css
.toolbar { display: flex; gap: 4px; }
.tool { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 8px; cursor: pointer; }
.tool:hover { background: #eef0f3; }
.tool svg { width: 20px; height: 20px; }
```

```js
const photo = document.querySelector('.photo');
const view = { turn: 0, flip: 1, zoom: 1 };
const ACTIONS = {
  'rotate-left': () => { view.turn -= 90; },
  'rotate-right': () => { view.turn += 90; },
  'flip': () => { view.flip *= -1; },
  'zoom-in': () => { view.zoom = Math.min(view.zoom + 0.25, 2); },
  'zoom-out': () => { view.zoom = Math.max(view.zoom - 0.25, 1); },
  'reset': () => { Object.assign(view, { turn: 0, flip: 1, zoom: 1 }); }
};
for (const tool of document.querySelectorAll('.tool')) {
  tool.addEventListener('click', () => {
    ACTIONS[tool.dataset.action]();
    photo.style.transform = `rotate(${view.turn}deg) scale(${view.flip * view.zoom}, ${view.zoom})`;
  });
}
```

- ツールはフォーカスを受けないspanの `click` にしか反応しないため、どのキーでもたどり着けず、押せません。
- spanには役割がないため、どのツールにも役割がありません。
- 中身はスクリーンリーダーから隠したアイコンだけのため、どのツールにも名前がありません。

## 修正後：ツールごとにボタンを使う

```html
<div class="photo-editor">
  <img class="photo" src="/uploads/mug.jpg" alt="ホーローのマグカップ、正面">
  <div class="toolbar">
    <button type="button" class="tool" aria-label="左に回転" data-action="rotate-left">
      <svg aria-hidden="true">…</svg>
    </button>
    <button type="button" class="tool" aria-label="右に回転" data-action="rotate-right">
      <svg aria-hidden="true">…</svg>
    </button>
    <button type="button" class="tool" aria-label="反転" data-action="flip">
      <svg aria-hidden="true">…</svg>
    </button>
    <button type="button" class="tool" aria-label="拡大" data-action="zoom-in">
      <svg aria-hidden="true">…</svg>
    </button>
    <button type="button" class="tool" aria-label="縮小" data-action="zoom-out">
      <svg aria-hidden="true">…</svg>
    </button>
    <button type="button" class="tool" aria-label="元に戻す" data-action="reset">
      <svg aria-hidden="true">…</svg>
    </button>
  </div>
  <button type="button">写真を保存</button>
</div>
```

```css
.toolbar { display: flex; gap: 4px; }
.tool { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 8px; cursor: pointer; }
.tool:hover { background: #eef0f3; }
.tool svg { width: 20px; height: 20px; }
/* ボタンが持つ枠線、背景、フォントを外し、アイコンの見た目に戻す。 */
.tool { border: 0; background: none; color: inherit; font: inherit; }
.tool:focus-visible { outline: 2px solid; outline-offset: 2px; }
```

```js
const photo = document.querySelector('.photo');
const view = { turn: 0, flip: 1, zoom: 1 };
const ACTIONS = {
  'rotate-left': () => { view.turn -= 90; },
  'rotate-right': () => { view.turn += 90; },
  'flip': () => { view.flip *= -1; },
  'zoom-in': () => { view.zoom = Math.min(view.zoom + 0.25, 2); },
  'zoom-out': () => { view.zoom = Math.max(view.zoom - 0.25, 1); },
  'reset': () => { Object.assign(view, { turn: 0, flip: 1, zoom: 1 }); }
};
for (const tool of document.querySelectorAll('.tool')) {
  tool.addEventListener('click', () => {
    ACTIONS[tool.dataset.action]();
    photo.style.transform = `rotate(${view.turn}deg) scale(${view.flip * view.zoom}, ${view.zoom})`;
  });
}
```

1. 各ツールをHTML標準のボタンにします。Tabキーで届き、EnterキーとSpaceキーで押せます。ブラウザーはどちらのキーも、スクリプトがもともと待っている `click` に変えるため、スクリプトを変える必要はありません。
2. `aria-label` で各ボタンに働きを表す名前を付けます。スクリーンリーダーは「右に回転、ボタン」と読み上げ、音声操作の利用者は「右に回転をクリック」と言えます。
3. CSSでボタン本来の見た目を消してアイコンの見た目に戻し、フォーカスの枠を加えます。

これで修正前の例は直り、2.1.1と4.1.2を満たします。ツールが6つなら、写真と「写真を保存」の間にTabの移動先が6つ並びます。

## 修正後：Tabの移動先を1つにして矢印キーで移動するツールバー

```html
<div class="photo-editor">
  <img class="photo" src="/uploads/mug.jpg" alt="ホーローのマグカップ、正面">
  <div class="toolbar" role="toolbar" aria-label="写真のツール">
    <button type="button" class="tool" aria-label="左に回転" data-action="rotate-left" tabindex="0">
      <svg aria-hidden="true">…</svg>
    </button>
    <button type="button" class="tool" aria-label="右に回転" data-action="rotate-right" tabindex="-1">
      <svg aria-hidden="true">…</svg>
    </button>
    <button type="button" class="tool" aria-label="反転" data-action="flip" tabindex="-1">
      <svg aria-hidden="true">…</svg>
    </button>
    <button type="button" class="tool" aria-label="拡大" data-action="zoom-in" tabindex="-1">
      <svg aria-hidden="true">…</svg>
    </button>
    <button type="button" class="tool" aria-label="縮小" data-action="zoom-out" tabindex="-1">
      <svg aria-hidden="true">…</svg>
    </button>
    <button type="button" class="tool" aria-label="元に戻す" data-action="reset" tabindex="-1">
      <svg aria-hidden="true">…</svg>
    </button>
  </div>
  <button type="button">写真を保存</button>
</div>
```

```css
.toolbar { display: flex; gap: 4px; }
.tool { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 8px; cursor: pointer; }
.tool:hover { background: #eef0f3; }
.tool svg { width: 20px; height: 20px; }
/* ボタンが持つ枠線、背景、フォントを外し、アイコンの見た目に戻す。 */
.tool { border: 0; background: none; color: inherit; font: inherit; }
.tool:focus-visible { outline: 2px solid; outline-offset: 2px; }
```

```js
const photo = document.querySelector('.photo');
const view = { turn: 0, flip: 1, zoom: 1 };
const ACTIONS = {
  'rotate-left': () => { view.turn -= 90; },
  'rotate-right': () => { view.turn += 90; },
  'flip': () => { view.flip *= -1; },
  'zoom-in': () => { view.zoom = Math.min(view.zoom + 0.25, 2); },
  'zoom-out': () => { view.zoom = Math.max(view.zoom - 0.25, 1); },
  'reset': () => { Object.assign(view, { turn: 0, flip: 1, zoom: 1 }); }
};
for (const tool of document.querySelectorAll('.tool')) {
  tool.addEventListener('click', () => {
    ACTIONS[tool.dataset.action]();
    photo.style.transform = `rotate(${view.turn}deg) scale(${view.flip * view.zoom}, ${view.zoom})`;
  });
}
const toolbar = document.querySelector('[role="toolbar"]');
const tools = [...toolbar.querySelectorAll('.tool')];
// Tabの移動先は1つ。どの方法であれ、最後にフォーカスを受けたツール。
toolbar.addEventListener('focusin', (event) => {
  for (const tool of tools) tool.tabIndex = tool === event.target ? 0 : -1;
});
toolbar.addEventListener('keydown', (event) => {
  const i = tools.indexOf(event.target);
  const to = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tools.length - 1 }[event.key];
  if (i < 0 || to === undefined) return;
  event.preventDefault();
  // 最後のツールで右を押すと最初へ、最初のツールで左を押すと最後へ回る。
  tools.at(to % tools.length).focus();
});
```

1. 名前を付けた同じボタンを、名前のある `role="toolbar"` にまとめます。ツールバーに入るときに、スクリーンリーダーが「写真のツール、ツールバー」と読み上げます。
2. ツールバー全体をTabの移動先1つにします。最初のツールに `tabindex="0"`、残りに `-1` を付けます。Tabキー1回でツールバーを出られます。
3. ARIA Authoring Practicesのツールバーのパターンのとおりにキーを処理します。右と左で次と前のツールへ移り、端では反対の端へ回ります。HomeとEndで最初と最後のツールへ移動します。EnterキーとSpaceキーでツールを押せるのは、これまでどおりボタン自体の働きです。
4. フォーカスを受けたツールに `tabindex="0"` を渡します。Shift + Tabで戻ると、最後にフォーカスがあったツールに着きます。キー操作のほか、クリックしたボタンにフォーカスを移すブラウザーではクリックでも渡ります（Safariは移しません）。

これで修正前の例は直り、2.1.1と4.1.2を満たします。

## どの方法を選ぶか

どちらも基準を満たします。ボタンをTabの順序に残す方法は、変更が小さく済みます。ARIAのロールもキー操作の処理も要らず、キーボード利用者は誰でも移動のしかたを知っています。Tabの移動先を1つにすることは、WCAGもARIAも求めていません。ボタンが6つ並べばTabキーを6回押すことになりますが、ときどき使う編集画面なら問題ありません。ツールバーは、その回数を省きます。Tabキー1回で列全体を越えられ、ツールがひとまとまりであることもスクリーンリーダー利用者に伝わります。十数個のツールが並ぶ文書エディターのように、ツールが多いときやよく使うときに選びます。ツールバーがいくつも並び、キーボード利用者が来るたびにTabキーで越えていくことになる場合も同じです。そのときは、ロール、名前、Tabの移動先1つ、矢印キーをすべてそろえます。ARIA Authoring Practicesは、toolbarロールでまとめるのはコントロールが3つ以上あるときだけにするよう勧めています。

## 実装の判断

各ツールには、ツールチップやヘルプページと同じ言葉で、働きを表す名前を付けます。名前の付け方は[名前がないアイコンだけのボタン](/ja/learn/icon-buttons)で詳しく扱っています。アイコンの意味が分からない人には、見える名前も必要です。ポインターを乗せたときとフォーカスしたときに同じ言葉を表示するツールチップは、どちらの人にも役立ちます。「切り抜き」や「太字」のようにオンのままになるツールはトグルで、`aria-pressed` が必要です。[押された状態が伝わらないトグルボタン](/ja/learn/toggle-buttons)を参照してください。

元の大きさのときの「縮小」のように、今は使えないツールは無効にできます。ツールバーのパターンでは、無効なツールは矢印キーで飛ばすのが一般的ですが、利用者が見つける必要があるときはフォーカスを受けられるままにしてもよいとしています。それには `aria-disabled="true"` を使います。[見つからず理由も分からない無効なボタン](/ja/learn/disabled-controls)で説明しているとおりです。画面の端に縦に並ぶツールバーには `aria-orientation="vertical"` を付け、右と左の代わりに下と上の矢印キーでツールの間を移動します。

ツールバーに入れるのは、ボタンか、自分では右と左の矢印キーを使わないコントロールにします。テキスト欄やスライダーはそのキーを使うため、パターンは入れないよう勧めており、どうしても入れるなら1つだけ、ツールバーの最後に置くとしています。狭い画面では、入りきらないツールを「その他」ボタンにまとめることがよくあります。その場合はメニューになり、キー操作も別です。[キーボードで入れず、開いても何も伝えない操作メニュー](/ja/learn/menu-button)で扱っています。ライブラリーのツールバー部品は、作りがさまざまです。使う前に、すべてのツールがボタンで、名前があり、キーを扱っているかを確かめます。

## 修正の確認

1. マウスを使わずに、ページの先頭からTabキーで進む。すべてのツールにたどり着き、EnterキーとSpaceキーで使ってから、「写真を保存」へ進める。ツールバーでは、Tabキー1回で入り、矢印キー、Home、Endでツールの間を移動でき、Tabキー1回で出て、Shift + Tabで最後に使ったツールに戻ることを確かめる。
2. スクリーンリーダーで各ツールへ移動する。「右に回転、ボタン」のように、何をするかを表すボタンとして読み上げられる。ツールバーでは、フォーカスが入るときに「写真のツール、ツールバー」とも読み上げられる。
3. 音声操作で「右に回転をクリック」「拡大をクリック」と言う。どのツールも、その名前で反応する。
4. 強制配色（Windowsのコントラスト テーマ）をオンにし、200%に拡大する。すべてのアイコンと各ツールのフォーカスの枠が見え、切れているツールがない。

OS、ブラウザーと支援技術のバージョン、ビルド、実施日、各手順の実際の結果を記録してください。ツールバー、その部品、ライブラリーを変更した際は、再度確認します。

## 制限

このサンプルは、どのアプリケーションでも同じ結果になることを保証するものではありません。ツールバーのスクリプトは最小限の例で、無効なツール、縦向き、ボタン以外のコントロールは扱っていません。エディターの入力欄とその名前は[見えているラベルで読み上げられないリッチテキストエディター](/ja/learn/rich-text-editors)で、divで作った1つのコントロールは[Tabキーで到達できないdivのボタン](/ja/learn/native-buttons)で扱っています。Tabの移動先を1つにする同じパターンをサイドバーで示したのが、[キーボードで開けず、階層も伝わらないツリービュー](/ja/learn/tree-views)です。対象とする支援技術で、一連の操作全体をテストしてください。

---

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