Tree View は、ファイルシステムや商品カテゴリのような階層構造を表示するための UI です。親ノードを展開すると子ノードが現れ、折りたたむと再び隠れます。見た目だけであれば、入れ子にした <ul> と開閉処理で簡単に作れそうに見えます。

しかし、見た目だけをツリー構造にしても支援技術を使うユーザーはそのことを理解できず、適切な操作が妨げられる要因となってしまいます。ウェブアクセシビリティの観点では、WAI-ARIA 仕様に沿ってロールや aria- 属性で UI の意味と状態を伝えたうえで、APG のパターンを参考にキーボード操作を実装する必要があります。例えば button ロールを付けた要素は、Enter または Space キーで押せることをユーザーに約束する、といった具合です。
この記事では WAI-ARIA Authoring Practices Guide(APG)の Tree View パターンを参考に、以下の機能を持つ単一選択の Tree View を React と TypeScript で実装します。
tree、treeitem、groupロールによる階層構造aria-expandedによる開閉状態aria-selectedによる選択状態- roving
tabindexによるフォーカス管理 - 上下左右、Home、End キーによる移動
- Enter または Space キーによる選択
- 文字入力による項目の検索
- マウス操作とキーボード操作で一貫したフォーカス管理
APG のパターンやサンプルは、本番環境であらゆるブラウザと支援技術の組み合わせに対応することを保証するものではありません。対象とする環境で必ずテストしてください。
No ARIA is better than Bad ARIA という原則
APG の Read Me Firstには「No ARIA is better than Bad ARIA」という原則が示されています。これは不正確な ARIA を付けるくらいなら、付けないほうがましという意味です。
たとえば role="button" を指定しても、ブラウザは Enter や Space キーの処理を追加しません。これは開発者の責任により実装する必要があるのです。これは「ロールとは約束である」という原則で示されています。支援技術によりボタンと読み上げられているのにも関わらず、Enter や Space キーで押せない場合、ユーザーの期待を裏切ることになってしまいます。
同様に role="tree" を指定する場合は、APG の Tree View パターンに示されたキーボード操作を実装する必要があります。さもなければ、ユーザーは期待通りに操作できず、混乱してしまいます。
つまり、見た目が階層的であるという理由だけで tree ロールを付けるべきではありません。例えば情報を上から順に読めればよい場合は、通常の入れ子リストを使用するのが良いでしょう。
Tree View はウェブアプリケーションよりもネイティブアプリケーションに似たナビゲーションを使用します。このため、Tree View を作成する前に、本当に必要かどうかを検討してください。
通常の <ul> と <li> はブラウザがリストとして扱います。Tree View の要件を実装できない場合、不完全な tree ロールを追加するよりも、ネイティブなリストのセマンティクスを維持するほうが安全です。
Tree View の構造と状態を設計する
はじめに、支援技術へ伝える構造を確認しましょう。
<ul role="tree" aria-label="プロジェクトファイル">
<li role="treeitem" aria-expanded="true" aria-selected="false">
src
<ul role="group">
<li role="treeitem" aria-selected="false">App.tsx</li>
<li role="treeitem" aria-selected="false">main.tsx</li>
</ul>
</li>
<li role="treeitem" aria-selected="false">package.json</li>
</ul>ルートの <ul> には role="tree" を指定し、aria-label または aria-labelledby で目的がわかる名前を付けます。各ノードには role="treeitem"、子ノードのまとまりには role="group" を指定します。group は親の treeitem の中に配置します。
group ロールは、関連する要素のまとまりを表します。Tree View ではこの treeitem に属する子項目をまとめるために使います。ファイルシステムの例で言えばディレクトリの中にあるファイルの集合を表す場合がわかりやすいですね。
親ノードだけに aria-expanded を指定する
子を持つ親ノードには、開いていれば aria-expanded="true"、閉じていれば aria-expanded="false" を指定します。子を持たない末端ノードには、この属性を指定しません。
aria-expanded={hasChildren ? isExpanded : undefined}末端ノードに aria-expanded="false" を付けると、現在は閉じているものの展開可能なノードであると誤って伝わってしまうので注意してください。開閉可能でないノードには、aria-expanded を付けないことが正しい実装です。
階層と位置を明示する
treeitem と group が正しく入れ子になっていれば、ブラウザは DOM の親子関係から以下の値を計算し、支援技術へ公開します。そのため、すべてのノードが DOM に存在する Tree View では、通常これらの属性を省略できます。
aria-level:Tree View 内の階層。ルートノードは1aria-posinset:同じ親を持つノードの中での位置。1から始まるaria-setsize:同じ親を持つノードの総数
一方、APG の computed properties サンプルでは、ブラウザと支援技術の組み合わせによって値が正しく計算・報告されない可能性が説明されています。正しい値を管理できる場合は、互換性への配慮として明示しておくと安全です。今回の実装では 3 つの属性を明示します。
ただし、DOM や実際のデータと異なる値を指定すると、支援技術へ誤った情報を伝えてしまいます。値を正しく管理できない場合は、不正確な属性を追加せず、ブラウザの計算に任せるほうが安全です。
仮想スクロールや遅延読み込みにより一部のノードだけを DOM に置く場合は、実際のデータ全体における位置と総数を指定する必要があります。
フォーカスと選択を分ける
Tree View では、フォーカスと選択は異なる状態です。
- フォーカス:現在キーボード操作の対象となる項目
- 選択:ファイルを開くなど、アプリケーションが処理の対象として選んだ項目
APG のキーボードインターフェイスのガイドでは、DOM フォーカスは
document.activeElement、選択は aria-selected="true" で表されると説明されています。
今回の実装では、矢印キーはフォーカスだけを移動します。Enter または Space キーを押したときに選択を変更します。フォーカス移動だけでファイルが開かれるような副作用が発生しないため、ユーザーは内容を変更せずにツリーを探索できます。
roving tabindex でフォーカスを管理する
すべての treeitem を tabindex="0" にすると、Tree View を抜けるまで項目の数だけ Tab キーを押さなければなりません。複合ウィジェットでは、ページのタブ順に含める項目を 1 つだけにします。これは roving
tabindex と呼ばれる手法です。同様の手法はタブパネルやメニューでも使われています。
roving tabindex は、以下の手順でフォーカスを管理します。
- 現在の項目だけを
tabindex="0"にする - ほかの項目を
tabindex="-1"にする - 移動先へ
HTMLElement.focus()で DOM フォーカスを移す - 移動先を
tabindex="0"に更新する
tabindex="-1" の要素は Tab キーの移動先にはなりませんが、JavaScript の focus() ではフォーカスできます。
実装では、各 treeitem の DOM 要素を Map に保存します。
const [focusedId, setFocusedId] = useState(items[0]?.id ?? "");
const itemRefs = useRef(new Map<string, HTMLLIElement>());
function focusNode(id: string) {
itemRefs.current.get(id)?.focus();
}レンダリング時には、focusedId と一致する項目だけをタブ順に含めます。
<li
ref={(element) => {
if (element) itemRefs.current.set(node.id, element);
else itemRefs.current.delete(node.id);
}}
role="treeitem"
tabIndex={focusedId === node.id ? 0 : -1}
data-tree-id={node.id}
onFocus={(event) => {
if (event.target === event.currentTarget) {
setFocusedId(node.id);
}
}}
>表示中のノードだけを移動対象にする
上下キーが移動する対象は、データ内のすべてのノードではありません。現在画面に表示されているノードです。閉じた親ノードの子孫は移動先に含めません。
木構造のまま「次のノード」や「前のノード」を探すと、親子と兄弟をまたぐ条件分岐が複雑になります。そこで、展開状態に応じて表示中のノードを深さ優先で並べた配列を作ります。
type VisibleNode = {
node: TreeNode;
parentId: string | null;
};
function flattenVisibleNodes(
nodes: TreeNode[],
expandedIds: Set<string>,
parentId: string | null = null,
): VisibleNode[] {
const result: VisibleNode[] = [];
for (const node of nodes) {
result.push({ node, parentId });
if (node.children?.length && expandedIds.has(node.id)) {
result.push(...flattenVisibleNodes(node.children, expandedIds, node.id));
}
}
return result;
}parentId は、左矢印キーで親へ移動するときに使用します。コンポーネント内では useMemo() を使い、データまたは展開状態が変わったときに再計算します。
const visibleNodes = useMemo(
() => flattenVisibleNodes(items, expandedIds),
[items, expandedIds],
);APG に沿ったキーボード操作を実装する
縦方向の Tree View では、APG で以下の主要なキー操作が示されています。
| キー | 動作 |
|---|---|
| ↓ | 開閉状態を変えず、次に表示されているノードへ移動 |
| ↑ | 開閉状態を変えず、前に表示されているノードへ移動 |
| → | 閉じた親なら開く。開いた親なら最初の子へ移動 |
| ← | 開いた親なら閉じる。閉じた親または末端ノードなら親へ移動 |
| Home | 最初に表示されているノードへ移動 |
| End | 最後に表示されているノードへ移動 |
| Enter / Space | フォーカス中のノードを選択 |
| 文字入力 | 入力文字列から始まる次のノードへ移動 |
文字検索はすべての Tree View で推奨されており、とくにルートノードが 7 個を超える場合に重要です。APG のパターン本体は Enter を既定アクションとして示していますが、ファイル選択の公式サンプルは Enter と Space の両方でノードを選択します。今回もこの操作に合わせます。
このほかに APG では、フォーカス中のノードと同じ階層の兄弟をすべて展開する * キーが optional として示されています。今回の実装では扱いません。
キーイベントはルートの tree でまとめて受け取ります。現在の treeitem を特定し、visibleNodes 上の位置から移動先を決めます。
function handleKeyDown(event: KeyboardEvent<HTMLUListElement>) {
// ブラウザや支援技術のショートカットを妨げない。
if (event.altKey || event.ctrlKey || event.metaKey) return;
const target = (event.target as HTMLElement).closest<HTMLElement>(
'[role="treeitem"]',
);
const id = target?.dataset.treeId;
if (!id) return;
const index = visibleNodes.findIndex(({ node }) => node.id === id);
const current = visibleNodes[index];
if (!current) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
if (visibleNodes[index + 1]) {
focusNode(visibleNodes[index + 1].node.id);
}
return;
case "ArrowUp":
event.preventDefault();
if (visibleNodes[index - 1]) {
focusNode(visibleNodes[index - 1].node.id);
}
return;
case "ArrowRight":
event.preventDefault();
if (!current.node.children?.length) return;
if (!expandedIds.has(current.node.id)) {
toggle(current.node);
} else {
focusNode(current.node.children[0].id);
}
return;
case "ArrowLeft":
event.preventDefault();
if (
current.node.children?.length &&
expandedIds.has(current.node.id)
) {
toggle(current.node);
} else if (current.parentId) {
focusNode(current.parentId);
}
return;
case "Home":
event.preventDefault();
if (visibleNodes[0]) focusNode(visibleNodes[0].node.id);
return;
case "End":
event.preventDefault();
if (visibleNodes.at(-1)) focusNode(visibleNodes.at(-1)!.node.id);
return;
case "Enter":
case " ":
event.preventDefault();
selectNode(current.node);
return;
}
}上下キーや Home、End キーでは、ブラウザのスクロールを防ぐため preventDefault() を呼びます。ただし、Alt、Ctrl、Meta キーとの組み合わせはブラウザや支援技術のショートカットとして使われる可能性があるため、Tree View では処理しません。右キーで閉じた親を開く場合はフォーカスを動かしません。すでに開いている場合だけ最初の子へ移動します。左キーも同様に、開いた親ではその場で閉じ、末端ノードまたは閉じた親では親へ移動します。
文字入力による検索を追加する
文字検索では、現在のノードの次から検索し、末尾まで一致しなければ先頭へ戻ります。短時間に入力された複数文字は 1 つの検索文字列として扱います。
const searchText = useRef("");
const searchTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
function handleTypeAhead(key: string, currentIndex: number) {
clearTimeout(searchTimer.current);
searchText.current += key.toLocaleLowerCase("ja-JP");
const candidates = [
...visibleNodes.slice(currentIndex + 1),
...visibleNodes.slice(0, currentIndex + 1),
];
const match = candidates.find(({ node }) =>
node.label.toLocaleLowerCase("ja-JP").startsWith(searchText.current),
);
if (match) focusNode(match.node.id);
searchTimer.current = setTimeout(() => {
searchText.current = "";
}, 500);
}修飾キーとの組み合わせを検索文字として扱わないよう、default 節で条件を確認します。
if (
event.key.length === 1 &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey
) {
handleTypeAhead(event.key, index);
}検索対象にも visibleNodes を使うため、閉じたノードの子孫へフォーカスが移ることはありません。
再帰コンポーネントで ARIA 属性を出力する
Tree View が受け取るデータと Props は以下の形にします。すべてのノードの id は Tree View 内で一意である必要があります。
export type TreeNode = {
id: string;
label: string;
children?: TreeNode[];
};
type TreeViewProps = {
items: TreeNode[];
label: string;
defaultExpandedIds?: string[];
onSelect?: (node: TreeNode) => void;
};各 treeitem には aria-labelledby でラベルを結び付けるため、あらかじめ一意な ID の材料を用意します。useId() で Tree View ごとの接頭辞を作り、全ノードへ通し番号を振っておきます。
function collectNodeIds(nodes: TreeNode[]): string[] {
return nodes.flatMap((node) => [
node.id,
...(node.children ? collectNodeIds(node.children) : []),
]);
}const reactId = useId();
// 折りたたみでノードが DOM から消えても番号が変わらないよう、
// 展開状態ではなくデータ全体から採番する
const nodeIndexes = useMemo(
() => new Map(collectNodeIds(items).map((id, index) => [id, index])),
[items],
);ノードを再帰的に描画し、現在の深さと兄弟内の位置を ARIA 属性へ渡します。
function renderNodes(nodes: TreeNode[], level: number) {
return nodes.map((node, index) => {
const hasChildren = Boolean(node.children?.length);
const isExpanded = hasChildren && expandedIds.has(node.id);
const labelId = `${reactId}-tree-label-${nodeIndexes.get(node.id)}`;
return (
<li
key={node.id}
ref={(element) => {
if (element) itemRefs.current.set(node.id, element);
else itemRefs.current.delete(node.id);
}}
role="treeitem"
aria-labelledby={labelId}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-selected={selectedId === node.id}
aria-level={level}
aria-posinset={index + 1}
aria-setsize={nodes.length}
tabIndex={focusedId === node.id ? 0 : -1}
data-tree-id={node.id}
onFocus={(event) => {
// React の focus イベントはバブルするため、祖先の treeitem では更新しない。
if (event.target === event.currentTarget) {
setFocusedId(node.id);
}
}}
>
<div className="tree-row">
<span className="tree-expander" aria-hidden="true">
{hasChildren ? (isExpanded ? "▾" : "▸") : null}
</span>
<span id={labelId}>{node.label}</span>
</div>
{isExpanded && node.children ? (
<ul role="group">{renderNodes(node.children, level + 1)}</ul>
) : null}
</li>
);
});
}aria-labelledby を各 treeitem に指定し、子孫のラベルまで親ノードの名前へ含まれないようにしています。ID には先ほどの接頭辞と通し番号を使うため、同じページに Tree View を複数置いてもラベルの ID が衝突しません。
展開アイコンは装飾として aria-hidden="true" にします。キーボードでは treeitem にフォーカスしたまま左右キーで開閉するため、アイコンを独立したタブストップにはしません。ポインター操作用の開閉処理では、対象の treeitem へフォーカスを移してから展開状態を更新します。
フォーカスと選択を視覚的に区別する
フォーカスと選択は異なる状態なので、CSS で見た目を区別する必要があります。
[role="treeitem"][aria-selected="true"] > .tree-row {
background: #dbeafe;
}
[role="treeitem"]:focus > .tree-row {
outline: 2px solid #1d4ed8;
outline-offset: -2px;
}選択状態は背景色、フォーカスはアウトラインで表します。色だけに依存せず形も変えるため、両者を判別しやすくなります。
Windows の強制カラーモードでは、システムカラーへ切り替えます。
@media (forced-colors: active) {
[role="treeitem"][aria-selected="true"] > .tree-row {
forced-color-adjust: none;
color: HighlightText;
background: Highlight;
}
[role="treeitem"]:focus > .tree-row {
outline-color: CanvasText;
}
}完成した Tree View の実装
これで一通りの機能が揃いました。次のように使えます。
const items: TreeNode[] = [
{
id: "src",
label: "src",
children: [
{ id: "app", label: "App.tsx" },
{ id: "main", label: "main.tsx" },
],
},
{ id: "package-json", label: "package.json" },
];
export function App() {
const [selectedNode, setSelectedNode] = useState<TreeNode | null>(null);
return (
<TreeView
items={items}
label="プロジェクトファイル"
defaultExpandedIds={["src"]}
onSelect={setSelectedNode}
/>
);
}ここまで実装した TreeView コンポーネントの全体は以下のとおりです。
src/TreeView.tsx の完全な実装
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent,
type MouseEvent,
} from "react";
export type TreeNode = {
id: string;
label: string;
children?: TreeNode[];
};
type TreeViewProps = {
items: TreeNode[];
label: string;
defaultExpandedIds?: string[];
onSelect?: (node: TreeNode) => void;
};
type VisibleNode = {
node: TreeNode;
parentId: string | null;
};
function flattenVisibleNodes(
nodes: TreeNode[],
expandedIds: Set<string>,
parentId: string | null = null,
): VisibleNode[] {
const result: VisibleNode[] = [];
for (const node of nodes) {
result.push({ node, parentId });
if (node.children?.length && expandedIds.has(node.id)) {
result.push(...flattenVisibleNodes(node.children, expandedIds, node.id));
}
}
return result;
}
function collectNodeIds(nodes: TreeNode[]): string[] {
return nodes.flatMap((node) => [
node.id,
...(node.children ? collectNodeIds(node.children) : []),
]);
}
export function TreeView({
items,
label,
defaultExpandedIds = [],
onSelect,
}: TreeViewProps) {
const [expandedIds, setExpandedIds] = useState(
() => new Set(defaultExpandedIds),
);
const [focusedId, setFocusedId] = useState(items[0]?.id ?? "");
const [selectedId, setSelectedId] = useState<string | null>(null);
const itemRefs = useRef(new Map<string, HTMLLIElement>());
const searchText = useRef("");
const searchTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
const reactId = useId();
const visibleNodes = useMemo(
() => flattenVisibleNodes(items, expandedIds),
[items, expandedIds],
);
const nodeIndexes = useMemo(
() => new Map(collectNodeIds(items).map((id, index) => [id, index])),
[items],
);
useEffect(() => {
return () => clearTimeout(searchTimer.current);
}, []);
function toggle(node: TreeNode) {
if (!node.children?.length) return;
setExpandedIds((current) => {
const next = new Set(current);
if (next.has(node.id)) {
next.delete(node.id);
} else {
next.add(node.id);
}
return next;
});
}
function focusNode(id: string) {
itemRefs.current.get(id)?.focus();
}
function selectNode(node: TreeNode) {
setSelectedId(node.id);
onSelect?.(node);
}
function handleTypeAhead(key: string, currentIndex: number) {
clearTimeout(searchTimer.current);
searchText.current += key.toLocaleLowerCase("ja-JP");
const candidates = [
...visibleNodes.slice(currentIndex + 1),
...visibleNodes.slice(0, currentIndex + 1),
];
const match = candidates.find(({ node }) =>
node.label.toLocaleLowerCase("ja-JP").startsWith(searchText.current),
);
if (match) focusNode(match.node.id);
searchTimer.current = setTimeout(() => {
searchText.current = "";
}, 500);
}
function handleKeyDown(event: KeyboardEvent<HTMLUListElement>) {
// ブラウザや支援技術のショートカットを妨げない。
if (event.altKey || event.ctrlKey || event.metaKey) return;
const target = (event.target as HTMLElement).closest<HTMLElement>(
'[role="treeitem"]',
);
const id = target?.dataset.treeId;
if (!id) return;
const index = visibleNodes.findIndex(({ node }) => node.id === id);
const current = visibleNodes[index];
if (!current) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
if (visibleNodes[index + 1]) {
focusNode(visibleNodes[index + 1].node.id);
}
return;
case "ArrowUp":
event.preventDefault();
if (visibleNodes[index - 1]) {
focusNode(visibleNodes[index - 1].node.id);
}
return;
case "ArrowRight":
event.preventDefault();
if (!current.node.children?.length) return;
if (!expandedIds.has(current.node.id)) {
toggle(current.node);
} else {
focusNode(current.node.children[0].id);
}
return;
case "ArrowLeft":
event.preventDefault();
if (
current.node.children?.length &&
expandedIds.has(current.node.id)
) {
toggle(current.node);
} else if (current.parentId) {
focusNode(current.parentId);
}
return;
case "Home":
event.preventDefault();
if (visibleNodes[0]) focusNode(visibleNodes[0].node.id);
return;
case "End":
event.preventDefault();
if (visibleNodes.at(-1)) focusNode(visibleNodes.at(-1)!.node.id);
return;
case "Enter":
case " ":
event.preventDefault();
selectNode(current.node);
return;
default:
if (
event.key.length === 1 &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey
) {
handleTypeAhead(event.key, index);
}
}
}
function renderNodes(nodes: TreeNode[], level: number) {
return nodes.map((node, index) => {
const hasChildren = Boolean(node.children?.length);
const isExpanded = hasChildren && expandedIds.has(node.id);
const labelId = `${reactId}-tree-label-${nodeIndexes.get(node.id)}`;
return (
<li
key={node.id}
ref={(element) => {
if (element) itemRefs.current.set(node.id, element);
else itemRefs.current.delete(node.id);
}}
role="treeitem"
aria-labelledby={labelId}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-selected={selectedId === node.id}
aria-level={level}
aria-posinset={index + 1}
aria-setsize={nodes.length}
tabIndex={focusedId === node.id ? 0 : -1}
data-tree-id={node.id}
onFocus={(event) => {
// React の focus イベントはバブルするため、祖先の treeitem では更新しない。
if (event.target === event.currentTarget) {
setFocusedId(node.id);
}
}}
>
<div
className="tree-row"
onClick={() => {
focusNode(node.id);
selectNode(node);
}}
>
{hasChildren ? (
<span
className="tree-expander"
aria-hidden="true"
onClick={(event: MouseEvent) => {
event.stopPropagation();
focusNode(node.id);
toggle(node);
}}
>
{isExpanded ? "▾" : "▸"}
</span>
) : (
<span className="tree-expander" aria-hidden="true" />
)}
<span id={labelId}>{node.label}</span>
</div>
{isExpanded && node.children ? (
<ul role="group">{renderNodes(node.children, level + 1)}</ul>
) : null}
</li>
);
});
}
return (
<ul className="tree" role="tree" aria-label={label} onKeyDown={handleKeyDown}>
{renderNodes(items, 1)}
</ul>
);
}VoiceOver の手動確認
実装した TreeView コンポーネントを実際に macOS の VoiceOver で操作して確認してみましょう。TreeView に移動すると「プロジェクトファイル、ひょう、何も選択されていません」と aria-label で指定したアクセシブルな名前とロールが読み上げられます。VoiceOver では Tree View の階層を「ひょう」と読み上げるようです。

何かしらのアイテムを選択している場合には、「行 2 / 5、選択中」のようにどの位置のアイテムが選択されているかも読み上げられます。

子要素を持つ親ノードを選択すると「public、字間広く、アウトライン行(2 / 3)」のように、展開状態と階層が読み上げられます(VoiceOver では aria-expanded=true の場合に「字間広く」aria-expanded=false の場合に「下位項目が折りたたまれました」と読み上げます)。

ツリー上で待機していると、操作方法も読み上げられます。これが適切なキーボード操作を実装する理由の 1 つですね。

キーボード操作も試してみましょう。上下キーでフォーカスを移動し、左右キーで開閉や親子移動ができます。Enter または Space キーで選択状態を切り替えられます。文字検索も試してみましょう。p を押すと「package.json」へ、a を押すと「App.tsx」へフォーカスが移動します。
自作せずコンポーネントライブラリを利用する
ここまで Tree View の操作モデルを理解するために一から実装しましたが、見ての通り APG の Tree View パターンに沿ったキーボード操作やフォーカス管理は複雑です。自作の保守が難しい場合は、アクセシビリティを考慮したコンポーネントライブラリを利用する方法があります。
たとえば React Aria は、固定の見た目を持たない React コンポーネントと Hooks を提供するライブラリです。固有の見た目を持たないので、アプリケーションのデザインシステムに合わせて自由にスタイルを適用できます。また React Aria はアクセシビリティの観点でも高い評価を受けています。
React Aria の Tree は、キーボードによる移動と選択、静的・動的なコレクション、制御・非制御の展開状態や選択状態に対応しています。
TreeItem の textValue は文字検索にも使用されます。公式ドキュメントでは react-aria-components を土台にした Tree と TreeItem の実装例が提供されており、CSS または Tailwind CSS のサンプルをプロジェクトへ取り込んでカスタマイズできます。
Adobe のデザインシステムと完成したスタイルを採用できる場合は、React Spectrum の TreeView も選択肢です。
まとめ
- Tree View は見た目が階層的であるだけではなく、キーボード操作やフォーカス管理などの操作モデルを備えた複合ウィジェットである
- Tree View の構造と状態を設計し、親ノードだけに
aria-expandedを指定する - 階層と位置を明示する場合は、
aria-level、aria-posinset、aria-setsizeを正しく管理する - フォーカスと選択は異なる状態であり、CSS で視覚的に区別する
- roving
tabindexでフォーカスを管理する - キーボード操作では、上下キーでフォーカス移動、左右キーで開閉や親子移動、Enter または Space キーで選択状態を切り替える。表示中のノードだけを移動対象にする
- コンポーネントの自作が難しい場合は、React Aria ようなアクセシビリティに配慮したコンポーネントライブラリを利用する
参考
Tree View Pattern | APG | WAI | W3C
Read Me First | APG | WAI | W3C
Developing a Keyboard Interface | APG | WAI | W3C
File Directory Treeview Example Using Computed Properties | APG | WAI | W3C
File Directory Treeview Example Using Declared Properties | APG | WAI | W3C
Accessible Rich Internet Applications(WAI-ARIA)1.2
Tree | React Aria
Quality | React Aria
TreeView | React Spectrum




