データ表示

ツリービュー

解説あり

Tree View

階層構造の表示。フォルダツリーのような展開・移動操作。

ツリービューとは?

ツリービューは、フォルダとファイルのように階層構造を折りたたみながら表示する UI です。 ファイラー、サイドメニュー、組織図、カテゴリ一覧などでよく使われます。

ポイントは「親子の入れ子(階層)」と「開閉できる枝」の2つです。 目で見ればインデントや三角マークで構造が分かりますが、 画面を見ない人にも「いま何階層目の・全何件中の何番目で・開いているか閉じているか」を 伝える必要があります。ここがアクセシビリティの肝です。

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

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

解決策は、コンテナを role="tree"、各行を role="treeitem"、 子のまとまりを role="group" にし、開閉できる枝にaria-expanded を付けることです。 さらにツリー全体でTabは1回だけ止まり(roving tabindex)、 中の移動は矢印キーで行います。

ライブデモ(推奨実装)

下のファイルツリーは APG に沿った実装です。マウスを使わず、キーボードだけで操作してみてください。

アクセシブルなツリービュー
  • 📁 ドキュメント
    • 📁 プロジェクト
      • 📄 計画.md
      • 📄 議事録.md
    • 📄 履歴書.pdf

試してみよう:Tab で一度だけツリーに入る → ↓ ↑ で項目移動 → → で枝を開く/子へ、← で枝を閉じる/親へ → Home / End で最初・最後へ。

ポイント

スクリーンリーダー(macOSなら +F5 で VoiceOver)をオンにすると、 項目にフォーカスしたとき「ドキュメント, 展開済み, 1 / 2, レベル 1」のように名前・開閉状態・位置・階層が読み上げられます。 これが role="tree"aria-expanded の効果です。

キーボード操作

キー動作必須/任意
Tabツリーに入る / ツリーから出る(ツリー全体で1か所だけ止まる)必須
/ 表示中の次 / 前の項目へフォーカス移動必須
閉じた枝なら展開。開いた枝なら最初の子へ。末端なら何もしない必須
開いた枝なら折りたたみ。それ以外なら親の項目へ移動必須
Home / End最初 / 最後の表示中の項目へ移動任意(推奨)

補足

ツリーは「ツリー全体でTabは1回」が原則です。 フォーカスを受け取れる項目を常に1つだけ(tabindex="0")にし、 残りは tabindex="-1" にして矢印キーで移動先を切り替えます。 この仕組みを roving tabindex と呼びます。

必要な WAI-ARIA / ロール

付ける場所属性 / ロール意味
コンテナrole="tree" + aria-label階層リスト全体。名前(ラベル)も付ける。
各行role="treeitem"ツリーの1項目(フォルダ/ファイル)。
開閉できる枝aria-expanded="true | false"子を開いているか。開閉に合わせて必ず更新する。末端(子なし)には付けない。
子のまとまりrole="group"ある項目の子 treeitem を入れる入れ物。
各行(任意)aria-level / aria-setsize / aria-posinset階層の深さ・同階層の総数・その中の位置。入れ子から自動推測されるが明示も可。
各行tabindex="0 | -1"フォーカスを受け取る1つだけ 0、他は -1(roving tabindex)。

実装:推奨パターン(Good)

良い例 / 推奨

tree / treeitem / group で階層を表し、aria-expanded と roving tabindex を JS で同期させます。

マークアップ:

<ul role="tree" aria-label="ファイル">
  <li role="treeitem" aria-expanded="true" tabindex="0">
    <span class="tree-label">📁 ドキュメント</span>
    <ul role="group">
      <li role="treeitem" aria-expanded="false" tabindex="-1">
        <span class="tree-label">📁 プロジェクト</span>
        <ul role="group">
          <li role="treeitem" tabindex="-1">
            <span class="tree-label">📄 計画.md</span>
          </li>
          <li role="treeitem" tabindex="-1">
            <span class="tree-label">📄 議事録.md</span>
          </li>
        </ul>
      </li>
      <li role="treeitem" tabindex="-1">
        <span class="tree-label">📄 履歴書.pdf</span>
      </li>
    </ul>
  </li>
</ul>

キーボード操作と状態同期のスクリプト:

const tree = document.querySelector('[role="tree"]');
// 表示中(祖先がすべて展開済み)の treeitem だけを順番に集める
function visibleItems() {
  return Array.from(tree.querySelectorAll('[role="treeitem"]'))
    .filter((el) => !el.closest('[role="group"][hidden]'));
}
function isParent(el) {
  return el.hasAttribute('aria-expanded');
}
function moveTo(el) {
  // roving tabindex:フォーカス先だけ 0、他は -1
  visibleItems().forEach((i) => i.setAttribute('tabindex', '-1'));
  el.setAttribute('tabindex', '0');
  el.focus();
}
function setExpanded(el, open) {
  el.setAttribute('aria-expanded', String(open));
  // 直下の子グループを開閉(孫は閉じたままにしておく)
  const group = el.querySelector(':scope > [role="group"]');
  if (group) group.hidden = !open;
}

tree.addEventListener('keydown', (e) => {
  const cur = e.target.closest('[role="treeitem"]');
  if (!cur) return;
  const items = visibleItems();
  const i = items.indexOf(cur);
  switch (e.key) {
    case 'ArrowDown':
      if (i < items.length - 1) moveTo(items[i + 1]);
      break;
    case 'ArrowUp':
      if (i > 0) moveTo(items[i - 1]);
      break;
    case 'ArrowRight':
      if (isParent(cur) && cur.getAttribute('aria-expanded') === 'false') {
        setExpanded(cur, true); // 閉じた親 → 展開
      } else if (isParent(cur)) {
        const child = cur.querySelector(':scope > [role="group"] [role="treeitem"]');
        if (child) moveTo(child); // 開いた親 → 最初の子へ
      }
      break;
    case 'ArrowLeft':
      if (isParent(cur) && cur.getAttribute('aria-expanded') === 'true') {
        setExpanded(cur, false); // 開いた親 → 折りたたみ
      } else {
        const parentGroup = cur.parentElement.closest('[role="group"]');
        const parent = parentGroup &&
          parentGroup.closest('[role="treeitem"]');
        if (parent) moveTo(parent); // それ以外 → 親へ
      }
      break;
    case 'Home':
      moveTo(items[0]);
      break;
    case 'End':
      moveTo(items[items.length - 1]);
      break;
    default:
      return;
  }
  e.preventDefault();
});

補足

矢印キーの「移動」は 表示中(祖先がすべて展開済み)の項目だけを対象にします。 折りたたまれた枝の中の項目は hiddengroup の中にあるので、 移動先の候補から外す点がポイントです。

アンチパターン(Bad)

下は <ul>/<li>onclick だけで作った「見た目だけツリー」です。マウスでは開閉できますが、キーボードでは一切操作できません。上のデモと違いを比べてみてください。

入れ子リストで作った壊れたツリー
  • 📁 ドキュメント
    • 📁 プロジェクト
    • 📄 履歴書.pdf

試してみよう:Tab を押しても項目にフォーカスが当たりません。スクリーンリーダーでは「ただのリスト」と読まれ、階層・開閉・位置が一切伝わりません。

<!-- ❌ アンチパターン -->
<!-- ただの入れ子リスト。role も aria-expanded も無い -->
<ul class="bad-tree">
  <li>
    <span onclick="toggle(this)">📁 ドキュメント</span>
    <ul>
      <li>
        <span onclick="toggle(this)">📁 プロジェクト</span>
        <ul style="display:none">
          <li><span>📄 計画.md</span></li>
        </ul>
      </li>
      <li><span>📄 履歴書.pdf</span></li>
    </ul>
  </li>
</ul>

悪い例 / 避ける

この実装の問題点:

  • キーボードで操作できないspan/li はフォーカスを受け取れず、矢印キーでの移動も無い。
  • ツリーだと伝わらないrole="tree" が無く、スクリーンリーダーには「ただのリスト」に聞こえる。
  • 開閉状態が伝わらないaria-expanded が無く、開いているか閉じているか分からない。
  • 階層・位置が伝わらない — 何階層目か、全何件中の何番目かが読み上げられない。

ポイント

入れ子の <ul>/<li> をベースに使うのは構いません。 その lirole="treeitem"、外側に role="tree"、 子の ulrole="group" を付け、aria-expanded・roving tabindex・矢印キー処理を足せば、 同じ見た目のままアクセシブルなツリーになります。

実装チェックリスト


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