Reactで、ファイルをドラッグして置ける場所を作る方法

​​React​Drag and Drop API,File API,Playwright,TypeScript,アクセシビリティ

パソコンのファイルをドラッグして置ける場所は、div に onDragEnter・onDragOver・onDragLeave・onDrop の4つを付けて作れます。肝心なのは、dragover で preventDefault() を呼んで「ここに置ける」とブラウザに伝えることと、落とされたファイルを drop のときに dataTransfer.files から読むことです。

そのうえで、ドラッグ中の枠の強調、枠の外に落としたときの扱い、マウスを使わない人のためのボタンを足すと、そのまま使える部品になります。この記事では、画像と PDF だけを受け付ける DropZone コンポーネントを作り、1つずつ確かめました。

補足

検証環境は 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 を操作しました。OS のファイルを持ってくるドラッグは、実際のマウス操作ではなく、Chrome DevTools Protocol の Input.dispatchDragEvent で再現しています。Firefox と Safari、スマートフォン、フォルダーのドラッグは試していません。

点線の枠にファイルをドラッグすると枠が水色になり、離すと受け付けたファイルが一覧に並ぶ。2回目はテキストファイルが除かれる
ファイルを枠に入れると色が変わり、離すと受け付けた件数と一覧が出る動き(ドラッグは Chrome DevTools Protocol の命令で再現)

4つのイベントでそれぞれすること

OS のファイルをブラウザに持ってきたときは、dragstart と dragend は起きません。MDN のファイルのドラッグ&ドロップの解説にあるとおり、入ってきたことは dragenter と dragleave で知ります。置く場所の側で使うのは、次の4つです。

イベント いつ届くか この部品ですること
dragenter 枠や、枠の中の要素に入ったとき 入った回数を1増やし、枠を強調する
dragover 枠の上にいるあいだ、くり返し preventDefault() を呼び、置けることを伝える
dragleave 枠や、枠の中の要素から出たとき 入った回数を1減らし、0になったら強調を消す
drop 枠の上でマウスを離したとき preventDefault() を呼び、ファイルを受け取る

React では、どれも DragEvent を受け取るハンドラーとして div に渡せます。e.dataTransfer から、ドラッグしているものの情報を読みます。

DropZone コンポーネントの全体

DropZone は、受け付ける種類(accept)と、受け付けたファイルを渡す関数(onFiles)を props で受け取ります。

tsx
import { useEffect, useRef, useState, type ChangeEvent, type DragEvent } from 'react';

type Props = {
  // 受け付ける種類。'image/*' のような書き方と 'application/pdf' のような書き方が使える
  accept: string[];
  onFiles: (files: File[]) => void;
};

// ドラッグしているものにファイルが含まれるか。中身は drop まで読めないが、types は読める
function hasFiles(e: DragEvent | globalThis.DragEvent) {
  return e.dataTransfer?.types.includes('Files') ?? false;
}

function isAccepted(file: File, accept: string[]) {
  return accept.some((type) =>
    type.endsWith('/*') ? file.type.startsWith(type.slice(0, -1)) : file.type === type,
  );
}

export function DropZone({ accept, onFiles }: Props) {
  const [dragging, setDragging] = useState(false);
  const [message, setMessage] = useState('');
  // 中の要素に入るたびに dragenter と dragleave が届くので、入った回数を数える
  const depth = useRef(0);
  const inputRef = useRef<HTMLInputElement>(null);

  // 枠の外にファイルを落としたとき、ブラウザがそのファイルを開かないようにする
  useEffect(() => {
    const onDragOver = (e: globalThis.DragEvent) => {
      if (!hasFiles(e)) return;
      e.preventDefault();
      e.dataTransfer!.dropEffect = 'none';
    };
    const onDrop = (e: globalThis.DragEvent) => {
      if (hasFiles(e)) e.preventDefault();
    };
    window.addEventListener('dragover', onDragOver);
    window.addEventListener('drop', onDrop);
    return () => {
      window.removeEventListener('dragover', onDragOver);
      window.removeEventListener('drop', onDrop);
    };
  }, []);

  const receive = (list: FileList) => {
    const files = [...list];
    const accepted = files.filter((file) => isAccepted(file, accept));
    const rejected = files.length - accepted.length;
    setMessage(
      `${accepted.length}件を受け付けました` +
        (rejected > 0 ? `(対応していない種類の${rejected}件を除きました)` : ''),
    );
    if (accepted.length > 0) onFiles(accepted);
  };

  const handleDragEnter = (e: DragEvent<HTMLDivElement>) => {
    if (!hasFiles(e)) return;
    depth.current += 1;
    setDragging(true);
  };

  const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
    if (!hasFiles(e)) return;
    e.preventDefault(); // これがないと drop が届かない
    e.stopPropagation(); // window の dragover で dropEffect を none にさせない
    e.dataTransfer.dropEffect = 'copy';
  };

  const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
    if (!hasFiles(e)) return;
    depth.current -= 1;
    if (depth.current === 0) setDragging(false);
  };

  const handleDrop = (e: DragEvent<HTMLDivElement>) => {
    if (!hasFiles(e)) return;
    e.preventDefault();
    depth.current = 0;
    setDragging(false);
    receive(e.dataTransfer.files);
  };

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) receive(e.target.files);
    e.target.value = ''; // 同じファイルをもう一度選んでも change が届くようにする
  };

  return (
    <div
      className={dragging ? 'dropzone dragging' : 'dropzone'}
      onDragEnter={handleDragEnter}
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      onDrop={handleDrop}
    >
      <p>ここにファイルをドラッグ</p>
      <button type="button" onClick={() => inputRef.current?.click()}>
        ファイルを選ぶ
      </button>
      <input
        ref={inputRef}
        type="file"
        multiple
        accept={accept.join(',')}
        hidden
        onChange={handleChange}
      />
      <p role="status">{message}</p>
    </div>
  );
}

使う側は、次のように渡します。検証では、受け取ったファイルの名前・種類・大きさを下に一覧で出しました。

tsx
<DropZone accept={['image/*', 'application/pdf']} onFiles={add} />

dragover で preventDefault を呼ばないと drop が届かない

ふつうの要素は、ドラッグしたものを落とされても受け取りません。MDN のドラッグ操作の解説によれば、dragover を preventDefault() で取り消した要素だけが、落とせる場所になります。React のリファレンスの onDragOver の説明にも、落とせるようにするにはここで e.preventDefault() を呼ぶ必要があると書かれています。

試しに、onDragOver で preventDefault() を呼ばない版を作り、枠の上でファイルを落としました。drop イベントは1回も届きませんでした。

text
--- 4. dragover で preventDefault を呼ばない版 ---
drop イベントの回数: 0

drop のほうでも preventDefault() を呼びます。MDN は、drop を取り消して初めて、ドロップが成功したとみなされると説明しています。

ドラッグ中はファイルの名前が読めない

置く側で dataTransfer.files の中身を読めるのは、drop のときだけです。ドラッグ中は空のリストが返ると、MDN の DataTransfer.files の説明に書かれています。検証で cat.png と memo.pdf をまとめてドラッグし、イベントごとに読める値を出すと、そのとおりでした。

text
dragenter: types=["Files"] files.length=0 items=["file:image/png","file:application/pdf"] names=[]
dragover: types=["Files"] files.length=0 items=["file:image/png","file:application/pdf"] names=[]
drop: types=["Files"] files.length=2 items=["file:image/png","file:application/pdf"] names=["cat.png","memo.pdf"]

ドラッグ中でも、dataTransfer.types は読めます。MDN の DataTransfer.types の説明によれば、ファイルが含まれていると、その中に Files という文字列が入ります。DropZone の hasFiles はこれを見て、ファイル以外のドラッグには反応しないようにしています。ファイルではなく文字のデータをドラッグしたときを再現すると、枠は強調されず、落としても何も受け付けませんでした。

検証では dataTransfer.items の種類(image/png など)もドラッグ中に読めました。ドラッグ中から「この種類は置けません」と出したいときは、MDN のファイルのドラッグ&ドロップの例のように、items の type を見て dropEffect を none にする方法があります。この記事の部品では、種類の確認は drop のあとにまとめて行いました。

中の要素に入ると dragleave が届く

枠の中には、文字やボタンがあります。マウスが枠の中の文字からボタンへ移ると、ボタンに dragenter が届き、それまでいた要素からは dragleave が届きます。どちらも枠の div まで伝わってくるので、dragleave が来たらすぐ強調を消す書き方では、枠の中にいるのに強調が消えてしまいます。

text
--- 3. dragleave ですぐ強調を消す版 ---
枠の中の文字の上: 強調=true status="" 一覧=[] URL=http://localhost:5173/?variant=naive-leave
枠の中のボタンの上: 強調=false status="" 一覧=[] URL=http://localhost:5173/?variant=naive-leave

そこで DropZone では、dragenter で1増やし、dragleave で1減らす数(depth)を useRef に持たせました。0に戻ったときだけ、枠の外に出たと判断します。描画には使わない値なので、state ではなく ref にしています。useRef のリファレンスにあるとおり、ref.current を書き換えても再レンダリングは起きません。同じ動きを DropZone で確かめると、ボタンの上でも強調が続き、枠の外に出たときだけ消えました。

text
--- 2. DropZone の強調と受け取り ---
枠の外: 強調=false status="" 一覧=[] URL=http://localhost:5173/
枠の中の文字の上: 強調=true status="" 一覧=[] URL=http://localhost:5173/
枠の中のボタンの上: 強調=true status="" 一覧=[] URL=http://localhost:5173/
枠の外に戻る: 強調=false status="" 一覧=[] URL=http://localhost:5173/
もう一度ボタンの上: 強調=true status="" 一覧=[] URL=http://localhost:5173/
ボタンの上で落とす: 強調=false status="2件を受け付けました(対応していない種類の1件を除きました)" 一覧=["cat.png(image/png、120バイト)","memo.pdf(application/pdf、15バイト)"] URL=http://localhost:5173/

マウスを離したときは、dragleave が届きません。検証でも、ボタンの上で落とす前後で、枠の中に届いた dragleave の数は2のまま変わりませんでした。そのため handleDrop で、数を0に戻して強調を消しています。

ドラッグを途中でやめた場合について、MDN のドラッグ操作の解説は、やめたときにも dragleave は必ず届くと説明しています。この検証の方法では、やめる操作を再現できませんでした。Chrome DevTools Protocol でやめる命令(dragCancel)を送っても dragleave は届かず、強調は残りました。実際のマウス操作で Esc を押したときの動きは確かめていません。

枠の外に落としたときにブラウザがファイルを開かないようにする

MDN のファイルのドラッグ&ドロップの解説によれば、置く場所の外にファイルを落とすと、ブラウザがそのファイルを開いたりダウンロードしたりすることがあります。これを防ぐため、DropZone は useEffect で window にも dragover と drop のリスナーを付けています。ファイルのドラッグなら、dragover で preventDefault() を呼んだうえで dropEffect を none にし、「ここには置けない」と伝えます。

この環境では、window のリスナーがない版で枠の外に落としても、ページは別のページに移りませんでした。ブラウザがファイルを開く動きそのものは、ここでは確かめられていません。確かめたのは、DropZone のリスナーが働いて、枠の外の dragover が取り消され、dropEffect が none になっていることです。

text
--- 5. window で止めない版で、枠の外に落とす ---
ページの移動: なし
window に届いたイベント: ["dragover(preventDefault 済み=false、dropEffect=copy)"]
--- 6. DropZone で、枠の外に落とす ---
ページの移動: なし
window に届いたイベント: ["dragover(preventDefault 済み=true、dropEffect=none)"]

枠の中で、window のリスナーが dropEffect を none に書き換えてしまうと困ります。そこで handleDragOver では e.stopPropagation() を呼んでいます。React 17 からは、イベントを document ではなく React を描画している root の要素で受けるようになりました(React 17 のリリース候補版の告知にある、イベント委譲の変更)。e.stopPropagation() を呼ぶと、イベントはこの root の要素で止まり、その外側の window には届きません。この記事の React 19 でも、続けて枠の中に落とすと、window に dragover は届かず、drop だけが届いて、ファイルを受け付けました。

text
window に届いたイベント: ["drop(preventDefault 済み=true、dropEffect=copy)"]
枠の中に落としたあと: 強調=false status="1件を受け付けました" 一覧=["cat.png(image/png、120バイト)"] URL=http://localhost:5173/

stopPropagation() を呼ぶので、ほかに window で dragover を受けている処理があれば、枠の中ではその処理にも届かなくなります。そうした処理があるときは、MDN の例のように window のリスナーの中で「枠の中かどうか」を contains で調べる書き方に変えてください。

受け付ける種類は自分で確かめる

input の accept 属性は、ファイルを選ぶ画面で候補を絞るためのものです。MDN の input type="file" の説明には、選ばれたファイルの種類を確かめるものではない、と書かれています。accept は input の属性なので、div に落とされたファイルには働きません。そのため DropZone では、ドロップでもボタンでも、receive の中で isAccepted を通しています。

cat.png・memo.pdf・notes.txt をまとめて落とした検証では、notes.txt だけが除かれ、「2件を受け付けました(対応していない種類の1件を除きました)」と表示されました。

注意

file.type は、ファイルの中身ではなく拡張子から決まります。MDN の Blob.type の説明によれば、拡張子を .txt に変えた PNG は text/plain になり、よく知られていない拡張子では空の文字列になります。ブラウザでの確認は使う人への案内と考え、アップロードを受けるサーバーの側でも種類を確かめてください。

キーボードでも使えるようにボタンからも選べるようにする

ドラッグ&ドロップを使えない人や、使いたくない人もいます。MDN の例も、そうした人のために input からファイルを選べるようにしています。DropZone では、hidden にした <input type="file"> を置き、「ファイルを選ぶ」ボタンから inputRef.current?.click() で開くようにしました。

検証では、Tab でボタンにフォーカスを移し、Enter でファイルを選ぶ画面が開きました。multiple を付けたので、複数選べる画面です。選んだファイルは、ドロップと同じ receive に渡ります。

handleChange の最後で e.target.value = '' としているのは、同じファイルをもう一度選んだときにも change が届くようにするためです。value を空に戻さない input で同じ cat.png を2回選ぶと、change は1回しか届きませんでした。DropZone では2回とも受け付け、一覧に2つ並びました。

text
--- 10. value を空に戻さない input で、同じファイルを2回選ぶ ---
1回目に cat.png を選んだあとの change の回数: 1
2回目に cat.png を選んだあとの change の回数: 1

受け取ったファイルをサーバーに送り、進み具合を出す方法は、以前の記事「Reactでファイルをアップロードし、進み具合を表示する方法」で書きました。

ポイント

ファイルを置ける場所は、dragover で preventDefault() を呼び、drop で dataTransfer.files を読めば動きます。ドラッグ中は types に Files があるかで判断し、強調は入った回数を数えて消します。枠の外への誤ったドロップは window で止め、種類は受け取ったあとに自分で確かめます。ファイルを選ぶボタンを足しておくと、キーボードで操作する人も同じように使えます。

​Posted by 86