フォーム
ファイル選択フィールド
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側検証の代わりとして使用する場合
