SCALE — Build Lab
開発パターン · REACT PATTERN

ドラッグ&ドロップ

CATEGORY開発パターン TYPEReact Pattern EFFORT180〜480分 DIFFICULTY
PRIMARY CODE
tsx
'use client';
import { useState } from 'react';

// シンプル並び替え(HTML5 Drag & Drop API・外部ライブラリ不要)
export function SortableList<T extends { id: string }>({
  items, onChange, render,
}: {
  items: T[];
  onChange: (newOrder: T[]) => void;
  render: (item: T) => React.ReactNode;
}) {
  const [dragId, setDragId] = useState<string | null>(null);
  const [overId, setOverId] = useState<string | null>(null);

  const reorder = (from: string, to: string) => {
    const fromIdx = items.findIndex((i) => i.id === from);
    const toIdx = items.findIndex((i) => i.id === to);
    if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return;
    const next = [...items];
    const [moved] = next.splice(fromIdx, 1);
    next.splice(toIdx, 0, moved);
    onChange(next);
  };

  return (
    <div>
      {items.map((it) => (
        <div key={it.id}
          draggable
          onDragStart={() => setDragId(it.id)}
          onDragOver={(e) => { e.preventDefault(); setOverId(it.id); }}
          onDragLeave={() => setOverId(null)}
          onDrop={() => { if (dragId) reorder(dragId, it.id); setDragId(null); setOverId(null); }}
          onDragEnd={() => { setDragId(null); setOverId(null); }}
          className={`cursor-move transition-all ${
            dragId === it.id ? 'opacity-40' : ''
          } ${
            overId === it.id && dragId !== it.id ? 'border-t-2 border-indigo-500' : ''
          }`}>
          {render(it)}
        </div>
      ))}
    </div>
  );
}

// ファイル D&D アップロード
export function FileDropzone({ onDrop }: { onDrop: (files: File[]) => void }) {
  const [over, setOver] = useState(false);
  return (
    <div
      onDragOver={(e) => { e.preventDefault(); setOver(true); }}
      onDragLeave={() => setOver(false)}
      onDrop={(e) => { e.preventDefault(); setOver(false); onDrop(Array.from(e.dataTransfer.files)); }}
      className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
        over ? 'border-indigo-500 bg-indigo-500/10' : 'border-zinc-700 bg-zinc-900/40'
      }`}>
      <p className="text-sm text-zinc-400">ファイルをここにドロップ</p>
    </div>
  );
}
前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • カンバン
  • タスク並び替え

ドラッグ&ドロップ

:LiTarget: 用途

カンバン・並び替え・ファイルアップロードのD&Dパターン。

:LiSparkle: 特徴

  • 並び替え
  • カンバン
  • ファイルD&D
  • タッチ対応

:LiCode: コード(コピペ用)

'use client';
import { useState } from 'react';

// シンプル並び替え(HTML5 Drag & Drop API・外部ライブラリ不要)
export function SortableList<T extends { id: string }>({
  items, onChange, render,
}: {
  items: T[];
  onChange: (newOrder: T[]) => void;
  render: (item: T) => React.ReactNode;
}) {
  const [dragId, setDragId] = useState<string | null>(null);
  const [overId, setOverId] = useState<string | null>(null);

  const reorder = (from: string, to: string) => {
    const fromIdx = items.findIndex((i) => i.id === from);
    const toIdx = items.findIndex((i) => i.id === to);
    if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return;
    const next = [...items];
    const [moved] = next.splice(fromIdx, 1);
    next.splice(toIdx, 0, moved);
    onChange(next);
  };

  return (
    <div>
      {items.map((it) => (
        <div key={it.id}
          draggable
          onDragStart={() => setDragId(it.id)}
          onDragOver={(e) => { e.preventDefault(); setOverId(it.id); }}
          onDragLeave={() => setOverId(null)}
          onDrop={() => { if (dragId) reorder(dragId, it.id); setDragId(null); setOverId(null); }}
          onDragEnd={() => { setDragId(null); setOverId(null); }}
          className={`cursor-move transition-all ${
            dragId === it.id ? 'opacity-40' : ''
          } ${
            overId === it.id && dragId !== it.id ? 'border-t-2 border-indigo-500' : ''
          }`}>
          {render(it)}
        </div>
      ))}
    </div>
  );
}

// ファイル D&D アップロード
export function FileDropzone({ onDrop }: { onDrop: (files: File[]) => void }) {
  const [over, setOver] = useState(false);
  return (
    <div
      onDragOver={(e) => { e.preventDefault(); setOver(true); }}
      onDragLeave={() => setOver(false)}
      onDrop={(e) => { e.preventDefault(); setOver(false); onDrop(Array.from(e.dataTransfer.files)); }}
      className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
        over ? 'border-indigo-500 bg-indigo-500/10' : 'border-zinc-700 bg-zinc-900/40'
      }`}>
      <p className="text-sm text-zinc-400">ファイルをここにドロップ</p>
    </div>
  );
}

:LiHandPointer: 使い方

対象プロジェクトに該当ファイルをコピーして、props を流し込むだけ。

:LiAlertCircle: 注意事項

  • 依存パッケージを忘れず追加