# キーボードでは展開できない表の行

Source: https://easeweb.dev/learn/expandable-rows
Topics: 複雑なウィジェット > 子の行を持つ表の行を、キーボードで開ける (WCAG 1.3.1, 4.1.2, 2.1.1)
The fix: 行を開くために aria-expanded 付きのHTML標準のボタンを行に置き、子の行の名前の最後に「サブタスク」のような、子の行であることを伝える言葉を入れます。あるいは完全なARIAのツリーグリッドを作り、各行に aria-level、aria-setsize、aria-posinset を、開く行に aria-expanded を付け、Tabの移動先を1つにして、矢印キーで行とセルを移動し、行を開閉できるようにします。
Test with: keyboard, screen reader, forced colors, zoom
References: https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/ https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/examples/treegrid-1/ https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/ https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/ https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html 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/#treegrid https://datatables.net/examples/api/row_details.html

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

## 場面

あるチームのプロジェクト管理画面では、タスクが「タスク」「担当者」「期限」の3列の表に並び、見出しの行は `th` のセルで作られています。表そのものの作りに問題はありません。サブタスクのあるタスクには、名前の前に矢印があります。矢印をクリックすると、その下にサブタスクが字下げされて開き、矢印が下を向きます。各タスクの名前は、そのタスクのページへのリンクです。「春のカタログを公開する」は開いていて、「ドメインを更新する」にはサブタスクがなく、「夏のセールを企画する」は閉じています。矢印は、CSSで描いた中身のないspanで、クリック処理が付いています。マウスを使わずにTabキーを押すと、フォーカスはタスクのリンクを、表示中のサブタスクも含めて順にたどり、表を出て「完了したタスク」へ移ります。「夏のセールを企画する」は開けないため、「メールマガジンの枠を押さえる」には届きません。スクリーンリーダーでは、表示中の行が表の行として読まれるだけで、タスクが開くことも、開いているかどうかも、「商品説明を書く」がすぐ上のタスクのサブタスクであることも伝わりません。

表のプラグインDataTablesが公式に載せている、行を開く例も、この作りです。クリック処理は表のセルに付いていて、`tabindex` もキー操作の処理もありません。そこでチームは、表を手っ取り早くツリーグリッドにします。表に `role="treegrid"` を付け、ほかには何もしません。キーボード操作は何も変わらず、Tabキーは今もすべての矢印を飛ばします。スクリーンリーダーは表をツリーグリッドと伝えるようになりますが、その中で矢印キーを押しても何も起きず、どの行も自分の階層を伝えず、どのタスクも開いているかどうかを伝えません。

## 確認できる問題

達成基準2.1.1「キーボード」は、マウスでできることはすべてキーボードでもできることを求めています。タスクを開く方法は、フォーカスを受けないspanのクリックだけで、ブラウザーがEnterキーとSpaceキーをクリックに変えるのはHTML標準のコントロールだけです。閉じたタスクのサブタスクには、マウスなしでは届きません。`role="treegrid"` を足しても、ここは何も変わりません。矢印は今もフォーカスを受けず、ツリーグリッドなら使えるはずの矢印キーを押しても何も起きません。

達成基準4.1.2「名前（name）・役割（role）及び値（value）」は、どのコントロールも名前、役割、状態をプログラムで判別できることを求めています。矢印は、クリックするとタスクが開くコントロールです。中身のないspanのため、役割も名前も、展開と折りたたみの状態もありません。スクリーンリーダーでは、タスクが開くことも、開いたタスクと閉じたタスクの違いも分かりません。W3Cはこれを失敗例として挙げています。スクリプトでspanやdivをコントロールにしながら役割を与えないものが失敗例F59、より広く、アクセシビリティAPIを使わないか一部しか使わない独自のコントロールが失敗例F15です。

達成基準1.3.1「情報及び関係性」は、画面上の構造がコードにもあることを求めています。字下げは、「商品説明を書く」が「春のカタログを公開する」に属することを示しています。表では、そのことがマークアップのどこにも書かれていません。どの行もほかの行と同じ行で、入れ子は余白でしかありません。ツリーグリッドの行には入れ子にするグループの要素がないため、階層を伝えるのは `aria-level` だけです。`aria-level` がなければ、ロールを付けても行が並ぶだけのグリッドになり、サブタスクがどのタスクに属するかは伝わらないままです。

## 利用者への影響

行が開く表は、プロジェクトやタスクの管理画面、注文が配送ごとに開く注文履歴、費目が明細に開く予算表、フォルダーがファイルに開くファイル一覧などにあります。下の行に並ぶサブタスクや配送、明細には、たいていその行からしかたどり着けません。開けない行があると、クリックできない人はその中身に届きません。

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

最初の表には、axeが確認するものがありません。クリック処理を付けたspanには役割がないため、名前や状態を求めるルールは当てはまらず、表の見出しにも問題はありません。同じ表に `role="treegrid"` を付けたものも、axeのWCAGのルールに合格します。どの行もツリーグリッドの中にあり、どのセルも行の中にあるからです。axeが確かめるのはロールの親子関係（表やグリッドの外にある `row` は `aria-required-parent`、行のない `treegrid` は `aria-required-children` として報告）だけで、キーが何かをするかも、行が階層を伝えているかも確かめません。

この診断ツールは、ページをTabキーで進みます。表のセルの中で行を開く矢印のうち、クラス名（toggle、expander、DataTablesの `dt-control` など）とポインターのカーソルから分かるものに、Tabキーが一度も届かなければ報告します。DataTablesのように、そのクラスがセルそのものに付いている場合は、そのセルを報告します。行全体でTabの移動先が1つだけのツリーグリッドでは、その移動先にフォーカスを置いて下、上、右の矢印キーとEnd、Homeを押し、フォーカスがまったく動かなければ、ほかの行に届かないとして報告します。何もフォーカスを受けないツリーグリッドも報告します。`aria-level` を持つ行のうち、次の行が1段深くて隠れているのに `aria-expanded` がないものも報告します。複数のリンクがTabの移動先で、矢印キーを押しても何も起きないツリーグリッドは、人が確認する項目として渡します。2つ目の表がこれに当たります。それと分かるクラス名のない矢印は見つからず、階層が聞く人にとって筋が通っているかは、どの確認でも分かりません。

2つ目の表は、AIによる修正でできてしまうことがある形です。`role="treegrid"` を加えただけで終わります。spanに `role="button"` と `tabindex="0"` を付ける修正もあり、Tabキーでは届くようになりますが、EnterキーとSpaceキーは何もしないままです。[Tabキーで到達できないdivのボタン](/ja/learn/native-buttons)で説明しているとおりです。

## 修正前：クリックにしか反応しない矢印

```html
<h2 id="tasks-title">タスク</h2>
<table class="tasks" aria-labelledby="tasks-title">
  <thead>
    <tr>
      <th scope="col">タスク</th>
      <th scope="col">担当者</th>
      <th scope="col">期限</th>
    </tr>
  </thead>
  <tbody>
    <tr id="task-101" class="is-open">
      <td>
        <span class="toggle"></span>
        <a href="/tasks/101">春のカタログを公開する</a>
      </td>
      <td>Ana Ruiz</td>
      <td>3月14日</td>
    </tr>
    <tr class="subtask" data-parent="task-101">
      <td>
        <a href="/tasks/102">商品説明を書く</a>
      </td>
      <td>Ben Carter</td>
      <td>3月7日</td>
    </tr>
    <tr class="subtask" data-parent="task-101">
      <td>
        <a href="/tasks/103">商品写真を撮る</a>
      </td>
      <td>Dana Park</td>
      <td>3月10日</td>
    </tr>
    <tr>
      <td>
        <a href="/tasks/104">ドメインを更新する</a>
      </td>
      <td>Eli Novak</td>
      <td>3月20日</td>
    </tr>
    <tr id="task-105">
      <td>
        <span class="toggle"></span>
        <a href="/tasks/105">夏のセールを企画する</a>
      </td>
      <td>Chris Moreau</td>
      <td>4月2日</td>
    </tr>
    <tr class="subtask" data-parent="task-105" hidden>
      <td>
        <a href="/tasks/106">割引を決める</a>
      </td>
      <td>Ana Ruiz</td>
      <td>3月25日</td>
    </tr>
    <tr class="subtask" data-parent="task-105" hidden>
      <td>
        <a href="/tasks/107">メールマガジンの枠を押さえる</a>
      </td>
      <td>Ben Carter</td>
      <td>3月28日</td>
    </tr>
  </tbody>
</table>
<a href="/tasks/completed">完了したタスク</a>
```

```css
.tasks { border-collapse: collapse; }
.tasks th, .tasks td { padding: 0.5rem; text-align: left; }
.tasks tbody td:first-child { position: relative; padding-left: 2rem; }
.tasks .subtask td:first-child { padding-left: 3.5rem; }
.tasks .toggle { position: absolute; left: 0.5rem; cursor: pointer; }
/* タスクが開いている間は矢印が下を向く。クラスだけで示している。 */
.tasks .toggle::before { content: ''; display: inline-block; padding: 3px; border: solid; border-width: 0 2px 2px 0; rotate: -45deg; }
.tasks .is-open .toggle::before { rotate: 45deg; }
```

```js
for (const toggle of document.querySelectorAll('.tasks .toggle')) {
  toggle.addEventListener('click', () => {
    const row = toggle.closest('tr');
    const open = !row.classList.contains('is-open');
    row.classList.toggle('is-open', open);
    for (const sub of document.querySelectorAll(`[data-parent="${row.id}"]`)) sub.hidden = !open;
  });
}
```

- 矢印はフォーカスを受けないspanで、`click` にしか反応しないため、閉じたタスクのサブタスクにはキーボードで届きません。
- タスクが開くことも、開いているかどうかも、マークアップのどこにも書かれていません。
- 後に続く行がそのタスクのサブタスクであることも書かれていません。入れ子は余白でしかありません。

## 修正前：同じ表に role="treegrid" を足しただけ

```html
<h2 id="tasks-title">タスク</h2>
<table class="tasks" role="treegrid" aria-labelledby="tasks-title">
  <thead>
    <tr>
      <th scope="col">タスク</th>
      <th scope="col">担当者</th>
      <th scope="col">期限</th>
    </tr>
  </thead>
  <tbody>
    <tr id="task-101" class="is-open">
      <td>
        <span class="toggle"></span>
        <a href="/tasks/101">春のカタログを公開する</a>
      </td>
      <td>Ana Ruiz</td>
      <td>3月14日</td>
    </tr>
    <tr class="subtask" data-parent="task-101">
      <td>
        <a href="/tasks/102">商品説明を書く</a>
      </td>
      <td>Ben Carter</td>
      <td>3月7日</td>
    </tr>
    <tr class="subtask" data-parent="task-101">
      <td>
        <a href="/tasks/103">商品写真を撮る</a>
      </td>
      <td>Dana Park</td>
      <td>3月10日</td>
    </tr>
    <tr>
      <td>
        <a href="/tasks/104">ドメインを更新する</a>
      </td>
      <td>Eli Novak</td>
      <td>3月20日</td>
    </tr>
    <tr id="task-105">
      <td>
        <span class="toggle"></span>
        <a href="/tasks/105">夏のセールを企画する</a>
      </td>
      <td>Chris Moreau</td>
      <td>4月2日</td>
    </tr>
    <tr class="subtask" data-parent="task-105" hidden>
      <td>
        <a href="/tasks/106">割引を決める</a>
      </td>
      <td>Ana Ruiz</td>
      <td>3月25日</td>
    </tr>
    <tr class="subtask" data-parent="task-105" hidden>
      <td>
        <a href="/tasks/107">メールマガジンの枠を押さえる</a>
      </td>
      <td>Ben Carter</td>
      <td>3月28日</td>
    </tr>
  </tbody>
</table>
<a href="/tasks/completed">完了したタスク</a>
```

```css
.tasks { border-collapse: collapse; }
.tasks th, .tasks td { padding: 0.5rem; text-align: left; }
.tasks tbody td:first-child { position: relative; padding-left: 2rem; }
.tasks .subtask td:first-child { padding-left: 3.5rem; }
.tasks .toggle { position: absolute; left: 0.5rem; cursor: pointer; }
/* タスクが開いている間は矢印が下を向く。クラスだけで示している。 */
.tasks .toggle::before { content: ''; display: inline-block; padding: 3px; border: solid; border-width: 0 2px 2px 0; rotate: -45deg; }
.tasks .is-open .toggle::before { rotate: 45deg; }
```

```js
for (const toggle of document.querySelectorAll('.tasks .toggle')) {
  toggle.addEventListener('click', () => {
    const row = toggle.closest('tr');
    const open = !row.classList.contains('is-open');
    row.classList.toggle('is-open', open);
    for (const sub of document.querySelectorAll(`[data-parent="${row.id}"]`)) sub.hidden = !open;
  });
}
```

違いはロールだけです。ロールを付けたことで、ツリーグリッドとしての動き（Tabの移動先が1つ、矢印キーでの移動、開閉の状態と階層）が期待されますが、どれも実装されていません。

- Tabキーは今も2つの矢印を飛ばし、表示中のタスクのリンクに1つずつ止まります。
- どの行にも `aria-level` がないため、サブタスクがタスクより1段深いことは伝わりません。
- どのタスクも、開いているかどうかを伝えません。
- axeのWCAGのルールはこれを合格にします。

## 修正後：行にボタンを置き、サブタスクであることを名前で伝える

```html
<h2 id="tasks-title">タスク</h2>
<table class="tasks" aria-labelledby="tasks-title">
  <thead>
    <tr>
      <th scope="col">タスク</th>
      <th scope="col">担当者</th>
      <th scope="col">期限</th>
    </tr>
  </thead>
  <tbody>
    <tr id="task-101">
      <td>
        <button type="button" class="toggle" aria-label="サブタスク" aria-expanded="true"></button>
        <a href="/tasks/101">春のカタログを公開する</a>
      </td>
      <td>Ana Ruiz</td>
      <td>3月14日</td>
    </tr>
    <tr class="subtask" data-parent="task-101">
      <td>
        <a href="/tasks/102">商品説明を書く<span class="visually-hidden">、サブタスク</span></a>
      </td>
      <td>Ben Carter</td>
      <td>3月7日</td>
    </tr>
    <tr class="subtask" data-parent="task-101">
      <td>
        <a href="/tasks/103">商品写真を撮る<span class="visually-hidden">、サブタスク</span></a>
      </td>
      <td>Dana Park</td>
      <td>3月10日</td>
    </tr>
    <tr>
      <td>
        <a href="/tasks/104">ドメインを更新する</a>
      </td>
      <td>Eli Novak</td>
      <td>3月20日</td>
    </tr>
    <tr id="task-105">
      <td>
        <button type="button" class="toggle" aria-label="サブタスク" aria-expanded="false"></button>
        <a href="/tasks/105">夏のセールを企画する</a>
      </td>
      <td>Chris Moreau</td>
      <td>4月2日</td>
    </tr>
    <tr class="subtask" data-parent="task-105" hidden>
      <td>
        <a href="/tasks/106">割引を決める<span class="visually-hidden">、サブタスク</span></a>
      </td>
      <td>Ana Ruiz</td>
      <td>3月25日</td>
    </tr>
    <tr class="subtask" data-parent="task-105" hidden>
      <td>
        <a href="/tasks/107">メールマガジンの枠を押さえる<span class="visually-hidden">、サブタスク</span></a>
      </td>
      <td>Ben Carter</td>
      <td>3月28日</td>
    </tr>
  </tbody>
</table>
<a href="/tasks/completed">完了したタスク</a>
```

```css
.tasks { border-collapse: collapse; }
.tasks th, .tasks td { padding: 0.5rem; text-align: left; }
.tasks tbody td:first-child { position: relative; padding-left: 2rem; }
.tasks .subtask td:first-child { padding-left: 3.5rem; }
.tasks .toggle { position: absolute; left: 0.5rem; cursor: pointer; }
/* ボタンが持つ枠線と背景を外す。押せる大きさとフォーカスの枠は残す。 */
.tasks .toggle { width: 1.25rem; height: 1.25rem; padding: 0; border: 0; background: none; color: inherit; }
.tasks .toggle:focus-visible { outline: 2px solid; outline-offset: 2px; }
/* 矢印は、スクリーンリーダーが読む状態から描く。 */
.tasks .toggle::before { content: ''; display: inline-block; padding: 3px; border: solid; border-width: 0 2px 2px 0; rotate: -45deg; }
.tasks .toggle[aria-expanded='true']::before { rotate: 45deg; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
```

```js
for (const toggle of document.querySelectorAll('.tasks .toggle')) {
  toggle.addEventListener('click', () => {
    const row = toggle.closest('tr');
    const open = toggle.getAttribute('aria-expanded') !== 'true';
    toggle.setAttribute('aria-expanded', String(open));
    for (const sub of document.querySelectorAll(`[data-parent="${row.id}"]`)) sub.hidden = !open;
  });
}
```

1. 矢印を「サブタスク」という名前のHTML標準のボタンにします。Tabキーで届き、EnterキーとSpaceキーで、スクリプトがもともと待っているクリックとしてタスクを開けます。
2. ボタンに `aria-expanded` を付け、クリックの処理で切り替えます。スクリーンリーダーは、タスク自身のリンクの直前で「サブタスク、ボタン、折りたたみ」や「展開」と読み上げます。
3. 各サブタスクのリンクの最後に見えない言葉を加えます。Tabキーでたどり着いても、「タスク」の列を下へ移動しても「商品説明を書く、サブタスク」と読まれます。名前は画面上の言葉で始まったままなので、音声操作の利用者はその言葉で呼べます。
4. 閉じたタスクの行には `hidden` を残し、Tabの順序から外します。開いたタスクの行はボタンのすぐ後に続くため、次のTabキーでその中を進みます。
5. 表の見出しはそのまま残し、矢印はクラスではなく `aria-expanded` から描きます。

これで二つの修正前の例はどちらも直り、1.3.1、2.1.1、4.1.2を満たします。表のライブラリーには、すでにこの形で矢印を描くものもあります。たとえばElement Plusは、矢印を `aria-expanded` 付きのボタンとして出力します。

## 修正後：完全なARIAのツリーグリッド

```html
<h2 id="tasks-title">タスク</h2>
<table class="tasks" role="treegrid" aria-labelledby="tasks-title">
  <thead>
    <tr>
      <th scope="col">タスク</th>
      <th scope="col">担当者</th>
      <th scope="col">期限</th>
    </tr>
  </thead>
  <tbody>
    <tr role="row" aria-level="1" aria-posinset="1" aria-setsize="3" aria-expanded="true" tabindex="0">
      <td role="gridcell">
        <a href="/tasks/101" tabindex="-1">春のカタログを公開する</a>
      </td>
      <td role="gridcell" tabindex="-1">Ana Ruiz</td>
      <td role="gridcell" tabindex="-1">3月14日</td>
    </tr>
    <tr role="row" aria-level="2" aria-posinset="1" aria-setsize="2" tabindex="-1">
      <td role="gridcell">
        <a href="/tasks/102" tabindex="-1">商品説明を書く</a>
      </td>
      <td role="gridcell" tabindex="-1">Ben Carter</td>
      <td role="gridcell" tabindex="-1">3月7日</td>
    </tr>
    <tr role="row" aria-level="2" aria-posinset="2" aria-setsize="2" tabindex="-1">
      <td role="gridcell">
        <a href="/tasks/103" tabindex="-1">商品写真を撮る</a>
      </td>
      <td role="gridcell" tabindex="-1">Dana Park</td>
      <td role="gridcell" tabindex="-1">3月10日</td>
    </tr>
    <tr role="row" aria-level="1" aria-posinset="2" aria-setsize="3" tabindex="-1">
      <td role="gridcell">
        <a href="/tasks/104" tabindex="-1">ドメインを更新する</a>
      </td>
      <td role="gridcell" tabindex="-1">Eli Novak</td>
      <td role="gridcell" tabindex="-1">3月20日</td>
    </tr>
    <tr role="row" aria-level="1" aria-posinset="3" aria-setsize="3" aria-expanded="false" tabindex="-1">
      <td role="gridcell">
        <a href="/tasks/105" tabindex="-1">夏のセールを企画する</a>
      </td>
      <td role="gridcell" tabindex="-1">Chris Moreau</td>
      <td role="gridcell" tabindex="-1">4月2日</td>
    </tr>
    <tr role="row" aria-level="2" aria-posinset="1" aria-setsize="2" tabindex="-1" hidden>
      <td role="gridcell">
        <a href="/tasks/106" tabindex="-1">割引を決める</a>
      </td>
      <td role="gridcell" tabindex="-1">Ana Ruiz</td>
      <td role="gridcell" tabindex="-1">3月25日</td>
    </tr>
    <tr role="row" aria-level="2" aria-posinset="2" aria-setsize="2" tabindex="-1" hidden>
      <td role="gridcell">
        <a href="/tasks/107" tabindex="-1">メールマガジンの枠を押さえる</a>
      </td>
      <td role="gridcell" tabindex="-1">Ben Carter</td>
      <td role="gridcell" tabindex="-1">3月28日</td>
    </tr>
  </tbody>
</table>
<a href="/tasks/completed">完了したタスク</a>
```

```css
.tasks { border-collapse: collapse; }
.tasks th, .tasks td { padding: 0.5rem; text-align: left; }
.tasks tbody td:first-child { position: relative; padding-left: 2rem; }
.tasks [aria-level='2'] td:first-child { padding-left: 3.5rem; }
.tasks [aria-expanded] { cursor: pointer; }
/* 矢印は、スクリーンリーダーが読む状態から描く。 */
.tasks [aria-expanded] td:first-child::before { content: ''; position: absolute; left: 0.75rem; top: 0.9rem; padding: 3px; border: solid; border-width: 0 2px 2px 0; rotate: -45deg; }
.tasks [aria-expanded='true'] td:first-child::before { rotate: 45deg; }
.tasks tr:focus-visible, .tasks td:focus-visible, .tasks a:focus-visible { outline: 2px solid; outline-offset: -2px; }
```

```js
const grid = document.querySelector('[role="treegrid"]');
const body = grid.tBodies[0];
// 移動できる行。閉じたタスクの下にないものすべて。
const rows = () => [...body.rows].filter((row) => !row.hidden);
const level = (row) => Number(row.getAttribute('aria-level'));
// 各セルでフォーカスが置かれるもの。中のリンクか、セルそのもの。
const targets = (row) => [...row.cells].map((cell) => cell.querySelector('a') ?? cell);
// タスクのサブタスク。後に続く行のうち、同じ階層の次の行までのもの。
function childrenOf(row) {
  const all = [...body.rows];
  const after = all.slice(all.indexOf(row) + 1);
  const end = after.findIndex((other) => level(other) <= level(row));
  return end === -1 ? after : after.slice(0, end);
}
function setOpen(row, open) {
  row.setAttribute('aria-expanded', String(open));
  for (const child of childrenOf(row)) child.hidden = !open;
}
// Tabの移動先は1つ。最後に移動した行かセル。
function moveTo(el) {
  if (!el) return;
  for (const other of body.querySelectorAll('[tabindex]')) other.tabIndex = -1;
  el.tabIndex = 0;
  el.focus();
}
grid.addEventListener('keydown', (event) => {
  const row = event.target.closest('tbody tr');
  if (!row) return;
  const list = rows();
  const r = list.indexOf(row);
  const cells = targets(row);
  // 行そのものにフォーカスがある間は -1。
  const c = cells.indexOf(event.target);
  const state = row.getAttribute('aria-expanded');
  // 下と上は、行どうし、またはセルなら同じ列のまま移動する。
  const at = (other) => (other && c >= 0 ? targets(other)[c] : other);
  switch (event.key) {
    case 'ArrowDown': moveTo(at(list[r + 1])); break;
    case 'ArrowUp': moveTo(at(list[r - 1])); break;
    case 'ArrowRight':
      if (c === -1 && state === 'false') setOpen(row, true);
      else moveTo(cells[c + 1]);
      break;
    case 'ArrowLeft':
      if (c > 0) moveTo(cells[c - 1]);
      else if (c === 0) moveTo(row);
      else if (state === 'true') setOpen(row, false);
      else if (level(row) > 1) moveTo(list.slice(0, r).findLast((other) => level(other) < level(row)));
      break;
    case 'Home': moveTo(c === -1 || event.ctrlKey ? at(list[0]) : cells[0]); break;
    case 'End': moveTo(c === -1 || event.ctrlKey ? at(list.at(-1)) : cells.at(-1)); break;
    case 'Enter':
      if (event.target.matches('a')) return; // リンク上のEnterは、そのリンクをたどる。
      if (state !== null) setOpen(row, state === 'false');
      else row.querySelector('a').click();
      break;
    default:
      return;
  }
  event.preventDefault();
});
grid.addEventListener('click', (event) => {
  const row = event.target.closest('tbody tr');
  if (!row || event.target.closest('a')) return;
  const state = row.getAttribute('aria-expanded');
  if (state !== null) setOpen(row, state === 'false');
  moveTo(row);
});
```

1. どの行にも、深さを示す `aria-level` と、同じ並びの中での位置を示す `aria-posinset` と `aria-setsize` を付けます。スクリーンリーダーは「メールマガジンの枠を押さえる」を「2項目中2、階層2」のように伝えられます。
2. 各タスクに状態を示す `aria-expanded` を付け、矢印もそこから描きます。
3. ツリーグリッド全体をTabの移動先1つにします。`moveTo()` が、最後に移動した行かセルに `tabindex="0"` を、それ以外に `-1` を付けます。
4. 「タスク」の列では、セルの代わりに中のリンクがフォーカスを受けるようにします。セルの中にウィジェットがあれば、セルではなくウィジェットがフォーカスを受けるというパターンのとおりです。
5. ARIA Authoring Practicesのツリーグリッドのパターンのとおりにキーを処理します。行に先にフォーカスを置く点は、パターンのメール受信トレイの例と同じです。下と上で次と前の行へ移動します。
6. 右矢印キーは、閉じたタスクを開き、開いたタスクやサブタスクの行では、その行のセルへ1つずつ入ります。左矢印キーは、開いたタスクを閉じ、サブタスクからはそのタスクへ、最初のセルからは行へ戻ります。サブタスクからタスクへ戻るのは受信トレイの例の動きで、パターン自体は、子の行がない行ではフォーカスを動かしません。
7. HomeとEndは最初と最後の行へ、セルにいるときは行の端へ、Ctrl + HomeとCtrl + Endは最初と最後の行へ、セルにいるときは同じ列のまま移動します。
8. 行の上のEnterキーは、タスクなら開閉し、下に行のない行ならそのリンクをたどります。リンク上のEnterキーは、そのリンクをたどります。
9. タスクの行は、リンク以外のどこをクリックしても開閉するようにします。

これで二つの修正前の例はどちらも直り、1.3.1、2.1.1、4.1.2を満たします。

## どの方法を選ぶか

どちらも基準を満たします。この例のように、開いて読んで閉じる表には、ボタンを使ってください。どのブラウザー、スクリーンリーダー、音声操作のソフトも知っているHTML標準のコントロールが、タスクごとに1つあるだけで、スクリプトは数行で済みます。ほかの表と同じように、Tabキーで移動でき、スクリーンリーダーの表のキーで読めます。ツリーグリッドを作るのは、各行の状態や担当者を変えていく問い合わせの一覧のように、表が行を順に処理していく作業の場で、行の中に操作するセルがあり、しかも行が多くてTabで1つずつ移動してはいられない場合です。そのときは、ロール、階層と位置、状態、Tabの移動先1つ、行とセルのすべてのキーをそろえます。

## 実装の判断

DataTablesの例のように、子の行ではなく1つの詳細欄が開く行にも、同じボタンを使います。行の最初のセルにボタンを置いて `aria-expanded` を付け、詳細は次の行に、すべての列にまたがる1つのセルで表示します。そうすれば、詳細はそれが属する行のすぐ後に読まれます。

ボタンには、開く中身を表す名前を付けます。「サブタスク」は短く、同じセルでタスクの名前が続くため、セルごとに移動するスクリーンリーダーでは両方が聞こえます。音声操作で「サブタスクをクリック」と言うと、該当するボタンすべてに番号が付き、そこから選ぶことになります。「『夏のセールを企画する』のサブタスク」のようにタスク名を含めれば直接1つに届きますが、タスク名を2回聞くことになります。ボタンとリンクは横に並べ、一方をもう一方の中に入れないでください。HTMLではボタンの中にリンクを入れられず、ボタンの中にあるものは、スクリーンリーダーにはボタンの名前の一部としてしか読まれません。

ツリーグリッドでは、ARIA Authoring Practicesのメール受信トレイの例が、行に先にフォーカスを置き、セルへは右矢印キーで入ります。セルにフォーカスを置けないツリーグリッドは、ツリーグリッドではなくツリービューだとも書かれています。チェックボックスや削除ボタンのようなコントロールがあるセルでは、セルではなくそのコントロールがフォーカスを受けます。ここでのリンクと同じです。`aria-selected` は、複数のタスクにチェックを入れてから「アーカイブ」を押すときのように、行を選ぶことと開くことが別の段階になるツリーグリッドのためのものです。複数の行を選べるときは、treegridに `aria-multiselectable="true"` を付けます。

見えている行だけをページに置く表や、タスクを開いたときにサブタスクを読み込む表では、ページにない行をブラウザーは数えられません。ツリーグリッドでは、ページに置く行にはすべて `aria-level`、`aria-posinset`、`aria-setsize` を付けたままにし、treegridに `aria-rowcount` を、各行に `aria-rowindex` を付けます。ライブラリーの表の部品も、サイトが写す例も、作りはさまざまです。使う前に、矢印がボタンで、行が開いているかどうかを伝え、その下の行が子の行であることを伝えているかを確かめます。

## 修正の確認

1. マウスを使わずに、Tabで表に入る。すべてのタスクを開き、すべてのサブタスクにたどり着ける。ツリーグリッドでは矢印キー、Home、Endを使い、Tabを1回押すとツリーグリッドを出ることを確かめる。
2. スクリーンリーダーで、開く各タスクへ移動し、開閉する。展開と折りたたみが読み上げられ、画面上の矢印と一致する。
3. サブタスクへ移動する。ツリーグリッドでない表では、リンクがサブタスクとして読み上げられる。ツリーグリッドでは、2項目中2、階層2のように、行の階層とタスクの下での位置が読み上げられる。
4. ツリーグリッドでは、すべてのキーをツリーグリッドのパターンとその受信トレイの例に照らし合わせる。右矢印キーは閉じたタスクを開き、次に行のセルへ入る。左矢印キーはセルから戻り、開いたタスクを閉じ、次にサブタスクからそのタスクへ移動する。サブタスク上のEnterキーはそのリンクをたどる。
5. 強制配色（Windowsのコントラスト テーマ）をオンにし、200%に拡大する。矢印、サブタスクの字下げ、フォーカスの枠がすべて見え、どの行も切れていない。

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

## 制限

このサンプルは、どのアプリケーションでも同じ結果になることを保証するものではありません。スクリプトは最小限の例で、ツリーグリッドにはPage UpとPage Down、選択、先頭文字での移動、必要なときのサブタスクの読み込みがありません。また、Tabキーを押すとどの行からでもグリッドの外へ出ます。パターンでは、同じ行に次のリンクがあれば、まずそこへ移ります。列のない、セクションとページのサイドバーはツリービューで、[キーボードで開けず、階層も伝わらないツリービュー](/ja/learn/tree-views)で扱っています。セルをその場で編集する表は、[キーボードでセルを移動・編集できないデータグリッド](/ja/learn/editable-grids)で扱っています。1つだけ開閉する部分は、[開いているか伝わらない表示・非表示ボタン](/ja/learn/show-hide-buttons)で扱っています。対象とする支援技術で、一連の操作全体をテストしてください。

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

`aria-expanded`: ボタンが開くメニューやセクションが、開いているかどうかを示します。

| スクリーンリーダー | ブラウザーと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）
