Reactで、フォルダのような階層をツリーで表示する方法
フォルダのような階層は、子の配列を持つデータを用意し、子の配列に対しても同じ描画の関数を呼ぶと、ツリーとして表示できます。開いているフォルダは、フォルダごとの state ではなく、ツリー全体で1つの Set に持たせます。こうすると、外側のフォルダを閉じて開き直しても、中のフォルダの開閉が残ります。
ライブラリを使わずに TreeView という部品を作り、クリックと矢印キーでの操作を確かめました。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作しました。スクリーンリーダーでの読み上げ、タッチ操作、Firefox と Safari は試していません。読み上げに使われる役割と名前は、Chromium が持つアクセシビリティツリーを CDP(Chrome DevTools Protocol)で読み出して確かめています。

GIF のカーソル、クリックの輪、操作の回数の表示は、撮影のために重ねたものです。クリックで変わった表示(「選んだ項目」の文字や、開いたまま戻った components の行)は、その瞬間だけピンクにしています。
階層は、子の配列を持つデータの入れ子にする
ツリーに渡すデータは、1つの項目を TreeNode という型で表します。children を持つものがフォルダ、持たないものがファイルです。id は、項目を見分けるための文字列です。React の key にも、開いているフォルダの記録にも使います。
ファイルの一覧が src/App.tsx のようなパスの配列で手元にあるときのために、パスを / で区切って入れ子のデータに組み立てる関数 buildTree を書きました。id には、先頭からその項目までのパスを使います。同じ名前のフォルダが別の場所にあっても、id は重なりません。
import type { TreeNode } from './TreeView';
// "src/App.tsx" のようなパスの一覧から、入れ子の TreeNode を作る
export function buildTree(paths: string[]): TreeNode[] {
const root: TreeNode[] = [];
for (const path of paths) {
let level = root;
const parts = path.split('/');
parts.forEach((name, i) => {
const id = parts.slice(0, i + 1).join('/');
let node = level.find((n) => n.id === id);
if (!node) {
node = i < parts.length - 1 ? { id, name, children: [] } : { id, name };
level.push(node);
}
if (node.children) level = node.children;
});
}
sortTree(root);
return root;
}
// フォルダを先に、同じ種類どうしは名前の順に並べる
function sortTree(nodes: TreeNode[]) {
nodes.sort((a, b) => Number(!a.children) - Number(!b.children) || a.name.localeCompare(b.name, 'ja'));
for (const n of nodes) if (n.children) sortTree(n.children);
}
フォルダとファイルが混ざっていると探しにくいので、sortTree でフォルダを先に、同じ種類どうしは名前の順に並べています。4つのパスを渡してみます。
buildTree(['src/components/Header.tsx', 'src/App.tsx', 'package.json', 'public/favicon.svg']);
結果を、フォルダの名前のあとに / を付けて、字下げした木の形で書くと次のようになります。
public/
favicon.svg
src/
components/
Header.tsx
App.tsx
package.json
package.json は public/favicon.svg より前に渡しましたが、フォルダの public と src のあとに並びました。
それぞれの id は、先頭からのパスです。たとえば Header.tsx の id は src/components/Header.tsx になります。
TreeView の全体
ツリーを表示する TreeView のコードです。nodes に buildTree の結果を、label にツリーの名前を渡します。項目を選んだときは onSelect が呼ばれます。
import { useRef, useState, type FocusEvent, type KeyboardEvent } from 'react';
export type TreeNode = {
id: string;
name: string;
children?: TreeNode[]; // あればフォルダ
};
type Props = {
nodes: TreeNode[];
label: string;
onSelect?: (node: TreeNode) => void;
};
type Visible = { node: TreeNode; parentId: string | null };
// 開いているフォルダの中だけをたどって、画面に見えている順に並べる
function listVisible(nodes: TreeNode[], expanded: Set<string>, parentId: string | null = null): Visible[] {
return nodes.flatMap((node) => [
{ node, parentId },
...(node.children && expanded.has(node.id) ? listVisible(node.children, expanded, node.id) : []),
]);
}
export function TreeView({ nodes, label, onSelect }: Props) {
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const [selectedId, setSelectedId] = useState<string | null>(null);
// Tab キーで止まる項目(最初は先頭)
const [focusedId, setFocusedId] = useState<string | null>(nodes[0]?.id ?? null);
const itemRefs = useRef(new Map<string, HTMLLIElement>());
const toggle = (id: string, open?: boolean) => {
setExpanded((prev) => {
const next = new Set(prev);
if (open ?? !next.has(id)) next.add(id);
else next.delete(id);
return next;
});
};
const moveFocus = (id: string) => {
setFocusedId(id);
itemRefs.current.get(id)?.focus();
};
const select = (node: TreeNode) => {
setSelectedId(node.id);
onSelect?.(node);
};
const handleKeyDown = (e: KeyboardEvent<HTMLUListElement>) => {
const visible = listVisible(nodes, expanded);
const index = visible.findIndex((v) => v.node.id === focusedId);
if (index < 0) return;
const { node, parentId } = visible[index];
const isOpen = expanded.has(node.id);
switch (e.key) {
case 'ArrowDown':
if (index < visible.length - 1) moveFocus(visible[index + 1].node.id);
break;
case 'ArrowUp':
if (index > 0) moveFocus(visible[index - 1].node.id);
break;
case 'ArrowRight':
if (node.children && !isOpen) toggle(node.id, true);
else if (node.children?.length) moveFocus(node.children[0].id);
break;
case 'ArrowLeft':
if (node.children && isOpen) toggle(node.id, false);
else if (parentId) moveFocus(parentId);
break;
case 'Home':
moveFocus(visible[0].node.id);
break;
case 'End':
moveFocus(visible[visible.length - 1].node.id);
break;
case 'Enter':
select(node);
if (node.children) toggle(node.id);
break;
default:
return; // ほかのキーは、ブラウザの動きのままにする
}
e.preventDefault();
};
// ツリーの外にフォーカスが出たら、次に Tab キーで入ったときは、選んだ項目に戻す
const handleBlur = (e: FocusEvent<HTMLUListElement>) => {
if (e.currentTarget.contains(e.relatedTarget)) return; // ツリーの中で移っただけ
const visible = listVisible(nodes, expanded);
if (visible.some((v) => v.node.id === selectedId)) setFocusedId(selectedId);
};
const renderNodes = (list: TreeNode[], level: number) =>
list.map((node) => {
const isFolder = node.children !== undefined;
const isOpen = expanded.has(node.id);
return (
<li
key={node.id}
role="treeitem"
aria-expanded={isFolder ? isOpen : undefined}
aria-selected={selectedId === node.id}
tabIndex={focusedId === node.id ? 0 : -1}
ref={(el) => {
if (el) itemRefs.current.set(node.id, el);
return () => {
itemRefs.current.delete(node.id);
};
}}
>
{/* li はフォルダの中身まで含むので、クリックは 1 行ぶんの div で受ける */}
<div
className="tree-row"
style={{ paddingLeft: level * 20 }}
onClick={() => {
moveFocus(node.id);
select(node);
if (isFolder) toggle(node.id);
}}
>
<span className={isFolder ? (isOpen ? 'chevron open' : 'chevron') : 'chevron none'} aria-hidden="true">
▶
</span>
<span className={isFolder ? 'icon folder' : 'icon file'} aria-hidden="true" />
<span>{node.name}</span>
</div>
{isFolder && isOpen && <ul role="group">{renderNodes(node.children!, level + 1)}</ul>}
</li>
);
});
return (
<ul role="tree" aria-label={label} className="tree" onKeyDown={handleKeyDown} onBlur={handleBlur}>
{renderNodes(nodes, 0)}
</ul>
);
}
使う側では、buildTree で作ったデータを渡し、選んだ項目の id を表示しています。最後の、比べるための版と切り替えて createRoot(...).render(...) で描画する行は省いています。
import { useState } from 'react';
import { createRoot } from 'react-dom/client';
import { TreeView } from './TreeView';
import { buildTree } from './buildTree';
const paths = [
'src/components/Header.tsx',
'src/components/TreeView.tsx',
'src/App.tsx',
'src/main.tsx',
'public/favicon.svg',
'package.json',
'README.md',
];
const tree = buildTree(paths);
function App() {
const [selected, setSelected] = useState('');
return (
<>
<TreeView nodes={tree} label="プロジェクトのファイル" onSelect={(node) => setSelected(node.id)} />
<p id="selected">選んだ項目:{selected || 'なし'}</p>
</>
);
}
ツリーの描画は、renderNodes が受け持ちます。項目ごとに li を作り、フォルダが開いていれば、その中に <ul role="group"> を置き、子の配列で renderNodes をもう一度呼びます。階層の深さは引数の level で数え、左の余白(paddingLeft)を1段ごとに20pxずつ増やしています。
子は、フォルダが開いているときだけ描画します。検証環境で試したところ、最初の状態で DOM にある項目は、いちばん上の4つだけでした。src を開くと7つ、その中の components を開くと9つに増えました。閉じているフォルダの中身は作られないので、項目が多いツリーでも、最初に描画する量は見えている分で済みます。
開いているフォルダは、ツリー全体で1つの Set に持つ
開いているフォルダは、TreeView の expanded という state に、フォルダの id の Set として持たせています。toggle では、元の Set を書き換えずに、new Set(prev) で写しを作ってから足し引きします。React の useState のリファレンスにあるとおり、state のオブジェクトはそのまま書き換えずに、新しいものに置き換えるためです。
開閉の state を、フォルダごとのコンポーネントに持たせる書き方もできます。比べるために、次のような版も作りました。
// 比べるための版:開閉の state を、フォルダごとのコンポーネントに持たせる
function Item({ node, level }: { node: TreeNode; level: number }) {
const [open, setOpen] = useState(false);
return (
<li role="treeitem" aria-expanded={node.children ? open : undefined} aria-selected={false}>
<div className="tree-row" style={{ paddingLeft: level * 20 }} onClick={() => setOpen(!open)}>
{node.name}
</div>
{node.children && open && (
<ul role="group">
{node.children.map((child) => (
<Item key={child.id} node={child} level={level + 1} />
))}
</ul>
)}
</li>
);
}
この版で src と components を開き、src を閉じてからもう一度開くと、components は閉じた状態に戻っていました。TreeView で同じ操作をすると、components は開いたままでした(上の GIF の4回目のクリック)。
| 操作 | フォルダごとに state を持つ版 | TreeView |
|---|---|---|
src と components を開く |
src、components が開いている |
src、components が開いている |
src を閉じて、もう一度開く |
src だけが開いている |
src、components が開いている |
フォルダごとに state を持つ版では、src を閉じると、中の Item が描画されなくなります。React の公式ドキュメントにある、state の保持とリセットの説明のとおり、描画されなくなったコンポーネントの state は捨てられます。components の open も、そのときに消えます。TreeView では expanded をツリー全体の state として持っているので、中身を描画しない間も、どのフォルダが開いていたかが残ります。
すべて開く・閉じるボタンや、選んだファイルのフォルダを開いておく処理を足すときも、expanded を1か所で持っていれば、Set を作り直すだけで済みます(この記事では作っていません)。
クリックは、li ではなく1行ぶんの div で受ける
フォルダの li は、開いているときは中の ul ごと囲みます。src を開いた状態で大きさを調べると、src の行(.tree-row)の高さは32pxでしたが、li は128pxありました。
src の li: y=48 高さ=128
src の行(.tree-row): y=48 高さ=32
React の公式ドキュメントにある、イベントの伝播の説明のとおり、子の要素で起きたクリックは、親の要素のハンドラーにも伝わります。このため、クリックを li で受けると、子の項目をクリックしたときにも、親のフォルダの処理まで動いてしまいます。TreeView では、onClick を1行ぶんの div に付けています。試しに、開いた src の li の真ん中をクリックすると、そこには子の App.tsx の行があり、選ばれたのは src/App.tsx でした。親の src は開いたままです。
見た目の枠と色も、同じ理由で li ではなく .tree-row に付けています。付けているのは、選んだ項目の背景と、キーボードで移ったときの枠です。li には、ブラウザが付ける既定のフォーカスの枠が出ないようにしています。消さないと、枠は li の大きさ(上の128px)に付くので、開いたフォルダでは中身まで囲まれます。
.tree li:focus {
outline: none;
}
/* li はフォルダの中身ごと囲むので、枠と色は 1 行ぶんの .tree-row に付ける */
[role="treeitem"][aria-selected="true"] > .tree-row {
background: #d4f3fa;
}
[role="treeitem"]:focus-visible > .tree-row {
outline: 2px solid #067a98;
outline-offset: -2px;
}
role="tree" と aria 属性で、ツリーだと伝える
見た目がツリーになっていても、ul と li のままでは、ただの入れ子のリストとして扱われます。W3C の ARIA Authoring Practices Guide にあるツリービューのパターンに沿って、次の属性を付けています。
- いちばん外の
ulにrole="tree"と、ツリーの名前のaria-label - 項目の
liにrole="treeitem" - フォルダの中身を囲む
ulにrole="group" - フォルダにだけ
aria-expanded(開いていればtrue、閉じていればfalse) - 選べる項目に
aria-selected
APG のパターンでは、ファイルのような子を持たない項目には aria-expanded を付けないことになっています。付けると、子を持つ項目として伝わってしまうためです。TreeView では、isFolder のときだけ値を渡し、ファイルでは undefined にして属性を出していません。
src と components を開いて components を選んだ状態で、Playwright の ariaSnapshot() で書き出した結果です。
- tree "プロジェクトのファイル":
- treeitem "public"
- treeitem "src" [expanded]:
- text: src
- group:
- treeitem "components" [expanded] [selected]:
- text: components
- group:
- treeitem "Header.tsx"
- treeitem "TreeView.tsx"
- treeitem "App.tsx"
- treeitem "main.tsx"
- treeitem "package.json"
- treeitem "README.md"
フォルダは [expanded]、選んだ components は [selected] になり、子の項目は group の中に入りました。
同じ状態で、Chromium 自身が持つアクセシビリティツリーも、CDP の Accessibility.getFullAXTree で読み出しました。tree、treeitem、group の部分を抜き出したものです(行の先頭の Chromium: は省いています)。
tree "プロジェクトのファイル"
treeitem "public" level=1 expanded=false selected=false
treeitem "src" level=1 expanded=true selected=false
treeitem "package.json" level=1 selected=false
treeitem "README.md" level=1 selected=false
group ""
treeitem "components" level=2 expanded=true selected=true
treeitem "App.tsx" level=2 selected=false
treeitem "main.tsx" level=2 selected=false
group ""
treeitem "Header.tsx" level=3 selected=false
treeitem "TreeView.tsx" level=3 selected=false
li の中には子の項目の文字も入っていますが、src の名前は "src" だけで、子の名前は含まれていませんでした。階層の深さ(level)は、aria-level を付けていなくても、group の入れ子から1〜3と数えられています。
矢印キーで、見えている項目を移る
キーボードの操作も、おおむね APG のパターンに合わせています(違う点は、後の節で書きます)。ツリーの中で Tab キーが止まるのは1か所だけで、項目の間は矢印キーで移ります。APG のキーボード操作の解説にある roving tabindex という書き方で、tabIndex を、Tab キーで止まる項目(focusedId)だけ 0 にし、ほかを -1 にしています。検証で操作のたびに数えたところ、tabindex="0" の項目はいつも1つでした。
上下の矢印キーで移る先は、いま画面に見えている項目の順です。listVisible で、開いているフォルダの中だけをたどって並べ、その中の前後に移ります。移るときは、itemRefs に覚えておいた li の focus() を呼びます。li は項目の数だけあるので、React の公式ドキュメントにある、ref のコールバックでリストの ref を管理する方法にならい、id をキーにした Map に入れています。
検証環境で、何も開いていない状態から、表の上から順にキーを押した結果です。
| 押したキー | フォーカスのある項目 | 開いているフォルダ | 選んだ項目 |
|---|---|---|---|
| Tab | public | なし | なし |
| ↓ | src | なし | なし |
| → | src | src | なし |
| → | components | src | なし |
| → | components | src、components | なし |
| ↓ | Header.tsx | src、components | なし |
| ← | components | src、components | なし |
| ← | components | src | なし |
| ← | src | src | なし |
| End | README.md | src | なし |
| Home | public | src | なし |
| Enter | public | public、src | public |
| ↓ | favicon.svg | public、src | public |
| ↑ | public | public、src | public |
| ↑(いちばん上でさらに上) | public | public、src | public |
右矢印キーは、閉じたフォルダでは開くだけで、フォーカスは動きません。開いたフォルダで押すと、最初の子に移ります。左矢印キーはその逆で、開いたフォルダでは閉じ、ファイルや閉じたフォルダでは親のフォルダに移ります。End キーでは、閉じている public の中は飛ばして、見えている最後の README.md に移りました。
矢印キーで移るだけでは、項目は選ばれません。選ぶのは Enter キーかクリックです。APG のパターンでも、フォーカスと選択は別のものとして扱い、見た目でも区別するように書かれています。TreeView では、選んだ項目を背景の色で、フォーカスのある項目を枠で見せています。検証環境では、矢印キーで移った favicon.svg の行に枠が出て、選んだ public の行は背景の色だけになりました。枠は :focus-visible に付けているので、検証環境の Chromium では、クリックで選んだ README.md の行には出ませんでした。
APG のパターンには、名前の先頭の文字を打ってその項目に移る操作(type-ahead)や、複数の項目を選ぶ操作もあります。TreeView では作っておらず、試していません。フォーカスの移動やスクロールに合わせて項目を読み込み、DOM にすべての項目がそろっていないツリーでは、APG のパターンは aria-level、aria-setsize、aria-posinset を付けるとしています。この記事の作りは、データをすべて手元に持っている場合だけです。
Tab キーで戻ったときは、選んだ項目にフォーカスする
APG のパターンでは、1つだけ選べるツリーにフォーカスが入ったとき、選んだ項目があればその項目に、なければ先頭の項目にフォーカスを置くとしています。TreeView では、handleBlur でこれに合わせています。フォーカスがツリーの外に出たとき(relatedTarget がツリーの中にないとき)に、選んだ項目が見えていれば、focusedId をその項目に戻します。
ツリーの後ろにボタンを置いて試しました。public を選び、favicon.svg にフォーカスがある状態で Tab キーを押すと、ボタンに移りました。そこで Shift+Tab キーを押すと、フォーカスは選んだ public に戻りました。
何も選んでいないときは、先頭ではなく、最後にフォーカスがあった項目に戻します。package.json まで矢印キーで移ってから Tab キーで出て、Shift+Tab キーで戻ると、package.json に戻りました。これは APG のパターンとは違う、筆者の決めた動きです。選んだ項目が閉じたフォルダの中にあるときも、最後にいた項目のままにします(この場合は試していません)。ツリーに初めて入るときは、focusedId の初めの値が先頭の項目なので、APG のとおり先頭にフォーカスが移ります。
ツリーの中では focusedId を矢印キーに合わせて動かし、外に出たときに、選んだ項目へ戻しておきます。tabIndex が 0 の項目は1つだけなので、ツリーの項目がいくつあっても、Tab キーを1回押せばツリーを通り過ぎます。





