バリデーション付きフォーム

以下では、DOMを用いた「バリデーション付きフォーム」の実装を、HTML標準の制約検証(Constraint Validation API)と、独自ロジック(カスタム検証・非同期検証)を交えて体系的に解説します。実装の目的は、入力エラーを早期に検出し、ユーザーに分かりやすく、かつアクセシブルにフィードバックすることです。最後に、実運用でのセキュリティ・UXの要点もまとめます。

1. まずはHTMLの標準バリデーションを使う

HTMLの属性を適切に指定すると、ブラウザ標準のバリデーションが有効になります。

  • required, minlength, maxlength, min, max, pattern

  • type=email|url|number|tel|password|date|time|datetime-local など

  • 標準の擬似クラス: :valid, :invalid, :required, :optional, :in-range, :out-of-range

html
<form id="signup" novalidate> <div class="field"> <label for="email">メールアドレス</label> <input id="email" name="email" type="email" required inputmode="email" autocomplete="email" /> <p class="error" id="email-error" aria-live="polite"></p> </div> <div class="field"> <label for="password">パスワード</label> <input id="password" name="password" type="password" required minlength="8" pattern="^(?=.*[A-Z])(?=.*[a-z])(?=.*\d).{8,}$" autocomplete="new-password" /> <small>大文字・小文字・数字を各1文字以上含め、8文字以上</small> <p class="error" id="password-error" aria-live="polite"></p> </div> <div class="field"> <label for="password2">パスワード(確認)</label> <input id="password2" name="password2" type="password" required autocomplete="new-password" /> <p class="error" id="password2-error" aria-live="polite"></p> </div> <button type="submit">登録</button> </form>

novalidate を付けるとブラウザのデフォルトエラーポップアップは抑制され、DOM(JavaScript)で制御しやすくなります。

簡易スタイル(標準疑似クラスで視覚フィードバック)

css
.field { margin-block: 1rem; } .error { color: #b00020; margin: .25rem 0 0; min-height: 1.2em; } input:invalid:not(:focus):not(:placeholder-shown) { border: 1px solid #b00020; } input:valid:not(:focus) { border: 1px solid #2e7d32; }

2. Constraint Validation APIの基礎

主要メソッド・プロパティ:

  • el.checkValidity():制約を満たしていれば true。満たさなければ false

  • el.reportValidity():結果表示を伴うチェック(標準UIを出す)。novalidate時は通常使わない。

  • el.setCustomValidity(message):カスタムエラーメッセージ設定。空文字で解除。

  • el.validity:詳細な状態(valueMissing, typeMismatch, patternMismatch, tooShort など)。

3. クライアントサイドのカスタム検証(同期)

以下は、入力イベントで逐次チェックし、エラーを各フィールド下に表示する例です。

html
<script> const form = document.getElementById('signup'); const email = document.getElementById('email'); const password = document.getElementById('password'); const password2 = document.getElementById('password2'); const errors = { email: document.getElementById('email-error'), password: document.getElementById('password-error'), password2: document.getElementById('password2-error'), }; // 共通: フィールドごとのエラーメッセージ表示ヘルパ function showError(input, msg) { const err = errors[input.id]; if (!err) return; err.textContent = msg || ''; input.setAttribute('aria-invalid', msg ? 'true' : 'false'); input.setAttribute('aria-describedby', msg ? err.id : ''); } // 各フィールドの検証ロジック function validateEmail() { email.setCustomValidity(''); // 既存のカスタムメッセージを一旦クリア if (email.validity.valueMissing) return 'メールアドレスは必須です。'; if (email.validity.typeMismatch) return '形式が正しくありません。'; return ''; // 問題なし } function validatePassword() { password.setCustomValidity(''); if (password.validity.valueMissing) return 'パスワードは必須です。'; if (password.validity.tooShort) return `8文字以上で入力してください(現在${password.value.length}文字)。`; if (password.validity.patternMismatch) { return '大文字・小文字・数字を各1文字以上含めてください。'; } return ''; } function validatePassword2() { password2.setCustomValidity(''); if (password2.validity.valueMissing) return '確認用パスワードは必須です。'; if (password.value && password2.value && password.value !== password2.value) { return 'パスワードが一致しません。'; } return ''; } // 入力時に即時フィードバック [email, password, password2].forEach(input => { input.addEventListener('input', () => { let message = ''; if (input === email) message = validateEmail(); if (input === password) message = validatePassword(); if (input === password2) message = validatePassword2(); input.setCustomValidity(message); showError(input, message); }); // フォーカスアウトで最終チェック input.addEventListener('blur', () => { input.reportValidity(); // 標準バブルUIは抑制される環境もあるが、checkValidityの代替 }); }); // 送信時の最終チェック form.addEventListener('submit', (e) => { const messages = [ [email, validateEmail()], [password, validatePassword()], [password2, validatePassword2()], ]; // メッセージの反映 let hasError = false; for (const [input, msg] of messages) { input.setCustomValidity(msg); showError(input, msg); if (msg) hasError = true; } if (hasError || !form.checkValidity()) { e.preventDefault(); // 最初のエラーにスクロール const firstInvalid = form.querySelector(':invalid'); firstInvalid?.focus({ preventScroll: false }); } }); </script>

ポイント:

  • setCustomValidity('') を先に呼び出し、前回のカスタムメッセージを必ずクリアする。

  • aria-invalid, aria-describedby を適切に設定し、スクリーンリーダー配慮を行う。

  • 送信時はすべてのフィールドを再評価して、最初のエラーにフォーカス。

4. 非同期検証(例: ユーザー名の重複チェック)

APIによる確認が必要な検証は、入力のたびに通信せず、デバウンスを挟みます。以下は概念例(実APIはダミー)。

html
<div class="field"> <label for="username">ユーザー名</label> <input id="username" name="username" required minlength="3" maxlength="20" pattern="^[a-zA-Z0-9_]+$" autocomplete="username" /> <p class="error" id="username-error" aria-live="polite"></p> </div> <script> const username = document.getElementById('username'); const usernameErr = document.getElementById('username-error'); let timer; function setUserError(msg) { username.setCustomValidity(msg || ''); usernameErr.textContent = msg || ''; username.setAttribute('aria-invalid', msg ? 'true' : 'false'); } async function checkUsernameAvailability(value) { // 実際は fetch('/api/users/exists?u=' + encodeURIComponent(value)) // などを用いる。ここでは擬似的に「test」は使用不可とする。 await new Promise(r => setTimeout(r, 300)); return value.toLowerCase() !== 'test'; // trueなら利用可能 } username.addEventListener('input', () => { clearTimeout(timer); setUserError(''); // まずはクリア if (!username.value) return; if (!username.checkValidity()) return; // 形式的エラーがあるならAPI呼ばない timer = setTimeout(async () => { const ok = await checkUsernameAvailability(username.value); if (!ok) setUserError('このユーザー名は既に使用されています。'); }, 400); // 400msデバウンス }); </script>

5. サーバーサイド検証は必須

クライアントでいくら検証しても、改変やスクリプト送信を防げません。必ずサーバーで同等以上の検証を行い、エラー時はフィールドごとにメッセージを返す設計にします。返ってきたサーバーエラーをDOMにマッピングして表示すると、クライアントとサーバーの一貫性が保てます。

6. アクセシビリティとUXの要点

  • エラーは入力欄の直下にテキストで表示し、aria-live="polite" で動的更新を支援。

  • labelforid の対応を正しく設定。

  • 可能なら、エラー時に短い説明文を併記(例: 期待フォーマット)。

  • フォーカス管理: 送信時は最初のエラーにフォーカス移動。

  • 漢字氏名や多言語入力では IME 中の input イベントに注意(変換確定後の検証や compositionend でのチェック)。

  • モバイル: inputmode, 適切な typeemail, tel, number)でキーボード最適化。

  • 逐次検証は過剰にならないよう、input では軽量チェック、詳細は blur または submit で行うと負担が減ります。

7. よくある落とし穴

  • hidden/type="hidden" は制約検証の対象外。disabled も対象外。

  • display:none の要素はフォーカス不可。エラー説明を不可視にしてしまわない。

  • pattern は全文一致。部分一致を期待して誤る例が多い。

  • ローカライズ: 数値・日付のフォーマットはロケール差異に注意。

  • 同期・非同期を混在させる際は、送信時の最終判定ですべてが解決済みであることを保証する(送信ボタンの一時無効化やローディング表示など)。

8. スキーマ駆動の検証(発展)

プロジェクト規模が大きい場合、DOM直書きではなく、スキーマ(例: JSON Schema)を用いて

  • ルール定義(型、必須、パターン、最小長など)

  • メッセージのローカライズ

  • 同じルールをクライアント/サーバーで共有
    といったアプローチが有効です。フォーム生成やルール変更の保守性が上がります。

9. セキュリティの観点

  • クライアント検証は利便性であり、安全性ではありません。必ずサーバーで再検証。

  • サーバーでは CSRF 対策、Rate Limiting、bot 対策(reCAPTCHA 等)を検討。

  • エラーには内部情報を含めない(スタックトレースやSQL情報など)。

  • パスワードはクライアントでもサーバーでもログに出さない。

10. まとまった実装サンプル(最小完全例)

以下は、標準属性+カスタム検証+送信時最終チェックを組み合わせた最小構成です。

html
<form id="register" novalidate> <div class="field"> <label for="email2">メールアドレス</label> <input id="email2" name="email" type="email" required autocomplete="email" /> <p class="error" id="email2-error" aria-live="polite"></p> </div> <div class="field"> <label for="pwd">パスワード</label> <input id="pwd" name="password" type="password" required minlength="8" pattern="^(?=.*[A-Z])(?=.*[a-z])(?=.*\d).{8,}$" autocomplete="new-password" /> <p class="error" id="pwd-error" aria-live="polite"></p> </div> <div class="field"> <label for="pwdc">パスワード(確認)</label> <input id="pwdc" name="password_confirm" type="password" required autocomplete="new-password" /> <p class="error" id="pwdc-error" aria-live="polite"></p> </div> <button type="submit" id="submitBtn">登録</button> </form> <style> .field { margin-block: 1rem; } .error { color: #b00020; min-height: 1.2em; } input:invalid:not(:focus):not(:placeholder-shown) { border: 1px solid #b00020; } input:valid:not(:focus) { border: 1px solid #2e7d32; } button[disabled] { opacity: 0.6; cursor: not-allowed; } </style> <script> const form2 = document.getElementById('register'); const email2 = document.getElementById('email2'); const pwd = document.getElementById('pwd'); const pwdc = document.getElementById('pwdc'); const btn = document.getElementById('submitBtn'); const errMap = { email2: document.getElementById('email2-error'), pwd: document.getElementById('pwd-error'), pwdc: document.getElementById('pwdc-error'), }; const validators = { email2() { email2.setCustomValidity(''); if (email2.validity.valueMissing) return 'メールアドレスは必須です。'; if (email2.validity.typeMismatch) return '形式が正しくありません。'; return ''; }, pwd() { pwd.setCustomValidity(''); if (pwd.validity.valueMissing) return 'パスワードは必須です。'; if (pwd.validity.tooShort) return `8文字以上で入力してください(現在${pwd.value.length}文字)。`; if (pwd.validity.patternMismatch) return '大文字・小文字・数字を各1文字以上含めてください。'; return ''; }, pwdc() { pwdc.setCustomValidity(''); if (pwdc.validity.valueMissing) return '確認用パスワードは必須です。'; if (pwd.value && pwdc.value && pwd.value !== pwdc.value) return 'パスワードが一致しません。'; return ''; } }; function applyError(input, msg) { const el = errMap[input.id]; if (!el) return; el.textContent = msg || ''; input.setAttribute('aria-invalid', msg ? 'true' : 'false'); input.setAttribute('aria-describedby', msg ? el.id : ''); } function validateInput(input) { const fn = validators[input.id]; const msg = fn ? fn() : ''; input.setCustomValidity(msg); applyError(input, msg); return !msg; } [email2, pwd, pwdc].forEach(i => { i.addEventListener('input', () => validateInput(i)); i.addEventListener('blur', () => i.reportValidity()); }); form2.addEventListener('submit', (e) => { const ok = [email2, pwd, pwdc].every(validateInput); if (!ok || !form2.checkValidity()) { e.preventDefault(); form2.querySelector(':invalid')?.focus(); return; } // ここでサーバー送信(fetch など)を行う // e.preventDefault(); fetch('/register', { method:'POST', body: new FormData(form2) }) }); </script>

まとめ

  1. HTML標準属性で基本的な制約を定義し、DOMから Constraint Validation API を使って制御する。

  2. 入力中は軽量チェック、フォーカスアウトや送信時に厳密チェックを行う。

  3. 非同期検証にはデバウンスを導入し、送信時には未解決の検証が残らないよう制御する。

  4. アクセシビリティ(aria-*、エラーのテキスト表示、フォーカス移動)とモバイル入力最適化を忘れない。

  5. セキュリティのため、必ずサーバー側でも検証し、両者を整合させる。

この流れをベースに、プロジェクト規模に応じてスキーマ駆動や共通ロジック化を取り入れると、保守性と品質が大きく向上します。

ChatGPT5 生成日:2025/09/11