ツリービュー
フォルダを開閉できるファイルツリー。クリックでフォルダが開き、キャレットが回転する。階層はインデントで表現し、キーボード(矢印キー)でも操作できる。
プレビュー
操作クリック/タップで動きます。
仕様書
- 向く場面
- ファイル・フォルダなど入れ子の構造を見せる
- 階層を開閉しながら1件を選ぶエクスプローラ
- キーボードで階層を辿らせたい業務画面
- 避ける場面
- 階層が1段しかない一覧(通常のリストでよい)
- サイト全体のナビ(ツリーは選択用)
- モバイルで横幅が足りずインデントが潰れる
- 構成要素
- ツリー
- 行項目
- キャレット
- アイコン
- 名前
- 子階層
- 持っている状態
- 通常
- ホバー
- フォーカス
- 押下中
- キーボード
Tab でツリーへ(1行だけ tabindex=0)・↑↓ で行移動・→ で展開/子へ・← で折りたたみ/親へ・Home/End で先頭/末尾・文字キーで名前の頭文字へ・Enter/Space で開閉
- 支援技術
role=tree/treeitem/group、フォルダに aria-expanded、選択行に aria-selected、ローミング tabindex(APG Tree View)。折りたたみ中は visibility:hidden で除外。reduced-motion 対応
- 動き
子階層 grid-template-rows 0.24s ease・キャレット回転 0.2s。閉じ終わってから非表示化
デザインの要点
- フォーカスはツリー全体で 1 行だけ tabindex=0(ローミング)。全行に止まると、20 行のツリーを抜けるのに Tab を 20 回押す
- → は「閉じていれば開く、開いていれば子へ」、← は「開いていれば閉じる、閉じていれば親へ」の2段階。APG Tree の定石で、1キーで階層を往復できる
- 折りたたんだ子は visibility:hidden まで切る。高さ 0 で隠しただけの行は矢印キーの移動先に混ざる
- インデントは --depth の CSS 変数で padding-left に掛ける。ネストごとに ul を字下げすると罫線・ホバー背景が行幅いっぱいに引けない
WAI-ARIA の tree パターンに倣い、フォルダ(tv-node)に aria-expanded、行に role=treeitem を付けています。フォーカスは常にツリー内で1つの行だけ tabindex=0 にする「ローミング tabindex」方式で、フォーカス中の行に aria-selected を同期します。矢印キーで上下移動、→で展開/子へ、←で折りたたみ/親へ、Home/End で先頭/末尾、文字キーで頭文字の一致する行へ、Enter/Space で開閉できます。開閉アニメは grid-template-rows: 0fr→1fr。グリッドアイテムは .tv-group の1つだけにし(複数だと暗黙行が潰れず内容がはみ出す)、折りたたみ中は visibility:hidden で操作対象からも外しています。
コード(コピーして使えます)
<ul class="tv" role="tree" aria-label="ファイル">
<li class="tv-node" role="treeitem" aria-expanded="true">
<div class="tv-row" tabindex="0" style="--depth:0">
<svg class="tv-caret" width="12" height="12" viewBox="0 0 12 12" fill="none"><polyline points="4,2 8,6 4,10" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
<svg class="tv-ic tv-folder" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6a1 1 0 0 1 1-1h5l2 2h8a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/></svg>
<span class="tv-name">src</span>
</div>
<div class="tv-children"><ul class="tv-group" role="group">
<li class="tv-node" role="treeitem" aria-expanded="false">
<div class="tv-row" tabindex="-1" style="--depth:1">
<svg class="tv-caret" width="12" height="12" viewBox="0 0 12 12" fill="none"><polyline points="4,2 8,6 4,10" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
<svg class="tv-ic tv-folder" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6a1 1 0 0 1 1-1h5l2 2h8a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/></svg>
<span class="tv-name">components</span>
</div>
<div class="tv-children"><ul class="tv-group" role="group">
<li class="tv-leaf" role="treeitem">
<div class="tv-row" tabindex="-1" style="--depth:2">
<span class="tv-caret tv-caret--empty"></span>
<svg class="tv-ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h8l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><polyline points="14,3 14,7 18,7"/></svg>
<span class="tv-name">Button.tsx</span>
</div>
</li>
<li class="tv-leaf" role="treeitem">
<div class="tv-row" tabindex="-1" style="--depth:2">
<span class="tv-caret tv-caret--empty"></span>
<svg class="tv-ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h8l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><polyline points="14,3 14,7 18,7"/></svg>
<span class="tv-name">Card.tsx</span>
</div>
</li>
</ul></div>
</li>
<li class="tv-leaf" role="treeitem">
<div class="tv-row" tabindex="-1" style="--depth:1">
<span class="tv-caret tv-caret--empty"></span>
<svg class="tv-ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h8l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><polyline points="14,3 14,7 18,7"/></svg>
<span class="tv-name">index.ts</span>
</div>
</li>
</ul></div>
</li>
<li class="tv-leaf" role="treeitem">
<div class="tv-row" tabindex="-1" style="--depth:0">
<span class="tv-caret tv-caret--empty"></span>
<svg class="tv-ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h8l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><polyline points="14,3 14,7 18,7"/></svg>
<span class="tv-name">package.json</span>
</div>
</li>
</ul>