ファイルアップロード画面(ドラッグ&ドロップ+進捗)

応用例 中級

この画面パターンについて

書類提出・画像登録など業務アプリで頻出する「複数ファイルをまとめてアップロードする」画面のパターンです。 ドラッグ&ドロップとボタンの両対応、受付時の形式・サイズ検証、ファイルごとの進捗バーと状態バッジまで一通り実装します。 進捗は setInterval の擬似処理なので、実案件では XMLHttpRequest / fetch のアップロードイベントに差し替えるだけで使えます。

こんな場面で使えます

  • 書類・申請ファイルの提出 — PDFをまとめて添付する
  • 画像の一括登録 — 商品写真などを複数アップロードする
  • データファイルの受け渡し — 形式とサイズを検証してから受け付ける

この画面で使っているUIコンポーネント

#パーツこの画面での役割
1ドラッグ&ドロップ受付エリアドラッグ中のハイライト+ドロップ受付
2ファイル選択ボタン隠したファイル入力の起動(複数選択)
3選択ファイル一覧名前・サイズ・種類アイコンの表示
4形式・サイズバリデーションNG理由をインライン表示
5ファイルごとの進捗バー擬似進捗0→100%と%表示
6状態バッジ待機・アップロード中・完了・エラー
7個別削除+すべてクリア一覧の整理とリセット
8サマリー+完了トースト「3件中2件完了」+全件完了通知

実装のポイント・注意点

D&D最大のハマりどころは、dragovere.preventDefault() を呼ばないと drop イベント自体が発火しないことです。 また、ドロップゾーンの子要素にカーソルが乗るたびに dragleave が発火してハイライトがちらつくため、enter / leave を数えるカウンター方式で防ぎます。 ファイル形式の判定は file.type がOSや拡張子によって空になることがあるため、ファイル名の拡張子(split('.').pop())で行うのが確実です。 同じファイルを選び直せるよう、選択後に input.value = '' でリセットするのも定番の一手間です。

擬似進捗は1件ずつ順に処理する uploadNext() の連鎖で実装しており、実通信に差し替えるときは setInterval の部分を XMLHttpRequest に置き換えて xhr.upload.onprogress で進捗を更新します(差し替えポイントはコードのコメントにも明記しています)。 なお、拡張子・サイズのクライアント検証はあくまでUX目的です。セキュリティ対策としては不十分なため、実案件では必ずサーバー側でも検証してください。

8個のUIコンポーネントをHTML・CSS・バニラJavaScriptのみで組み合わせており、 フレームワーク不要で画面ごとコピペして使えます。

動作サンプル

ファイルアップロード画面(ドラッグ&ドロップ+進捗)のデモ画面 動作サンプルを別ウィンドウで確認 ↗

試してみる:

  • ファイルをドラッグしてドロップゾーンのハイライト変化を確認
  • 5MB超や対応外の形式のファイルを追加して、エラー表示と理由を確認
  • 「アップロード開始」で進捗バーが1件ずつ進む様子を確認

そのほかの操作も自由に試してみてください。

サンプルソース

3つのファイルを同じフォルダに保存し、ブラウザで index.html を開くと動作確認できます。
ファイル名:index.html / style.css / script.js
保存時の文字コードは UTF-8 を指定してください。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ファイルアップロード画面 サンプル</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>

<div class="upload-screen">
  <h1 class="screen-title">ファイルアップロード</h1>

  <!-- ===== ドロップゾーン ===== -->
  <div class="drop-zone" id="dropZone">
    <p class="drop-icon" aria-hidden="true">📁</p>
    <p class="drop-text">ここにファイルをドロップ</p>
    <p class="drop-sub">または</p>
    <button type="button" class="btn-secondary" id="selectBtn">ファイルを選択</button>
    <p class="drop-note">png / jpg / pdf・1ファイル5MBまで</p>
    <input type="file" id="fileInput" multiple accept=".png,.jpg,.jpeg,.pdf" hidden>
  </div>

  <!-- ===== サマリー+一括操作 ===== -->
  <div class="list-header" id="listHeader" hidden>
    <p class="upload-summary" id="uploadSummary"></p>
    <button type="button" class="link-btn" id="clearAllBtn">すべてクリア</button>
  </div>

  <!-- ===== ファイル一覧 ===== -->
  <ul class="file-list" id="fileList">
    <!-- JSで生成:
      li.file-item > .file-row(種類アイコン + .file-name + .file-size + .file-status + 削除ボタン)
                   + .file-progress-row(アップロード中のみ) + .file-error-text(エラー時のみ)
    -->
  </ul>

  <!-- ===== アップロード開始 ===== -->
  <div class="upload-actions" id="uploadActions" hidden>
    <button type="button" class="btn-primary" id="startBtn">アップロード開始</button>
  </div>

  <!-- ===== トースト ===== -->
  <div class="toast" id="toast" role="status" hidden></div>
</div>

<script src="./script.js"></script>
</body>
</html>
/* ===== ファイルアップロード画面(ドラッグ&ドロップ+進捗) — style.css ===== */
*, *::before, *::after { box-sizing: border-box; }

:root {
  --color-primary: #2B7FE8;
  --color-danger:  #D64545;
  --color-text:    #1E293B;
  --color-muted:   #64748B;
  --color-border:  #D0D7E0;
  --color-bg:      #F4F6F9;
  --color-card:    #FFFFFF;
}

body {
  margin: 0;
  font-family: "Hiragino Kaku Gothic ProN", "Hiragino Sans", Meiryo, sans-serif;
  background: var(--color-bg);
  color: var(--color-text);
}

/* ===== 画面レイアウト ===== */
.upload-screen {
  max-width: 640px;
  margin: 0 auto;
  padding: 24px 20px 48px;
}

.screen-title {
  font-size: 22px;
  margin: 0 0 16px;
}

/* ===== ドロップゾーン ===== */
.drop-zone {
  padding: 40px 16px;
  text-align: center;
  background: var(--color-card);
  border: 2px dashed var(--color-border);
  border-radius: 10px;
  transition: border-color 0.15s, background 0.15s;
}

/* ドラッグ中のハイライト(JSが is-dragover を付け外しする) */
.drop-zone.is-dragover {
  border-style: solid;
  border-color: var(--color-primary);
  background: #F0F6FF;
}

.drop-icon {
  margin: 0 0 8px;
  font-size: 32px;
}

.drop-text {
  margin: 0;
  font-size: 15px;
  font-weight: 700;
}

.drop-sub {
  margin: 8px 0;
  font-size: 13px;
  color: var(--color-muted);
}

.drop-note {
  margin: 14px 0 0;
  font-size: 12px;
  color: var(--color-muted);
}

/* ===== サマリー+一括操作 ===== */
.list-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 20px;
}

.upload-summary {
  margin: 0;
  font-size: 13px;
  font-weight: 700;
}

.link-btn {
  padding: 4px 2px;
  font-size: 13px;
  font-family: inherit;
  color: var(--color-primary);
  background: transparent;
  border: none;
  text-decoration: underline;
  cursor: pointer;
}

.link-btn:disabled {
  color: var(--color-muted);
  text-decoration: none;
  cursor: default;
}

/* ===== ファイル一覧 ===== */
.file-list {
  margin: 8px 0 0;
  padding: 0;
  list-style: none;
}

.file-item {
  padding: 10px 12px;
  margin-bottom: 8px;
  background: var(--color-card);
  border: 1px solid var(--color-border);
  border-radius: 8px;
}

/* 検証NGの行は薄赤の背景で目立たせる */
.file-item.is-error {
  background: #FDF2F2;
  border-color: #F2C4C4;
}

.file-row {
  display: flex;
  align-items: center;
  gap: 10px;
}

.file-icon { flex-shrink: 0; }

.file-name {
  flex: 1;
  min-width: 0;
  font-size: 14px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* サイズは右寄せ・等幅フォントで桁を揃える */
.file-size {
  flex-shrink: 0;
  min-width: 64px;
  font-size: 12px;
  font-family: Consolas, "Courier New", monospace;
  color: var(--color-muted);
  text-align: right;
}

/* ===== 状態バッジ(ピル型) ===== */
.file-status {
  flex-shrink: 0;
  padding: 2px 10px;
  font-size: 11px;
  font-weight: 700;
  border-radius: 9999px;
  white-space: nowrap;
}

.status-waiting   { color: #5A6A7A; background: #EDF1F5; }
.status-uploading { color: #1D6AD0; background: #E3EEFC; }
.status-done      { color: #1F8A4C; background: #E5F6EC; }
.status-error     { color: #C03434; background: #FBE4E4; }

/* ===== 削除ボタン ===== */
.file-remove {
  flex-shrink: 0;
  width: 24px;
  height: 24px;
  font-size: 16px;
  line-height: 1;
  color: var(--color-muted);
  background: transparent;
  border: none;
  border-radius: 50%;
  cursor: pointer;
  transition: background 0.15s, color 0.15s;
}

.file-remove:hover {
  color: var(--color-text);
  background: #E9EEF4;
}

.file-remove:disabled {
  color: #C3CCD6;
  background: transparent;
  cursor: default;
}

/* ===== 進捗バー ===== */
.file-progress-row {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 8px;
}

.file-progress {
  flex: 1;
  height: 6px;
  background: #E6EBF1;
  border-radius: 9999px;
  overflow: hidden;
}

.file-progress-fill {
  height: 100%;
  background: var(--color-primary);
  border-radius: 9999px;
  transition: width 0.2s;
}

.file-percent {
  flex-shrink: 0;
  min-width: 40px;
  font-size: 12px;
  font-family: Consolas, "Courier New", monospace;
  color: var(--color-muted);
  text-align: right;
}

/* ===== エラー理由 ===== */
.file-error-text {
  margin: 6px 0 0;
  font-size: 12px;
  color: var(--color-danger);
}

/* ===== ボタン ===== */
.btn-primary,
.btn-secondary {
  padding: 10px 22px;
  font-size: 14px;
  font-family: inherit;
  border-radius: 6px;
  cursor: pointer;
  transition: background 0.15s, border-color 0.15s;
}

.btn-primary {
  color: #fff;
  background: var(--color-primary);
  border: 1.5px solid var(--color-primary);
}

.btn-primary:hover { background: #1D6AD0; }

.btn-primary:disabled {
  background: #9DBCE6;
  border-color: #9DBCE6;
  cursor: default;
}

.btn-secondary {
  color: var(--color-muted);
  background: var(--color-card);
  border: 1.5px solid var(--color-border);
}

.btn-secondary:hover {
  background: var(--color-bg);
  border-color: #9AA5B4;
}

.upload-actions {
  margin-top: 16px;
  text-align: right;
}

/* ===== トースト ===== */
.toast {
  position: fixed;
  bottom: 24px;
  right: 24px;
  z-index: 20;
  padding: 12px 20px;
  font-size: 14px;
  color: #fff;
  background: #1E293B;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
  animation: toast-in 0.2s ease;
}

@keyframes toast-in {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: none; }
}

/* hidden 属性を確実に効かせる(display 指定との競合対策) */
.upload-screen [hidden] { display: none !important; }

/* ===== レスポンシブ(768px以下) ===== */
@media (max-width: 768px) {
  .drop-zone { padding: 28px 12px; }

  .file-size { min-width: 0; }

  .toast {
    left: 16px;
    right: 16px;
    text-align: center;
  }
}
/* =====================================================
   ファイルアップロード画面のスクリプト

   仕組み:追加されたファイルは fileItems 配列
   ({ file, status, progress, errorMessage })で一元管理し、
   変更のたびに render() が一覧・サマリー・ボタンを描き直す。
   ドロップとファイル選択はどちらも addFiles() に合流し、
   検証NGのファイルも「エラー」として一覧に残して理由を見せる。
   アップロードはタイマーの擬似処理で1件ずつ直列に進める
   (実通信への差し替えポイントは uploadNext のコメント参照)。
   ===================================================== */

// ===== 設定値 =====
var ALLOWED_EXTENSIONS = ['png', 'jpg', 'jpeg', 'pdf']; // 受け付ける拡張子
var MAX_SIZE = 5 * 1024 * 1024;  // 1ファイルの上限サイズ(5MB)
var PROGRESS_TICK_MS = 200;      // 擬似進捗の更新間隔
var PROGRESS_STEP_MIN = 8;       // 1回の更新で進む最小%
var PROGRESS_STEP_MAX = 20;      // 1回の更新で進む最大%
var TOAST_DURATION_MS = 3000;    // トーストの表示時間

// 状態バッジの表示文言とCSSクラス(status の値と対応)
var STATUS_LABEL = { waiting: '待機', uploading: 'アップロード中', done: '完了', error: 'エラー' };
var STATUS_CLASS = { waiting: 'status-waiting', uploading: 'status-uploading', done: 'status-done', error: 'status-error' };

// ===== DOM要素 =====
var dropZone      = document.getElementById('dropZone');
var fileInput     = document.getElementById('fileInput');
var selectBtn     = document.getElementById('selectBtn');
var listHeader    = document.getElementById('listHeader');
var uploadSummary = document.getElementById('uploadSummary');
var clearAllBtn   = document.getElementById('clearAllBtn');
var fileList      = document.getElementById('fileList');
var uploadActions = document.getElementById('uploadActions');
var startBtn      = document.getElementById('startBtn');
var toastEl       = document.getElementById('toast');

// ===== 状態 =====
// 一覧に表示するファイルの唯一の情報源。
// status: 'waiting'(待機)| 'uploading' | 'done' | 'error'(検証NG)
var fileItems = [];

// ===== ドラッグ&ドロップ =====
// dragover で preventDefault() を呼ばないと drop イベント自体が発火せず、
// ブラウザがファイルをそのまま開いてしまう(D&D実装の最重要ポイント)
dropZone.addEventListener('dragover', function (e) {
  e.preventDefault();
});

// 子要素(アイコン・ボタン)に乗るたび dragleave が発火してちらつくため、
// enter / leave を数えて 0 になったときだけハイライトを解除する
var dragDepth = 0;

dropZone.addEventListener('dragenter', function (e) {
  e.preventDefault();
  dragDepth++;
  dropZone.classList.add('is-dragover');
});

dropZone.addEventListener('dragleave', function () {
  dragDepth--;
  if (dragDepth === 0) {
    dropZone.classList.remove('is-dragover');
  }
});

// ドロップ → ハイライトを解除してファイルを受け取る
dropZone.addEventListener('drop', function (e) {
  e.preventDefault();
  dragDepth = 0;
  dropZone.classList.remove('is-dragover');
  addFiles(e.dataTransfer.files);
});

// ===== ファイル選択ボタン =====
// 「ファイルを選択」クリック → 隠しておいた input[type="file"] を起動
selectBtn.addEventListener('click', function () {
  fileInput.click();
});

// ファイル選択 → 一覧に追加して input をリセット
// (リセットしないと同じファイルの再選択で change が発火しない)
fileInput.addEventListener('change', function () {
  addFiles(fileInput.files);
  fileInput.value = '';
});

// ===== ファイル追加(ドロップ・選択の合流先) =====
function addFiles(files) {
  Array.prototype.forEach.call(files, function (file) {
    // 同名ファイルは追加せずトーストで知らせる
    var isDuplicate = fileItems.some(function (item) {
      return item.file.name === file.name;
    });
    if (isDuplicate) {
      showToast('同じ名前のファイルが追加済みです');
      return;
    }

    // 検証NGのファイルも一覧に載せて理由を見せる
    // (黙って弾くと「ドロップしたのに追加されない」と混乱させるため)
    var errorMessage = validate(file);
    fileItems.push({
      file: file,
      status: errorMessage ? 'error' : 'waiting',
      progress: 0,
      errorMessage: errorMessage
    });
  });
  render();
}

// 検証:NGなら理由の文字列、OKなら空文字を返す。
// file.type はOSや拡張子によって空になることがあるため、
// 形式の判定はファイル名の拡張子で行う
function validate(file) {
  if (ALLOWED_EXTENSIONS.indexOf(getExtension(file.name)) === -1) {
    return '対応していない形式です';
  }
  if (file.size > MAX_SIZE) {
    return '5MBを超えています';
  }
  return '';
}

// ===== 擬似アップロード =====
// 「アップロード開始」クリック → 待機中のファイルを上から順に処理する
startBtn.addEventListener('click', function () {
  uploadNext();
});

// 待機中の先頭ファイルを擬似アップロードし、完了したら次のファイルへ。
// 実案件ではこの setInterval を XMLHttpRequest(または fetch)に差し替え、
// xhr.upload.onprogress の e.loaded / e.total で progress を更新する
function uploadNext() {
  var item = null;
  for (var i = 0; i < fileItems.length; i++) {
    if (fileItems[i].status === 'waiting') {
      item = fileItems[i];
      break;
    }
  }

  // 待機中がなくなったら全件完了
  if (!item) {
    showToast('すべてのアップロードが完了しました');
    render();
    return;
  }

  item.status = 'uploading';
  item.progress = 0;
  render();

  var timerId = setInterval(function () {
    // 8〜20%ずつランダムに進める(1ファイルあたり1〜2秒)
    item.progress += PROGRESS_STEP_MIN +
      Math.floor(Math.random() * (PROGRESS_STEP_MAX - PROGRESS_STEP_MIN + 1));
    if (item.progress >= 100) {
      item.progress = 100;
      item.status = 'done';
      clearInterval(timerId);
      uploadNext(); // 次の待機ファイルへ(render は uploadNext 側で行う)
      return;
    }
    render();
  }, PROGRESS_TICK_MS);
}

// ===== 削除・クリア =====
// 「すべてクリア」 → 一覧を空にして初期表示へ(個別削除は行生成側で設定)
clearAllBtn.addEventListener('click', function () {
  fileItems = [];
  render();
});

// ===== 描画 =====
// fileItems の内容から一覧・サマリー・ボタンの状態をすべて作り直す
function render() {
  var isUploading = fileItems.some(function (item) { return item.status === 'uploading'; });
  var doneCount = 0;
  var waitingCount = 0;
  fileItems.forEach(function (item) {
    if (item.status === 'done') { doneCount++; }
    if (item.status === 'waiting') { waitingCount++; }
  });

  // ファイルがなければ初期表示(一覧・サマリー・開始ボタンを隠す)
  var hasFiles = fileItems.length > 0;
  listHeader.hidden = !hasFiles;
  uploadActions.hidden = !hasFiles;

  // サマリー(例:3件中 1件完了)
  uploadSummary.textContent = fileItems.length + '件中 ' + doneCount + '件完了';

  // アップロード中は開始・クリアを無効化(個別削除は行側で無効化)
  startBtn.disabled = isUploading || waitingCount === 0;
  clearAllBtn.disabled = isUploading;

  // 一覧を作り直す
  fileList.textContent = '';
  fileItems.forEach(function (item) {
    fileList.appendChild(createFileItem(item, isUploading));
  });
}

// 1ファイル分の行を生成する。ファイル名はユーザー入力由来のため
// innerHTML は使わず createElement + textContent で組み立てる
function createFileItem(item, isUploading) {
  var li = document.createElement('li');
  li.className = 'file-item';
  if (item.status === 'error') {
    li.classList.add('is-error');
  }

  // --- 1行目:アイコン・名前・サイズ・状態バッジ・削除ボタン ---
  var row = document.createElement('div');
  row.className = 'file-row';

  var icon = document.createElement('span');
  icon.className = 'file-icon';
  icon.setAttribute('aria-hidden', 'true');
  icon.textContent = getFileIcon(item.file.name);

  var name = document.createElement('span');
  name.className = 'file-name';
  name.textContent = item.file.name;
  name.title = item.file.name;

  var size = document.createElement('span');
  size.className = 'file-size';
  size.textContent = formatSize(item.file.size);

  var status = document.createElement('span');
  status.className = 'file-status ' + STATUS_CLASS[item.status];
  status.textContent = STATUS_LABEL[item.status];

  // 行の「×」クリック → そのファイルだけ一覧から外す
  var removeBtn = document.createElement('button');
  removeBtn.type = 'button';
  removeBtn.className = 'file-remove';
  removeBtn.setAttribute('aria-label', item.file.name + ' を削除');
  removeBtn.textContent = '×';
  removeBtn.disabled = isUploading;
  removeBtn.addEventListener('click', function () {
    fileItems.splice(fileItems.indexOf(item), 1);
    render();
  });

  row.appendChild(icon);
  row.appendChild(name);
  row.appendChild(size);
  row.appendChild(status);
  row.appendChild(removeBtn);
  li.appendChild(row);

  // --- 2行目:進捗バー(アップロード中のみ) ---
  if (item.status === 'uploading') {
    var progressRow = document.createElement('div');
    progressRow.className = 'file-progress-row';

    var track = document.createElement('div');
    track.className = 'file-progress';
    var fill = document.createElement('div');
    fill.className = 'file-progress-fill';
    fill.style.width = item.progress + '%';
    track.appendChild(fill);

    var percent = document.createElement('span');
    percent.className = 'file-percent';
    percent.textContent = item.progress + '%';

    progressRow.appendChild(track);
    progressRow.appendChild(percent);
    li.appendChild(progressRow);
  }

  // --- 2行目:エラー理由(検証NGのみ) ---
  if (item.status === 'error') {
    var errorText = document.createElement('p');
    errorText.className = 'file-error-text';
    errorText.textContent = item.errorMessage;
    li.appendChild(errorText);
  }

  return li;
}

// ===== ヘルパー =====
// ファイル名から小文字の拡張子を取り出す
function getExtension(fileName) {
  return fileName.split('.').pop().toLowerCase();
}

// 拡張子から種類アイコンを返す(画像は🖼・PDFは📄・その他は📎)
function getFileIcon(fileName) {
  var ext = getExtension(fileName);
  if (ext === 'png' || ext === 'jpg' || ext === 'jpeg') { return '🖼'; }
  if (ext === 'pdf') { return '📄'; }
  return '📎';
}

// バイト数を読みやすい表記にする(1MB未満はKB表示)
function formatSize(bytes) {
  if (bytes < 1024 * 1024) {
    return (bytes / 1024).toFixed(0) + 'KB';
  }
  return (bytes / 1024 / 1024).toFixed(1) + 'MB';
}

// ===== トースト =====
var toastTimerId = null;

// 3秒で自動的に消える。連続表示のときはタイマーを張り直す
function showToast(message) {
  toastEl.textContent = message;
  toastEl.hidden = false;
  if (toastTimerId) { clearTimeout(toastTimerId); }
  toastTimerId = setTimeout(function () {
    toastEl.hidden = true;
    toastTimerId = null;
  }, TOAST_DURATION_MS);
}

AI用プロンプト

以下のプロンプトをコピーしてAIに渡すと、同様の画面パターンを生成できます。

ChatGPTやClaudeにこのプロンプトを渡すと、同様の画面をゼロから生成・カスタマイズできます。対応形式の変更やサイズ上限の調整など、要件を追記して使うのがおすすめです。

※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。

💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。

# ファイルアップロード画面 作成依頼

## 概要
ドラッグ&ドロップとファイル選択ボタンの両方からファイルを追加でき、
ファイルごとの検証・進捗バー・状態バッジを表示するアップロード画面を作成してください。

## 要件
- 点線枠のドロップゾーン(アイコン+「ここにファイルをドロップ」+「ファイルを選択」ボタン)
- ドラッグ中はドロップゾーンの枠と背景色をハイライトする
- 複数ファイル対応。追加されたファイルは一覧(種類アイコン・ファイル名・サイズ・状態バッジ)に表示する
- 検証:拡張子は png / jpg / jpeg / pdf のみ、サイズは1ファイル5MBまで。
  NGファイルも一覧に表示し、「対応していない形式です」「5MBを超えています」の理由を赤字で示す
- 同名ファイルの重複追加はスキップし、トーストで通知する
- 「アップロード開始」ボタンで待機中ファイルを上から順に擬似アップロードする
  (実際の送信はせず、タイマーで進捗を0%から100%まで進める。1ファイル1〜2秒程度)
- 状態バッジは 待機(グレー)→ アップロード中(青・進捗バー+%表示)→ 完了(緑)と遷移する
- アップロード中は開始ボタン・削除ボタンを無効化する
- ファイルの個別削除(×ボタン)と「すべてクリア」を用意する
- 「3件中 2件完了」のようなサマリーを表示する

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- サーバーへの送信は行わない(進捗処理は XMLHttpRequest / fetch に差し替えられる構造にし、
  差し替えポイントをコメントで明記する)
- レスポンシブ対応:必要

## 動作詳細
- dragover イベントで preventDefault を呼ぶ(drop イベントを有効にするため)
- ファイルの状態(待機・進捗・エラー理由)は配列で一元管理し、変更のたびに一覧を再描画する
- ファイル名の表示は textContent を使い、innerHTML に変数を結合しない
- input[type="file"] は処理後に value をリセットし、同じファイルの再選択でも反応するようにする

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。