# キーボードでセルを移動・編集できないデータグリッド

Source: https://easeweb.dev/learn/editable-grids
Topics: 表とデータ > 編集できる表をキーボードで移動・編集できる (WCAG 2.1.1, 4.1.2)
The fix: 編集できるセルごとに、行と列の見出しから aria-labelledby で名前を付けた入力欄を置けば、スクリプトなしでTabで届きます。または、表を完全なARIAのグリッドにします。role="grid" を付け、Tabの移動先を1つにして矢印キーでセルからセルへ動かし、EnterキーかF2キーで、セルの見出しから名前を付けた入力欄を開き、Escapeキーで取り消せるようにします。
Test with: keyboard, screen reader, forced colors, zoom
References: https://www.w3.org/WAI/ARIA/apg/patterns/grid/ https://www.w3.org/WAI/ARIA/apg/patterns/grid/examples/data-grids/ 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/#grid

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

## 場面

あるお店の管理画面に在庫表があります。「品目」「価格」「在庫数」の3列で、商品を1行ずつ並べた表です。見出しは `scope` を付けた `th` で、表の作りそのものに問題はありません。価格をクリックすると入力欄に変わり、新しい値を入れてEnterキーを押すと、表に反映されます。マウスを使わずにTabキーを押すと、フォーカスは表を素通りして「変更を保存」へ進みます。セルはフォーカスを受けないため、価格は一つも変えられません。値を変えられることも、どこにも示されていません。Chromeでは、各価格はただの表のセルです。表のセルは内容のためのロールで、操作部品のロールではありません。クリックでセルを開いても、スクリプトがそこに入れる入力欄には独自の名前がなく、どの品目のどの列を編集しているのかは、コードのどこにも書かれていません。

そこで、チームは手早い方法で表をグリッドにします。表に `role="grid"` を付け、それ以外は何もしません。すると、Chromeでは価格がグリッドのセルになります。Tabの移動先が1つで、矢印キーでセルからセルへ動けるウィジェットだと約束するロールです。ところが、その約束を果たすものは何もありません。どのセルもフォーカスを受けず、Tabキーは今も表を飛ばし、矢印キーを押す場所もありません。クリックで開く入力欄にも、名前がないままです。

## 確認できる問題

達成基準2.1.1「キーボード」は、マウスでできることはすべてキーボードでもできることを求めています。価格を変える方法は、フォーカスを受けないセルのクリックだけのため、マウスなしでは値を一つも変えられません。`role="grid"` を足しても、この点は変わりません。セルは今もフォーカスを受けず、グリッドが約束する矢印キーは何もしません。

達成基準4.1.2「名前（name）・役割（role）及び値（value）」は、操作部品の名前と役割をソフトウェアが判別できることを求めています。価格はクリックで入力欄に変わる操作部品です。最初の表では、各価格は表のセルとしてしか伝わらず、変えられることを示すものがありません。W3Cの失敗例では、F59（スクリプトで要素を操作部品にしながら役割を与えないもの。F59が例に挙げるのはdivとspan）がこれに当たり、より広くはF15（アクセシビリティAPIを使わない独自の操作部品）にも当たります。`role="grid"` を付けると、セルは操作できることもある役割を得ますが、どのキーもセルに届きません。どちらの表でも、クリックでセルに入る入力欄はスクリプトがラベルなしで作るため、名前がありません。スクリーンリーダーが入力欄そのものから得られるのは編集欄と数字だけで、どのセルなのかは分かりません。

## 利用者への影響

このような表は、多くの管理画面にあります。在庫と価格、勤務表、予算、座席表、注文の一括編集などです。値を変える手段がこの表しかないことも多く、キーボードで入れない表があると、その仕事そのものができない人が出てきます。

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

axeはどちらの表も合格にします。クリック処理を付けただけの `td` には規則が確かめられるものがなく、見出しのセルを持つ表に `role="grid"` を付けるのは正しいマークアップだからです。セルがフォーカスを受けるか、矢印キーでフォーカスが動くかを確かめる規則はありません。クリックで開く入力欄はaxeの `label` の規則なら指摘できますが、クリックするまでページにないため、読み込んだときのページを調べても見つかりません。

この診断ツールはキーを押します。各グリッドで、セルかセルの中の要素がTabでフォーカスを受けるかを確かめ、どれも受けなければ、どのセルにも届かないとして報告します。1つのセルがTabの移動先なら、そこにフォーカスを置いて矢印キー、Home、Endを押し、フォーカスがまったく動かなければ、ほかのセルに届かないとして、これも報告します。人が確認する項目として渡すものは3つあります。複数のセルがTabの移動先なのに矢印キーが何もしないグリッド。ポインターやセル、テキストの形のカーソルが出るのに、フォーカスを受けず入力欄もないセルを持つ表。そして、gridやsheetと名付けた、divで作った要素のうち、そうしたセルを持つものです。クリックできるセルでも、セル自体にカーソルの指定がなければ、その表は見つけられません。セルを編集のために開いて入力欄の名前を確かめるチェックもないため、そこは人が確かめます。

2つ目の表は、AIによる修正でできることがある形で、`role="grid"` を加えたところで止まっています。すべてのセルに `tabindex="0"` を付ける修正もありえます。どのセルにもTabで止まれるようにはなりますが、セルを開くキーはないままです。

## 修正前：クリックでしか開かないセルの表

```html
<h2 id="stock-title">在庫表</h2>
<table class="sheet"
  aria-labelledby="stock-title">
  <thead>
    <tr>
      <th scope="col">品目</th>
      <th scope="col">価格</th>
      <th scope="col">在庫数</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">オーツミルク</th>
      <td>2.40</td>
      <td>36</td>
    </tr>
    <tr>
      <th scope="row">紙コップ</th>
      <td>0.12</td>
      <td>500</td>
    </tr>
    <tr>
      <th scope="row">エスプレッソ豆</th>
      <td>14.00</td>
      <td>8</td>
    </tr>
  </tbody>
</table>
<button type="button">変更を保存</button>
```

```css
.sheet {
  border-collapse: collapse;
}
.sheet th,
.sheet td {
  padding: 0.5rem;
  border-bottom: 1px solid #ddd;
  text-align: left;
}
.sheet td {
  cursor: cell;
}
.sheet input {
  width: 5rem;
  font: inherit;
}
```

```js
function edit(cell) {
  if (cell.querySelector('input')) return;
  const input = document.createElement('input');
  input.value = cell.textContent;
  cell.replaceChildren(input);
  input.focus();
  input.addEventListener('keydown', (event) => {
    if (event.key === 'Enter') input.blur();
  });
  input.addEventListener('blur', () => {
    cell.textContent = input.value;
  });
}
for (const cell of document.querySelectorAll('.sheet td')) {
  cell.onclick = () => edit(cell);
}
```

セルは `click` にしか反応せず、フォーカスも受けないため、どのキーでも届きません。`td` は表のセルなので、変えられることを示すものはなく、スクリプトが作る入力欄にもラベルがありません。

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

```html
<h2 id="stock-title">在庫表</h2>
<table class="sheet" role="grid"
  aria-labelledby="stock-title">
  <thead>
    <tr>
      <th scope="col">品目</th>
      <th scope="col">価格</th>
      <th scope="col">在庫数</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">オーツミルク</th>
      <td>2.40</td>
      <td>36</td>
    </tr>
    <tr>
      <th scope="row">紙コップ</th>
      <td>0.12</td>
      <td>500</td>
    </tr>
    <tr>
      <th scope="row">エスプレッソ豆</th>
      <td>14.00</td>
      <td>8</td>
    </tr>
  </tbody>
</table>
<button type="button">変更を保存</button>
```

```css
.sheet {
  border-collapse: collapse;
}
.sheet th,
.sheet td {
  padding: 0.5rem;
  border-bottom: 1px solid #ddd;
  text-align: left;
}
.sheet td {
  cursor: cell;
}
.sheet input {
  width: 5rem;
  font: inherit;
}
```

```js
function edit(cell) {
  if (cell.querySelector('input')) return;
  const input = document.createElement('input');
  input.value = cell.textContent;
  cell.replaceChildren(input);
  input.focus();
  input.addEventListener('keydown', (event) => {
    if (event.key === 'Enter') input.blur();
  });
  input.addEventListener('blur', () => {
    cell.textContent = input.value;
  });
}
for (const cell of document.querySelectorAll('.sheet td')) {
  cell.onclick = () => edit(cell);
}
```

違いはロールだけです。このロールで各 `td` はグリッドのセルになり、Tabの移動先が1つで矢印キーで動くグリッドだと約束されますが、約束を果たすものは何もありません。どのセルにも `tabindex` がなく、キーを扱うスクリプトもありません。Tabキーは今もすべてのセルを飛ばし、クリックで開く入力欄にも名前がないままで、axeはこれを合格にします。

## 修正後：セルごとに入力欄を置く

```html
<h2 id="stock-title">在庫表</h2>
<table class="sheet"
  aria-labelledby="stock-title">
  <thead>
    <tr>
      <th scope="col" id="col-item">品目</th>
      <th scope="col" id="col-price">価格</th>
      <th scope="col" id="col-stock">在庫数</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row" id="row-oat">オーツミルク</th>
      <td>
        <input value="2.40"
          aria-labelledby="row-oat col-price">
      </td>
      <td>
        <input value="36"
          aria-labelledby="row-oat col-stock">
      </td>
    </tr>
    <tr>
      <th scope="row" id="row-cups">紙コップ</th>
      <td>
        <input value="0.12"
          aria-labelledby="row-cups col-price">
      </td>
      <td>
        <input value="500"
          aria-labelledby="row-cups col-stock">
      </td>
    </tr>
    <tr>
      <th scope="row" id="row-beans">エスプレッソ豆</th>
      <td>
        <input value="14.00"
          aria-labelledby="row-beans col-price">
      </td>
      <td>
        <input value="8"
          aria-labelledby="row-beans col-stock">
      </td>
    </tr>
  </tbody>
</table>
<button type="button">変更を保存</button>
```

```css
.sheet {
  border-collapse: collapse;
}
.sheet th,
.sheet td {
  padding: 0.5rem;
  border-bottom: 1px solid #ddd;
  text-align: left;
}
.sheet input {
  width: 5rem;
  font: inherit;
  border: 1px solid #767676;
  border-radius: 4px;
}
```

```js
// スクリプトは不要。どの入力欄もHTML標準なので、
// Tab、入力、ブラウザーの取り消しが初めから使える。
```

各値は表のセルの中にあるHTML標準の入力欄のため、Tabキーで読む順にすべてに届き、入力すれば値が変わります。あとはブラウザーが受け持つので、クリックのスクリプトは要らなくなります。表のセルに置いた入力欄は、表の見出しを名前として受け取らないため、`aria-labelledby` で行と列の見出しから名前を付けます。読み上げは「紙コップ 価格、編集、0.12」のようになります。入力欄は見える枠を残しているので、入力できる場所だと見て分かります。

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

```html
<h2 id="stock-title">在庫表</h2>
<table class="sheet" role="grid"
  aria-labelledby="stock-title">
  <thead>
    <tr>
      <th scope="col" id="col-item">品目</th>
      <th scope="col" id="col-price">価格</th>
      <th scope="col" id="col-stock">在庫数</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row" id="row-oat">オーツミルク</th>
      <td tabindex="0">2.40</td>
      <td tabindex="-1">36</td>
    </tr>
    <tr>
      <th scope="row" id="row-cups">紙コップ</th>
      <td tabindex="-1">0.12</td>
      <td tabindex="-1">500</td>
    </tr>
    <tr>
      <th scope="row" id="row-beans">エスプレッソ豆</th>
      <td tabindex="-1">14.00</td>
      <td tabindex="-1">8</td>
    </tr>
  </tbody>
</table>
<button type="button">変更を保存</button>
```

```css
.sheet {
  border-collapse: collapse;
}
.sheet th,
.sheet td {
  padding: 0.5rem;
  border-bottom: 1px solid #ddd;
  text-align: left;
}
.sheet td {
  cursor: cell;
}
.sheet td:focus-visible {
  outline: 2px solid;
  outline-offset: -2px;
}
.sheet input {
  width: 5rem;
  font: inherit;
}
```

```js
const grid = document.querySelector('[role="grid"]');
const rows = [...grid.querySelectorAll('tbody tr')];
const cells = rows.map((row) => [...row.cells].slice(1));
const heads = [...grid.querySelectorAll('thead th')];
// Tabの移動先は1つ。最後に移動したセル。
function moveTo(cell) {
  if (!cell) return;
  for (const other of cells.flat()) {
    other.tabIndex = other === cell ? 0 : -1;
  }
  cell.focus();
}
// EnterかF2で、見出しから名前を付けた入力欄を開く。
function edit(cell) {
  const was = cell.textContent;
  const input = document.createElement('input');
  input.value = was;
  const rowHead = cell.parentElement.querySelector('th');
  const colHead = heads[cell.cellIndex];
  const label = `${rowHead.id} ${colHead.id}`;
  input.setAttribute('aria-labelledby', label);
  cell.replaceChildren(input);
  input.focus();
  let open = true;
  const close = (keep) => {
    if (!open) return;
    open = false;
    cell.textContent = keep ? input.value : was;
  };
  input.addEventListener('keydown', (event) => {
    // 編集中のキーは、グリッドではなく入力欄のもの。
    event.stopPropagation();
    if (event.key === 'Enter' || event.key === 'F2') {
      event.preventDefault();
      close(true);
      moveTo(cell);
    } else if (event.key === 'Escape') {
      close(false);
      moveTo(cell);
    }
  });
  // Tabやほかの場所のクリックでは、変更を残す。
  input.addEventListener('blur', () => close(true));
}
grid.addEventListener('keydown', (event) => {
  const cell = event.target.closest('td');
  if (!cell) return;
  const r = rows.indexOf(cell.parentElement);
  const c = cells[r].indexOf(cell);
  switch (event.key) {
    case 'ArrowRight': moveTo(cells[r][c + 1]); break;
    case 'ArrowLeft': moveTo(cells[r][c - 1]); break;
    case 'ArrowDown': moveTo(cells[r + 1]?.[c]); break;
    case 'ArrowUp': moveTo(cells[r - 1]?.[c]); break;
    case 'Home':
      moveTo(event.ctrlKey ? cells[0][0] : cells[r][0]);
      break;
    case 'End': {
      const row = event.ctrlKey ? cells.at(-1) : cells[r];
      moveTo(row.at(-1));
      break;
    }
    case 'Enter':
    case 'F2': edit(cell); break;
    default: return;
  }
  event.preventDefault();
});
grid.addEventListener('click', (event) => {
  const cell = event.target.closest('td');
  if (!cell || cell.querySelector('input')) return;
  moveTo(cell);
  edit(cell);
});
```

表の行と見出しはそのままで、`role="grid"` が約束するものがそろいます。グリッド全体がTabの移動先1つになり、キー操作はARIA Authoring Practicesのデータグリッドのとおりです。矢印キーで1セルずつ、HomeとEndで行の端へ、Ctrl + HomeとCtrl + Endで最初と最後のセルへ移動します。EnterキーかF2キーでセルに「紙コップ 価格」という名前の入力欄が開き、もう一度EnterキーかF2キーを押すと変更を確定してセルに戻り、Escapeキーで元の値に戻ります。クリックでも、同じコードでセルが開きます。Authoring Practicesによれば、並べ替えのような機能を持たない見出しのセルは、フォーカスを受けなくてかまいません。

## どの方法を選ぶか

どちらも基準を満たします。セルごとに入力欄を置く方法は、スクリプトも新しく覚えるキーも要りません。どの入力欄もTabの移動先になり、入力、文字の選択、ブラウザーの取り消しも初めから使えます。この例のような数行の表や、表の形に並べたフォームにはこちらを選んでください。グリッドは表全体をTabの移動先1つにまとめるため、何百行もある注文の一覧のように、入力欄を1つずつTabでたどっていられない長い表や、利用者が表計算ソフトのキー操作を期待するアプリケーションに向いています。そのときは、Tabの移動先1つ、すべてのキー、開くセルごとの名前付きの入力欄をそろえます。

## 実装の判断

Authoring Practicesは、表計算ソフトのように、セルで文字や数字を入力すると編集が始まる操作や、長いグリッドでのPage UpとPage Downも勧めています。この例にはどちらもありません。ほかの列から計算する合計のように変えられない列は、フォーカスは受けたまま、そのセルに `aria-readonly="true"` を付けます。`aria-selected` は、フォーカスとは別にセルや行を選べるグリッドのためのものです。

キーを扱う代わりに、グリッドのすべてのセルに `tabindex="0"` を付けるのはやめてください。Tabですべてのセルに届くようにはなりますが、グリッドのセルは矢印キーを約束しており、セルを開くキーもありません。セルからセルへTabで移動させたいなら、最初の修正のように、セルごとに入力欄を置きます。

表がすでに持っている構造は、別の問題です。divで作ったグリッドには各行のセルを囲む行が必要で、見えている行だけを描画するグリッドには、スクリーンリーダーが各セルの位置を伝えられるよう、グリッドに `aria-rowcount`、各行に `aria-rowindex` が必要です。どちらも[見出しが太字だけのデータ表](/ja/learn/table-headers)と同じトピックで扱います。受信トレイのスレッドのように、行を開くと子の行が現れるものは、ツリーグリッドという別のパターンです。リッチテキストエディターを入れたセルには、そのエディター自身のキー操作が必要です。

## 修正の確認

1. マウスを使わずに、Tabで表に入る。編集できるすべてのセルに届き、値を変えて確定し、別の値を変えて取り消す。グリッドでは、Tab 1回でグリッドを出ることを確かめる。
2. グリッドでは、すべてのキーを確かめる。矢印キー、Home、End、Ctrl + Home、Ctrl + Endで、データグリッドのパターンのとおり1セルずつ、または端へ移動し、EnterキーとF2キーでセルが開く。このガイドのスクリプトでは、EnterキーかF2キーで確定、Escapeキーで取り消して、そのたびにフォーカスがセルに戻る。
3. スクリーンリーダーで、セルの入力欄へTabで移動するか、セルを編集のために開く。編集欄として、品目と列、続いて値が読み上げられる。グリッドでは、フォーカスが移るたびに、各セルが見出しと一緒に読まれる。
4. 強制配色（Windowsのコントラスト テーマ）をオンにし、200%に拡大する。各セルのフォーカスの枠と、セルに開く入力欄が見え、どの列も切れていない。

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

## 制限

このサンプルは、どのアプリケーションでも同じ結果になることを保証するものではありません。グリッドのスクリプトは最小限の例で、入力で始まる編集、Page UpとPage Down、選択、コピーと貼り付け、必要なときの行の読み込みがありません。列での並べ替えは別の問題で、並べ替えた列とその向きを伝えるトピックで扱います。同じTabの移動先1つのパターンをサイドバーで使う例は、[キーボードで開けず、階層も伝わらないツリービュー](/ja/learn/tree-views)にあります。データグリッドの部品は、ライブラリーによって作りがさまざまです。使う前に、すべてのセルに届き、すべてのキーを扱っているかを確かめます。対象とする支援技術で、一連の操作全体をテストしてください。

---

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