データ表示
ツリーグリッド
解説ありTreegrid
行を展開/折りたたみできるグリッド。ツリーとグリッドの複合。
ツリーグリッド(treegrid)とは?
ツリーグリッドは、「行を開いたり閉じたりできる表(グリッド)」です。 メールのスレッド一覧、ファイル/フォルダの一覧、コメントツリーなど、 「表形式のデータ」と「階層(親 → 子)の展開」を同時に扱いたいときに使います。
ただの表(table)に「行をたためる」機能が加わったもの、と考えると分かりやすいです。 だからこそ、表としての構造(行・セル・見出し)と、ツリーとしての状態(開いている / 閉じている)の両方を支援技術に伝える必要があります。
なぜアクセシビリティが大事なの?
見た目だけで作ると、次の人たちが取り残されます。
- キーボードだけで操作する人。行が
<div>だとTab でフォーカスできず、矢印で行を移動することも、開閉することもできません。 - スクリーンリーダーを使う人。
roleが無いと「表」とも「行」とも認識されず、 どのセルがどの見出しの値なのか、そしてその行が開いているのか閉じているのかが伝わりません。
解決策は、role="treegrid" / role="row" / role="gridcell" で構造を伝え、 親行に aria-expanded を付けて開閉状態を伝えること。 そして矢印キーで操作できるようにすることです。
ライブデモ(推奨実装)
下は APG に沿って実装したメールのスレッド一覧です。マウスを使わず、キーボードだけで操作してみてください。
| 差出人 | 件名 | 日時 |
|---|---|---|
| 山田さん | 忘年会の件(3件) | 12/10 |
| 山田さん | Re: 候補日のご相談 | 12/10 |
| 佐藤さん | Re: お店を予約しました | 12/11 |
| 鈴木さん | 請求書の送付(2件) | 12/12 |
| 鈴木さん | Re: 11月分の請求書 | 12/12 |
| 経理部 | Re: 入金を確認しました | 12/13 |
試してみよう:Tab でグリッドに入る → ↑ ↓ で行移動 → → で親行を展開 / ← で折りたたみ → Home / End で最初・最後の行へ。
ポイント
スクリーンリーダー(macOSなら ⌘+F5 で VoiceOver)をオンにすると、 親行で「展開済み / 折りたたみ」や「レベル1, 2項目中1番目」のように開閉状態と階層・位置が読み上げられます。 これが aria-expanded と aria-level / aria-posinset / aria-setsize の効果です。
キーボード操作
| キー | 動作 | 必須/任意 |
|---|---|---|
| Tab | グリッド全体に入る / 出る(中は1つのタブストップ) | 必須 |
| ↓ / ↑ | 次 / 前の(見えている)行へフォーカス移動 | 必須 |
| → | 閉じている親行を展開する | 必須 |
| ← | 開いている親行を折りたたむ | 必須 |
| Home / End | 最初 / 最後の(見えている)行へ移動 | 任意(推奨) |
補足
ツリーグリッドの中は1つのタブストップです。Tab を連打しても各行に止まらず、 一度入ったら矢印キーで行を移動します。これを roving tabindex(いま選択中の行だけtabindex="0"、ほかは -1)で実現します。
必要な WAI-ARIA / ロール
| 付ける場所 | 属性 / ロール | 意味 |
|---|---|---|
| 外枠 | role="treegrid" + aria-label | 「開閉できる表」だと伝える。名前(ラベル)も付ける。 |
| 各行 | role="row" | 表の1行であることを示す。 |
| 見出しセル | role="columnheader" | 列の見出し。各セルがどの列の値かを関連付ける。 |
| データセル | role="gridcell" | 行の中の1つのセル。 |
| 展開できる親行 | aria-expanded="true | false" | その行が開いているか。開閉に合わせて必ず更新する。 |
| 各行 | aria-level / aria-posinset / aria-setsize | 階層の深さと、同階層での位置(任意・推奨)。 |
| 行(フォーカス管理) | tabindex="0 | -1" | roving tabindex。今いる行だけ 0、ほかは -1。 |
| 閉じている子行 | hidden | 閉じている間はDOMから隠し、キーボード/読み上げの対象外にする。 |
実装:推奨パターン(Good)
良い例 / 推奨
ネイティブの <table> に role="treegrid" 系のロールを重ね、 親行の aria-expanded と roving tabindex を JS で同期させます。
マークアップ:
<table role="treegrid" aria-label="受信トレイ">
<thead>
<tr role="row">
<th role="columnheader">差出人</th>
<th role="columnheader">件名</th>
<th role="columnheader">日時</th>
</tr>
</thead>
<tbody>
<!-- 親行:展開できる。aria-expanded で開閉を伝える -->
<tr role="row"
aria-level="1" aria-posinset="1" aria-setsize="2"
aria-expanded="true" tabindex="0">
<td role="gridcell">山田さん</td>
<td role="gridcell">忘年会の件(3件)</td>
<td role="gridcell">12/10</td>
</tr>
<!-- 子行:親が閉じている間は hidden -->
<tr role="row" aria-level="2" aria-posinset="1" aria-setsize="2" tabindex="-1">
<td role="gridcell">山田さん</td>
<td role="gridcell">Re: 候補日のご相談</td>
<td role="gridcell">12/10</td>
</tr>
<tr role="row" aria-level="2" aria-posinset="2" aria-setsize="2" tabindex="-1">
<td role="gridcell">佐藤さん</td>
<td role="gridcell">Re: お店を予約しました</td>
<td role="gridcell">12/11</td>
</tr>
</tbody>
</table>キーボード操作と状態同期のスクリプト:
const grid = document.querySelector('[role="treegrid"]');
const rows = Array.from(grid.querySelectorAll('tbody [role="row"]'));
// いま操作できる(画面に見えている)行だけを集める
const visibleRows = () => rows.filter((r) => !r.hidden);
// 1つだけ tabindex="0"、残りは "-1"(roving tabindex)
function focusRow(row) {
rows.forEach((r) => r.setAttribute('tabindex', r === row ? '0' : '-1'));
row.focus();
}
// 親行に続く、1段深い子行を開閉する
function setExpanded(row, open) {
row.setAttribute('aria-expanded', String(open));
let next = row.nextElementSibling;
while (next && next.getAttribute('aria-level') === '2') {
next.hidden = !open; // 閉じる行はキーボード/読み上げの対象外に
next = next.nextElementSibling;
}
}
grid.addEventListener('keydown', (e) => {
const row = e.target.closest('[role="row"]');
if (!row) return;
const list = visibleRows();
const i = list.indexOf(row);
const expandable = row.hasAttribute('aria-expanded');
const isOpen = row.getAttribute('aria-expanded') === 'true';
let target = null;
switch (e.key) {
case 'ArrowDown': target = list[Math.min(i + 1, list.length - 1)]; break;
case 'ArrowUp': target = list[Math.max(i - 1, 0)]; break;
case 'Home': target = list[0]; break;
case 'End': target = list[list.length - 1]; break;
case 'ArrowRight':
if (expandable && !isOpen) { e.preventDefault(); setExpanded(row, true); }
return;
case 'ArrowLeft':
if (expandable && isOpen) { e.preventDefault(); setExpanded(row, false); }
return;
default: return;
}
if (target) { e.preventDefault(); focusRow(target); }
});補足
ポイントは2つ。(1) 矢印で移動する行は「見えている行」だけに絞ること (閉じた子行へはフォーカスを送らない)。(2) →/← で開閉したらaria-expanded と子行の hidden を必ず一致させること。 見た目だけ変えても支援技術には伝わりません。
アンチパターン(Bad)
下は <div> と onclick だけで作った「見た目だけ同じ」スレッド一覧です。マウスでは開けますが、キーボードでは一切操作できません。上のデモと同じように Tab や矢印キーを試して、違いを体感してください。
試してみよう:Tab を押しても行にフォーカスが当たらず、矢印で移動も展開もできません。スクリーンリーダーには「表」とも「開閉できる行」とも伝わりません。
<!-- ❌ アンチパターン -->
<div class="grid">
<div class="head">差出人 / 件名 / 日時</div>
<!-- div なのでフォーカスできず、行・セル・開閉の意味も持たない -->
<div class="row" onclick="toggle('thread')">
山田さん|忘年会の件(3件)|12/10 +
</div>
<div id="thread" style="display:none">
<div class="row">山田さん|Re: 候補日のご相談|12/10</div>
<div class="row">佐藤さん|Re: お店を予約しました|12/11</div>
</div>
</div>悪い例 / 避ける
この実装の問題点:
- 表・行・セルの意味が無い —
role="treegrid"等が無く、スクリーンリーダーには「ただのテキストの塊」に聞こえる。どのセルがどの列かも関連付かない。 - 開閉状態が伝わらない —
aria-expandedが無く、開いているのか閉じているのか分からない。 - キーボードで操作できない —
divはフォーカスを受け取れず、矢印での移動・展開・折りたたみができない。 - roving tabindex が無い — そもそもフォーカスの管理が存在しない。
display:noneのインラインstyle頼みで、hidden属性や状態管理が無い。
ポイント
どうしても <div> で組むなら、各行に role・tabindex・矢印キー処理・aria-expanded をすべて自前で足す必要があります。 最初からネイティブの <table> を土台にすれば、行・セル・見出しの関連付けがタダで手に入ります。
実装チェックリスト
- 外枠に
role="treegrid"とaria-label(名前)が付いている - 行は
role="row"、セルはrole="gridcell"/role="columnheader" - 展開できる親行に
aria-expandedがあり、開閉と常に一致している - 閉じている子行は
hiddenでキーボード/読み上げから外れている - グリッド内は1タブストップで、↑↓ で行移動できる(roving tabindex)
- → で展開、← で折りたたみができる
- (任意)Home / End で最初・最後の行へ移動できる
- (任意)
aria-level/aria-posinset/aria-setsizeで階層と位置を伝える - キーボードだけで操作でき、フォーカスが見える(フォーカスリングを消していない)