データ表示
グリッド
解説ありGrid
セル単位でキーボード操作できる対話的な表(データグリッド)。
グリッド(データグリッド)とは?
グリッドは、表形式のデータをキーボードの矢印キーで自由に動き回れるように作った、対話的な UI です。表計算ソフトのように「セルからセルへ」フォーカスを移しながら 操作できるのが特徴です。
ここで言うグリッドは、CSS のレイアウト用 grid とは別物です。 また、ただ並べて見せるだけの表なら <table> を使うべきで、 グリッドにする必要はありません。「セル単位でフォーカスを動かしたい」「セル内に操作要素がある」 といった対話が必要なときにだけグリッドを使います。
なぜアクセシビリティが大事なの?
表を「触れるもの」にするほど、設計を誤ると操作不能な人が出ます。
- キーボードだけで操作する人。セルに
onclickを付けただけだと、Tab でも矢印でもセルに到達できず、まったく操作できません。 - スクリーンリーダーを使う人。
role="grid"が無いと「グリッド」と 認識されず、行・列・セルの構造や「いま何行目の何列目か」が伝わりません。
解決策は、role="grid" で構造を宣言し、矢印キーでフォーカスを動かす (ローミング tabindex)ことです。グリッド全体を1つのタブストップにして、 中は矢印で移動させます。
ライブデモ(推奨実装)
下の社員一覧は APG に沿った対話的グリッドです。マウスを使わず、Tab で一度フォーカスを入れたら、あとは矢印キーだけで セル間を移動してみてください。
社員一覧
試してみよう:Tab でグリッドに入る → ↑ ↓ ← → でセル移動 → Home / End で行の先頭・末尾、Ctrl + Home / Ctrl + End でグリッドの先頭・末尾。
ポイント
スクリーンリーダー(macOSなら ⌘+F5 で VoiceOver)をオンにすると、 セルに入ったとき「グリッド, 4列, 5行」のように構造が読み上げられ、移動するたびに 「氏名 列, 佐藤 花子」のように列見出しとセルの値が読まれます。 これが role="grid" と列見出しの効果です。
キーボード操作
| キー | 動作 | 必須/任意 |
|---|---|---|
| Tab | グリッド全体に出入りする(中は1タブストップ) | 必須 |
| → / ← | 右 / 左のセルへフォーカス移動 | 必須 |
| ↓ / ↑ | 下 / 上のセルへフォーカス移動 | 必須 |
| Home / End | その行の先頭 / 末尾のセルへ | 必須 |
| Ctrl+Home / Ctrl+End | グリッドの先頭 / 末尾のセルへ | 任意(推奨) |
補足
グリッドの中で Tab を「セル移動」に割り当ててはいけません。Tab はあくまでグリッドの外へ出るためのキーにし、セル移動は矢印キーに任せます。これがネイティブな表との大きな違いです。
必要な WAI-ARIA / ロール
| 付ける場所 | 属性 / ロール | 意味 |
|---|---|---|
| 外枠 | role="grid" | 対話的なグリッドであることを宣言する。 |
| 外枠 | aria-label / aria-labelledby | グリッドに名前を付ける(何の表かを伝える)。 |
| 各行 | role="row" | 行のまとまりを示す。 |
| 見出しセル | role="columnheader" | 列の見出し。各列のセルと関連付けて読まれる。 |
| データセル | role="gridcell" | 1つ1つのセル。 |
| フォーカスするセル | tabindex="0"(1つだけ)/ "-1"(残り全部) | ローミング tabindex。グリッドを1タブストップにし、移動時に付け替える。 |
実装:推奨パターン(Good)
良い例 / 推奨
role="grid" で構造を宣言し、常に1つのセルだけ tabindex="0"にして矢印キーでフォーカスを動かします(ローミング tabindex)。
マークアップ:
<div role="grid" aria-labelledby="grid-cap">
<div role="row">
<span role="columnheader" tabindex="-1">社員ID</span>
<span role="columnheader" tabindex="-1">氏名</span>
<span role="columnheader" tabindex="-1">部署</span>
<span role="columnheader" tabindex="-1">内線</span>
</div>
<div role="row">
<!-- グリッド全体で最初のセルだけ tabindex="0"(残りは -1) -->
<span role="gridcell" tabindex="0">E-001</span>
<span role="gridcell" tabindex="-1">佐藤 花子</span>
<span role="gridcell" tabindex="-1">営業部</span>
<span role="gridcell" tabindex="-1">1201</span>
</div>
<!-- 以降の行も role="row" + role="gridcell" tabindex="-1" を並べる -->
</div>キーボード操作のスクリプト(フォーカス移動と tabindex の付け替えがすべて):
const grid = document.getElementById('grid-good');
if (grid) {
// 2次元配列としてセルを把握する(行ごとのセル配列)
const rows = Array.from(grid.querySelectorAll<HTMLElement>('[role="row"]'));
const cells = rows.map((row) =>
Array.from(row.querySelectorAll<HTMLElement>('[role="columnheader"],[role="gridcell"]'))
);
let r = 0; // 現在の行
let c = 0; // 現在の列
// 現在位置のセルだけ tabindex="0"、他は -1 にして 1タブストップにする
function focusCell(nextR: number, nextC: number) {
const maxR = cells.length - 1;
r = Math.max(0, Math.min(nextR, maxR));
const maxC = cells[r].length - 1;
c = Math.max(0, Math.min(nextC, maxC));
cells.flat().forEach((cell) => cell.setAttribute('tabindex', '-1'));
const target = cells[r][c];
target.setAttribute('tabindex', '0');
target.focus();
}
grid.addEventListener('keydown', (e) => {
switch (e.key) {
case 'ArrowRight': focusCell(r, c + 1); break;
case 'ArrowLeft': focusCell(r, c - 1); break;
case 'ArrowDown': focusCell(r + 1, c); break;
case 'ArrowUp': focusCell(r - 1, c); break;
case 'Home':
if (e.ctrlKey) focusCell(0, 0); // 先頭セルへ
else focusCell(r, 0); // 行の先頭へ
break;
case 'End':
if (e.ctrlKey) focusCell(cells.length - 1, // 末尾セルへ
cells[cells.length - 1].length - 1);
else focusCell(r, cells[r].length - 1); // 行の末尾へ
break;
default: return; // 関係ないキーはブラウザに任せる
}
e.preventDefault();
});
// クリックしたセルにも現在位置を合わせる
cells.forEach((row, ri) =>
row.forEach((cell, ci) => {
cell.addEventListener('click', () => focusCell(ri, ci));
})
);
}補足
セルの中にボタンやリンクを置く場合は、まずセル間を矢印で移動できるようにし、Enter や F2 で「セル内の操作モード」に入る、といった拡張を検討します。 まずは1セル=1フォーカスの基本形を確実に動かすことが先決です。
アンチパターン(Bad)
下は <table> のセルに onclick を付けただけの「対話できるつもり」の表です。マウスでは選べますが、キーボードでは一切操作できません。上のデモと同じように Tab → 矢印キーを試して、違いを体感してください。
| 社員ID | 氏名 | 部署 | 内線 |
|---|---|---|---|
| E-001 | 佐藤 花子 | 営業部 | 1201 |
| E-002 | 鈴木 太郎 | 開発部 | 1310 |
| E-003 | 高橋 美咲 | 人事部 | 1105 |
| E-004 | 田中 健 | 開発部 | 1322 |
試してみよう:Tab を押してもセルにフォーカスが入らず、矢印でも動けません。スクリーンリーダーでは「グリッド」とも認識されず、行・列・現在位置が伝わりません。
<!-- ❌ アンチパターン -->
<!-- 見た目は表だが「対話的グリッド」としては成立していない -->
<table>
<tr>
<th>社員ID</th><th>氏名</th><th>部署</th><th>内線</th>
</tr>
<tr>
<!-- onclick だけ。role も tabindex もキー処理も無い -->
<td onclick="select(this)">E-001</td>
<td onclick="select(this)">佐藤 花子</td>
<td onclick="select(this)">営業部</td>
<td onclick="select(this)">1201</td>
</tr>
</table>悪い例 / 避ける
この実装の問題点:
- キーボードで操作できない — セルに
tabindexもキー処理も無く、矢印で動けない。 - グリッドと認識されない —
role="grid"が無く、支援技術には「ただの表」に聞こえる。 - 1タブストップになっていない — ローミング tabindex の設計が無い。
- 現在位置が伝わらない — 何行目・何列目にいるのか、選択状態も読み上げられない。
ポイント
そもそもクリックやキー操作が不要な表なら、無理にグリッドにせず ネイティブの <table>(<th scope> 付き)で十分です。 「セル単位の対話」が本当に必要なときだけ、role="grid" とローミング tabindex を導入しましょう。
実装チェックリスト
- 外枠に
role="grid"と名前(aria-label/aria-labelledby)がある - 行は
role="row"、見出しはrole="columnheader"、セルはrole="gridcell" - 常に1つのセルだけ
tabindex="0"、残りは"-1"(ローミング tabindex) - 矢印キーでセル移動でき、Home/End で行の端へ移動できる
- (推奨)Ctrl+Home/End でグリッドの端へ移動できる
- Tab はセル移動ではなく「グリッドの外へ出る」キーになっている
- キーボードだけで操作でき、フォーカスが見える(フォーカスリングを消していない)