Reactで、フォルダのような階層をツリーで表示する方法

​​React​Playwright,TypeScript,useRef,useState,WAI-ARIA,アクセシビリティ

フォルダのような階層は、子の配列を持つデータを用意し、子の配列に対しても同じ描画の関数を呼ぶと、ツリーとして表示できます。開いているフォルダは、フォルダごとの 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)で読み出して確かめています。

ファイルのツリーで、src をクリックすると中身が開き、components をクリックするとその中のファイルが出る。src をクリックして閉じ、もう一度クリックして開くと、components も開いた状態で表示される
4回のクリックで、src を閉じて開き直しても components が開いたまま戻る様子

GIF のカーソル、クリックの輪、操作の回数の表示は、撮影のために重ねたものです。クリックで変わった表示(「選んだ項目」の文字や、開いたまま戻った components の行)は、その瞬間だけピンクにしています。

階層は、子の配列を持つデータの入れ子にする

ツリーに渡すデータは、1つの項目を TreeNode という型で表します。children を持つものがフォルダ、持たないものがファイルです。id は、項目を見分けるための文字列です。React の key にも、開いているフォルダの記録にも使います。

ファイルの一覧が src/App.tsx のようなパスの配列で手元にあるときのために、パスを / で区切って入れ子のデータに組み立てる関数 buildTree を書きました。id には、先頭からその項目までのパスを使います。同じ名前のフォルダが別の場所にあっても、id は重なりません。

ts
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つのパスを渡してみます。

ts
buildTree(['src/components/Header.tsx', 'src/App.tsx', 'package.json', 'public/favicon.svg']);

結果を、フォルダの名前のあとに / を付けて、字下げした木の形で書くと次のようになります。

text
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 が呼ばれます。

tsx
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(...) で描画する行は省いています。

tsx
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 を、フォルダごとのコンポーネントに持たせる書き方もできます。比べるために、次のような版も作りました。

tsx
// 比べるための版:開閉の 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ありました。

text
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)に付くので、開いたフォルダでは中身まで囲まれます。

css
.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() で書き出した結果です。

text
- 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: は省いています)。

text
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回押せばツリーを通り過ぎます。

​Posted by 86