データ表示
ツリービュー
解説ありTree View
階層構造の表示。フォルダツリーのような展開・移動操作。
ツリービューとは?
ツリービューは、フォルダとファイルのように階層構造を折りたたみながら表示する UI です。 ファイラー、サイドメニュー、組織図、カテゴリ一覧などでよく使われます。
ポイントは「親子の入れ子(階層)」と「開閉できる枝」の2つです。 目で見ればインデントや三角マークで構造が分かりますが、 画面を見ない人にも「いま何階層目の・全何件中の何番目で・開いているか閉じているか」を 伝える必要があります。ここがアクセシビリティの肝です。
なぜアクセシビリティが大事なの?
見た目だけのツリーを作ると、次の人たちが取り残されます。
- キーボードだけで操作する人。各行が
<div>や<li>のままだと Tab でフォーカスできず、枝を開けません。 - スクリーンリーダーを使う人。ただの入れ子リストは 「リスト項目」としか読まれず、階層の深さ・開閉状態・全体での位置が一切伝わりません。
解決策は、コンテナを role="tree"、各行を role="treeitem"、 子のまとまりを role="group" にし、開閉できる枝にaria-expanded を付けることです。 さらにツリー全体でTabは1回だけ止まり(roving tabindex)、 中の移動は矢印キーで行います。
ライブデモ(推奨実装)
下のファイルツリーは APG に沿った実装です。マウスを使わず、キーボードだけで操作してみてください。
- 📁 ドキュメント
- 📁 プロジェクト
- 📄 計画.md
- 📄 議事録.md
- 📁 画像
- 🖼️ ロゴ.png
- 🖼️ 図解.svg
- 📄 履歴書.pdf
- 📁 プロジェクト
- 📁 ダウンロード
- 📦 setup.zip
試してみよう: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();
});補足
矢印キーの「移動」は 表示中(祖先がすべて展開済み)の項目だけを対象にします。 折りたたまれた枝の中の項目は hidden な group の中にあるので、 移動先の候補から外す点がポイントです。
アンチパターン(Bad)
下は <ul>/<li> と onclick だけで作った「見た目だけツリー」です。マウスでは開閉できますが、キーボードでは一切操作できません。上のデモと違いを比べてみてください。
- 📁 ドキュメント
- 📁 プロジェクト
- 📄 計画.md
- 📄 議事録.md
- 📄 履歴書.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> をベースに使うのは構いません。 その li に role="treeitem"、外側に role="tree"、 子の ul に role="group" を付け、aria-expanded・roving tabindex・矢印キー処理を足せば、 同じ見た目のままアクセシブルなツリーになります。
実装チェックリスト
- コンテナに
role="tree"とaria-label(名前)がある - 各行が
role="treeitem"、子のまとまりがrole="group"である - 開閉できる枝に
aria-expandedがあり、開閉と常に一致している - ツリー全体で Tab は1か所だけ止まる(roving tabindex:
tabindexが1つだけ0) - ↓↑ で表示中の項目を移動、→ で展開/子へ、← で折りたたみ/親へ
- (任意)Home/End で最初・最後へ移動できる
- 折りたたまれた枝の中はキーボード/読み上げから外れている(
hidden) - キーボードだけで操作でき、フォーカスが見える(フォーカスリングを消していない)