フォーム

ファイル選択フィールド

native inputで選んだファイルの名前、形式、容量を送信前に確認し、不正理由と解除方法を示すレシピです。

動きを試す

プレビュー

プレビュー設定

プレビューを準備しています。

必要なファイルを選ぶ

コード

index.html

利用前に変更する箇所

  • 許可形式、容量、件数を実装先のserver制約と一致させる

<!doctype html>
<html lang="ja" data-theme="light">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>ファイル選択フィールド</title>
    <link rel="stylesheet" href="./styles.css" />
    <script src="./file-selection.js" defer></script>
  </head>
  <body>
    <main>
      <section class="upload-card" aria-labelledby="page-title" data-root>
        <p class="eyebrow">送信前の選択例</p>
        <h1 id="page-title">参考資料を選ぶ</h1>
        <p class="lead">選んだファイルの名前、形式、容量を確認できます。このデモは内容を読み取らず、外部へ送信しません。</p>

        <div class="field" data-field>
          <label for="attachments">ファイル</label>
          <p id="file-hint" class="hint">PDF、TXT、PNGを2件まで。1件あたり2 MB以下。</p>
          <input
            id="attachments"
            name="attachments"
            type="file"
            accept=".pdf,.txt,image/png"
            multiple
            aria-describedby="file-hint file-error"
            data-input
          />
          <p id="file-error" class="error" role="alert" data-error hidden></p>
        </div>

        <section class="selection" aria-labelledby="selection-title" data-selection hidden>
          <div class="selection-heading">
            <div>
              <h2 id="selection-title">選択したファイル</h2>
              <p class="selection-count" data-count></p>
            </div>
            <button type="button" data-clear>すべて解除</button>
          </div>
          <ul class="file-list" data-list></ul>
        </section>
        <p class="privacy-note">ファイル内容、端末内の場所、画像previewは取得しません。</p>
      </section>
    </main>
  </body>
</html>

styles.css

利用前に変更する箇所

  • 色tokenと余白を実装先のデザインシステムへ接続する

:root {
  color-scheme: light;
  --bg: #f4f7f8;
  --surface: #ffffff;
  --surface-subtle: #e9f0f2;
  --text: #152129;
  --muted: #53656f;
  --border: #c6d3d8;
  --accent: #006e82;
  --accent-strong: #004f60;
  --error: #a52a3a;
  --error-surface: #fff0f1;
  --focus: #007c92;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans JP", sans-serif;
}

:root[data-theme="dark"] {
  color-scheme: dark;
  --bg: #0b1117;
  --surface: #111a21;
  --surface-subtle: #19252e;
  --text: #edf5f7;
  --muted: #a8b8c0;
  --border: #33434e;
  --accent: #35c9e8;
  --accent-strong: #70def2;
  --error: #ff8794;
  --error-surface: #351820;
  --focus: #5adaf2;
}

* {
  box-sizing: border-box;
}

body {
  min-width: 0;
  margin: 0;
  padding: clamp(12px, 4vw, 28px);
  color: var(--text);
  background: var(--bg);
  font-size: 16px;
  line-height: 1.6;
}

main {
  width: min(100%, 720px);
  margin-inline: auto;
}
.upload-card {
  min-width: 0;
  padding: clamp(16px, 4vw, 28px);
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--surface);
}
.eyebrow {
  margin: 0 0 4px;
  color: var(--muted);
  font-size: 0.875rem;
  font-weight: 800;
}
h1,
h2 {
  margin: 0;
  line-height: 1.35;
  overflow-wrap: anywhere;
}
h1 {
  font-size: clamp(1.4rem, 5vw, 2rem);
}
h2 {
  font-size: 1.1rem;
}
.lead {
  margin: 8px 0 24px;
  color: var(--muted);
}
.field {
  display: grid;
  min-width: 0;
  gap: 8px;
  padding: 16px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface-subtle);
}
.field label {
  font-weight: 800;
}
.hint,
.selection-count,
.privacy-note {
  margin: 0;
  color: var(--muted);
  font-size: 0.875rem;
}
input[type="file"] {
  width: 100%;
  min-width: 0;
  min-height: 44px;
  padding: 7px;
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text);
  background: var(--surface);
  font: inherit;
}
input[type="file"]::file-selector-button {
  min-height: 36px;
  margin-right: 10px;
  padding: 6px 12px;
  border: 0;
  border-radius: 6px;
  color: #fff;
  background: var(--accent-strong);
  font: inherit;
  font-weight: 800;
  cursor: pointer;
}
:root[data-theme="dark"] input[type="file"]::file-selector-button {
  color: #062128;
  background: var(--accent);
}
input:focus-visible,
button:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 3px;
}
input:disabled {
  cursor: not-allowed;
  opacity: 0.62;
}
.field.is-invalid {
  border-color: var(--error);
  background: var(--error-surface);
}
.error {
  margin: 0;
  color: var(--error);
  font-weight: 800;
  overflow-wrap: anywhere;
}
.selection {
  margin-top: 18px;
  padding: 16px;
  border: 1px solid var(--border);
  border-radius: 10px;
}
.selection-heading {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
}
button {
  min-height: 44px;
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text);
  background: var(--surface);
  font: inherit;
  font-weight: 800;
  cursor: pointer;
}
button:hover:not(:disabled) {
  border-color: var(--accent);
  color: var(--accent-strong);
}
button:disabled {
  cursor: not-allowed;
  opacity: 0.62;
}
.file-list {
  display: grid;
  gap: 8px;
  margin: 14px 0 0;
  padding: 0;
  list-style: none;
}
.file-item {
  display: flex;
  min-width: 0;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface-subtle);
}
.file-details {
  display: grid;
  min-width: 0;
  gap: 2px;
}
.file-details strong {
  overflow-wrap: anywhere;
}
.file-details span {
  color: var(--muted);
  font-size: 0.875rem;
}
.privacy-note {
  margin-top: 16px;
}
[hidden] {
  display: none;
}

@media (max-width: 440px) {
  body {
    padding: 10px;
  }
  .upload-card {
    padding: 14px;
  }
  .selection-heading,
  .file-item {
    display: grid;
  }
  .selection-heading button,
  .file-item button {
    width: 100%;
  }
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    scroll-behavior: auto;
    transition-duration: 0.01ms;
  }
}

file-selection.js

利用前に変更する箇所

  • client側検証に加えてserver側でも形式、容量、件数を検証する

(() => {
  const params = new URLSearchParams(window.location.search);
  const instance = params.get("instance") ?? "missing-instance";
  const validStates = new Set(["idle", "selected", "invalid-type", "too-large", "disabled"]);
  const validThemes = new Set(["light", "dark"]);
  const allowedExtensions = new Set(["pdf", "txt", "png"]);
  const maximumBytes = 2 * 1024 * 1024;
  const maximumFiles = 2;
  const input = document.querySelector("[data-input]");
  const field = document.querySelector("[data-field]");
  const selection = document.querySelector("[data-selection]");
  const list = document.querySelector("[data-list]");
  const count = document.querySelector("[data-count]");
  const clearButton = document.querySelector("[data-clear]");
  const error = document.querySelector("[data-error]");
  let selectedFiles = [];

  const sampleFiles = [
    new File([new Uint8Array(18432)], "meeting-notes.txt", { type: "text/plain" }),
    new File([new Uint8Array(348160)], "reference-sheet.pdf", { type: "application/pdf" }),
  ];

  function formatBytes(bytes) {
    if (bytes < 1024) return `${bytes} B`;
    if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`;
    return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  }

  function fileType(file) {
    const extension = file.name.includes(".") ? file.name.split(".").pop().toLowerCase() : "";
    return extension ? extension.toUpperCase() : file.type || "不明";
  }

  function showError(message) {
    if (error instanceof HTMLElement) {
      error.textContent = message;
      error.hidden = !message;
    }
    if (field instanceof HTMLElement) field.classList.toggle("is-invalid", Boolean(message));
  }

  function syncNativeInput() {
    if (!(input instanceof HTMLInputElement)) return;
    if (
      typeof DataTransfer === "undefined" ||
      selectedFiles.some((file) => !(file instanceof File))
    ) {
      input.value = "";
      return;
    }
    const transfer = new DataTransfer();
    for (const file of selectedFiles) transfer.items.add(file);
    input.files = transfer.files;
  }

  function renderFiles() {
    if (!(list instanceof HTMLElement) || !(selection instanceof HTMLElement)) return;
    list.replaceChildren();
    selection.hidden = selectedFiles.length === 0;
    if (count instanceof HTMLElement) count.textContent = `${selectedFiles.length}件を選択中`;

    selectedFiles.forEach((file, index) => {
      const item = document.createElement("li");
      item.className = "file-item";
      const details = document.createElement("div");
      details.className = "file-details";
      const name = document.createElement("strong");
      name.textContent = file.name;
      const metadata = document.createElement("span");
      metadata.textContent = `${fileType(file)}・${formatBytes(file.size)}`;
      const remove = document.createElement("button");
      remove.type = "button";
      remove.textContent = "解除";
      remove.dataset.removeIndex = String(index);
      remove.setAttribute("aria-label", `${file.name}を解除`);
      details.append(name, metadata);
      item.append(details, remove);
      list.append(item);
    });
  }

  function isAllowed(file) {
    const extension = file.name.includes(".") ? file.name.split(".").pop().toLowerCase() : "";
    return allowedExtensions.has(extension);
  }

  function applyState(nextState) {
    if (!nextState || !validStates.has(nextState)) return;
    showError("");
    selectedFiles = [];
    if (input instanceof HTMLInputElement) {
      input.disabled = nextState === "disabled";
      input.value = "";
    }
    if (clearButton instanceof HTMLButtonElement) clearButton.disabled = nextState === "disabled";

    if (nextState === "selected") {
      selectedFiles = sampleFiles.slice();
      syncNativeInput();
    }
    if (nextState === "invalid-type")
      showError("選択した形式は利用できません。PDF、TXT、PNGから選び直してください。");
    if (nextState === "too-large")
      showError("選択したファイルが2 MBを超えています。容量を小さくして選び直してください。");
    document.documentElement.dataset.state = nextState;
    renderFiles();
  }

  input?.addEventListener("change", () => {
    if (!(input instanceof HTMLInputElement)) return;
    const files = Array.from(input.files ?? []);
    showError("");
    if (files.length > maximumFiles) {
      input.value = "";
      selectedFiles = [];
      renderFiles();
      showError(`選択できるのは${maximumFiles}件までです。件数を減らして選び直してください。`);
      return;
    }
    const invalid = files.find((file) => !isAllowed(file));
    const oversized = files.find((file) => file.size > maximumBytes);
    if (invalid || oversized) {
      input.value = "";
      selectedFiles = [];
      renderFiles();
      showError(
        invalid
          ? `${invalid.name}は利用できない形式です。PDF、TXT、PNGから選び直してください。`
          : `${oversized.name}は2 MBを超えています。容量を小さくして選び直してください。`,
      );
      return;
    }
    selectedFiles = files;
    document.documentElement.dataset.state = selectedFiles.length ? "selected" : "idle";
    renderFiles();
  });

  list?.addEventListener("click", (event) => {
    const button =
      event.target instanceof Element ? event.target.closest("[data-remove-index]") : null;
    if (!(button instanceof HTMLButtonElement)) return;
    const index = Number(button.dataset.removeIndex);
    if (!Number.isInteger(index)) return;
    selectedFiles.splice(index, 1);
    syncNativeInput();
    renderFiles();
    if (!selectedFiles.length) document.documentElement.dataset.state = "idle";
  });

  clearButton?.addEventListener("click", () => {
    selectedFiles = [];
    if (input instanceof HTMLInputElement) input.value = "";
    showError("");
    renderFiles();
    document.documentElement.dataset.state = "idle";
    if (input instanceof HTMLInputElement) input.focus();
  });

  function setTheme(theme) {
    if (theme && validThemes.has(theme)) document.documentElement.dataset.theme = theme;
  }

  window.addEventListener("message", (event) => {
    if (event.source !== window.parent || typeof event.data !== "object" || event.data === null)
      return;
    const data = event.data;
    if (data.source !== "wpl-host" || data.instance !== instance) return;
    if (data.type === "wpl-preview-state")
      applyState(typeof data.value === "string" ? data.value : null);
    if (data.type === "wpl-preview-theme")
      setTheme(typeof data.value === "string" ? data.value : null);
  });

  setTheme(validThemes.has(params.get("theme")) ? params.get("theme") : "light");
  applyState(validStates.has(params.get("state")) ? params.get("state") : "idle");
  window.parent.postMessage({ source: "wpl-preview", type: "wpl-preview-ready", instance }, "*");
})();
プレビューで試せる動き
  • 未選択: 制約とnative inputを表示します。
  • 選択済み: 許可された複数ファイルのmetadataを表示します。
  • 形式エラー: 利用できない形式と選び直し方を示します。
  • 容量エラー: 容量上限と選び直し方を示します。
  • 無効: 選択操作を利用できない状態です。

使い分けを確認する

このレシピが合う場面

適する場面

  • 送信前に添付候補と制約を確認する入力
  • 複数ファイルの選択結果を一覧で見直す入力
  • 形式や容量の問題を選択直後に修正する入力

適さない場面

  • 機密ファイルの内容previewが必要な場合
  • 大容量・分割uploadや進捗表示が必要な場合
  • server側検証の代わりとして使用する場合