Old-School Front-End / 2026 ・ No.09
いにしえのフロントエンド入門
第9回 JavaScript ③ ── 入力チェック:分かりやすく止めて、直し方を伝える
公開
AI に頼まず、一行ずつ手で書く「いにしえの」フロントエンド入門。第9回は入力チェックです。
第1回で「ルールを画面で守らせる方法は第9回で」、第3回で「3万円以上なら領収書が必須、を守らせるのは第9回」と約束しました。
ブラウザの検査にどこまで任せられるかを測り、足りないところを JavaScript で引き取って、
まとめて知らせ、直し方を伝え、直したらすぐ許すフォームにします。
今回のゴール
新井先輩、件名を空のまま「申請する」を押してみたら……あれ、止まりました。「このフィールドを入力してください。」って。
古井第3回で付けた required が効いているんだ。ブラウザの検査だな。
新井じゃあ、入力チェックはもう完成ですね!
古井金額に「約1万」と入れて押してみろ。
新井……通りました。
古井それに「合計3万円以上なら領収書」は、どの欄の required にも書けない。今日はその続きを JavaScript で作る。
いまここ:第9回
第8回の申請フォームに、次の入力チェックを付けます。
- まとめて知らせる ── 引っかかった項目を、フォームの上に一覧で。項目を押すと、その欄へ飛ぶ
- 直し方を伝える ── 「2行目の金額は「14720」のように数字だけで入力してください。」
- 欄をまたぐルール ── 合計3万円以上なら領収書が必須。未来の日付はだめ。空の行は数えない
- 責めない ── 打っている最中は赤い枠を増やさず、直したらその場で消す
ルールは新しく作る js/validate.js に、画面の仕事は第8回の js/form.js に足します。第7回の js/app.js は、今回も1行も変えません。
ブラウザの検査、どこまで任せる?
第3回で付けた required や pattern は、ブラウザに備わった検査の仕組み(制約検証)です。JavaScript なしの小さなフォームで、Chromium がどう振る舞うかを確かめました。
# ブラウザまかせの検査(Chromium・日本語。JS なしの小さなフォームで実測) 「送信」を押すと → 送信されない。最初の欄(件名)へカーソルが移る 引っかかった欄 → 3 つ。でも、知らせてくれるのは最初の欄だけ 件名(required・空) → 「このフィールドを入力してください。」 チェック(required) → 「次に進むにはこのチェックボックスをオンにしてください。」 金額(required)に「約1万」 → 合格(空でなければ通る) 金額(pattern="[0-9]+") → 「指定されている形式で入力してください。」
ブラウザに任せられること、任せられないこと
空欄を止めるだけなら、ブラウザで十分です。しかし文言はブラウザと言語の設定で決まり、「指定されている形式」と言われても、利用者にはどの形式か分かりません。もう一つ、第6回で見たとおり、Bootstrap の was-validated を付けると、空の任意の欄にまで緑のチェックが付きます。「入力していないのに OK?」と迷わせるので、これも使いません。
方針:novalidate で引き取る
そこで、ブラウザの検査を止めて、JavaScript が自分でチェックします。止める方法は form の novalidate 属性ですが、HTML に書くのではなく、JavaScript の中で付けます。
form.noValidate = true; // JS が動いたときだけ、ブラウザの検査を止める
こうしておけば、JavaScript が読み込めなかったときには、required によるブラウザの検査が残ります。HTML の required を消さないのも同じ理由で、ブラウザの検査は保険です。
「申請する」が押されてから
新井「下書き保存」には第3回で formnovalidate を付けましたよね。novalidate にしたら、意味がなくなりませんか?
古井ブラウザにとってはな。だが属性は残っている。event.submitter は押されたボタンだから、event.submitter.formNoValidate を見れば「下書きだからチェックしない」と JS が判断できる。HTML に書いた意図を、JS が引き継ぐわけだ。
件名の欄で Enter キーを押すと、フォームは送信されます。このとき「押されたボタン」になるのは、フォームの中で最初に書かれた送信ボタンです。このフォームでは「下書き保存」が先にあるので、Enter で送ると、チェックなしの下書き保存として扱われました。今回は下書きなので害は小さいのですが、ボタンの並び順にも意味があることは覚えておきましょう。
ルールを関数にする
第7回・第8回と同じく、ルールは画面から切り離して関数にします。validateClaim は申請の中身(ただのオブジェクト)を受け取り、「どの欄が・なぜだめか」の配列を返します。空の配列なら合格です。
| 欄 | ルール | メッセージ(例) |
|---|---|---|
| 件名 | 空白だけもだめ | 件名を入力してください。 |
| 明細 | まったく空の行は数えない。1行もなければだめ | 明細を1行以上入力してください。 |
| 日付 | 必須。未来の日付はだめ | 3行目の日付が未来になっています。使った日を入力してください。 |
| 区分・内容 | 必須 | 2行目の内容を入力してください(例:新幹線 東京→新大阪)。 |
| 金額 | 空・読めない・0円はだめ | 2行目の金額は「14720」のように数字だけで入力してください。 |
| 領収書 | 円の行の合計が3万円以上なら必須 | 合計が3万円以上なので、領収書を添付してください。 |
| 規程の確認 | チェックが必須 | 経費規程を確認して、チェックを入れてください。 |
金額は第7回の toAmount で読みます。あのとき「空欄なら null、読めなければ NaN を返す」と決めたのは、ここで「入力してください」と「数字だけで」を出し分けるためでした。
// 経費精算システム:入力チェックのルール(第9回)
// 画面には触らない。申請の中身を受け取り、「どの欄が・なぜだめか」のリストを返す
// app.js の toAmount・calcTotal・needsReceipt を使う
/** 今日の日付を 'YYYY-MM-DD' で返す(パソコンの時計の、その土地の日付) */
function localDate(d = new Date()) {
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${month}-${day}`;
}
/** 明細の行が、まったくの空か(通貨は最初から選ばれているので見ない) */
function isBlankRow(row) {
return row.date === '' && row.category === '' && row.item.trim() === '' && row.amount.trim() === '';
}
/**
* 申請の中身をチェックする
* claim = { title, rows: [{ date, category, item, amount, currency }], receipts, agreed }
* 戻り値 = [{ field: 'amount-2', message: '…' }, …](空の配列なら合格)
*/
function validateClaim(claim, today = localDate()) {
const errors = [];
const add = (field, message) => errors.push({ field, message });
if (claim.title.trim() === '') add('title', '件名を入力してください。');
const items = [];
let filled = 0;
claim.rows.forEach((row, i) => {
if (isBlankRow(row)) return; // まったく空の行は数えない(forEach の return は「次の行へ」)
filled++;
const n = i + 1;
if (row.date === '') add(`date-${n}`, `${n}行目の日付を入力してください。`);
else if (row.date > today) add(`date-${n}`, `${n}行目の日付が未来になっています。使った日を入力してください。`); // 'YYYY-MM-DD' どうしは、文字の大小で日付の前後が分かる
if (row.category === '') add(`category-${n}`, `${n}行目の区分を選んでください。`);
if (row.item.trim() === '') add(`item-${n}`, `${n}行目の内容を入力してください(例:新幹線 東京→新大阪)。`);
const amount = toAmount(row.amount);
if (amount === null) add(`amount-${n}`, `${n}行目の金額を入力してください。`); // 空欄
else if (Number.isNaN(amount)) add(`amount-${n}`, `${n}行目の金額は「14720」のように数字だけで入力してください。`); // 数字として読めない
else if (amount === 0) add(`amount-${n}`, `${n}行目の金額が 0 円です。使った金額を入力してください。`);
else if (row.currency === 'JPY') items.push({ amount }); // 合計は円の行だけ(換算は第10回)
});
if (filled === 0) add('date-1', '明細を1行以上入力してください。');
if (needsReceipt(calcTotal(items)) && claim.receipts === 0) add('receipt', '合計が3万円以上なので、領収書を添付してください。');
if (!claim.agreed) add('agree', '経費規程を確認して、チェックを入れてください。');
return errors;
}
- field は画面の欄の id ── form.js が行番号付きの id(amount-2 など)を付けておき、それで赤い枠を付ける欄を探す
- 空の行は責めない ── 「+ 行を追加」を押しすぎた空の行に、4件もエラーを出さない。forEach の中の return は「この行は飛ばして次へ」
- today は引数で渡せる ── ふだんは今日の日付。テストでは「2026-10-17」と決め打ちにして、時計に左右されないようにする
画面に触らない関数なので、第7回と同じ手作りのテストが書けます。境目(29,999円と30,000円、今日と明日)を必ず入れるのがコツです。
✓ 正しい申請は合格 ✓ 件名が全角スペースだけ ✓ 明細がすべて空 ✓ 空の行は数えない ✓ 金額「約1万」 ✓ 金額「0」 ✓ 明日の日付 ✓ 今日の日付は OK ✓ 合計 29,999 円・領収書なし ✓ 合計 30,000 円・領収書なし ✓ 合計 30,000 円・領収書あり ✓ ドルの行は合計に入れない ✓ 規程の確認なし ✓ 2行目だけ内容が空 入力チェックのテスト: 14 件中 14 件 合格
正規表現 /^[0-9]+$/ を読む
第7回の toAmount で「詳しくは第9回で」と言った決まり文句です。正規表現は、文字の並び方の決まりを書く小さな言語で、1文字ずつ読めば怖くありません。
記号を1つずつ読む
# 正規表現の確かめ(Chromium で実行) 文字列 /^[0-9]+$/ /[0-9]+/ '14720' ○ ○ '14,720' × ○ '14720' × × '' × × '12a' × ○ ← 数字が1つでもあれば通ってしまう '約1万' × ○ ← 数字が1つでもあれば通ってしまう '-500' × ○ ← 数字が1つでもあれば通ってしまう '1.5' × ○ '14720 ' × ○
^ と $ を忘れると、「どこかに数字が1つあれば合格」になり、「約1万」も「-500」も通ってしまいます。toAmount は、先に全角を半角に直し、カンマと前後の空白を取ってから、この式で最初から最後まで数字だけかを確かめています。だから「14,720」は読めて、「約1万」は読めません。
新井AI に頼むと、もっと長い正規表現が出てきます。呪文みたいで読めません……。
古井読めないものは、上の表のように例を並べて試す。通ってほしいものと、止めてほしいもの。両方が期待どおりなら使っていい。
日付の罠:toISOString
「未来の日付はだめ」を確かめるには「今日」が要ります。ネットでよく見かけるのが new Date().toISOString().slice(0, 10) という書き方。時計と時間帯を固定して、試してみました。
# 同じ瞬間の「今日」(Chromium で時計と時間帯を固定して実測) 時間帯と時刻 toISOString() localDate() 東京 08:30 2026-10-16 2026-10-17 ← ずれる 東京 09:30 2026-10-17 2026-10-17 上海 07:30 2026-10-16 2026-10-17 ← ずれる ニューヨーク 21:00 2026-10-18 2026-10-17 ← ずれる
同じ10月17日でも、朝9時までは「昨日」
toISOString() は世界標準時(UTC)で日付を出します。日本は UTC より9時間進んでいるので、朝9時までは前日です。これで「今日」を作ると、朝8時半に今朝のタクシー代を申請した人が「未来の日付です」と叱られます。validate.js の localDate() は、getFullYear・getMonth(0から数えるので +1)・getDate で、その土地の日付を組み立てています。
もう一つの小技が、padStart(2, '0') で月日を2桁にそろえること。'2026-10-09' のように桁がそろっていれば、文字の大小を比べるだけで日付の前後が分かります('2026-10-9' だと、'2026-10-10' より後ろと判定されてしまう)。
メッセージは「直し方」を書く
分かりにくいメッセージ、分かりやすいメッセージ
- どこ ── 何行目の、どの欄か。明細のように同じ欄が並ぶときは、行番号が欠かせない
- どう直す ── 「不正です」ではなく、次にする行動を書く
- 例 ── 形式は、言葉で説明するより例で見せるのがいちばん早い
- 色だけに頼らない ── 赤い枠に加えて文字で伝え、aria-invalid="true" で読み上げソフトにも「この欄に問題がある」と伝える
新井AI に「エラーメッセージを付けて」と頼んだら、全部「入力値が不正です」でした。
古井「不正」は、言われた側には濡れ衣だよ。こちらの決まりを知らなかっただけなんだから。
画面に出す:赤い枠・まとめ・フォーカス
validateClaim の結果を、form.js の showErrors が画面に出します。やることは三つです。
- 引っかかった欄にだけ赤い枠 ── Bootstrap の is-invalid クラスと aria-invalid を付ける。was-validated は使わないので、空の任意の欄に緑のチェックは付かない(第6回の宿題はこれで解決)
- フォームの上に「まとめ」 ── 件数と、各項目へのリンク。長いフォームでも、何をいくつ直せばいいかが最初に分かる
- まとめにフォーカスを移す ── 画面がまとめまで動き、読み上げソフトにもまとめの中身(件数)が伝わる
<div class="alert alert-danger" id="error-summary" tabindex="-1" hidden> <h2 class="h6 alert-heading">入力内容を確認してください</h2> <ul class="mb-0"></ul> <!-- ここに li とリンクを JS で入れる --> </div>
tabindex="-1" は、「Tab キーでは止まらないが、JS の focus() でならフォーカスできる」という印です。div はふつうフォーカスできないので、これを付けておきます。
まとめのリンクは、実はリンクのままでも Chromium はその欄にカーソルを入れてくれました(実測)。ただ、URL の末尾に #amount-2 が付いてブラウザの「戻る」の履歴が1つ増えます。しかも欄は画面のいちばん上にぴったり寄せられ(実測)、上にある見出しや行が隠れがちです。そこでクリックを横取りし、scrollIntoView で欄を画面の真ん中に出してから focus しています。
最後に、赤い枠を増やすのは「申請する」を押したときだけ、減らすのはその場で、というルールです。入力のたびに全部チェックし直すと、2行目の日付を入れた瞬間に「内容を入力してください」「金額を入力してください」と赤くなります。まだ打っている途中なのに、です。そこで入力のたびには、すでに赤い欄だけをチェックし直して、直っていれば赤を外します。
// ---- 第9回:入力チェック ----
const form = document.querySelector('form');
const summary = document.querySelector('#error-summary');
const result = document.querySelector('#result');
form.noValidate = true; // ブラウザの検査は止めて、自分でチェックする(JS が動かないときは、ブラウザの検査が残る)
/** 画面から申請の中身を読む */
function readClaim() {
return {
title: form.elements.title.value,
rows: [...tbody.rows].map((row) => ({
date: row.querySelector('[name="date"]').value,
category: row.querySelector('[name="category"]').value,
item: row.querySelector('[name="item"]').value,
amount: row.querySelector('[name="amount"]').value,
currency: row.querySelector('[name="currency"]').value,
})),
receipts: form.elements.receipt.files.length,
agreed: form.elements.agree.checked,
};
}
/** チェックの結果を画面に出す(赤い枠と、上のまとめ) */
function showErrors(errors) {
for (const field of form.querySelectorAll('.is-invalid')) {
field.classList.remove('is-invalid');
field.removeAttribute('aria-invalid');
}
const items = errors.map(({ field, message }) => {
const el = document.getElementById(field);
el.classList.add('is-invalid');
el.setAttribute('aria-invalid', 'true');
const a = document.createElement('a');
a.href = '#' + field;
a.className = 'alert-link';
a.textContent = message; // 文言は textContent で(第8回)
const li = document.createElement('li');
li.append(a);
return li;
});
summary.querySelector('ul').replaceChildren(...items);
summary.querySelector('h2').textContent = `入力内容を確認してください(${errors.length}件)`;
summary.hidden = errors.length === 0;
}
/** 赤い枠の欄だけ、もう一度チェックする(直ったら赤はすぐ消す。赤を増やすのは「申請する」のときだけ) */
function recheck() {
const marked = new Set([...form.querySelectorAll('.is-invalid')].map((el) => el.id));
showErrors(validateClaim(readClaim()).filter((e) => marked.has(e.field)));
}
form.addEventListener('submit', (event) => {
event.preventDefault(); // 送信のしくみは第10回。今回はここで止める
result.hidden = true;
if (event.submitter.formNoValidate) { // 「下書き保存」はチェックしない(第3回の formnovalidate)
result.textContent = '下書きはチェックせずに保存します(保存のしくみは第10回)。';
result.hidden = false;
return;
}
const errors = validateClaim(readClaim());
showErrors(errors);
if (errors.length > 0) {
summary.focus(); // まとめへ移動。読み上げソフトにも「何件あるか」が伝わる
} else {
result.textContent = 'チェックを通りました。送信のしくみは第10回で作ります。';
result.hidden = false;
}
});
form.addEventListener('input', recheck); // 直したら、その場で赤が消える
// まとめのリンク:その欄へカーソルを移す(URL に #… を付けず、「戻る」の履歴も増やさない)
summary.addEventListener('click', (event) => {
const link = event.target.closest('a');
if (!link) return;
event.preventDefault();
const field = document.getElementById(link.hash.slice(1));
field.scrollIntoView({ block: 'center' }); // 欄を画面の真ん中に(上の行や見出しも見えるように)
field.focus({ preventScroll: true });
});
動かしてみる
Playwright で実際に入力・クリックし、そのたびにまとめの件数・赤い枠の欄・フォーカスの位置を記録しました。日付のチェックがあるので、ブラウザの時計は 2026年10月17日 10:00(東京)に固定しています。
# 実際に操作して記録した値(Chromium) 操作 まとめ 赤い枠の欄 フォーカス 開いた直後 ― ― ― 何も入れずに「申請する」 3件 title date-1 agree error-summary 件名を入力 2件 date-1 agree title 1行目を入力 1件 agree amount-1 2・3行目を入力 ※ 1件 agree amount-3 「申請する」 4件 item-2 amount-2 date-3 agree error-summary まとめの「2行目の金額…」を押す 4件 item-2 amount-2 date-3 agree amount-2 2行目の金額を「12800」に 3件 item-2 date-3 agree amount-2 内容と日付を直し、規程にチェック ― ― agree 「申請する」 1件 receipt error-summary 領収書を添付 ― ― ― 「申請する」 ― 「チェックを通りました」 BUTTON ※ 2行目は内容が空・金額「約1万」、3行目は明日の日付(2026-10-18)
2・3行目を入力しているあいだ、まとめは1件のまま増えていません。「申請する」で初めて4件になり、直すたびに減って、0件でまとめが消えました。領収書のルールは、金額を直して合計が 42,240 円になったあとの「申請する」で現れています。
ここまでのチェックは、すべて利用者のブラウザの中で動いています。ブラウザの開発者ツールを使えば、JavaScript を書きかえたり、フォームを通さずに直接送ったりして、すり抜けることができます。フロントのチェックは「親切」、守りはサーバー。送った先でも同じルールをもう一度確かめるのが、いにしえからの鉄則です。
結 — 一枚まとめと次回予告
- ブラウザの検査(required・pattern)は保険。知らせるのは最初の1つだけ、文言は選べず、欄をまたぐルールは書けない。JS が動いたときだけ novalidate にして引き取る。
- ルールは validate.js の関数に。画面に触らず「どの欄が・なぜ」の配列を返すので、手作りのテストで境目まで確かめられる。
- 正規表現は ^ と $ で「最初から最後まで」を縛る。「今日」は toISOString ではなく、その土地の日付を組み立てる。
- メッセージは「どこ・どう直す・例」。赤い枠は引っかかった欄だけに付け、上のまとめにフォーカスを移す。
- 赤い枠は送信のときだけ増やし、直したらすぐ消す。フロントのチェックは親切、守りはサーバー。
新井これで「申請する」を押せば、経理に届くんですね!
古井今はまだ「通りました」と言って止まるだけだ。次回は非同期通信。fetch で、画面を切り替えずにサーバーとやりとりする。ずっと後回しにしてきたドルの換算も、そこで片付けるぞ。
参考
- MDN — Using HTML form validation and the Constraint Validation API
- MDN — HTML attribute: pattern
- MDN — SubmitEvent: submitter property
- MDN — Regular expressions
- MDN — Date.prototype.toISOString()
- MDN — ARIA: aria-invalid attribute
- MDN — tabindex
- MDN — Element: scrollIntoView() method
- Bootstrap 5.3 — Validation
- W3C — Understanding SC 3.3.1: Error Identification
- W3C — Understanding SC 3.3.3: Error Suggestion
- W3C — G139: Creating a mechanism that allows users to jump to errors
- OWASP — Input Validation Cheat Sheet
ページの写真は、筆者が作ったデモページを2026年10月17日にクラウド上の Chromium(Playwright)で撮影したものです。撮影環境には Hiragino Sans がないため、日本語は Noto Sans CJK JP で表示されています。ブラウザまかせの検査のメッセージ、まとめの件数・赤い枠の欄・フォーカスの移り先、まとめのリンクの動き、Enter キーで送ったときの扱い、正規表現の結果、時計と時間帯を固定したときの toISOString と localDate の日付、手作りのテスト14件は、同じ Chromium を Playwright で操作・実行して記録した実際の値です。ブラウザの検査のメッセージは、ブラウザの種類や言語の設定によって変わります。form.html は Nu Html Checker(vnu)でエラーがないことを確かめています。登場人物は架空の人物です。
いにしえのフロントエンド入門 第9回 — JavaScript ③:入力チェック / 2026.10