# キーボードでは系列を切り替えられないグラフの凡例

Source: https://easeweb.dev/learn/chart-legends
Topics: 表とデータ > グラフの点、凡例、ツールチップをキーボードで操作できる (WCAG 2.1.1, 4.1.2)
The fix: ライブラリーがキャンバスに描く凡例をオフにし、凡例をHTMLで作ります。各項目は、ボタンにして系列の表示中は aria-pressed を true にするか、fieldset の中のチェックボックスにして表示中はチェックを入れます。どちらもキーボードで操作でき、状態を伝えます。
Test with: keyboard, screen reader
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/F54 https://www.w3.org/WAI/WCAG22/Techniques/failures/F59 https://www.w3.org/WAI/ARIA/apg/patterns/button/ https://www.chartjs.org/docs/latest/configuration/legend.html https://www.chartjs.org/docs/latest/samples/legend/html.html

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

## 場面

ネットショップの管理画面に、日別の注文数の棒グラフがあり、今週の棒と先週の棒が並んでいます。凡例には二つの系列の名前が、それぞれの色の見本と一緒に並んでいます。「先週」をクリックすると先週の棒が消え、今週の棒だけを読めるようになります。凡例の項目には、もう一度クリックするまで取り消し線が引かれます。Chart.js では、ショップ側でコードを書かなくても、初めからこう動きます。

マウスを使わずにTabキーを押すと、フォーカスはカードの上から、グラフの下の「すべての注文を見る」へ一気に移ります。凡例の項目には一度も止まらないため、キーボードでは先週の棒を消せません。スクリーンリーダー利用者にとって、凡例はキャンバスに描かれていれば何もないのと同じで、リストの項目で作られていれば「今週」「先週」という2行の文字でしかありません。どちらでも、項目には押せることを示す役割も、押した後にどの系列が隠れているかを示す状態もありません。

## 確認できる問題

WCAG 2.1.1「キーボード」は、ポインターでできることはすべてキーボードでもできるよう求めています。系列を隠すのはこのグラフの機能の一つですが、クリックでしか実行できません。Chart.js の標準の凡例では、項目は要素ですらありません。ライブラリーが項目をキャンバスに描き、ポインターの位置からどの項目がクリックされたかを割り出しています。キャンバスは初期状態ではフォーカスを受け取らず、キーの入力を待つ処理もありません。二つ目の作り方では凡例をHTMLにしています。Chart.js の公式サンプル「HTML Legend」と同じで、系列ごとに `li` を作り、`onclick` とポインターのカーソルを付けています。クリックの処理はどの要素にも付けられますが、Tabキーで移動できるのはリンク、ボタン、フォームの入力欄などだけなので、リストの項目は飛ばされます。どちらの作り方でも、系列を隠せるのはポインターだけです。失敗例F54は、これを `onmousedown` のようなポインター専用のイベントハンドラーで説明しています。`click` のハンドラー自体はポインター専用ではなく、ボタンならキーボードでも発生しますが、Tabキーで届かない要素では、ポインターでしか発生させられません。

WCAG 4.1.2「名前（name）・役割（role）及び値（value）」は、すべてのコントロールの種類、名前、状態を支援技術に伝えるよう求めています。系列を隠す凡例の項目は、系列が表示されているか隠れているかという状態を持つコントロールです。キャンバスに描かれた項目には、名前も役割も状態もありません。`li` で作った項目には文字という名前はあるものの、役割はリストの項目で、状態は `text-decoration` で引いた線という見た目でしかなく、支援技術に状態として伝わるものではありません。これは失敗例F59にあたります。F59は `div` や `span` の例ですが、スクリプトで要素をコントロールにしながら、コントロールの役割を与えていない点は同じです。

## 利用者への影響

系列を隠せる凡例は、混み合ったグラフで2本の線を比べたり、大きな系列の陰で小さく見える系列を読んだりするための手段です。使えなければ、グラフはすべての系列を一度に見せたままで、凡例で読みやすくなるはずの部分も読みにくいままです。

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

キャンバスに描いた凡例は、ページの中に調べられるものを何も残しません。axe にはキャンバスが見えるだけで、`role="img"` と名前があれば合格にします。クリックの処理を付けたリストの項目もマークアップとしては正しく、axe はクリックの処理までは調べないため、これも合格にします。この診断のキーボードのチェックは、クラスやidで凡例と分かる要素の中を調べ、ポインターのカーソルかスクリプトで設定した `onclick` でクリックを受け付けるのにTabキーでは届かない項目があれば、問題として報告します。Chart.js の「HTML Legend」のサンプルどおりに作った凡例が、これにあたります。キャンバスに描いた凡例はこの方法では見つからないため、ライブラリーが描いたグラフの中に、フォーカスを受け取るものが一つもなければ、確認候補として出します。凡例や点をクリックしてから、同じことをキーボードで試すのは人の役割です。独自の凡例をAIのアシスタントに頼むと、`li.onclick` も含めて、ライブラリーのサンプルをそのまま写すことがあります。

## 修正前：Chart.js がキャンバスに描く凡例

```html
<section class="card">
  <h2>Orders per day</h2>
  <canvas id="orders" role="img" aria-label="…"></canvas>
  <a href="/orders">View all orders</a>
</section>
```

```css
.legend {
  display: flex;
  gap: 16px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.swatch {
  display: inline-block;
  width: 12px;
  height: 12px;
  margin-inline-end: 6px;
}
```

```js
const chart = new Chart(document.getElementById('orders'), {
  type: 'bar',
  data: {
    labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
    datasets: [
      { label: 'This week', data: [12, 15, 14, 18, 24, 31, 20], backgroundColor: '#4a86d4' },
      { label: 'Last week', data: [10, 13, 15, 14, 20, 26, 22], backgroundColor: '#a9c4ea' }
    ]
  }
});
```

- 凡例はキャンバスに描かれているため、Tabキーは「This week」にも「Last week」にも止まらず、系列を隠せるキーもない。
- 項目はピクセルでしかなく、スクリーンリーダーが読める名前も役割も状態もない。

## 修正前：リストの項目で作ったHTMLの凡例

```html
<section class="card">
  <h2>Orders per day</h2>
  <canvas id="orders" role="img" aria-label="…"></canvas>
  <ul class="legend" id="legend"></ul>
  <a href="/orders">View all orders</a>
</section>
```

```css
.legend {
  display: flex;
  gap: 16px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.swatch {
  display: inline-block;
  width: 12px;
  height: 12px;
  margin-inline-end: 6px;
}
```

```js
const chart = new Chart(document.getElementById('orders'), {
  type: 'bar',
  data: {
    labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
    datasets: [
      { label: 'This week', data: [12, 15, 14, 18, 24, 31, 20], backgroundColor: '#4a86d4' },
      { label: 'Last week', data: [10, 13, 15, 14, 20, 26, 22], backgroundColor: '#a9c4ea' }
    ]
  },
  options: { plugins: { legend: { display: false } } }
});

const legend = document.getElementById('legend');
chart.data.datasets.forEach((dataset, i) => {
  const li = document.createElement('li');
  const swatch = document.createElement('span');
  swatch.className = 'swatch';
  swatch.style.background = dataset.backgroundColor;
  li.append(swatch, dataset.label);
  li.style.cursor = 'pointer';
  li.onclick = () => {
    chart.setDatasetVisibility(i, !chart.isDatasetVisible(i));
    chart.update();
    li.style.textDecoration = chart.isDatasetVisible(i) ? '' : 'line-through';
  };
  legend.append(li);
});
```

凡例がページの中に入り、スクリーンリーダーが読める文字になった点は改善です。問題は、リストの項目でクリックを受け付けていることです。

- Tabキーは `li` に止まらないため、やはりキーボードでは系列を隠せない。
- 各項目は「This week」「Last week」というリストの項目として読まれ、押せることを示す役割はない。
- 隠れた系列の取り消し線は見た目だけで、隠れていることを示す状態はない。

## 修正後：系列ごとのトグルボタン

```html
<section class="card">
  <h2>Orders per day</h2>
  <canvas id="orders" role="img" aria-label="…"></canvas>
  <ul class="legend" id="legend" aria-label="Series shown"></ul>
  <a href="/orders">View all orders</a>
</section>
```

```css
.legend {
  display: flex;
  gap: 16px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.swatch {
  display: inline-block;
  width: 12px;
  height: 12px;
  margin-inline-end: 6px;
}
.legend button {
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
  cursor: pointer;
}
.legend [aria-pressed="false"] {
  text-decoration: line-through;
}
```

```js
const chart = new Chart(document.getElementById('orders'), {
  type: 'bar',
  data: {
    labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
    datasets: [
      { label: 'This week', data: [12, 15, 14, 18, 24, 31, 20], backgroundColor: '#4a86d4' },
      { label: 'Last week', data: [10, 13, 15, 14, 20, 26, 22], backgroundColor: '#a9c4ea' }
    ]
  },
  options: { plugins: { legend: { display: false } } }
});

const legend = document.getElementById('legend');
chart.data.datasets.forEach((dataset, i) => {
  const li = document.createElement('li');
  const button = document.createElement('button');
  const swatch = document.createElement('span');
  swatch.className = 'swatch';
  swatch.style.background = dataset.backgroundColor;
  button.type = 'button';
  button.setAttribute('aria-pressed', 'true');
  button.append(swatch, dataset.label);
  button.addEventListener('click', () => {
    const shown = !chart.isDatasetVisible(i);
    chart.setDatasetVisibility(i, shown);
    chart.update();
    button.setAttribute('aria-pressed', String(shown));
  });
  li.append(button);
  legend.append(li);
});
```

1. Chart.js がキャンバスに描く凡例を `plugins.legend.display: false` でオフにし、凡例をページの中の一つだけにする。
2. 各リストの項目に `button` を置き、名前は系列名の「This week」「Last week」だけにする。Tabキーで届き、EnterキーとSpaceキーで押せる。
3. ボタンに `aria-pressed="true"` を付け、押すたびに、系列が表示されているかどうかに合わせて設定し直す。スクリーンリーダーは、たとえば「Last week、トグルボタン、押下」と読み、棒が隠れると「押下なし」と読む。
4. 隠れた系列の取り消し線は、インラインのスタイルではなく `aria-pressed="false"` から描き、見た目と読み上げを一つの値から決める。
5. リストに「Series shown」という名前を付け、どの系列が表示されているかを伝えるボタンのまとまりとして聞こえるようにする。

これで二つの修正前の例はどちらも直り、2.1.1と4.1.2を満たします。色の見本と名前、オフのときの取り消し線という凡例の見た目はそのままです。

## 修正後：系列ごとのチェックボックス

```html
<section class="card">
  <h2>Orders per day</h2>
  <canvas id="orders" role="img" aria-label="…"></canvas>
  <fieldset class="legend" id="legend">
    <legend>Show</legend>
  </fieldset>
  <a href="/orders">View all orders</a>
</section>
```

```css
.legend {
  display: flex;
  gap: 16px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.swatch {
  display: inline-block;
  width: 12px;
  height: 12px;
  margin-inline-end: 6px;
}
.legend {
  border: 0;
}
.legend legend {
  float: left;
  padding: 0;
}
.legend input:not(:checked) + .swatch {
  opacity: 0.3;
}
```

```js
const chart = new Chart(document.getElementById('orders'), {
  type: 'bar',
  data: {
    labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
    datasets: [
      { label: 'This week', data: [12, 15, 14, 18, 24, 31, 20], backgroundColor: '#4a86d4' },
      { label: 'Last week', data: [10, 13, 15, 14, 20, 26, 22], backgroundColor: '#a9c4ea' }
    ]
  },
  options: { plugins: { legend: { display: false } } }
});

const legend = document.getElementById('legend');
chart.data.datasets.forEach((dataset, i) => {
  const label = document.createElement('label');
  const box = document.createElement('input');
  const swatch = document.createElement('span');
  swatch.className = 'swatch';
  swatch.style.background = dataset.backgroundColor;
  box.type = 'checkbox';
  box.checked = true;
  label.append(box, swatch, dataset.label);
  box.addEventListener('change', () => {
    chart.setDatasetVisibility(i, box.checked);
    chart.update();
  });
  legend.append(label);
});
```

1. キャンバスの凡例を `plugins.legend.display: false` でオフにする。
2. 凡例のあった場所に、`legend` を「Show」にした `fieldset` を置き、チェックボックスが「Show、グループ」としてまとまって聞こえるようにする。
3. 系列ごとに、色の見本と名前を入れた `label` の中にチェックボックスを置く。Tabキーでそれぞれへ移動し、Spaceキーでチェックを付け外しできる。スクリーンリーダーは「This week、チェックボックス、チェック」と読む。
4. 変更のたびに、チェックボックスの `checked` から系列を表示したり隠したりし、チェックのない系列の色の見本は `:checked` から薄く描く。こうすれば、チェックボックス、棒、色の見本が食い違わない。

これで二つの修正前の例はどちらも直り、2.1.1と4.1.2を満たします。ふだんの見た目は変わり、各項目にチェックボックスが付いて、凡例はグラフの上や下の絞り込みのように見えます。

## どの方法を選ぶか

どちらの方法でも基準を満たします。トグルボタンは、色の見本と名前という描いたとおりの凡例を保ち、キーボード操作と状態だけを加えるため、凡例がデザインの一部になっているグラフに向いています。ただ、押下状態はチェックボックスほど知られていないため、何が押下を意味するのかをリストの名前で伝えています。チェックボックスは、誰もが知っている形で、押すと何が起きるのかをはっきり伝える代わりに、見た目が変わります。系列が多く、そこから選ぶ管理画面のように、凡例がすでに絞り込みの役割を持っている場合に選んでください。

## 実装の判断

**名前は変えない。** ボタンは、系列が表示されていても隠れていても「Last week」のままで、変わるのは `aria-pressed` だけです。文字まで「Show last week」に変えると、スクリーンリーダーには状態が二通りの方法で伝わり、互いに矛盾することもあります。ARIA Authoring Practicesのボタンのパターンも、トグルボタンの状態が変わってもラベルを変えないことが重要だとしています。

**リストの項目を補修する場合。** `li` に `role="button"`、`tabindex="0"`、`aria-pressed`、EnterキーとSpaceキーの処理を加えても基準は満たせます。ただ、`button` ならそのすべてが初めから備わっていて、補修は途中で抜けが出やすくなります。

**最後に残った系列。** すべての系列を隠すと、グラフは目盛りだけの空の枠になります。最後の一つは押しても隠れないようにするか、グラフの場所に、表示している系列がないことを書いて伝えます。

**グラフのテキストの代替。** 系列を隠して変わるのは描く内容で、データではありません。グラフの名前と表は、[スクリーンリーダーで数値が伝わらないグラフ](/ja/learn/chart-alternatives)のとおり、すべての系列を伝えるままにしておきます。スクリーンリーダー利用者は、凡例を使わなくても数値にたどり着けます。

**ほかのライブラリー。** Highcharts のアクセシビリティモジュールは、凡例も含めた独自のキーボード操作を備えているため、別の凡例を作る前に、何が用意されているかを確かめてください。凡例を、ポインターのカーソルを付けた `div` で描くライブラリーは、ここのリストの項目と同じ問題を抱えていて、同じ修正が当てはまります。

ホバーしたときだけツールチップに出る値は、[キーボードでは表示できないグラフのツールチップ](/ja/learn/chart-tooltips)で扱っています。クリックするとページを絞り込む棒は、同じ問題の別の形ですが、まだガイドはありません。色の見本だけで系列を見分けさせ、名前を添えない凡例は、1.4.1「色の使用」の問題です。ステータスの印での例を[色だけで区別されるステータス表示](/ja/learn/status-colours)で扱っています。

## 修正の確認

1. コードで凡例を探し、ライブラリー自身の凡例がオフになっていること、各項目が `aria-pressed` の付いた `button` かチェックボックスで、クリックを受け付ける `li`、`div`、`span` がないことを確認する。
2. マウスを使わずに、Tabキーで各項目へ移動して押す。ボタンならEnterキーかSpaceキー、チェックボックスならSpaceキーで、系列が隠れ、もう一度押すと戻ることを確認する。
3. スクリーンリーダーで、Tabキーで各項目へ移動し、系列の名前のトグルボタンかチェックボックスとして読まれ、系列の表示中は押下かチェック済みと読まれることを確認する。
4. 項目を押して読み上げを聞き、新しい状態が伝わることを確認する。ページを読み込み直したときや、期間を変えてグラフが描き直されたときにも、状態が棒と合っていることを確認する。

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

## 制限

このサンプルは、どのグラフでも同じ結果になることを保証するものではありません。`Chart` は Chart.js のことで、`setDatasetVisibility` は系列を隠すための Chart.js の機能です。ほかのライブラリーでは、それにあたる機能に置き換えてください。`aria-pressed` の読み方はスクリーンリーダーによって異なり、「トグルボタン」と読むものもあれば、「ボタン」の後に押下状態を読むものもあります。管理画面全体を、キーボードと対象とする支援技術で確認してください。

## スクリーンリーダーでの動き（EaseWebが実機で確認）

`aria-pressed`: ボタンを、オンとオフを切り替えるトグルにします。

| スクリーンリーダー | ブラウザーとOS | 押す | Tabで移動 |
| --- | --- | --- | --- |
| NVDA | Chrome, Windows 11 | 未確認 | 未確認 |
| JAWS | Chrome, Windows 11 | 未確認 | 未確認 |
| VoiceOver | Safari, iOS | 未確認 | 未確認 |
| TalkBack | Chrome, Android | 未確認 | 未確認 |

https://easeweb.dev/ja/learn/screen-reader-support

---

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