Reactで、フォルダーを選んで中のファイルを一覧にする方法

​​React​File API,Playwright,TypeScript

<input type="file"> に webkitdirectory 属性を付けると、ファイルではなくフォルダーを選べるようになります。選んだフォルダーの中のファイルは、サブフォルダーの分も含めて files に平らに並び、それぞれの webkitRelativePath から、フォルダーの中のどこにあったかが分かります。

React と TypeScript で、選んだフォルダーの中身をフォルダーごとに一覧にするコンポーネントを作り、Chromium で動きを確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、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 の setInputFiles にフォルダーのパスを渡して選びました。このとき Playwright はパスを Chromium に渡すだけで、フォルダーの中を読むのは Chromium です。ダイアログで人が選ぶ操作、Firefox と Safari、スマートフォンのブラウザ、Windows と macOS、本番用ビルドでは試していません。

「フォルダーを選ぶ」の下の「Choose File」のボタンの横に「7 files」と出ている。その下に「5 個のファイル(合計 364.8 KB)、ほかに隠しファイル 2 個」とあり、「cat-album/」「cat-album/2026/夏/」「cat-album/2026/春/」の見出しごとに、ファイル名と大きさが並んでいる
検証用のフォルダーを選んだときの表示(表示の幅375px)

webkitdirectory を TypeScript の JSX に書く

@types/react 19.3.0 の型には webkitdirectory がありません。そのまま <input type="file" webkitdirectory="" /> と書くと、tsc が次のエラーを出しました。

text
error TS2322: Type '{ type: "file"; webkitdirectory: string; onChange: (e: ChangeEvent<HTMLInputElement, HTMLInputElement>) => void; }' is not assignable to type 'DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>'.
  Property 'webkitdirectory' does not exist on type 'DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>'.

型定義のファイルを1つ足して、React の InputHTMLAttributes に webkitdirectory を加えます。src の中に置けば、tsc がそのまま読み込みます。

ts
// react-webkitdirectory.d.ts
// @types/react の input の属性に webkitdirectory を足す
import 'react';

declare module 'react' {
  interface InputHTMLAttributes<T> {
    webkitdirectory?: string;
  }
}

型を boolean ではなく string にしたのは、true を渡すと React が属性を付けなかったためです。webkitdirectory="" と書いた input には、DOM にも webkitdirectory="" が付き、input.webkitdirectory は true になりました。webkitdirectory={true} に当たる値を渡すと、開発用ビルドでは次の警告が出て、属性は DOM に付かず、input.webkitdirectory は false のままでした。

text
Received `true` for a non-boolean attribute `webkitdirectory`.

React は、webkitdirectory を true / false を受け取る属性として扱っていません。空の文字列を渡せば、属性が付いて input.webkitdirectory が true になります。

MDN の互換性データによると、対応しているブラウザは、パソコン向けの Chrome、Edge、Firefox、Safari のほか、Android 版の Chrome 132 以降、Android 版の Firefox 142 以降、iOS の Safari 18.4 以降です。

選んだファイルのパスは webkitRelativePath で分かる

検証用に、次のフォルダーを作りました。. で始まるファイルとフォルダー、空のフォルダー、シンボリックリンクを入れてあります。

text
cat-album/
├── .hiddenrc
├── .thumbs/a.bin
├── 2026/夏/data.json
├── 2026/夏/hammock.png
├── 2026/春/memo.txt
├── 2026/春/桜と猫.jpg
├── README.md
├── link-to-2026 → 2026 を指すシンボリックリンク
├── link-to-readme.md → README.md を指すシンボリックリンク
└── 空のフォルダー/

cat-album を選んだときの change イベントで、files の中身を出しました。並びは files に入っていた順のままです。

webkitRelativePath name size type
cat-album/.hiddenrc .hiddenrc 7 (空)
cat-album/README.md README.md 21 text/markdown
cat-album/2026/夏/data.json data.json 8 application/json
cat-album/2026/夏/hammock.png hammock.png 250000 image/png
cat-album/2026/春/桜と猫.jpg 桜と猫.jpg 120000 image/jpeg
cat-album/2026/春/memo.txt memo.txt 3500 text/plain
cat-album/.thumbs/a.bin a.bin 2048 application/octet-stream

webkitRelativePath は、選んだフォルダーの名前から始まるパスです。MDN の webkitdirectory の説明にあるとおり、files 自体は平らな並びなので、フォルダーの階層はこのパスから組み立てます。name はパスの最後の部分だけです。

表から、次のことが分かりました。

  • . で始まるファイルも、.thumbs のように . で始まるフォルダーの中のファイルも入る
  • 空のフォルダー は、ファイルがないので、どこにも出てこない
  • シンボリックリンクの link-to-2026 と link-to-readme.md は入らない
  • files の並びは、パスの順にはなっていない

type は、ファイルの中身ではなく拡張子で決まっていました。hammock.png と 桜と猫.jpg は、中身がすべて 0 のバイトでできた、画像ではないファイルです。それでも type は image/png と image/jpeg でした。

フォルダーごとにまとめて表示する

files を配列に移してパスの順に並べ、フォルダーのパスごとにまとめる関数を、React から切り離して書きました。

ts
// fileTree.ts
export type FileRow = {
  path: string; // 選んだフォルダーからの相対パス(例:photos/2026/cat.png)
  size: number;
  type: string;
  file: File;
};

// FileList は input の値を消すと空になるので、先に配列へ移す
export function toRows(files: FileList | null): FileRow[] {
  return Array.from(files ?? [], (file) => ({
    path: file.webkitRelativePath || file.name,
    size: file.size,
    type: file.type,
    file,
  })).sort((a, b) => a.path.localeCompare(b.path, 'ja'));
}

// パスの途中に「.」で始まる名前があるもの(.git/ や .DS_Store など)
export const isHidden = (path: string) =>
  path.split('/').some((part) => part.startsWith('.'));

// フォルダーごとにまとめ、フォルダーのパス(例:photos/2026)の順に並べる
export function groupByFolder(rows: FileRow[]): [string, FileRow[]][] {
  const groups = new Map<string, FileRow[]>();
  for (const row of rows) {
    const folder = row.path.split('/').slice(0, -1).join('/');
    const list = groups.get(folder) ?? [];
    list.push(row);
    groups.set(folder, list);
  }
  return [...groups].sort(([a], [b]) => a.localeCompare(b, 'ja'));
}

export function formatBytes(n: number): string {
  if (n < 1024) return `${n} B`;
  if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
  return `${(n / 1024 / 1024).toFixed(1)} MB`;
}

toRows で FileList を配列に移しているのは、input の値を消すと、取っておいた FileList まで空になったためです。フォルダーを選んだ後に input.files を変数に取っておき、input.value = '' で値を消すと、取っておいた FileList の length も 0 になりました。先に Array.from で移した配列には、7つの File が残っていました。

webkitRelativePath が空の文字列のときは、name を使います。MDN の互換性データによると、Android 版の Firefox 141 では、フォルダーを選んでも webkitRelativePath が空の文字列になる、と書かれています。

コンポーネントは、選んだファイルを state に持ち、. で始まるものを出すかどうかをチェックボックスで切り替えます。

tsx
// FolderFileList.tsx
import { useState } from 'react';
import { toRows, isHidden, groupByFolder, formatBytes, type FileRow } from './fileTree';

export function FolderFileList() {
  const [rows, setRows] = useState<FileRow[]>([]);
  const [showHidden, setShowHidden] = useState(false);

  const visible = showHidden ? rows : rows.filter((r) => !isHidden(r.path));
  const total = visible.reduce((sum, r) => sum + r.size, 0);
  const groups = groupByFolder(visible);

  return (
    <div className="demo">
      <label className="picker">
        フォルダーを選ぶ
        <input
          type="file"
          webkitdirectory=""
          onChange={(e) => setRows(toRows(e.currentTarget.files))}
        />
      </label>
      <label>
        <input
          type="checkbox"
          checked={showHidden}
          onChange={(e) => setShowHidden(e.currentTarget.checked)}
        />
        「.」で始まるファイルも出す
      </label>
      <p className="summary">
        {visible.length} 個のファイル(合計 {formatBytes(total)})
        {rows.length > visible.length && `、ほかに隠しファイル ${rows.length - visible.length} 個`}
      </p>
      {groups.map(([folder, list]) => (
        <section key={folder}>
          <h3>{folder}/</h3>
          <ul>
            {list.map((r) => (
              <li key={r.path}>
                <span className="name">{r.file.name}</span>
                <span className="size">{formatBytes(r.size)}</span>
              </li>
            ))}
          </ul>
        </section>
      ))}
    </div>
  );
}

cat-album を選ぶと、冒頭の画像のように「5 個のファイル(合計 364.8 KB)、ほかに隠しファイル 2 個」と出ました。フォルダーの見出しは cat-album/、cat-album/2026/夏/、cat-album/2026/春/ の順です。チェックボックスをオンにすると、.hiddenrc と cat-album/.thumbs/ の a.bin が加わって「7 個のファイル(合計 366.8 KB)」になりました。

.git のような . で始まるフォルダーを一覧に出したくないときは、isHidden のような関数で絞ります。

中身のないフォルダーを選んだとき

ファイルが1つもない empty-folder を選ぶと、change イベントは起きましたが、files.length は 0 でした。画面には「0 個のファイル(合計 0 B)」と出ました。

files が空なので、webkitRelativePath から選んだフォルダーの名前を知ることはできません。「フォルダーが空でした」のような表示を出したいときは、files.length が 0 かどうかで分けます。

Playwright でフォルダーを選ぶテスト

検証では、Playwright の setInputFiles にフォルダーのパスを渡して、フォルダーを選びました。

ts
await page.locator('input[type=file]').setInputFiles('fixture/cat-album');

webkitdirectory のない input に同じようにフォルダーを渡すと、setInputFiles は次のエラーで止まりました。属性が付いていないことを、テストで見つけられます。

text
locator.setInputFiles: Error: File input does not support directories, pass individual files instead

​Posted by 86