Old-School Front-End / 2026 ・ No.09

いにしえのフロントエンド入門

第9回 JavaScript ③ ── 入力チェック:分かりやすく止めて、直し方を伝える

公開

Bootstrap の申請フォームで「申請する」を押したところ。フォームの上に赤い帯のまとめがあり、「入力内容を確認してください(4件)」の下に4つのリンク。明細の2行目の内容と金額(約1万)、3行目の日付(2026/10/18)に赤い枠が付いている
今回のゴール。引っかかった欄にだけ赤い枠を付け、上のまとめで「どこを・どう直すか」を伝える(筆者のデモページを、クラウド上の Chromium で撮影。Playwright で実際に入力・クリックしたところ。2026年10月17日。ブラウザの枠は説明のために描き足したもの)

AI に頼まず、一行ずつ手で書く「いにしえの」フロントエンド入門。第9回は入力チェックです。
第1回で「ルールを画面で守らせる方法は第9回で」、第3回で「3万円以上なら領収書が必須、を守らせるのは第9回」と約束しました。
ブラウザの検査にどこまで任せられるかを測り、足りないところを JavaScript で引き取って、
まとめて知らせ、直し方を伝え、直したらすぐ許すフォームにします。

一

今回のゴール

新井先輩、件名を空のまま「申請する」を押してみたら……あれ、止まりました。「このフィールドを入力してください。」って。

古井第3回で付けた required が効いているんだ。ブラウザの検査だな。

新井じゃあ、入力チェックはもう完成ですね!

古井金額に「約1万」と入れて押してみろ。

新井……通りました。

古井それに「合計3万円以上なら領収書」は、どの欄の required にも書けない。今日はその続きを JavaScript で作る。

いまここ:第9回

1 準備 2 HTML の骨組み 3 フォーム 4 CSS の基本 5 レイアウト 6 Bootstrap 7 JS の基本 8 DOM・イベント 9 入力チェック 10 通信 11 ログイン 12 承認の流れ 13 集計と印刷 14 jQuery 15 仕上げ いまここ 準備 HTML CSS JavaScript 実戦 仕上げ

第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]+") → 「指定されている形式で入力してください。」

ブラウザに任せられること、任せられないこと

required・pattern に任せると(Chromium で実測) 空欄のまま送られるのを止めてくれる JavaScript が動かなくても効く 知らせてくれるのは、最初の1つだけ(3つあっても) 文言は選べない:「指定されている形式で入力してください。」 「約1万」でも、required は通してしまう 「3万円以上なら領収書」のような、欄をまたぐルールは書けない → ブラウザの検査は保険として残し、JS で引き取る(novalidate)

空欄を止めるだけなら、ブラウザで十分です。しかし文言はブラウザと言語の設定で決まり、「指定されている形式」と言われても、利用者にはどの形式か分かりません。もう一つ、第6回で見たとおり、Bootstrap の was-validated を付けると、空の任意の欄にまで緑のチェックが付きます。「入力していないのに OK?」と迷わせるので、これも使いません。

三

方針:novalidate で引き取る

そこで、ブラウザの検査を止めて、JavaScript が自分でチェックします。止める方法は form の novalidate 属性ですが、HTML に書くのではなく、JavaScript の中で付けます。

form.noValidate = true;   // JS が動いたときだけ、ブラウザの検査を止める

こうしておけば、JavaScript が読み込めなかったときには、required によるブラウザの検査が残ります。HTML の required を消さないのも同じ理由で、ブラウザの検査は保険です。

「申請する」が押されてから

「申請する」 submit イベント 下書き? いいえ validateClaim 画面の中身を渡す はい チェックしない 下書き保存 0件? はい 通過 送信は第10回 いいえ 赤い枠+まとめ まとめへフォーカス 最初に event.preventDefault() で送信を止める(送るのは第10回) 下書きかどうかは event.submitter(押されたボタン)で分かる

新井「下書き保存」には第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つずつ読む

/ ^ [0-9] + $ / ^ 先頭から [0-9] 数字1文字(0〜9) + …を1回以上くり返して $ 末尾まで(ほかの文字はなし) ^ と $ を忘れると(実測) 約 1 万 /[0-9]+/.test(…) → true。中の「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時までは「昨日」

localDate() 東京の日付 10月17日 toISOString() 世界標準時の日付 10月16日 10月17日 0:00 9:00 24:00 8:30 に申請(実測) toISOString() は世界標準時(UTC)。日本では朝9時まで「昨日」になる 「今日」は getFullYear・getMonth・getDate で、その土地の日付を組み立てる

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' より後ろと判定されてしまう)。

七

メッセージは「直し方」を書く

分かりにくいメッセージ、分かりやすいメッセージ

「入力エラーです。」 どこが? なぜ? どうすれば? が分からない 「2行目の金額は「14720」のように数字だけで入力してください。」 どこ 2行目の金額 どう直す 数字だけで 例 「14720」のように 利用者を責める言葉(「不正な値」「エラー」)より、次にする行動を書く 打っている最中は赤を増やさない。直したら、その場で消す
  • どこ ── 何行目の、どの欄か。明細のように同じ欄が並ぶときは、行番号が欠かせない
  • どう直す ── 「不正です」ではなく、次にする行動を書く
  • 例 ── 形式は、言葉で説明するより例で見せるのがいちばん早い
  • 色だけに頼らない ── 赤い枠に加えて文字で伝え、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 はふつうフォーカスできないので、これを付けておきます。

スマホの幅で「申請する」を押したところ。画面のいちばん上に赤い帯のまとめ「入力内容を確認してください(3件)」と3つのリンク。その下に、赤い枠の件名の欄とメッセージ
スマホでも、まとめは画面のいちばん上。フォーカスもそこへ移る(筆者のデモページを、クラウド上の Chromium で撮影。Playwright で画面の幅を 390px にしたところ。2026年10月17日)

まとめのリンクは、実はリンクのままでも 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(東京)に固定しています。

3つの場面。1 何も入れずに申請すると、まとめに3件、件名の欄が赤くなり、その下にメッセージ。2 直して合計が 42,240 円になると、領収書の欄だけが赤い。3 領収書を添付して申請すると、赤い枠が消え、緑の帯で「チェックを通りました」
①空のまま申請、②直したら3万円を超えて領収書が必要に、③領収書を添えて通過(筆者のデモページを、クラウド上の Chromium で撮影。Playwright で実際に入力・クリックしたところ。2026年10月17日。ブラウザの枠は説明のために描き足したもの)
# 実際に操作して記録した値(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 円になったあとの「申請する」で現れています。

POINT

ここまでのチェックは、すべて利用者のブラウザの中で動いています。ブラウザの開発者ツールを使えば、JavaScript を書きかえたり、フォームを通さずに直接送ったりして、すり抜けることができます。フロントのチェックは「親切」、守りはサーバー。送った先でも同じルールをもう一度確かめるのが、いにしえからの鉄則です。

結 — 一枚まとめと次回予告

  1. ブラウザの検査(required・pattern)は保険。知らせるのは最初の1つだけ、文言は選べず、欄をまたぐルールは書けない。JS が動いたときだけ novalidate にして引き取る。
  2. ルールは validate.js の関数に。画面に触らず「どの欄が・なぜ」の配列を返すので、手作りのテストで境目まで確かめられる。
  3. 正規表現は ^ と $ で「最初から最後まで」を縛る。「今日」は toISOString ではなく、その土地の日付を組み立てる。
  4. メッセージは「どこ・どう直す・例」。赤い枠は引っかかった欄だけに付け、上のまとめにフォーカスを移す。
  5. 赤い枠は送信のときだけ増やし、直したらすぐ消す。フロントのチェックは親切、守りはサーバー。

新井これで「申請する」を押せば、経理に届くんですね!

古井今はまだ「通りました」と言って止まるだけだ。次回は非同期通信。fetch で、画面を切り替えずにサーバーとやりとりする。ずっと後回しにしてきたドルの換算も、そこで片付けるぞ。

参考

ページの写真は、筆者が作ったデモページを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

← 記事一覧へ