CSVインポート画面(プレビュー+エラー行表示)
この画面パターンについて
一括登録やデータ移行で必ず登場する「CSVを取り込む前に中身を検証して見せる」画面のパターンです。
FileReader でCSVをクライアントサイドで読み込み、プレビューテーブルに展開して、行単位の検証結果をエラー行ハイライト+理由つきで表示します。
エラーが0件になるまで取込ボタンを無効化するガード設計が実装の核心です。
こんな場面で使えます
- ユーザー・会員の一括登録 — 他システムからの移行データを取り込む
- 商品マスタの一括更新 — Excelで編集したデータをCSVで反映する
- 名簿・リストの取り込み — 形式を検証してから登録する
この画面で使っているUIコンポーネント
| # | パーツ | この画面での役割 |
|---|---|---|
| 1 | ファイル選択(D&D+ボタン) | CSVファイルの受け付け |
| 2 | サンプルCSVダウンロード | Blobでテンプレートを生成・配布 |
| 3 | プレビューテーブル | CSV内容を行番号付きで表示 |
| 4 | 行単位バリデーション | エラー行の赤ハイライト+理由表示 |
| 5 | 検証サマリー | OK/エラー件数のバッジ表示 |
| 6 | エラー行のみ表示トグル | エラー行だけを抽出して確認 |
| 7 | 取込実行ボタン | エラー0件のときだけ活性化 |
| 8 | 完了トースト+結果表示 | 取込完了のフィードバック |
実装のポイント・注意点
検証エラーは「どの行の何が悪いか」をユーザーが自力で直せる形で見せることが品質を決めます。 行番号は配列の添字ではなく元ファイルの行番号(ヘッダー=1行目・データは2行目〜)で表示し、Excelで開いたときの行と突き合わせられるようにします。 エラー行の赤ハイライトに加えて、該当セルを赤字+波下線で示し、検証結果列に理由を出すことで「直す場所」まで案内します。 プレビュー上でその場編集はさせず、「CSVを直して再読み込み」のシンプルなフローに割り切るのがおすすめです。
パースは split(',') の簡易方式のため、引用符内のカンマ("東京都,千代田区")には対応していません。厳密な対応が必要な場合は Papa Parse 等のライブラリを使ってください。
また、Excelで保存したCSVは文字コードが Shift_JIS のことが多く、readAsText のデフォルト(UTF-8)では文字化けします。
その場合は readAsText(file, 'shift_jis') への切り替えと、UTF-8(BOM付き)のBOM除去にも注意してください。
セルの表示はCSVというユーザー由来データを扱うため、textContent で組み立てています(XSS対策)。
8個のUIコンポーネントをHTML・CSS・バニラJavaScriptのみで組み合わせており、 フレームワーク不要で画面ごとコピペして使えます。
動作サンプル
動作サンプルを別ウィンドウで確認 ↗
試してみる:
- 「エラー入りサンプルCSV」をダウンロードして読み込み、エラー行の表示を確認
- 「エラー行のみ表示」トグルで、エラー行だけを抽出できることを確認
- エラーがある間は取込ボタンが無効のままであることを確認
そのほかの操作も自由に試してみてください。
サンプルソース
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>CSVインポート画面 サンプル</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div class="import-screen">
<h1 class="screen-title">CSVインポート</h1>
<!-- ===== 説明・サンプルCSVダウンロード ===== -->
<div class="import-guide">
<p>形式:<code>名前,メールアドレス,区分</code>(1行目はヘッダー・区分は A / B / C)</p>
<p>
<button type="button" class="link-btn" id="dlSampleBtn">サンプルCSV</button> /
<button type="button" class="link-btn" id="dlSampleNgBtn">エラー入りサンプルCSV</button>
をダウンロードして試せます
</p>
</div>
<!-- ===== ドロップゾーン ===== -->
<div class="drop-zone" id="dropZone">
<p class="drop-text">CSVファイルをここにドロップ</p>
<p class="drop-sub">または <button type="button" class="btn-secondary" id="selectBtn">ファイルを選択</button></p>
<input type="file" id="fileInput" accept=".csv" hidden>
</div>
<!-- ===== 検証結果(読み込み後に表示) ===== -->
<section class="preview-section" id="previewSection" hidden>
<p class="file-name" id="fileName"></p>
<div class="summary-bar">
<span class="badge badge-ok" id="okBadge"></span>
<span class="badge badge-ng" id="ngBadge" hidden></span>
<label class="toggle-label"><input type="checkbox" id="errorOnlyToggle"> エラー行のみ表示</label>
</div>
<div class="table-wrapper">
<table class="preview-table">
<thead>
<tr><th>#</th><th>名前</th><th>メールアドレス</th><th>区分</th><th>検証結果</th></tr>
</thead>
<tbody id="previewBody"><!-- JSで生成:tr.row-ok / tr.row-error --></tbody>
</table>
</div>
<div class="import-actions">
<button type="button" class="btn-primary" id="importBtn" disabled></button>
<p class="import-note" id="importNote" hidden>エラーを修正したCSVを再度読み込んでください</p>
</div>
</section>
<!-- ===== 取込完了 ===== -->
<section class="complete-section" id="completeSection" hidden>
<p class="complete-icon" aria-hidden="true">✅</p>
<p class="complete-text" id="completeText"></p>
</section>
<!-- ===== トースト ===== -->
<div class="toast" id="toast" role="status" hidden></div>
</div>
<script src="./script.js"></script>
</body>
</html>
/* ===== CSVインポート画面(プレビュー+エラー行表示) — style.css ===== */
*, *::before, *::after { box-sizing: border-box; }
:root {
--color-primary: #2B7FE8;
--color-danger: #D64545;
--color-ok: #1F8A4C;
--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);
}
/* ===== 画面レイアウト ===== */
.import-screen {
max-width: 880px;
margin: 0 auto;
padding: 24px 20px 48px;
}
.screen-title {
font-size: 22px;
margin: 0 0 12px;
}
/* ===== 説明・サンプルCSVダウンロード ===== */
.import-guide {
margin-bottom: 16px;
font-size: 13px;
color: var(--color-muted);
}
.import-guide p { margin: 0 0 4px; }
.import-guide code {
padding: 1px 6px;
font-family: Consolas, "Courier New", monospace;
background: #E9EEF4;
border-radius: 4px;
color: var(--color-text);
}
.link-btn {
padding: 0;
font-size: 13px;
font-family: inherit;
color: var(--color-primary);
background: transparent;
border: none;
text-decoration: underline;
cursor: pointer;
}
/* ===== ドロップゾーン ===== */
.drop-zone {
padding: 32px 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-text {
margin: 0;
font-size: 15px;
font-weight: 700;
}
.drop-sub {
margin: 10px 0 0;
font-size: 13px;
color: var(--color-muted);
}
/* ===== 検証結果セクション ===== */
.preview-section { margin-top: 24px; }
.file-name {
margin: 0;
font-size: 14px;
font-weight: 700;
}
/* ===== サマリーバッジ+トグル ===== */
.summary-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin: 12px 0;
}
.badge {
padding: 3px 12px;
font-size: 12px;
font-weight: 700;
border-radius: 9999px;
white-space: nowrap;
}
.badge-ok { color: var(--color-ok); background: #E5F6EC; }
.badge-ng { color: #C03434; background: #FBE4E4; }
.toggle-label {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
font-size: 13px;
cursor: pointer;
user-select: none;
}
/* ===== プレビューテーブル ===== */
/* 行数が多いCSVでもページが伸びすぎないよう、表示だけスクロールさせる */
.table-wrapper {
max-height: 480px;
overflow: auto;
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: 8px;
}
.preview-table {
width: 100%;
min-width: 640px; /* スマホでは横スクロールで対応 */
border-collapse: collapse;
font-size: 13px;
}
.preview-table th,
.preview-table td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid #E6EBF1;
}
/* 縦スクロールしてもヘッダー行を固定表示する */
.preview-table thead th {
position: sticky;
top: 0;
background: #F1F5F9;
font-size: 12px;
color: #5A6A7A;
white-space: nowrap;
}
/* 行番号(元ファイルの行番号)は等幅フォントで桁を揃える */
.cell-num {
width: 48px;
font-family: Consolas, "Courier New", monospace;
color: var(--color-muted);
text-align: right;
white-space: nowrap;
}
/* ===== エラー行・エラーセル ===== */
tr.row-error { background: #FDF2F2; }
/* エラー行の行頭に「!」マークを付ける */
tr.row-error .cell-num::before {
content: "!";
margin-right: 4px;
font-weight: 700;
color: var(--color-danger);
}
/* エラーのあるセルは赤字+波下線で該当箇所を特定できるようにする */
.cell-error {
color: var(--color-danger);
text-decoration: underline wavy;
text-underline-offset: 3px;
}
/* 検証結果列:OKは控えめに、エラーは理由を赤字で */
.result-ok { font-size: 12px; color: var(--color-muted); }
.result-ng { font-size: 12px; color: var(--color-danger); }
/* ===== 取込ボタン ===== */
.import-actions {
margin-top: 16px;
text-align: right;
}
.import-note {
margin: 8px 0 0;
font-size: 12px;
color: var(--color-danger);
}
/* ===== ボタン ===== */
.btn-primary,
.btn-secondary {
font-size: 14px;
font-family: inherit;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.btn-primary {
padding: 10px 22px;
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 {
padding: 6px 14px;
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;
}
/* ===== 取込完了 ===== */
.complete-section {
margin-top: 24px;
padding: 32px 16px;
text-align: center;
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: 10px;
}
.complete-icon {
margin: 0 0 8px;
font-size: 36px;
}
.complete-text {
margin: 0;
font-size: 16px;
font-weight: 700;
}
/* ===== トースト ===== */
.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 指定との競合対策) */
.import-screen [hidden] { display: none !important; }
/* ===== レスポンシブ(768px以下) ===== */
@media (max-width: 768px) {
.drop-zone { padding: 24px 12px; }
.toggle-label {
margin-left: 0;
width: 100%;
}
.toast {
left: 16px;
right: 16px;
text-align: center;
}
}
/* =====================================================
CSVインポート画面のスクリプト
仕組み:CSVを FileReader で読み込み、行ごとに
{ rowNum, cols, errors } の配列(rows)へ変換する。
プレビュー表・サマリーバッジ・エラー行のみ表示・
取込ボタンの活性は、すべてこの rows から導出して描画する。
パースは split(',') の簡易方式(引用符入りCSVは非対応)。
セルの生成は createElement + textContent(XSS対策)。
===================================================== */
// ===== 設定値 =====
var REQUIRED_COLUMN_COUNT = 3; // 名前・メールアドレス・区分の3列
var MAX_NAME_LENGTH = 50; // 名前の最大文字数
var VALID_CATEGORIES = ['A', 'B', 'C']; // 区分で許可する値
var EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // メール書式の簡易チェック
var TOAST_DURATION_MS = 3000; // トーストの表示時間
// 列番号(cols 配列の添字と検証エラーの対応付けに使う)
var COL_NAME = 0;
var COL_EMAIL = 1;
var COL_CATEGORY = 2;
// ===== サンプルCSVデータ(Blob生成用) =====
var SAMPLE_CSV_OK = [
'名前,メールアドレス,区分',
'サンプル太郎,[email protected],A',
'サンプル花子,[email protected],B',
'サンプル一郎,[email protected],C',
'テスト健太,[email protected],A',
'テスト美咲,[email protected],B',
'テスト大輔,[email protected],C',
'デモ陽子,[email protected],A',
'デモ翔太,[email protected],B',
'デモ恵子,[email protected],C',
'サンプル誠,[email protected],A',
'テスト直樹,[email protected],B',
'デモ由美,[email protected],C'
].join('\n');
// エラー入り版:正常12行に 名前空・メール書式不正・区分の値域外 の3行を追加
var SAMPLE_CSV_NG = SAMPLE_CSV_OK +
'\n,[email protected],A' +
'\nサンプル春子,haruko@example,B' +
'\nサンプル次郎,[email protected],D';
// ===== DOM要素 =====
var dlSampleBtn = document.getElementById('dlSampleBtn');
var dlSampleNgBtn = document.getElementById('dlSampleNgBtn');
var dropZone = document.getElementById('dropZone');
var selectBtn = document.getElementById('selectBtn');
var fileInput = document.getElementById('fileInput');
var previewSection = document.getElementById('previewSection');
var fileNameEl = document.getElementById('fileName');
var okBadge = document.getElementById('okBadge');
var ngBadge = document.getElementById('ngBadge');
var errorOnlyToggle = document.getElementById('errorOnlyToggle');
var previewBody = document.getElementById('previewBody');
var importBtn = document.getElementById('importBtn');
var importNote = document.getElementById('importNote');
var completeSection = document.getElementById('completeSection');
var completeText = document.getElementById('completeText');
var toastEl = document.getElementById('toast');
// ===== 状態 =====
// 読み込んだCSVの検証結果。プレビュー・サマリー・ボタン活性の唯一の情報源。
// 各要素:{ rowNum: 元ファイルの行番号, cols: 列の値, errors: [{ col, message }] }
var rows = [];
// ===== サンプルCSVダウンロード =====
// Blob → オブジェクトURL → a[download] をクリック → URL破棄 の定石パターン
function downloadCsv(csvText, saveName) {
var blob = new Blob([csvText], { type: 'text/csv' });
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = saveName;
link.click();
URL.revokeObjectURL(url);
}
// 「サンプルCSV」クリック → 正常データ12行のCSVをダウンロード
dlSampleBtn.addEventListener('click', function () {
downloadCsv(SAMPLE_CSV_OK, 'sample.csv');
});
// 「エラー入りサンプルCSV」クリック → エラー3行入りの15行版をダウンロード
dlSampleNgBtn.addEventListener('click', function () {
downloadCsv(SAMPLE_CSV_NG, 'sample-error.csv');
});
// ===== ファイル受け付け(ドロップ・選択の両対応) =====
// 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');
if (e.dataTransfer.files.length > 0) {
loadFile(e.dataTransfer.files[0]);
}
});
// 「ファイルを選択」クリック → 隠しておいた input[type="file"] を起動
selectBtn.addEventListener('click', function () {
fileInput.click();
});
// ファイル選択 → 読み込んで input をリセット
// (リセットしないと同じファイルの再選択で change が発火しない)
fileInput.addEventListener('change', function () {
if (fileInput.files.length > 0) {
loadFile(fileInput.files[0]);
}
fileInput.value = '';
});
// ===== CSV読み込み =====
// CSV以外は受け付けない。読み込めたらパース→検証→描画まで進める
function loadFile(file) {
if (file.name.split('.').pop().toLowerCase() !== 'csv') {
showToast('CSVファイルを選択してください');
return;
}
var reader = new FileReader();
reader.onload = function () {
rows = parseCsv(reader.result);
fileNameEl.textContent = 'ファイル名:' + file.name;
errorOnlyToggle.checked = false;
completeSection.hidden = true;
previewSection.hidden = false;
render();
};
// UTF-8前提で読み込む。Excel保存のCSV(Shift_JIS)が文字化けする場合は
// reader.readAsText(file, 'shift_jis') に切り替える
reader.readAsText(file);
}
// ===== パース+検証 =====
// 【注意】split(',') の簡易パーサーのため、引用符内のカンマ
// ("東京都,千代田区" のような値)には対応していない。
// 厳密な対応が必要な場合は Papa Parse 等のCSVライブラリを使う
function parseCsv(text) {
var lines = text.split(/\r\n|\n/);
var result = [];
// i = 0 はヘッダー行なので読み飛ばす。
// 行番号は元ファイル基準(ヘッダー=1行目・データは2行目〜)にして、
// Excelで開いたときの行と突き合わせられるようにする
for (var i = 1; i < lines.length; i++) {
if (lines[i].trim() === '') { continue; } // 末尾改行などの空行はスキップ
var cols = lines[i].split(',');
result.push({ rowNum: i + 1, cols: cols, errors: validateRow(cols) });
}
return result;
}
// 1行分を検証してエラーの配列を返す(col はエラーのある列番号)
function validateRow(cols) {
var errors = [];
if (cols.length !== REQUIRED_COLUMN_COUNT) {
errors.push({ col: -1, message: '列の数が正しくありません' });
return errors; // 列がずれている行は個別の検証をしても意味がない
}
if (cols[COL_NAME] === '') {
errors.push({ col: COL_NAME, message: '名前が空です' });
} else if (cols[COL_NAME].length > MAX_NAME_LENGTH) {
errors.push({ col: COL_NAME, message: '名前が長すぎます' });
}
if (cols[COL_EMAIL] === '') {
errors.push({ col: COL_EMAIL, message: 'メールアドレスが空です' });
} else if (!EMAIL_PATTERN.test(cols[COL_EMAIL])) {
errors.push({ col: COL_EMAIL, message: 'メールの形式が不正です' });
}
if (VALID_CATEGORIES.indexOf(cols[COL_CATEGORY]) === -1) {
errors.push({ col: COL_CATEGORY, message: '区分はA・B・Cのいずれかで指定してください' });
}
return errors;
}
// ===== 描画 =====
// rows の内容からプレビュー表・サマリー・取込ボタンをすべて作り直す
function render() {
var okCount = 0;
var ngCount = 0;
rows.forEach(function (row) {
if (row.errors.length === 0) { okCount++; } else { ngCount++; }
});
// サマリーバッジ(エラー0件のときはOKバッジのみ)
okBadge.textContent = 'OK ' + okCount + '件';
ngBadge.textContent = 'エラー ' + ngCount + '件';
ngBadge.hidden = ngCount === 0;
// 取込ボタン:エラー0件かつデータありのときだけ活性化
importBtn.textContent = okCount + '件を取り込む';
importBtn.disabled = ngCount > 0 || okCount === 0;
importNote.hidden = ngCount === 0;
// プレビュー表を作り直す
previewBody.textContent = '';
rows.forEach(function (row) {
previewBody.appendChild(createRow(row));
});
applyErrorOnlyFilter();
}
// 1行分の <tr> を生成する。CSVの中身はユーザー由来データそのものなので
// innerHTML は使わず createElement + textContent で組み立てる
function createRow(row) {
var tr = document.createElement('tr');
var hasError = row.errors.length > 0;
tr.className = hasError ? 'row-error' : 'row-ok';
// 行番号(元ファイルの行番号)
var numCell = document.createElement('td');
numCell.className = 'cell-num';
numCell.textContent = row.rowNum;
tr.appendChild(numCell);
// 名前・メールアドレス・区分(エラーの列は赤字+波下線で強調)
for (var col = 0; col < REQUIRED_COLUMN_COUNT; col++) {
var td = document.createElement('td');
td.textContent = row.cols[col] !== undefined ? row.cols[col] : '';
if (hasColumnError(row, col)) {
td.classList.add('cell-error');
}
tr.appendChild(td);
}
// 検証結果列(OKは控えめに・エラーは理由を赤字で)
var resultCell = document.createElement('td');
if (hasError) {
resultCell.className = 'result-ng';
resultCell.textContent = row.errors.map(function (err) {
return err.message;
}).join(' / ');
} else {
resultCell.className = 'result-ok';
resultCell.textContent = 'OK';
}
tr.appendChild(resultCell);
return tr;
}
// その列にエラーがあるか(cell-error の付与判定)
function hasColumnError(row, col) {
return row.errors.some(function (err) {
return err.col === col;
});
}
// ===== エラー行のみ表示 =====
// トグルON → OK行に hidden を付けて隠すだけ(再描画はしない)
errorOnlyToggle.addEventListener('change', applyErrorOnlyFilter);
function applyErrorOnlyFilter() {
var errorOnly = errorOnlyToggle.checked;
var okRows = previewBody.querySelectorAll('tr.row-ok');
Array.prototype.forEach.call(okRows, function (tr) {
tr.hidden = errorOnly;
});
}
// ===== 取り込み =====
// 取込クリック → プレビューを完了表示に切り替えてトーストで知らせる
// (ボタンはエラー0件のときだけ活性なので rows は全行OK。実際の登録処理は行わない)
importBtn.addEventListener('click', function () {
var message = rows.length + '件を取り込みました';
completeText.textContent = message;
previewSection.hidden = true;
completeSection.hidden = false;
showToast(message);
});
// ===== トースト =====
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 など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。
# CSVインポート画面 作成依頼
## 概要
CSVファイルを読み込んでプレビュー表示し、行単位の検証エラーを示してから
取り込みを実行するインポート画面を作成してください。サーバーは使わず、
ブラウザ内(FileReader)で完結させます。
## 要件
- CSV形式:「名前,メールアドレス,区分」の3列。1行目はヘッダーとして読み飛ばす
- ファイルはドラッグ&ドロップとファイル選択ボタンの両方で受け付ける(.csvのみ)
- 読み込んだ内容を行番号付きのプレビューテーブルに表示する
- 行単位で検証する:列数が3でない/名前が空または50文字超/メールが空または形式不正/
区分が A・B・C 以外 はエラー
- エラー行は赤背景+行頭に「!」マークを付け、エラーのセルは赤字+波下線で強調し、
行末の検証結果列に理由(「メールの形式が不正です」等)を表示する
- 「OK 12件」「エラー 3件」のサマリーバッジを表示する
- 「エラー行のみ表示」チェックボックスでOK行を隠せる
- エラーが0件のときだけ「12件を取り込む」ボタンを活性化し、クリックで
完了トースト「12件を取り込みました」を表示する(実際の登録処理は行わない)
- 動作確認用に「サンプルCSV」(正常12行)と「エラー入りサンプルCSV」(正常12行+
名前空・メール書式不正・区分D の3行)をBlobで生成してダウンロードできるリンクを置く
## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- CSVパースは カンマ区切りの単純分割で実装し、引用符付きCSVに対応しない簡易版である旨を
コードコメントに明記する
- レスポンシブ対応:必要(テーブルは横スクロール)
## 動作詳細
- FileReader.readAsText でテキストとして読み込み、改行(\r\n / \n)で行分割する
- 検証結果は行ごとのオブジェクト配列で保持し、テーブル描画・サマリー・ボタン活性を
すべてその配列から導出する
- セルの表示は textContent を使い、innerHTML にCSVの値を結合しない
## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。