F-009 Form advanced

How to build an accessible file upload dropzone アクセシブルなファイルアップロードドロップゾーンの作り方

Accept files by drag-and-drop or click, validate type and size, and animate a per-file upload progress bar. ドラッグ&ドロップまたはクリックでファイルを受け付け、種類とサイズを検証してファイルごとの進捗バーを表示します。

ライブデモ Live Demo

ドラッグ&ドロップ、またはクリックして選択 Drag files here, or click to browse

JPG・PNG・PDF、1ファイルあたり5MBまで JPG, PNG, or PDF — up to 5MB each

    概要・用途・特徴Overview, Usage & Features

    何ができるかWhat it does

    An upload zone that accepts files by drag-and-drop or by click, validates each file's type and size, and renders a progress row per accepted file.

    ドラッグ&ドロップとクリックの両方でファイルを受け付け、ファイルごとに種類とサイズを検証してから進捗行を表示するアップロードゾーンです。

    どこで使うかWhere to use

    Use it wherever users attach files: profile photos, document submission, support tickets, or any form that collects uploads before a real submit action.

    プロフィール画像、書類の提出、サポート問い合わせなど、送信前にファイルを添付させる場面で利用できます。

    特徴Key features

    Drag highlight, keyboard access via a role="button" zone, client-side type and size validation, simulated per-file upload progress, individual remove buttons, a clear-all action, and aria-live status announcements.

    ドラッグ時のハイライト、role="button"ゾーンによるキーボード操作、クライアント側の種類・サイズ検証、擬似的なファイルごとの進捗表示、個別の削除ボタン、一括削除、aria-liveによる状態通知を備えます。

    調整可能パラメータ Adjustable Parameters

    Parameter Default Description
    accept.jpg,.jpeg,.png,.pdfComma-separated extensions or MIME types accepted by the dropzone.
    data-max-size-mb5Maximum size per file in megabytes; exceeding it rejects the file.
    multipletrueWhether more than one file can be queued at once.
    locale (React)enUse ja for Japanese headings and status copy.
    --upload-accent#0e7490Zone border, progress fill, and focus outline color.

    実装コード Implementation Code

    <div class="upload-dropzone demo" data-upload-dropzone>
      <div class="upload-dropzone__zone" data-upload-zone role="button" tabindex="0" aria-describedby="upload-hint">
        <svg class="upload-dropzone__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
          <path d="M12 16V4m0 0 4 4m-4-4-4 4" stroke-linecap="round" stroke-linejoin="round" />
          <path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" stroke-linejoin="round" />
        </svg>
        <p class="upload-dropzone__title">Drag files here, or click to browse</p>
        <p class="upload-dropzone__hint" id="upload-hint">JPG, PNG, or PDF — up to 5MB each</p>
        <input class="upload-dropzone__input" type="file" id="upload-input" name="files" multiple tabindex="-1" accept=".jpg,.jpeg,.png,.pdf" data-max-size-mb="5" />
      </div>
      <ul class="upload-dropzone__list" data-upload-list aria-label="Selected files"></ul>
      <button class="upload-dropzone__clear" type="button" data-upload-clear hidden>Clear all</button>
      <p class="upload-dropzone__sr" role="status" aria-atomic="true"><span data-upload-status-en></span></p>
    </div>
    
    <script>
    document.querySelectorAll('[data-upload-dropzone]').forEach((root) => {
      const zone = root.querySelector('[data-upload-zone]');
      const input = root.querySelector('input[type="file"]');
      const list = root.querySelector('[data-upload-list]');
      const clearBtn = root.querySelector('[data-upload-clear]');
      const maxSizeMB = Number(input.dataset.maxSizeMb || 5);
      const maxSizeBytes = maxSizeMB * 1024 * 1024;
      const accept = (input.getAttribute('accept') || '')
        .split(',').map((token) => token.trim().toLowerCase()).filter(Boolean);
      const timers = new Map();
      let seq = 0;
    
      function announce(text) {
        const status = root.querySelector('[data-upload-status-en]');
        if (status) status.textContent = text;
      }
    
      function formatSize(bytes) {
        if (bytes < 1024) return bytes + ' B';
        if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
        return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
      }
    
      function isAccepted(file) {
        if (!accept.length) return true;
        const name = file.name.toLowerCase();
        return accept.some((token) => (token.startsWith('.') ? name.endsWith(token) : file.type === token));
      }
    
      function updateClearVisibility() {
        clearBtn.hidden = list.children.length === 0;
      }
    
      function removeItem(li, file) {
        const timer = timers.get(li);
        if (timer) clearInterval(timer);
        timers.delete(li);
        li.remove();
        updateClearVisibility();
        announce(file.name + ' removed.');
      }
    
      function buildItem(file) {
        seq += 1;
        const li = document.createElement('li');
        li.className = 'upload-dropzone__item';
        li.id = 'upload-item-' + seq;
        li.innerHTML =
          '<div class="upload-dropzone__item-main">' +
            '<p class="upload-dropzone__item-name"></p>' +
            '<p class="upload-dropzone__item-meta"><span data-label></span><span data-size></span></p>' +
            '<div class="upload-dropzone__progress" data-progress hidden><div class="upload-dropzone__progress-fill" data-fill></div></div>' +
          '</div>' +
          '<button type="button" class="upload-dropzone__item-remove" data-remove>' +
            '<span aria-hidden="true">&times;</span>' +
            '<span class="upload-dropzone__sr" data-remove-label></span>' +
          '</button>';
        li.querySelector('.upload-dropzone__item-name').textContent = file.name;
        li.querySelector('[data-remove-label]').textContent = 'Remove ' + file.name;
        li.querySelector('[data-remove]').addEventListener('click', () => removeItem(li, file));
        return li;
      }
    
      function rejectFile(li, file, label) {
        li.dataset.state = 'error';
        li.querySelector('[data-label]').textContent = label;
        list.append(li);
        updateClearVisibility();
        return { accepted: false, file, label };
      }
    
      function startUpload(li, file) {
        li.dataset.state = 'uploading';
        li.querySelector('[data-label]').textContent = 'Uploading…';
        li.querySelector('[data-size]').textContent = ' · ' + formatSize(file.size);
        const progress = li.querySelector('[data-progress]');
        const fill = li.querySelector('[data-fill]');
        progress.hidden = false;
        list.append(li);
        updateClearVisibility();
    
        let value = 0;
        const timer = setInterval(() => {
          value = Math.min(100, value + Math.random() * 18 + 7);
          fill.style.width = value + '%';
          if (value >= 100) {
            clearInterval(timer);
            timers.delete(li);
            li.dataset.state = 'done';
            li.querySelector('[data-label]').textContent = 'Uploaded';
            announce(file.name + ' uploaded.');
          }
        }, 220);
        timers.set(li, timer);
        return { accepted: true, file };
      }
    
      function addFile(file) {
        const li = buildItem(file);
        if (!isAccepted(file)) {
          return rejectFile(li, file, 'Unsupported file type.');
        }
        if (file.size > maxSizeBytes) {
          return rejectFile(li, file, 'Exceeds ' + maxSizeMB + 'MB limit.');
        }
        return startUpload(li, file);
      }
    
      function handleFiles(fileList) {
        const files = Array.from(fileList || []);
        if (!files.length) return;
        const results = files.map(addFile);
        if (results.length === 1) {
          const [result] = results;
          announce(result.accepted ? result.file.name + ' added.' : result.file.name + ': ' + result.label);
          return;
        }
        const added = results.filter((result) => result.accepted).length;
        const rejected = results.length - added;
        announce(`${added} added, ${rejected} rejected.`);
      }
    
      zone.addEventListener('click', () => input.click());
      zone.addEventListener('keydown', (event) => {
        if (event.key === 'Enter' || event.key === ' ') {
          event.preventDefault();
          input.click();
        }
      });
      ['dragenter', 'dragover'].forEach((type) => zone.addEventListener(type, (event) => {
        event.preventDefault();
        zone.classList.add('is-dragover');
      }));
      ['dragleave', 'dragend', 'drop'].forEach((type) => zone.addEventListener(type, () => zone.classList.remove('is-dragover')));
      zone.addEventListener('drop', (event) => {
        event.preventDefault();
        handleFiles(event.dataTransfer && event.dataTransfer.files);
      });
      input.addEventListener('change', () => {
        handleFiles(input.files);
        input.value = '';
      });
      if (clearBtn) {
        clearBtn.addEventListener('click', () => {
          timers.forEach((timer) => clearInterval(timer));
          timers.clear();
          list.replaceChildren();
          updateClearVisibility();
          announce('All files cleared.');
          zone.focus();
        });
      }
      updateClearVisibility();
    });
    </script>
    .upload-dropzone {
      --upload-accent: #0e7490;
      --upload-error: #b42332;
      --upload-success: #1f8a53;
      box-sizing: border-box;
      width: 100%;
      max-width: 480px;
      margin: 0 auto;
      padding: clamp(20px, 5vw, 28px);
      border: 1px solid #d7dde0;
      border-radius: 18px;
      background: #fbfcfc;
      color: #1f2b2e;
      font-family: Inter, system-ui, sans-serif;
    }
    .upload-dropzone * { box-sizing: border-box; }
    .upload-dropzone__zone { position: relative; display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 28px 16px; border: 2px dashed #a9b7bc; border-radius: 14px; background: #fff; text-align: center; cursor: pointer; transition: border-color .15s ease, background-color .15s ease; }
    .upload-dropzone__zone.is-dragover { border-color: var(--upload-accent); background: #eaf5f7; }
    .upload-dropzone__zone:focus-visible { outline: 3px solid var(--upload-accent); outline-offset: 3px; }
    .upload-dropzone__icon { width: 32px; height: 32px; color: var(--upload-accent); }
    .upload-dropzone__title { margin: 0; font-size: 15px; font-weight: 700; }
    .upload-dropzone__hint { margin: 0; color: #5b6b6f; font-size: 12px; line-height: 1.5; }
    .upload-dropzone__input { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
    .upload-dropzone__list { list-style: none; margin: 16px 0 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
    .upload-dropzone__item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid #dbe3e5; border-radius: 10px; background: #fff; }
    .upload-dropzone__item[data-state="error"] { border-color: var(--upload-error); background: #fdf1f2; }
    .upload-dropzone__item[data-state="done"] { border-color: #bfe3cc; background: #f2fbf6; }
    .upload-dropzone__item-main { flex: 1; min-width: 0; }
    .upload-dropzone__item-name { margin: 0 0 2px; font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .upload-dropzone__item-meta { margin: 0; font-size: 11px; color: #5b6b6f; }
    .upload-dropzone__item[data-state="error"] .upload-dropzone__item-meta { color: var(--upload-error); }
    .upload-dropzone__item[data-state="done"] .upload-dropzone__item-meta { color: var(--upload-success); }
    .upload-dropzone__progress { margin-top: 6px; height: 6px; border-radius: 999px; background: #e4eaeb; overflow: hidden; }
    .upload-dropzone__progress-fill { height: 100%; width: 0%; border-radius: inherit; background: var(--upload-accent); transition: width .2s ease; }
    .upload-dropzone__item[data-state="done"] .upload-dropzone__progress-fill { background: var(--upload-success); }
    .upload-dropzone__item-remove { flex-shrink: 0; width: 28px; height: 28px; border: 1px solid #c3cdd0; border-radius: 8px; background: transparent; color: #45535a; font-size: 16px; line-height: 1; cursor: pointer; }
    .upload-dropzone__item-remove:hover { background: #eef2f3; }
    .upload-dropzone__item-remove:focus-visible, .upload-dropzone__clear:focus-visible { outline: 3px solid var(--upload-accent); outline-offset: 2px; }
    .upload-dropzone__clear { margin-top: 14px; min-height: 40px; padding: 8px 14px; border: 1px solid #c3cdd0; border-radius: 8px; background: transparent; color: #1f2b2e; font: inherit; font-size: 13px; cursor: pointer; }
    .upload-dropzone__clear:hover { background: #eef2f3; }
    .upload-dropzone__sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
    
    import { useEffect, useId, useRef, useState } from 'react';
    import './F-009.css';
    
    let seq = 0;
    
    export default function FileUploadDropzone({ locale = 'en', accept = '.jpg,.jpeg,.png,.pdf', maxSizeMB = 5 }) {
      const inputId = useId();
      const inputRef = useRef(null);
      const timersRef = useRef(new Map());
      const [items, setItems] = useState([]);
      const [isDragOver, setIsDragOver] = useState(false);
      const [status, setStatus] = useState('');
      const ja = locale === 'ja';
      const acceptTokens = accept.split(',').map((token) => token.trim().toLowerCase()).filter(Boolean);
      const maxSizeBytes = maxSizeMB * 1024 * 1024;
    
      useEffect(() => {
        const timers = timersRef.current;
        return () => {
          timers.forEach((timer) => clearInterval(timer));
          timers.clear();
        };
      }, []);
    
      function formatSize(bytes) {
        if (bytes < 1024) return bytes + ' B';
        if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
        return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
      }
      function isAccepted(file) {
        if (!acceptTokens.length) return true;
        const name = file.name.toLowerCase();
        return acceptTokens.some((token) => (token.startsWith('.') ? name.endsWith(token) : file.type === token));
      }
      function removeItem(id) {
        const timer = timersRef.current.get(id);
        if (timer) clearInterval(timer);
        timersRef.current.delete(id);
        setItems((all) => {
          const removed = all.find((item) => item.id === id);
          if (removed) setStatus((removed.name) + (ja ? ' を削除しました。' : ' removed.'));
          return all.filter((item) => item.id !== id);
        });
      }
      function startUpload(id, file) {
        let value = 0;
        const timer = setInterval(() => {
          value = Math.min(100, value + Math.random() * 18 + 7);
          setItems((all) => all.map((item) => (item.id === id ? { ...item, progress: value } : item)));
          if (value >= 100) {
            clearInterval(timer);
            timersRef.current.delete(id);
            setItems((all) => all.map((item) => (item.id === id ? { ...item, state: 'done' } : item)));
            setStatus(file.name + (ja ? ' のアップロードが完了しました。' : ' uploaded.'));
          }
        }, 220);
        timersRef.current.set(id, timer);
      }
      function addFile(file) {
        seq += 1;
        const id = seq;
        if (!isAccepted(file)) {
          const label = ja ? '対応していない形式です。' : 'Unsupported file type.';
          setItems((all) => [...all, { id, name: file.name, state: 'error', label, progress: 0, size: 0 }]);
          return { accepted: false, file, label };
        }
        if (file.size > maxSizeBytes) {
          const label = ja ? maxSizeMB + 'MBを超えています。' : 'Exceeds ' + maxSizeMB + 'MB limit.';
          setItems((all) => [...all, { id, name: file.name, state: 'error', label, progress: 0, size: 0 }]);
          return { accepted: false, file, label };
        }
        setItems((all) => [...all, { id, name: file.name, state: 'uploading', label: ja ? 'アップロード中…' : 'Uploading…', progress: 0, size: file.size }]);
        startUpload(id, file);
        return { accepted: true, file };
      }
      function handleFiles(fileList) {
        const files = Array.from(fileList || []);
        if (!files.length) return;
        const results = files.map(addFile);
        if (results.length === 1) {
          const [result] = results;
          setStatus(result.accepted
            ? result.file.name + (ja ? ' を追加しました。' : ' added.')
            : result.file.name + ': ' + result.label);
          return;
        }
        const added = results.filter((result) => result.accepted).length;
        const rejected = results.length - added;
        setStatus(ja ? `${added}件追加、${rejected}件拒否しました。` : `${added} added, ${rejected} rejected.`);
      }
    
      return (
        <div className="upload-dropzone">
          <div
            className={'upload-dropzone__zone' + (isDragOver ? ' is-dragover' : '')}
            role="button"
            tabIndex={0}
            aria-describedby={inputId + '-hint'}
            onClick={() => inputRef.current?.click()}
            onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); inputRef.current?.click(); } }}
            onDragOver={(event) => { event.preventDefault(); setIsDragOver(true); }}
            onDragLeave={() => setIsDragOver(false)}
            onDrop={(event) => { event.preventDefault(); setIsDragOver(false); handleFiles(event.dataTransfer.files); }}
          >
            <p className="upload-dropzone__title">{ja ? 'ドラッグ&ドロップ、またはクリックして選択' : 'Drag files here, or click to browse'}</p>
            <p className="upload-dropzone__hint" id={inputId + '-hint'}>{ja ? `${accept} / 1ファイルあたり${maxSizeMB}MBまで` : `${accept} — up to ${maxSizeMB}MB each`}</p>
            <input ref={inputRef} className="upload-dropzone__input" type="file" id={inputId} multiple tabIndex={-1} accept={accept} onChange={(event) => { handleFiles(event.target.files); event.target.value = ''; }} />
          </div>
          <ul className="upload-dropzone__list" aria-label="Selected files">
            {items.map((item) => (
              <li key={item.id} className="upload-dropzone__item" data-state={item.state}>
                <div className="upload-dropzone__item-main">
                  <p className="upload-dropzone__item-name">{item.name}</p>
                  <p className="upload-dropzone__item-meta">
                    <span>{item.state === 'done' ? (ja ? 'アップロード完了' : 'Uploaded') : item.label}</span>
                    {item.size > 0 && <span> · {formatSize(item.size)}</span>}
                  </p>
                  {item.state !== 'error' && (
                    <div className="upload-dropzone__progress"><div className="upload-dropzone__progress-fill" style={{ width: item.progress + '%' }} /></div>
                  )}
                </div>
                <button type="button" className="upload-dropzone__item-remove" onClick={() => removeItem(item.id)} aria-label={(ja ? `${item.name} を削除` : `Remove ${item.name}`)}>
                  <span aria-hidden="true">&times;</span>
                </button>
              </li>
            ))}
          </ul>
          {items.length > 0 && (
            <button type="button" className="upload-dropzone__clear" onClick={() => {
              timersRef.current.forEach((timer) => clearInterval(timer));
              timersRef.current.clear();
              setItems([]);
              setStatus(ja ? 'すべてのファイルを削除しました。' : 'All files cleared.');
            }}>{ja ? 'すべて削除' : 'Clear all'}</button>
          )}
          <p className="upload-dropzone__sr" role="status" aria-atomic="true">{status}</p>
        </div>
      );
    }
    
    .upload-dropzone {
      --upload-accent: #0e7490;
      --upload-error: #b42332;
      --upload-success: #1f8a53;
      box-sizing: border-box;
      width: 100%;
      max-width: 480px;
      margin: 0 auto;
      padding: clamp(20px, 5vw, 28px);
      border: 1px solid #d7dde0;
      border-radius: 18px;
      background: #fbfcfc;
      color: #1f2b2e;
      font-family: Inter, system-ui, sans-serif;
    }
    .upload-dropzone * { box-sizing: border-box; }
    .upload-dropzone__zone { position: relative; display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 28px 16px; border: 2px dashed #a9b7bc; border-radius: 14px; background: #fff; text-align: center; cursor: pointer; transition: border-color .15s ease, background-color .15s ease; }
    .upload-dropzone__zone.is-dragover { border-color: var(--upload-accent); background: #eaf5f7; }
    .upload-dropzone__zone:focus-visible { outline: 3px solid var(--upload-accent); outline-offset: 3px; }
    .upload-dropzone__title { margin: 0; font-size: 15px; font-weight: 700; }
    .upload-dropzone__hint { margin: 0; color: #5b6b6f; font-size: 12px; line-height: 1.5; }
    .upload-dropzone__input { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
    .upload-dropzone__list { list-style: none; margin: 16px 0 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
    .upload-dropzone__item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid #dbe3e5; border-radius: 10px; background: #fff; }
    .upload-dropzone__item[data-state="error"] { border-color: var(--upload-error); background: #fdf1f2; }
    .upload-dropzone__item[data-state="done"] { border-color: #bfe3cc; background: #f2fbf6; }
    .upload-dropzone__item-main { flex: 1; min-width: 0; }
    .upload-dropzone__item-name { margin: 0 0 2px; font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .upload-dropzone__item-meta { margin: 0; font-size: 11px; color: #5b6b6f; }
    .upload-dropzone__item[data-state="error"] .upload-dropzone__item-meta { color: var(--upload-error); }
    .upload-dropzone__item[data-state="done"] .upload-dropzone__item-meta { color: var(--upload-success); }
    .upload-dropzone__progress { margin-top: 6px; height: 6px; border-radius: 999px; background: #e4eaeb; overflow: hidden; }
    .upload-dropzone__progress-fill { height: 100%; width: 0%; border-radius: inherit; background: var(--upload-accent); transition: width .2s ease; }
    .upload-dropzone__item[data-state="done"] .upload-dropzone__progress-fill { background: var(--upload-success); }
    .upload-dropzone__item-remove { flex-shrink: 0; width: 28px; height: 28px; border: 1px solid #c3cdd0; border-radius: 8px; background: transparent; color: #45535a; font-size: 16px; line-height: 1; cursor: pointer; }
    .upload-dropzone__item-remove:hover { background: #eef2f3; }
    .upload-dropzone__item-remove:focus-visible, .upload-dropzone__clear:focus-visible { outline: 3px solid var(--upload-accent); outline-offset: 2px; }
    .upload-dropzone__clear { margin-top: 14px; min-height: 40px; padding: 8px 14px; border: 1px solid #c3cdd0; border-radius: 8px; background: transparent; color: #1f2b2e; font: inherit; font-size: 13px; cursor: pointer; }
    .upload-dropzone__clear:hover { background: #eef2f3; }
    .upload-dropzone__sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
    

    仕組みとカスタマイズHow It Works & Customization

    仕組みHow it works

    A visually hidden file input sits inside a role="button" zone with tabindex=-1, so only the zone is reachable by keyboard and calling input.click() opens the native picker. Dropped or selected File objects are checked against the accept attribute and a data-max-size-mb limit; rejected files render an error row with no progress bar, while accepted files append a list item whose progress fill is animated with setInterval until it reaches 100%, at which point the row switches to a done state.

    視覚的に隠したinput要素をtabindex=-1でrole="button"ゾーンの中に置き、ゾーンだけをキーボードで操作できるようにして、クリック時はinput.click()でネイティブのファイル選択を開きます。ドロップまたは選択されたFileオブジェクトはaccept属性とdata-max-size-mbの上限で検証し、無効なファイルは進捗バーなしのエラー行のみ表示します。有効なファイルはリスト項目を追加し、setIntervalで進捗バーを100%まで進めてから完了状態に切り替えます。

    カスタマイズ方法Customization

    Change the accept attribute and data-max-size-mb to control which files pass validation. Swap the simulated setInterval progress for real XHR upload.onprogress or fetch streaming progress when wiring this to a backend. Localize via the locale prop in React or by editing the .lang spans in the HTML version.

    accept属性とdata-max-size-mbを変更すると検証条件を調整できます。実際のバックエンドに接続する際は、擬似的なsetIntervalの進捗をXHRのupload.onprogressやfetchのストリーミング進捗に置き換えてください。Reactではlocaleプロパティ、HTML版は.langスパンの編集で言語を切り替えます。

    注意点Caveats

    This demo never submits data; progress is simulated. Client-side validation is a convenience and must be duplicated on the server. The hidden input's tabindex=-1 keeps it out of the tab order so keyboard users only interact with the role="button" zone.

    このデモは実際にデータを送信せず、進捗は模擬表示です。クライアント側検証は利便性のためのものでサーバー側でも必ず再検証してください。隠したinputはtabindex=-1でタブ順から外し、キーボード操作はrole="button"ゾーンに限定しています。

    よくある質問 Frequently Asked Questions

    Why click() a hidden input instead of styling the native file input directly? ネイティブのinputを直接装飾せず、隠したinputをclick()する理由は?

    Browsers do not allow fully custom styling of a native file input's button. Keeping a real input but triggering it from a styled, keyboard-accessible zone preserves the native file picker while giving full control over appearance.

    ブラウザはネイティブのファイル選択ボタンを完全にはスタイリングできません。実際のinputは残しつつ、装飾済みでキーボード操作可能なゾーンから呼び出すことで、見た目を自由に制御しながらネイティブの選択ダイアログを使えます。

    How would this connect to a real upload endpoint? 実際のアップロード先に接続するにはどうしますか?

    Replace the setInterval simulation with XMLHttpRequest's upload.onprogress handler, or a fetch call using a ReadableStream, and update the same progress fill and status elements with the real percentage.

    setIntervalによる模擬進捗を、XMLHttpRequestのupload.onprogressハンドラやReadableStreamを使ったfetch呼び出しに置き換え、同じ進捗バーと状態表示を実際の割合で更新してください。

    Does client-side validation replace server-side checks? クライアント側の検証でサーバー側の検証は不要になりますか?

    No. The accept attribute and size check only improve the user experience by rejecting obviously invalid files early. The server must re-validate file type, size, and content before storing anything.

    いいえ。accept属性とサイズ検証は、明らかに無効なファイルを早期に拒否してユーザー体験を改善するだけです。サーバー側でも種類・サイズ・内容を必ず再検証してから保存してください。

    AIへの指示テンプレート AI Prompt Template

    以下をAIに貼り付けると実装を依頼できます。 Paste the following into your AI assistant to request implementation.