データ表示

ツリーグリッド

解説あり

Treegrid

行を展開/折りたたみできるグリッド。ツリーとグリッドの複合。

ツリーグリッド(treegrid)とは?

ツリーグリッドは、「行を開いたり閉じたりできる表(グリッド)」です。 メールのスレッド一覧、ファイル/フォルダの一覧、コメントツリーなど、 「表形式のデータ」と「階層(親 → 子)の展開」を同時に扱いたいときに使います。

ただの表(table)に「行をたためる」機能が加わったもの、と考えると分かりやすいです。 だからこそ、表としての構造(行・セル・見出し)と、ツリーとしての状態(開いている / 閉じている)の両方を支援技術に伝える必要があります。

なぜアクセシビリティが大事なの?

見た目だけで作ると、次の人たちが取り残されます。

解決策は、role="treegrid" / role="row" / role="gridcell" で構造を伝え、 親行に aria-expanded を付けて開閉状態を伝えること。 そして矢印キーで操作できるようにすることです。

ライブデモ(推奨実装)

下は APG に沿って実装したメールのスレッド一覧です。マウスを使わず、キーボードだけで操作してみてください。

アクセシブルなツリーグリッド(受信トレイ)
差出人件名日時
山田さん忘年会の件(3件)12/10
山田さんRe: 候補日のご相談12/10
佐藤さんRe: お店を予約しました12/11

試してみよう:Tab でグリッドに入る → ↑ ↓ で行移動 → → で親行を展開 / ← で折りたたみ → Home / End で最初・最後の行へ。

ポイント

スクリーンリーダー(macOSなら +F5 で VoiceOver)をオンにすると、 親行で「展開済み / 折りたたみ」や「レベル1, 2項目中1番目」のように開閉状態と階層・位置が読み上げられます。 これが aria-expandedaria-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 や矢印キーを試して、違いを体感してください。

div で作った壊れたツリーグリッド
差出人 / 件名 / 日時
山田さん|忘年会の件(3件)|12/10

試してみよう: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> で組むなら、各行に roletabindex・矢印キー処理・aria-expandedすべて自前で足す必要があります。 最初からネイティブの <table> を土台にすれば、行・セル・見出しの関連付けがタダで手に入ります。

実装チェックリスト


原文(英語):Treegrid Pattern — W3C APG(新しいタブで開きます)