使い回せる JavaScript バリデーションの設計と実装(サンプルコード付き)

お問い合わせフォームや会員登録フォームなど、正確な入力を求める場面では、信頼できる入力バリデーションが欠かせません。しかし、毎回 JavaScript を書くのは大変ですし、非効率です。

この記事では、複数のフォームに対応可能な汎用バリデーションスクリプトを紹介し、その仕組みと各機能(必須チェック・文字数制限・パターンマッチ・確認用入力の一致チェックなど)を解説します。

掲載するサンプルコードは、コピーしてそのままご利用いただける構成になっており、エラーメッセージの文言やクラス名の変更、独自の検証ルールの追加など柔軟なカスタマイズも可能です。

多言語対応バージョンもあります。

作成日:2025年6月19日

スクリプトの特徴

以下の JavaScript スクリプトは、HTML フォームに対するリアルタイムおよび送信時のバリデーションを提供します。以下はこのスクリプトの特徴です。

  • スクリプトで指定したクラスの付いたフォーム全てに適用(複数フォームに対応)
  • バリデーションルールを HTML 要素のクラス属性やカスタムデータ属性で定義
  • フォーム送信後のみエラーを表示する「初回制御」付き
  • カスタムエラーメッセージ対応(カスタムデータ属性に指定)
  • 入力文字数のリアルタイムカウント(日本語・絵文字にも対応)
  • ラジオボタンやチェックボックスにも対応
  • アクセシビリティ考慮(aria-live によるエラーメッセージの通知)

主に以下の機能を実装しています。

  • 必須入力チェック(required)
  • パターン(正規表現)チェック(pattern)
  • 入力値の一致チェック(equal-to)
  • 最小文字数・最大文字数チェック(minlength / maxlength)
  • 文字数カウント表示機能(show-count)
  • エラーメッセージの動的追加・削除
  • エラーがある場合は送信を中止し、最初のエラー位置にスクロール(オフセット調整可能)

メールアドレスや電話番号の形式チェック、必須入力チェック、文字数制限など、一般的な検証項目に対応し、チェックボックスやラジオボタンなどにも対応しています。

使用例(HTML)

以下は検証を行うフォーム要素の例です(見やすいように input 要素の属性を改行しています)。

<form class="js-form-validation" novalidate>
  <div>
    <label for="uname">ユーザー名(半角英数字)</label><br>
    <input
      class="required pattern minlength maxlength"
      data-minlength="3"
      data-maxlength="20"
      data-pattern="alphanum"
      data-error-pattern="ユーザー名は半角英数字のみが使用できます"
      data-error-required="ユーザー名は必須です"
      type="text"
      name="uname"
      id="uname">
  </div>
  <div>
    <label for="uemail">メールアドレス </label><br>
    <input
      class="required pattern"
      data-pattern="email"
      data-error-required="メールアドレスは必須です"
      data-error-pattern="メールアドレスの形式が正しくありません"
      type="email"
      id="uemail"
      name="uemail"
      size="30">
  </div>
  <button name="send">検証</button>
</form>

以下のサンプルに入力して「検証」をクリックすると、検証を実行します。検証が成功すると、データがクリアされるだけで実際にはフォームは送信されません。



バリデーションスクリプト

以下が検証スクリプトの JavaScript です。コードの詳細は JavaScript の詳細を御覧ください。

document.addEventListener("DOMContentLoaded", () => {
  const settings = {
    formSelector: ".js-form-validation",
    errorClassName: "error-js",
    errorContainerClass: ".error-container",
    messages: {
      required: "入力は必須です",
      requiredSelect: "選択は必須です",
      pattern: "入力された値が正しくないようです",
      equalTo: "入力された値が一致しません",
      minlength: (min) => `${min}文字以上で入力してください`,
      maxlength: (max) => `${max}文字以内で入力してください`,
    },
    counter: {
      wrapperClass: "count-span-wrapper",
      countClass: "count-span",
      overLimitClass: "over-max-count",
      overLimitColor: "red",
    },
    scroll: {
      offset: 40,
      behavior: "smooth",
    },
  };

  const validationForms = document.querySelectorAll(settings.formSelector);

  if (!validationForms.length) return;

  validationForms.forEach((validationForm) => {
    let hasSubmittedOnce = false;

    if (validationForm.dataset.realtimeValidation === "true") {
      hasSubmittedOnce = true;
    }

    const requiredElems = validationForm.querySelectorAll(".required");
    const patternElems = validationForm.querySelectorAll(".pattern");
    const equalToElems = validationForm.querySelectorAll(".equal-to");
    const minlengthElems = validationForm.querySelectorAll(".minlength");
    const maxlengthElems = validationForm.querySelectorAll(".maxlength");
    const showCountElems = validationForm.querySelectorAll(".show-count");

    const removeError = (elem, className) => {
      const errorContainer =
        elem.closest(settings.errorContainerClass) || elem.parentNode;
      const errorSpan = errorContainer.querySelector(
        `.${settings.errorClassName}.${className}`
      );
      if (errorSpan) errorSpan.remove();
    };

    const addError = (elem, className, defaultMessage) => {
      if (!hasSubmittedOnce) return;
      removeError(elem, className);

      const errorMessage =
        elem.getAttribute(`data-error-${className}`) || defaultMessage;

      const errorSpan = document.createElement("span");
      errorSpan.classList.add(settings.errorClassName, className);
      errorSpan.setAttribute("aria-live", "polite");
      errorSpan.textContent = errorMessage;
      const errorContainer =
        elem.closest(settings.errorContainerClass) || elem.parentNode;
      errorContainer.appendChild(errorSpan);
    };

    const getValueLength = (value) =>
      (value.match(/([\uD800-\uDBFF][\uDC00-\uDFFF]|[\s\S])/g) || []).length;

    const isValueMissing = (elem) => {
      const className = "required";

      if ((elem.type === "radio" || elem.type === "checkbox") && elem.name) {
        const group = validationForm.querySelectorAll(`[name="${elem.name}"]`);
        const checked = [...group].some((el) => el.checked);

        const representative = group[0];

        if (!checked) {
          if (elem === representative) {
            addError(elem, className, settings.messages.requiredSelect);
          }
          return true;
        } else {
          if (elem === representative) {
            removeError(elem, className);
          }
          return false;
        }
      }

      if (!elem.value.trim()) {
        addError(
          elem,
          className,
          elem.tagName === "SELECT"
            ? settings.messages.requiredSelect
            : settings.messages.required
        );
        return true;
      }

      removeError(elem, className);
      return false;
    };

    const isPatternMismatch = (elem) => {
      const className = "pattern";
      const patternType = elem.getAttribute("data-pattern");
      let value = elem.value;

      const patternRegistry = {
        tel: {
          pattern: /^0\d{9,10}$/,
          preprocess: (v) => v.replace(/-/g, ""),
        },
        email: {
          pattern:
            /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/,
        },
        zip: { pattern: /^\d{3}-\d{4}$/ },
        alphanum: { pattern: /^[a-zA-Z0-9]+$/ },
        kana: { pattern: /^[\u30A0-\u30FFー\s]+$/ },
      };

      const def = patternRegistry[patternType];
      if (!def) return false;

      if (typeof def.preprocess === "function") {
        value = def.preprocess(value);
      }

      if (value && !def.pattern.test(value)) {
        addError(elem, className, settings.messages.pattern);
        return true;
      }

      removeError(elem, className);
      return false;
    };

    const isNotEqualTo = (elem) => {
      const className = "equal-to";
      const target = document.getElementById(
        elem.getAttribute("data-equal-to")
      );
      if (target && elem.value && target.value && elem.value !== target.value) {
        addError(elem, className, settings.messages.equalTo);
        return true;
      }
      removeError(elem, className);
      return false;
    };

    const isTooShort = (elem) => {
      const className = "minlength";
      const minlength = parseInt(elem.getAttribute("data-minlength"), 10);
      const valueLength = getValueLength(elem.value);
      if (elem.value && valueLength < minlength) {
        addError(elem, className, `${settings.messages.minlength(minlength)}`);
        return true;
      }
      removeError(elem, className);
      return false;
    };

    const isTooLong = (elem) => {
      const className = "maxlength";
      const maxlength = parseInt(elem.getAttribute("data-maxlength"), 10);
      const valueLength = getValueLength(elem.value);
      if (elem.value && valueLength > maxlength) {
        addError(elem, className, `${settings.messages.maxlength(maxlength)}`);
        return true;
      }
      removeError(elem, className);
      return false;
    };

    const attachCounter = () => {
      showCountElems.forEach((elem) => {
        const max = parseInt(elem.getAttribute("data-maxlength"), 10);
        if (!isNaN(max) && !elem.dataset.hasCounter) {
          elem.dataset.hasCounter = "true";
          const countElem = document.createElement("p");
          countElem.classList.add(settings.counter.wrapperClass);
          const countClass = settings.counter.countClass;
          countElem.innerHTML = `<span class="${countClass}">0</span>/${max}`;
          elem.parentNode.appendChild(countElem);
          const countSpan = countElem.querySelector(`.${countClass}`);

          function updateCharCount() {
            const count = getValueLength(elem.value);
            countSpan.textContent = count;
            countSpan.classList.toggle(
              settings.counter.overLimitClass,
              count > max
            );
            countSpan.style.color =
              count > max ? settings.counter.overLimitColor : "";
          }

          updateCharCount();
          elem.addEventListener("input", updateCharCount);
        }
      });
    };

    const attachValidation = () => {
      requiredElems.forEach((elem) => {
        const eventType =
          elem.type === "radio" || elem.type === "checkbox"
            ? "change"
            : "input";

        if (elem.type === "radio" || elem.type === "checkbox") {
          const group = validationForm.querySelectorAll(
            `[name="${elem.name}"]`
          );
          if (group.length && group[0] === elem) {
            group.forEach((el) => {
              el.addEventListener(eventType, () => {
                group.forEach((target) => isValueMissing(target));
              });
            });
          }
        } else {
          elem.addEventListener(eventType, () => isValueMissing(elem));
        }
      });

      patternElems.forEach((elem) =>
        elem.addEventListener("input", () => isPatternMismatch(elem))
      );

      equalToElems.forEach((elem) => {
        elem.addEventListener("input", () => isNotEqualTo(elem));
        const target = document.getElementById(
          elem.getAttribute("data-equal-to")
        );
        if (target) target.addEventListener("input", () => isNotEqualTo(elem));
      });

      minlengthElems.forEach((elem) =>
        elem.addEventListener("input", () => isTooShort(elem))
      );

      maxlengthElems.forEach((elem) =>
        elem.addEventListener("input", () => isTooLong(elem))
      );
    };

    const validateAll = () => {
      let hasError = false;
      requiredElems.forEach((elem) => {
        if (isValueMissing(elem)) hasError = true;
      });
      patternElems.forEach((elem) => {
        if (isPatternMismatch(elem)) hasError = true;
      });
      equalToElems.forEach((elem) => {
        if (isNotEqualTo(elem)) hasError = true;
      });
      minlengthElems.forEach((elem) => {
        if (isTooShort(elem)) hasError = true;
      });
      maxlengthElems.forEach((elem) => {
        if (isTooLong(elem)) hasError = true;
      });
      return hasError;
    };

    validationForm.addEventListener("submit", (e) => {
      hasSubmittedOnce = true;
      const hasError = validateAll();
      if (hasError) {
        e.preventDefault();
        const errorElem = validationForm.querySelector(
          `.${settings.errorClassName}`
        );
        const offset =
          parseInt(validationForm.dataset.errorOffset, 10) ||
          settings.scroll.offset;
        if (errorElem) {
          window.scrollTo({
            top: errorElem.offsetTop - offset,
            behavior: settings.scroll.behavior,
          });
        }
      }
    });

    attachValidation();
    attachCounter();
  });
});

【更新情報】

  • 2025/06/20:一部修正
  • 2025/06/24:一部更新

使い方

  • HTML の form 要素に class="js-form-validation"novalidate 属性を指定します。
  • form 要素に指定するクラス名はスクリプト側で変更可能です(デフォルトは .js-form-validation)。
  • 各 input 要素を div 要素でラップします(チェックボックスやラジオボタンは全体をラップします)。
  • 入力項目に検証用のクラスや属性を追加します。詳細は対応バリデーションを参照
  • 検証用のスクリプト(form-validation.js)を body の閉じタグの前などで読み込みます。
<form class="js-form-validation" novalidate>
  <div>
    <label for="name">お名前 </label><br>
    <input class="required maxlength" data-maxlength="30" data-error-required="お名前は必須です" type="text" name="name" id="name">
  </div>
  ...
</form>

 ...
<script src="path/to/form-validation.js"></script>

エラー表示

デフォルトでは、エラーは対象要素の直近の親要素(div 要素)の子要素として .error-js クラスと検証用クラスが指定された span 要素として出力されます。

<div>
  <label for="uname">ユーザー名</label>
  <input class="required minlength maxlength" data-error-required="ユーザー名は必須です" data-minlength="3" data-maxlength="20" type="text" name="uname" id="uname">
  <!-- 挿入されるエラーの span 要素 -->
  <span class="error-js required" aria-live="polite">ユーザー名は必須です</span>
</div>

必要に応じて(入れ子構造が複雑な場合など)、closest で参照できる親要素に error-container クラスを指定すれば、その要素の子要素としてエラーを挿します。

リアルタイム検証

デフォルトでは、リアルタイムでの検証は初回フォーム送信後に行いますが、form 要素に data-realtime-validation="true" を指定すれば、送信前でもリアルタイムでの検証を行うことができます。

自動スクロール

最初のエラー位置に自動的にスクロールします。スクロール位置のオフセットをピクセル単位で、form 要素の data-error-offset 属性に指定して調整できます。

対応バリデーション

以下は対応可能なバリデーションの一覧です。

required 以外は検証用クラスと一緒に data-◯◯属性を指定します。同時に複数の検証を設定可能です。

対応バリデーション一覧
検証用クラス / 属性 内容
.required 空入力の禁止(必須入力) 必須入力欄やラジオボタン、チェックボックスなど
.pattern + data-pattern 正規表現で形式をチェック data-pattern="email"、data-pattern="tel"など
.equal-to + data-equal-to="inputID" 入力値の一致をチェック(確認用) メールアドレスやパスワード確認などに
.minlength + data-minlength 最小文字数制限 data-minlength="5"
.maxlength + data-maxlength 最大文字数制限 data-maxlength="20"
.show-count + data-maxlength 文字数カウントを表示 入力欄の下に 現在の文字数 / 上限 を表示

以下は検証が通らない場合に表示されるデフォルトのエラーメッセージと、カスタムエラーメッセージを表示する場合に指定するカスタムデータ属性です。

デフォルトエラーメッセージ
検証の種類(クラス) デフォルトエラーメッセージ カスタムデータ属性
必須(required) 入力は必須です(または 選択は必須です) data-error-required
パターン(pattern) 入力された値が正しくないようです data-error-pattern
値の一致(equal-to) 入力された値が一致しません data-error-equal-to
最小文字数制限(minlength) ◯◯文字以上で入力ください data-error-minlength
最大文字数制限(maxlength) ◯◯文字以内で入力ください data-error-maxlength
文字数カウント
(show-count + data-maxlength)
◯◯文字以内で入力ください data-error-maxlength

required

必須入力欄(テキスト入力欄、テキストエリア)や必須入力項目(ラジオボタン、チェックボックス、セレクトボックス)に required クラスを指定します。

デフォルトのエラーメッセージは、テキスト入力欄やテキストエリアでは『入力は必須です』、チェックボックスやラジオボタン、セレクトボックスでは『選択は必須です』と表示されます。

カスタムエラーメッセージを使いたい場合は、data-error-required 属性を使用ます。

<div>
  <label for="name">お名前 </label><br>
  <input class="required" data-error-required="お名前は必須です" type="text" name="name" id="name">
</div>

ラジオボタンやチェックボックスの場合は、グループ全体を div 要素でラップし、最初の input 要素に required クラスを指定します。

<div>
  <p>連絡方法を選択してください(必須:複数選択可)</p>
  <input class="required" data-error-required="連絡方法の選択は必須です" type="checkbox" name="contact[]" id="by-email" value="Email">
  <label for="by-email"> メール</label>
  <input type="checkbox" name="contact[]" id="by-tel" value="Telephone">
  <label for="by-tel"> 電話</label>
  <input type="checkbox" name="contact[]" id="by-mail" value="Mail">
  <label for="by-mail"> 郵便 </label>
</div>

セレクトボックスの場合は select 要素を div 要素でラップします。

pattern

パターンを検証する要素には、pattern クラスと data-pattern 属性にパターン名を指定します。

data-error-pattern 属性を使用してカスタムエラーメッセージを設定することができます。

デフォルトで用意されているパターンは以下になります。必要に応じてバリデーションスクリプト(form-validation.js)に正規表現パターンを追加することもできます。

パターン名 説明
tel 電話番号。10〜11桁の数値(ハイフンありまたはなし)
email メールアドレス。RFC 仕様に準拠
zip 日本の郵便番号。123-4567 など
alphanum 半角英数字のみ
kana カタカナ

以下は電話番号のパターン検証を設定する例です。pattern クラスを指定して、data-pattern 属性に tel を指定しています。

この例では data-error-pattern 属性を使用してカスタムエラーメッセージを設定しています。

<div>
  <label for="tel">電話番号 </label><br>
  <input class="pattern" data-pattern="tel" data-error-pattern="10〜11桁の日本の電話番号を、ハイフンありまたはなしで入力してください。" type="tel" name="tel" id="tel">
</div>

equal-to

入力値の一致をチェックする要素には equal-to クラスを指定し、data-equal-to 属性に比較する対象の要素の id を指定します。

data-error-equal-to 属性を使用してカスタムエラーメッセージを設定できます。

以下は「メールアドレス 再入力」の input 要素に equal-to クラスを指定し、data-equal-to 属性に比較するメールアドレス入力の要素の id(email1)を指定しています。

<div>
  <label for="email1">メールアドレス </label><br>
  <input
    class="pattern"
    data-pattern="email"
    data-error-pattern="メールアドレスの形式が正しくありません"
    type="email" id="email1" name="email1" size="30">
</div>
<div>
  <label for="email2">メールアドレス 再入力(確認用)</label><br>
  <input
    class="equal-to"
    data-equal-to="email1"
    data-error-equal-to="メールアドレスが一致しません"
    type="email" id="email2" name="email2" size="30">
</div>

minlength と maxlength

最小文字数制限を設定する要素には、minlength クラスを指定し、data-minlength 属性に最小文字数を指定します。デフォルトのエラーメッセージは「◯◯文字以上で入力ください」で、◯◯ には指定した最小文字数が入ります。

最大文字数制限を設定する要素には、maxlength クラスを指定し、data-maxlength 属性に最大文字数を指定します。デフォルトのエラーメッセージは「◯◯文字以内で入力ください」で、◯◯ には指定した最小文字数が入ります。

data-error-minlength や data-error-maxlength 属性を使ってカスタムエラーメッセージを設定することができます。

以下は最小文字数を3、最大文字数を20に設定する例です。また、同時にパターン検証も設定しています。

<div>
  <label for="uname">ユーザー名(半角英数字)</label><br>
  <input
    class="required pattern minlength maxlength"
    data-minlength="3"
    data-maxlength="20"
    data-pattern="alphanum"
    data-error-pattern="ユーザー名は半角英数字のみが使用できます"
    data-error-required="ユーザー名は必須です"
    type="text"
    name="uname"
    id="uname">
</div>

show-count

テキスト入力欄やテキストエリアに show-count クラスを指定して、data-maxlength に最大文字数を設定すると、入力欄の下に「現在の文字数 / 上限」 を表示します。

現在入力された文字数が最大文字数を超えると、「現在の文字数」部分が赤く表示されます。

カスタムエラーメッセージを表示するには data-error-maxlength 属性を使用します。

<div>
  <label for="inquiry">お問い合わせ内容</label><br>
  <textarea
    class="required maxlength show-count"
    data-maxlength="1000"
    data-error-required="お問い合わせ内容は必須です"
    data-error-maxlength="お問い合わせ内容は1000文字以内でお願いします。"
    name="inquiry" id="inquiry" rows="5" cols="50"></textarea>
</div>

入力チェックのタイミング

フォーム送信時に全項目の検証が実行され、エラーがあると送信をブロックしてエラーを表示します。

デフォルトでは送信後にエラーがあると、その後はリアルタイムで検証が行われますが、form 要素に data-realtime-validation="true" を指定すれば送信前でもリアルタイムでの検証を行います。

テキストフィールドやテキストエリアへの入力では、input イベントでのリアルタイムでバリデーションが行われ、ラジオボタンやチェックボックスは change イベントで検証されます。

スクロールのオフセット調整

エラー時にエラーのある箇所にスクロールしますが、form 要素に data-error-offset="100" のようにオフセット値(ピクセル)を指定可能です。

デフォルトでは、エラー要素の offsetTop から 40px 下の位置にスクロールします(top: errorElem.offsetTop - 40)。

動作確認サンプル

以下は動作確認用のフォームのサンプルです。

動作確認用のフォームサンプル





色を選択してください(必須)

連絡方法を選択してください(必須:複数選択可)



HTML

以下は上記サンプルフォームの HTML です。

別途スクリプト(form-validation.js)やスタイルの読み込みが必要です。

<form class="js-form-validation" data-realtime-validation="false" novalidate>
  <div>
    <label for="name">お名前 </label><br>
    <input class="required maxlength" data-maxlength="30" data-error-required="お名前は必須です" type="text" name="name" id="name">
  </div>
  <div>
    <label for="tel">電話番号 </label><br>
    <input class="pattern" data-pattern="tel" data-error-pattern="10〜11桁の日本の電話番号を、ハイフンありまたはなしで入力してください。" type="tel" name="tel" id="tel">
  </div>
  <div>
    <label for="uemail">メールアドレス </label><br>
    <input class="required pattern" data-pattern="email" data-error-required="メールアドレスは必須です" data-error-pattern="メールアドレスの形式が正しくありません" type="email" id="email1" name="email1" size="30">
  </div>
  <div>
    <label for="email2">メールアドレス 再入力(確認用)</label><br>
    <input class="required equal-to" data-equal-to="email1" data-error-required="確認用メールアドレスは必須です" data-error-equal-to="メールアドレスが一致しません" type="email" id="email2" name="email2" size="30">
  </div>
  <div>
    <p>色を選択してください(必須)</p>
    <input class="required" data-error-required="いずれかの色を選択してください" type="radio" name="color" value="blue" id="blue">
    <label for="blue"> 青 </label>
    <input type="radio" name="color" value="red" id="red">
    <label for="red"> 赤 </label>
    <input type="radio" name="color" value="green" id="green">
    <label for="green"> 緑 </label>
  </div>
  <div>
    <p>連絡方法を選択してください(必須:複数選択可)</p>
    <input class="required" data-error-required="連絡方法の選択は必須です" type="checkbox" name="contact[]" id="by-email" value="Email">
    <label for="by-email"> メール</label>
    <input type="checkbox" name="contact[]" id="by-tel" value="Telephone">
    <label for="by-tel"> 電話</label>
    <input type="checkbox" name="contact[]" id="by-mail" value="Mail">
    <label for="by-mail"> 郵便 </label>
  </div>
  <div>
    <select class="required" data-error-required="季節が選択されていません" name="season" id="season">
      <option value="">季節を選択してください(必須)</option>
      <option value="spring">春</option>
      <option value="summer">夏</option>
      <option value="autumn">秋</option>
      <option value="winter">冬</option>
    </select>
  </div>
  <div>
    <label for="subject">件名</label><br>
    <input type="text" class="required maxlength" data-maxlength="30" data-error-required="件名は必須です" id="subject" name="subject">
  </div>
  <div>
    <label for="inquiry">お問い合わせ内容</label><br>
    <textarea class="required maxlength show-count" data-maxlength="100" data-error-required="お問い合わせ内容は必須です" data-error-maxlength="お問い合わせ内容は100文字以内でお願いします" name="inquiry" id="inquiry" rows="5" cols="50"></textarea>
  </div>
  <button name="send">検証</button>
</form>

CSS

以下は上記サンプルフォームの CSS の例です。

form div {
  margin-bottom: 20px;
}

textarea {
  display: block;
}

input[type="checkbox"]:not(input[type="checkbox"]:first-of-type),
input[type="radio"]:not(input[type="radio"]:first-of-type) {
  margin-left: 20px;
}

input[type="text"],
input[type="email"],
input[type="tel"],
textarea {
  width: 100%;
  padding: 8px 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  font-size: 14px;
  transition: border-color 0.3s, box-shadow 0.3s;
  max-width: 400px;
}

select {
  padding: 8px;
}

input:focus,
textarea:focus {
  border-color: #007bff;
  box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.2);
  outline: none;
}

textarea {
  resize: vertical;
  min-height: 120px;
}

button {
  background-color: #168505d4;
  color: white;
  border: none;
  padding: 8px 16px;
  font-size: 14px;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.3s;
}

button:hover {
  background-color: #0f5a04d4;
}

/* エラーメッセージのスタイル */
.error-js {
  width: 100%;
  padding: 0;
  display: inline-block;
  font-size: 90%;
  color: red;
  box-sizing: border-box;
}

JavaScript の詳細

以下はコメント付きのコードです。

document.addEventListener("DOMContentLoaded", () => {
  // 設定オブジェクト
  const settings = {
    // バリデーション対象とするフォームのクラス名
    formSelector: ".js-form-validation",

    // エラー表示用の span 要素に付与するクラス名
    errorClassName: "error-js",

    // エラー表示を追加する親要素のクラス(省略可)
    errorContainerClass: ".error-container",

    // 各種検証項目に対応したデフォルトのエラーメッセージ
    messages: {
      required: "入力は必須です", // テキストやテキストエリアが未入力の場合
      requiredSelect: "選択は必須です", // チェックボックスやラジオボタン、セレクトボックスで未選択の場合
      pattern: "入力された値が正しくないようです", // パターン(正規表現)検証に失敗した場合
      equalTo: "入力された値が一致しません", // 他の項目と値が一致していない場合
      minlength: (min) => `${min}文字以上で入力してください`, // 指定文字数未満
      maxlength: (max) => `${max}文字以内で入力してください`, // 指定文字数超過
    },

    // 入力文字数カウント機能の見た目に関する設定
    counter: {
      wrapperClass: "count-span-wrapper", // カウント表示全体のラッパー要素に使うクラス
      countClass: "count-span", // 実際の文字数を表示する要素のクラス
      overLimitClass: "over-max-count", // 上限超過時に付与されるクラス(スタイル変更用)
      overLimitColor: "red", // 上限を超えた場合の文字色(インラインスタイルに設定。例 color: red)
    },

    // スクロール関連の設定
    scroll: {
      // スクロール位置のオフセット(data-error-offset が未指定の場合に使う)
      offset: 40,
      // スクロール動作("smooth"|"auto"|"instant")
      behavior: "smooth",
    },
  };

  // バリデーション対象の全てのフォーム要素を取得
  const validationForms = document.querySelectorAll(settings.formSelector);

  // バリデーション対象のフォームが1つもない場合は処理を終了
  if (!validationForms.length) return;

  // バリデーション対象の各フォーム要素ごとに処理
  validationForms.forEach((validationForm) => {
    // フォーム送信済みかどうかを示すフラグ(false の間はリアルタイム検証を無効化)
    let hasSubmittedOnce = false;

    // form 要素に data-realtime-validation="true" が指定されていれば送信前でもリアルタイムでの検証を行う
    if (validationForm.dataset.realtimeValidation === "true") {
      hasSubmittedOnce = true;
    }

    // 各種バリデーション対象の要素を取得
    const requiredElems = validationForm.querySelectorAll(".required");
    const patternElems = validationForm.querySelectorAll(".pattern");
    const equalToElems = validationForm.querySelectorAll(".equal-to");
    const minlengthElems = validationForm.querySelectorAll(".minlength");
    const maxlengthElems = validationForm.querySelectorAll(".maxlength");
    const showCountElems = validationForm.querySelectorAll(".show-count");

    /**
     * エラーメッセージを削除する関数
     */
    const removeError = (elem, className) => {
      // error-container クラスの要素または親要素
      const errorContainer =
        elem.closest(settings.errorContainerClass) || elem.parentNode;
      const errorSpan = errorContainer.querySelector(
        `.${settings.errorClassName}.${className}`
      );
      if (errorSpan) errorSpan.remove();
    };

    /**
     * エラーメッセージを表示する関数
     */
    const addError = (elem, className, defaultMessage) => {
      // まだ送信していなければリアルタイム検証しない
      if (!hasSubmittedOnce) return;
      // すでに同じエラーがあれば削除(重複防止)
      removeError(elem, className);

      // data-error-[className] 属性に独自メッセージが指定されていればそれを優先、なければ defaultMessage を使用
      const errorMessage =
        elem.getAttribute(`data-error-${className}`) || defaultMessage;

      // エラーメッセージ用の要素を作成して追加
      const errorSpan = document.createElement("span");
      errorSpan.classList.add(settings.errorClassName, className);
      errorSpan.setAttribute("aria-live", "polite"); // 音声読み上げ対応
      errorSpan.textContent = errorMessage;
      // error-container クラスが指定されている親要素があればそこへ、なければ親要素にエラーを出力
      const errorContainer =
        elem.closest(settings.errorContainerClass) || elem.parentNode;
      errorContainer.appendChild(errorSpan);
    };

    // マルチバイト文字(サロゲートペア含む)対応の文字数カウント(絵文字・日本語などに対応)
    const getValueLength = (value) =>
      (value.match(/([\uD800-\uDBFF][\uDC00-\uDFFF]|[\s\S])/g) || []).length;

    /**
     * 必須項目のバリデーション
     */
    const isValueMissing = (elem) => {
      const className = "required";

      // ラジオボタン・チェックボックスの場合
      if ((elem.type === "radio" || elem.type === "checkbox") && elem.name) {
        // ラジオボタン・チェックボックスは同じnameのグループで判定
        const group = validationForm.querySelectorAll(`[name="${elem.name}"]`);
        // 選択されているかをチェック
        const checked = [...group].some((el) => el.checked);

        // 代表要素(最初の要素)
        const representative = group[0];

        if (!checked) {
          // 代表要素だけがエラーの表示/削除を行う(エラーメッセージの上書きを防ぐ)
          if (elem === representative) {
            addError(elem, className, settings.messages.requiredSelect);
          }
          return true;
        } else {
          if (elem === representative) {
            removeError(elem, className);
          }
          return false;
        }
      }

      // 通常の入力要素(text, select など)の場合
      if (!elem.value.trim()) {
        addError(
          elem,
          className,
          elem.tagName === "SELECT"
            ? settings.messages.requiredSelect
            : settings.messages.required
        );
        return true;
      }

      removeError(elem, className);
      return false;
    };

    /**
     * パターン(正規表現)バリデーション
     */
    const isPatternMismatch = (elem) => {
      const className = "pattern";
      const patternType = elem.getAttribute("data-pattern");
      let value = elem.value;

      // 正規表現パターンを定義
      const patternRegistry = {
        tel: {
          pattern: /^0\d{9,10}$/,
          preprocess: (v) => v.replace(/-/g, ""), // ハイフン除去
        },
        email: {
          pattern:
            /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/,
        },
        zip: { pattern: /^\d{3}-\d{4}$/ }, // 郵便番号
        alphanum: { pattern: /^[a-zA-Z0-9]+$/ }, // 英数字
        kana: { pattern: /^[\u30A0-\u30FFー\s]+$/ }, // カタカナ
        // 必要に応じて他のパターンも追加可能
      };

      // 定義したパターンに patternType が含まれているか確認
      const def = patternRegistry[patternType];
      // 未定義のパターンタイプはチェックしない
      if (!def) return false;

      // 前処理(例: ハイフン除去など)
      if (typeof def.preprocess === "function") {
        value = def.preprocess(value);
      }

      // バリデーションを実行し、マッチしなければエラーを表示
      if (value && !def.pattern.test(value)) {
        addError(elem, className, settings.messages.pattern);
        return true;
      }

      removeError(elem, className);
      return false;
    };

    /**
     * 他の入力と一致しているか(値一致の確認用)
     */
    const isNotEqualTo = (elem) => {
      const className = "equal-to";
      // data-equal-to 属性に指定された id を使って比較対象の要素を取得
      const target = document.getElementById(
        elem.getAttribute("data-equal-to")
      );
      // 両方に値があり、かつ不一致ならエラーを表示
      if (target && elem.value && target.value && elem.value !== target.value) {
        addError(elem, className, settings.messages.equalTo);
        return true;
      }
      removeError(elem, className);
      return false;
    };

    /**
     * 最小文字数バリデーション
     */
    const isTooShort = (elem) => {
      const className = "minlength";
      const minlength = parseInt(elem.getAttribute("data-minlength"), 10);
      const valueLength = getValueLength(elem.value);
      if (elem.value && valueLength < minlength) {
        addError(elem, className, `${settings.messages.minlength(minlength)}`);
        return true;
      }
      removeError(elem, className);
      return false;
    };

    /**
     * 最大文字数バリデーション
     */
    const isTooLong = (elem) => {
      const className = "maxlength";
      const maxlength = parseInt(elem.getAttribute("data-maxlength"), 10);
      const valueLength = getValueLength(elem.value);
      if (elem.value && valueLength > maxlength) {
        addError(elem, className, `${settings.messages.maxlength(maxlength)}`);
        return true;
      }
      removeError(elem, className);
      return false;
    };

    /**
     * 入力文字数カウンター表示
     */
    const attachCounter = () => {
      showCountElems.forEach((elem) => {
        const max = parseInt(elem.getAttribute("data-maxlength"), 10);
        if (!isNaN(max) && !elem.dataset.hasCounter) {
          // 追加済みフラグを設定(同じ要素に複数回 append されるのを防ぐ)
          elem.dataset.hasCounter = "true";
          const countElem = document.createElement("p");
          countElem.classList.add(settings.counter.wrapperClass);
          const countClass = settings.counter.countClass;
          countElem.innerHTML = `<span class="${countClass}">0</span>/${max}`;
          elem.parentNode.appendChild(countElem);
          // 生成したカウント用の span 要素を取得
          const countSpan = countElem.querySelector(`.${countClass}`);

          // 文字数を更新する関数
          function updateCharCount() {
            const count = getValueLength(elem.value);
            countSpan.textContent = count;
            countSpan.classList.toggle(
              settings.counter.overLimitClass,
              count > max
            );
            countSpan.style.color =
              count > max ? settings.counter.overLimitColor : "";
          }

          // 初期状態でのカウント表示
          updateCharCount();
          // リアルタイムで文字数更新
          elem.addEventListener("input", updateCharCount);
        }
      });
    };

    /**
     * リアルタイムバリデーションイベントの設定
     */
    const attachValidation = () => {
      requiredElems.forEach((elem) => {
        const eventType =
          elem.type === "radio" || elem.type === "checkbox"
            ? "change"
            : "input";

        // ラジオボタンまたはチェックボックスの場合
        if (elem.type === "radio" || elem.type === "checkbox") {
          const group = validationForm.querySelectorAll(
            `[name="${elem.name}"]`
          );
          // グループ内で1回だけイベント登録処理を行うための条件
          if (group.length && group[0] === elem) {
            group.forEach((el) => {
              el.addEventListener(eventType, () => {
                group.forEach((target) => isValueMissing(target));
              });
            });
          }
        } else {
          // ラジオボタン・チェックボックス以外の場合
          elem.addEventListener(eventType, () => isValueMissing(elem));
        }
      });

      patternElems.forEach((elem) =>
        elem.addEventListener("input", () => isPatternMismatch(elem))
      );

      equalToElems.forEach((elem) => {
        elem.addEventListener("input", () => isNotEqualTo(elem));
        const target = document.getElementById(
          elem.getAttribute("data-equal-to")
        );
        if (target) target.addEventListener("input", () => isNotEqualTo(elem));
      });

      minlengthElems.forEach((elem) =>
        elem.addEventListener("input", () => isTooShort(elem))
      );

      maxlengthElems.forEach((elem) =>
        elem.addEventListener("input", () => isTooLong(elem))
      );
    };

    /**
     * 全項目のバリデーションを実行
     */
    const validateAll = () => {
      let hasError = false;
      requiredElems.forEach((elem) => {
        if (isValueMissing(elem)) hasError = true;
      });
      patternElems.forEach((elem) => {
        if (isPatternMismatch(elem)) hasError = true;
      });
      equalToElems.forEach((elem) => {
        if (isNotEqualTo(elem)) hasError = true;
      });
      minlengthElems.forEach((elem) => {
        if (isTooShort(elem)) hasError = true;
      });
      maxlengthElems.forEach((elem) => {
        if (isTooLong(elem)) hasError = true;
      });
      return hasError;
    };

    /**
     * フォーム送信イベント
     */
    validationForm.addEventListener("submit", (e) => {
      hasSubmittedOnce = true; // 以降エラー表示を有効にする
      const hasError = validateAll();
      if (hasError) {
        // エラーがあるため送信をキャンセル
        e.preventDefault();
        const errorElem = validationForm.querySelector(
          `.${settings.errorClassName}`
        );
        // form 要素の data-error-offset を数値として取得(スクロールのオフセット調整)
        const offset =
          parseInt(validationForm.dataset.errorOffset, 10) ||
          settings.scroll.offset;
        if (errorElem) {
          window.scrollTo({
            top: errorElem.offsetTop - offset,
            behavior: settings.scroll.behavior,
          });
        }
      }
    });

    // バリデーションとカウンターを初期化
    attachValidation();
    attachCounter();
  });
});

以下詳細です。

設定オブジェクト settings

このバリデーションスクリプトでは、使用するクラス名や表示メッセージ、スタイルの一部を settings オブジェクトで定義しています。

この構造により、スクリプトの挙動や表示内容を一箇所でまとめて管理・カスタマイズできるようになっています。

const settings = {
  // バリデーション対象とするフォームのクラス名
  formSelector: ".js-form-validation",
  // エラー表示用の span 要素に付与するクラス名
  errorClassName: "error-js",
  // エラー表示を追加する親要素のクラス(省略可)
  errorContainerClass: ".error-container",

  // 各種検証項目に対応したデフォルトのエラーメッセージ
  messages: {
    required: "入力は必須です", // テキストやテキストエリアが未入力の場合
    requiredSelect: "選択は必須です", // チェックボックスやラジオボタン、セレクトボックスで未選択の場合
    pattern: "入力された値が正しくないようです", // パターン(正規表現)検証に失敗した場合
    equalTo: "入力された値が一致しません", // 他の項目と値が一致していない場合
    minlength: (min) => `${min}文字以上で入力してください`, // 指定文字数未満
    maxlength: (max) => `${max}文字以内で入力してください`, // 指定文字数超過
  },

  // 入力文字数カウント機能の見た目に関する設定
  counter: {
    wrapperClass: "count-span-wrapper", // カウント表示全体のラッパー要素に使うクラス
    countClass: "count-span", // 実際の文字数を表示する要素のクラス
    overLimitClass: "over-max-count", // 上限超過時に付与されるクラス(スタイル変更用)
    overLimitColor: "red", // 上限を超えた場合の文字色(インラインスタイルに設定。例 color: red)
  },

  // スクロール関連の設定
  scroll: {
    // スクロール位置のオフセット(data-error-offset が未指定の場合に使う)
    offset: 40,
    // スクロール動作("smooth"|"auto"|"instant")
    behavior: "smooth",
  },
};

対象フォームのクラスを変更したい場合

任意のクラス名に変更すれば、そのクラスを持つフォームが検証対象になります。

formSelector: ".my-form"

デフォルトのエラーメッセージを変更する例

messages: {
  required: "This field is required",
  ...
}

カウント表示の色を変更する例

counter: {
  ...
  overLimitColor: "#007bff"
}

複数フォームに対応

document.addEventListener("DOMContentLoaded", () => {
  const settings = { ... };

  // バリデーション対象のフォームをすべて取得
  const validationForms = document.querySelectorAll(settings.formSelector);
  if (!validationForms.length) return;

  validationForms.forEach((validationForm) => {
    // フォームごとの処理
    ...
  });
});
  • DOMContentLoaded イベントを使って、HTML の構造(DOMツリー)の解析が完了したタイミングでスクリプトを実行することで、スクリプトが対象の要素を正しく取得できるようになります。
  • settings.formSelector で指定されたクラスが付与されたすべてのフォームを取得します。
  • 該当するフォームが1つもなければ、処理を終了します(return)。
  • フォームが1つ以上ある場合、それぞれのフォームに対して、バリデーション関連の処理を個別に実行していきます。

forEach() を使う理由は、1つのページに複数のフォームが存在する可能性があるため、それぞれのフォームごとに独立してバリデーション処理を設定する必要があるからです。

querySelectorAll() は静的な NodeList を返し、モダンなブラウザでは forEach() を使ってループ処理が可能です(IE など古いブラウザは未対応)。

リアルタイム検証の制御

let hasSubmittedOnce = false;

if (validationForm.dataset.realtimeValidation === "true") {
  hasSubmittedOnce = true;
}
  • hasSubmittedOnce は、フォームが一度でも送信されたかどうかを示すフラグです。
    • 初期状態では false に設定されており、リアルタイムでのバリデーションは行われません。
    • 一度送信処理が行われた後に true へと切り替わり、以後はリアルタイム検証が有効になります。
  • 但し、フォーム要素に data-realtime-validation="true" 属性があらかじめ指定されている場合は、このフラグは初期状態から true に設定され、送信前でもリアルタイムバリデーションが行われます。

各種バリデーション対象の要素をグルーピング

const requiredElems = validationForm.querySelectorAll(".required");
const patternElems = validationForm.querySelectorAll(".pattern");
const equalToElems = validationForm.querySelectorAll(".equal-to");
const minlengthElems = validationForm.querySelectorAll(".minlength");
const maxlengthElems = validationForm.querySelectorAll(".maxlength");
const showCountElems = validationForm.querySelectorAll(".show-count");
  • 各種バリデーション条件(必須・形式・文字数など)に対応する要素を クラス名で分類 し、それぞれ取得しています。
  • 取得対象は document 全体ではなく、対象のフォーム要素(validationForm)を起点としているため、複数フォームがある場合も干渉せずに個別にバリデーションが行えます。

各クラスの役割は以下の通りです

クラス名 対応バリデーションの種類
.required 未入力チェック(必須項目)
.pattern 正規表現による形式チェック
.equal-to 他の入力と一致しているかのチェック
.minlength 最小文字数のチェック
.maxlength 最大文字数のチェック
.show-count 入力文字数カウンターの表示対象

各クラスは JavaScript 側で認識されるため、HTML 側での付け忘れや誤記に注意する必要があります。

エラーを削除する関数:removeError()

const removeError = (elem, className) => {
  const errorContainer =
    elem.closest(settings.errorContainerClass) || elem.parentNode;
  const errorSpan = errorContainer.querySelector(
    `.${settings.errorClassName}.${className}`
  );
  if (errorSpan) errorSpan.remove();
};

elem.closest()で、elem(バリデーション対象の要素)に最も近い settings.errorContainerClass で指定したクラス(.error-container)を持つ親要素を探し、存在しない場合は elem.parentNode(親要素)を代わりにエラー表示先とします。

errorContainer を起点に querySelector を使って、次のようなクラスを持つ span を探します。以下の error-js は汎用的なエラー表示クラス(settings.errorClassName で指定したクラス)、required は具体的なエラー種別を表します。

<span class="error-js required">入力は必須です</span>

そして、対象のエラー要素が存在すれば remove() で削除 します。

エラーを表示する関数:addError()

const addError = (elem, className, defaultMessage) => {
  if (!hasSubmittedOnce) return;
  removeError(elem, className);

  const errorMessage =
    elem.getAttribute(`data-error-${className}`) || defaultMessage;

  const errorSpan = document.createElement("span");
  errorSpan.classList.add(settings.errorClassName, className);
  errorSpan.setAttribute("aria-live", "polite");
  errorSpan.textContent = errorMessage;
  const errorContainer =
    elem.closest(settings.errorContainerClass) || elem.parentNode;
  errorContainer.appendChild(errorSpan);
};
  • hasSubmittedOnce が false の場合は、フォーム未送信状態とみなして、リアルタイムのエラー表示を抑制します。
  • エラーの重複防止のために removeError() を呼び出して、既存のエラーメッセージを削除してから新たなメッセージを追加します。

カスタムエラーメッセージの仕組み:

  • 要素に data-error-[className] 属性があれば、そのメッセージを優先的に表示。なければ関数の引数 defaultMessage を使用。
  • 例: <input class="required" data-error-required="この項目は必須です">

エラーメッセージは span 要素として動的に生成し、以下を設定:

  • class="[settings.errorClassName] [className]"(例:class="error-js required")
  • aria-live="polite"(画面読み上げユーザーへの配慮)

エラーの表示先として、次のいずれかに span を追加:

  • settings.errorContainerClass(例 .error-container)がついた一番近い親要素
  • なければその要素の parentNode

文字数を取得する関数:getValueLength()

const getValueLength = (value) =>
  (value.match(/([\uD800-\uDBFF][\uDC00-\uDFFF]|[\s\S])/g) || []).length;

この関数は、入力された文字列の「正確な文字数」をカウントするための関数で、サロゲートペア(例:絵文字や一部の漢字)に対応しています。【注】但し、複合絵文字には対応していません。

正規表現 /([\uD800-\uDBFF][\uDC00-\uDFFF]|[\s\S])/gは以下の2つのパターンにマッチします。

  1. サロゲートペア:[\uD800-\uDBFF][\uDC00-\uDFFF]
    • 高位サロゲートと低位サロゲートの組み合わせ(絵文字など)を1文字として扱います。
  2. その他すべての文字:[\s\S]
    • 空白(\s)も非空白(\S)も含む「任意の1文字」にマッチします。

match() でこれらすべての文字を配列にして、length で数を取得することで、絵文字なども含めた文字数をカウントしています。

この関数は、文字数制限のバリデーション(minlength, maxlength, show-count)で使用します。

複合絵文字(ZWJ 結合)を正しくカウント

👩‍🔧 や 👨‍👩‍👧‍👦 などの絵文字は、複数のコードポイントが ZWJ(ゼロ幅接合子)で結合されて1つの絵文字として表示されるので、通常の文字列処理ではこれらを1文字としてカウントできないことがあります。

このような複合絵文字を視覚上の1文字(grapheme cluster)として正確にカウントしたい場合は、例えば、以下のように Intl.Segmenter を使用するのが有効です。

ただし、Intl.Segmenter に対応していない古いブラウザでは、簡易的なフォールバックで対応する必要があります(正確なカウントにはなりません)。

const getValueLength = (value) => {
  if (typeof Intl !== "undefined" && Intl.Segmenter) {
    const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });
    return Array.from(segmenter.segment(value)).length;
  }
  // フォールバック(古いブラウザでは妥協する)
  return (value.match(/([\uD800-\uDBFF][\uDC00-\uDFFF]|[\s\S])/g) || []).length;
};

サーバー側(PHP)

フロントエンドで Intl.Segmenter を使って文字数制限を行う場合、サーバー側(PHP)でもできるだけ同じ基準で文字数を判定することが望ましいです。

そのためには、PHP の grapheme_strlen() を使用するのが最も近い方法ですが、環境依に依存します。grapheme_strlen() を使うには PHP の intl 拡張モジュールが有効である必要があります。

フォールバックが必要な場合は mb_strlen() に代替できますが、絵文字連結などには完全対応していないので注意が必要です。mb_strlen() はサロゲートペアには対応していますが、ZWJ などによるグラフィムクラスタには非対応です。そのため、複合絵文字を1文字として数えることはできません。

JavaScript の関数と対応する PHP 関数の比較
JavaScript の関数 PHP の関数 カウント単位 特徴・説明
Intl.Segmenter(...).segment() grapheme_strlen() 書記素クラスタ(grapheme) 複合絵文字も1文字としてカウント。
value.match(/([\uD800-\uDBFF][\uDC00-\uDFFF]|[\s\S])/g).length mb_strlen($value, 'UTF-8') コードポイント数 サロゲートペア対応あり。
value.length strlen() コードユニット / バイト JS は UTF-16 の code unit 数、PHP はバイト数。
  • 書記素クラスタ(grapheme cluster): 視覚上の「1文字」。複数コードポイントから成る合成文字も1文字としてカウント。
  • コードユニット(code unit): UTF-16 の場合 16bit 単位(JS の .length はこれを数える)。
  • コードポイント(code point): Unicode 文字の1単位(U+XXXX)。サロゲートペア対応に必須。

バリデーション関数

各検証用関数(例:isValueMissing()、isPatternMismatch()、isTooShort() など)は、共通して次の共通ルールに従って設計されています。

バリデーション関数の基本構造と共通ルール

  1. 引数として対象の要素 (elem) を受け取る
    • 関数は常に検証対象の DOM 要素を引数として受け取ります。
    • これにより、どの要素に対しても共通のロジックで検証でき、複数フォームや複数要素に対応しやすくなります。
  2. 検証に失敗した場合
    • addError(elem, className, message) を使って、指定要素にエラーメッセージを表示します。
    • true を返し、「この要素にはバリデーションエラーがある」ことを示します。
  3. 検証に成功した場合
    • removeError(elem, className) を呼び出して、該当エラーが表示されていれば削除します。
    • false を返し、「この要素にエラーはない」ことを示します。

検証失敗時は true を返し、検証成功時は false を返すという統一した動作をもたせることで、validateAll() のようなまとめ処理で、各関数の結果を集計しやすくしています。

必須入力の検証:isValueMissing()
const isValueMissing = (elem) => {
  const className = "required";

  // ラジオボタンまたはチェックボックスで name 属性が設定されている(空でない)場合
  if ((elem.type === "radio" || elem.type === "checkbox") && elem.name) {
    // 同じ  name 属性の要素を全て取得
    const group = validationForm.querySelectorAll(`[name="${elem.name}"]`);
    // name 属性を共有するラジオボタンやチェックボックスのグループに対して少なくとも1つ以上チェックされているかを検査
    const checked = [...group].some((el) => el.checked);

    // 代表要素として最初の要素を取得
    const representative = group[0];

    if (!checked) {
      // 代表要素だけがエラーの表示/削除を行う(エラーメッセージの上書きを防ぐ)
      if (elem === representative) {
        addError( elem, className, settings.messages.requiredSelect );
      }
      return true;
    } else {
      if (elem === representative) {
        removeError(elem, className);
      }
      return false;
    }
  }

  // 通常の入力要素(text, select など)の場合
  if (!elem.value.trim()) {
    addError(
      elem,
      className,
      elem.tagName === "SELECT"
        ? settings.messages.requiredSelect
        : settings.messages.required
    );
    return true;
  }

  removeError(elem, className);
  return false;
};

この関数は、指定された要素(elem)に「入力漏れ」があるかを判定する必須入力チェック用バリデーション関数です。

ラジオボタンまたはチェックボックスの場合は、同じ name 属性のグループに対して、少なくとも1つ以上チェックされているかを検査し、

  • チェックされていない場合はエラーを表示しますが、同じグループ内で何度もメッセージを取得して上書きされないように、最初の要素(代表要素)のみにエラーメッセージを表示します。
  • チェックされていればグループ全体のエラー表示を削除

その他のテキストやセレクトの場合は、value が空、または空白文字だけの場合は「入力なし」とみなし、エラーを表示します。

エラーがなければエラー表示を削除します。

この関数は、バリデーションエラーがある場合に true を返し、エラーがなければ false を返します(これは他の検証関数と共通の挙動です)。

パターンの検証:isPatternMismatch()
const isPatternMismatch = (elem) => {
  const className = "pattern";
  // data-pattern 属性を取得
  const patternType = elem.getAttribute("data-pattern");
  // 検証する値を取得
  let value = elem.value;

  // 正規表現パターンを定義
  const patternRegistry = {
    tel: {
      pattern: /^0\d{9,10}$/,
      preprocess: (v) => v.replace(/-/g, ""), // ハイフン除去
    },
    email: {
      pattern:
        /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/,
    },
    zip: { pattern: /^\d{3}-\d{4}$/ }, // 郵便番号
    alphanum: { pattern: /^[a-zA-Z0-9]+$/ }, // 英数字
    kana: { pattern: /^[\u30A0-\u30FFー\s]+$/ }, // カタカナ
    // 必要に応じて他のパターンも追加可能
  };

  // 定義したパターンに含まれているか確認
  const def = patternRegistry[patternType];

  // 未定義のパターンタイプはチェックしない
  if (!def) return false;

  // 前処理(例: ハイフン除去など)
  if (typeof def.preprocess === "function") {
    value = def.preprocess(value);
  }

  // バリデーション(正規表現によるチェック)
  if (value && !def.pattern.test(value)) {
    addError(elem, className, "入力された値が正しくないようです");
    return true;
  }

  removeError(elem, className);
  return false;
};

この関数は、入力値が特定のパターン(正規表現)に合っているかどうかを判定する関数です。

data-pattern 属性を取得し、どの種類のパターン検証を行うかを決定します。

各パターンごとに正規表現(pattern)を定義しています。

  • "tel":市外局番付きの日本の電話番号(ハイフン除去して 10 桁または 11 桁)
  • "email":メールアドレスの一般的な形式(RFC 仕様に準拠)
  • "zip":日本の郵便番号(例:123-4567)
  • "alphanum":半角英数字のみ
  • "kana":カタカナのみ

また、各パターンを定義する際に必要であれば preprocess 関数を指定することで、入力値に対して正規表現の検証前に前処理(例:ハイフン削除など)を行うことができます。

  • preprocess は任意の関数として定義でき、正規表現にマッチさせる前に値を加工(変更)できます。
  • typeof def.preprocess === "function" によって、関数が定義されている場合だけ安全に呼び出すようになっています。

def.pattern.test(value) で正規表現によるチェックを行い、入力値が正規表現にマッチしない場合、エラーメッセージを表示し true を返します。

検証にパスした場合は表示中のエラーを削除します。

必要であれば patternRegistry に独自パターンを追加することもできます。

独自パターンの追加

検証ルールを追加したい場合は、patternRegistry に以下の形式で定義を追加することで、独自の検証パターンを簡単に利用できます。

const patternRegistry = {
  パターン名: {
    pattern: 正規表現パターン,
    preprocess: 入力値を受け取り加工して返す関数(任意)
  },
  ...
};
  • pattern: 入力値とマッチさせる正規表現。
  • preprocess(任意): 検証前に値を整形するための関数(例:ハイフン削除、全角→半角変換など)。

以下は、大文字・小文字・数字・特殊記号をすべて1つ以上含み、かつ9文字以上であることをチェックするパターン(pass)を追加する例です。

const patternRegistry = {
  //・・・既存のパターン・・・,

  // 独自のパターンを追加
  pass: { pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{9,}$/ },
};
  • (?=.*[a-z]) 小文字を少なくとも1文字含む
  • (?=.*[A-Z]) 大文字を少なくとも1文字含む
  • (?=.*\d) 数字を少なくとも1文字含む
  • (?=.*[^\w\s]) 英数字でも空白でもない文字(≒特殊文字)少なくとも1文字含む
  • {9,} 9文字以上
<div>
  <label for="password">パスワード </label><br>
  <input type="text" class="pattern" data-pattern="pass" data-error-pattern="パスワードは9文字以上で、英大文字・小文字・数字・特殊文字をすべて含めてください。" name="password" id="password">
</div>

正規表現の動的生成に関する注意

isPatternMismatch() で任意の文字列から正規表現を生成して使用する場合、以下のリスクが伴います。

  • ReDoS(正規表現によるDoS攻撃)の危険
    • 特定の悪意ある入力で、正規表現の処理に非常に長い時間がかかり、ブラウザやサーバーをフリーズさせる可能性があります。
  • 実装ミスや脆弱性の混入
    • 動的なパターンが意図しないマッチングを引き起こしたり、検証が不十分なまま通過してしまうことがあります。

そのため、ユーザーが任意のパターンを指定できるような動的な正規表現の使用は極力避け、事前に定義された安全なパターンのみを使用するのが無難です。

入力値一致の検証:isNotEqualTo()
const isNotEqualTo = (elem) => {
const className = "equal-to";
// 比較対象の要素を取得(data-equal-to 属性に指定された id)
const target = document.getElementById(
  elem.getAttribute("data-equal-to")
);
// 両方に値があり、かつ不一致ならエラーを表示
if (target && elem.value && target.value && elem.value !== target.value) {
  addError(elem, className, settings.messages.equalTo);
  return true;
}
removeError(elem, className);
return false;
    };

この関数は、指定された要素の値が 別の要素の値と一致しているかを検証します。

  • 比較対象の要素を取得
    • data-equal-to="対象の id" という属性を通じて、比較対象となる要素の id を指定します。
    • その id に対応する要素を document.getElementById() で取得します。
  • 比較の条件
    • 比較対象(target)とその value が存在し、
    • 検証対象の elem.value と target.value が 一致していない 場合、
    • エラーメッセージを表示して true を返します(= エラーあり)。
最小文字数の検証:isTooShort()
const isTooShort = (elem) => {
  const className = "minlength";
  const minlength = parseInt(elem.getAttribute("data-minlength"), 10);
  const valueLength = getValueLength(elem.value);
  if (elem.value && valueLength < minlength) {
    addError(elem, className, `${settings.messages.minlength(minlength)}`);
    return true;
  }
  removeError(elem, className);
  return false;
};

data-minlength 属性に指定された「最小文字数」より短いかを検証する関数です。

  • 対象要素から data-minlength 属性の数値を取得します。
  • 入力値の長さは getValueLength() を使って、サロゲートペア(絵文字など)も1文字としてカウントします。
  • 入力値が最小文字数未満であれば addError() でエラーを表示し、true を返します。
  • そうでない場合はエラーを削除し、false を返します。

なお、エラーメッセージの表示には、settings.messages.minlength(minlength) のように settings で関数形式で指定されたメッセージテンプレートを使用しています。これにより、${minlength}文字以上で入力ください のような動的なメッセージを柔軟に生成できます。

最大文字数の検証:isTooLong()
const isTooLong = (elem) => {
  const className = "maxlength";
  const maxlength = parseInt(elem.getAttribute("data-maxlength"), 10);
  const valueLength = getValueLength(elem.value);
  if (elem.value && valueLength > maxlength) {
    addError(elem, className, `${settings.messages.maxlength(maxlength)}`);
    return true;
  }
  removeError(elem, className);
  return false;
};

data-maxlength に指定された「最大文字数」を超えていないかを検証します。

  • data-maxlength 属性から 最大文字数を取得します。
  • getValueLength() を使って正確な文字数(入力値の長さ)を計算します。
  • 実際の文字数が最大値を超えていないかをチェックし、超えていればエラーを表示し true、それ以外はエラーを削除して false を返します。

エラーメッセージの表示には、isTooShort() 同様、settings.messages.maxlength(maxlength) のように settings で関数形式で指定されたメッセージテンプレートを使用しています

入力文字数のリアルタイム表示:attachCounter()

const attachCounter = () => {
  showCountElems.forEach((elem) => {
    const max = parseInt(elem.getAttribute("data-maxlength"), 10);
    if (!isNaN(max) && !elem.dataset.hasCounter) {
      // 追加済みフラグを設定(同じ要素に複数回 append されるのを防ぐ)
      elem.dataset.hasCounter = "true";
      const countElem = document.createElement("p");
      countElem.classList.add(settings.counter.wrapperClass);
      const countClass = settings.counter.countClass;
      countElem.innerHTML = `<span class="${countClass}">0</span>/${max}`;
      elem.parentNode.appendChild(countElem);
      // 生成したカウント用の span 要素を取得
      const countSpan = countElem.querySelector(`.${countClass}`);

      // 文字数を更新する関数
      function updateCharCount() {
        const count = getValueLength(elem.value);
        countSpan.textContent = count;
        countSpan.classList.toggle(
          settings.counter.overLimitClass,
          count > max
        );
        countSpan.style.color =
          count > max ? settings.counter.overLimitColor : "";
      }

      // 初期状態でのカウント表示
      updateCharCount();
      // リアルタイムで文字数更新
      elem.addEventListener("input", updateCharCount);
    }
  });
};

この関数は、data-maxlength が指定されていて、かつ .show-count クラスが付いた要素に対して、現在の入力文字数をリアルタイムでカウント・表示する機能を追加します。

対象要素の条件

  • .show-count クラスがある(showCountElems が対象)
  • data-maxlength 属性の値(max)が数値として有効
  • まだ data-has-counter 属性が設定されていない(カウンターが追加されていない:再追加防止)

処理内容

  1. 文字数カウンター要素を生成
    • 入力要素の data-maxlength 属性から最大文字数を取得し、それに対応するカウンター要素(<p><span>0</span>/100 など)を生成します。
    • 生成された <p> 要素は settings.counter.wrapperClass を、内包される <span> 要素は settings.counter.countClass を持ちます。
    • 入力欄の直後(親要素の末尾)に追加され、初期状態でも正確な文字数が表示されます。
  2. リアルタイムで文字数更新
    • input イベントで入力値の変化を検知し、getValueLength() 関数でマルチバイト文字に対応した文字数をカウント。
    • 現在の文字数をカウンター内の <span> に反映します。
  3. 制限オーバーの視覚フィードバック:入力文字数が最大値を超えると
    • settings.counter.overLimitClass クラスが <span> に付与され、スタイル変更が可能。
    • settings.counter.overLimitColor(例:赤)で文字色が変更され、ユーザーに視覚的な注意喚起。
  4. 二重追加を防止
    • すでにカウンターが生成されている要素に対しては、data-has-counter="true" がセットされていることを確認し、再度生成しないようにします。

リアルタイムバリデーションの設定:attachValidation()

const attachValidation = () => {
  // .required 要素へのイベント登録
  requiredElems.forEach((elem) => {
    const eventType =
      elem.type === "radio" || elem.type === "checkbox"
        ? "change"
        : "input";

    // ラジオボタンまたはチェックボックスの場合
    if (elem.type === "radio" || elem.type === "checkbox") {
      // 同じ name を持つ要素を1つのグループとして取得(ラジオボタン・チェックボックスのセット)
      const group = validationForm.querySelectorAll(
        `[name="${elem.name}"]`
      );
      // グループ内で1回だけイベント登録処理を行うための条件
      if (group.length && group[0] === elem) {
        group.forEach((el) => {
          el.addEventListener(eventType, () => {
            group.forEach((target) => isValueMissing(target));
          });
        });
      }
    } else {
      // ラジオボタン・チェックボックス以外の場合
      elem.addEventListener(eventType, () => isValueMissing(elem));
    }
  });

  // .pattern 要素へのイベント登録
  patternElems.forEach((elem) =>
    elem.addEventListener("input", () => isPatternMismatch(elem))
  );

  // .equal-to 要素へのイベント登録
  equalToElems.forEach((elem) => {
    elem.addEventListener("input", () => isNotEqualTo(elem));
    const target = document.getElementById(
      elem.getAttribute("data-equal-to")
    );
    if (target) target.addEventListener("input", () => isNotEqualTo(elem));
  });

  // .minlength 要素へのイベント登録
  minlengthElems.forEach((elem) =>
    elem.addEventListener("input", () => isTooShort(elem))
  );

  // .maxlength 要素へのイベント登録
  maxlengthElems.forEach((elem) =>
    elem.addEventListener("input", () => isTooLong(elem))
  );
};

この関数は、各バリデーション対象の要素にイベントリスナーを追加し、入力中に自動で検証を実行するようにします。リアルタイム検証は、主に input または change イベントを使って実行されます。

.required 要素へのイベント登録

  • 入力必須の .required 要素には、タイプに応じて input または change イベントを設定します(radio や checkbox には change イベント)。
  • radio や checkbox の場合は 同じ name 属性を持つ要素のグループを取得し、グループ全体で検証を行います。

ラジオボタンやチェックボックスは、同じ name 属性を持つ要素が1つのグループとして扱われます。 この場合、通常はグループ内の先頭の1要素だけに .required クラスを付けて、グループ全体が「必須である」ことを表現します。

<div>
  <p>色を選択してください(必須)</p>
  <input class="required" data-error-required="いずれかの色を選択してください" type="radio" name="color" value="blue" id="blue">
  <label for="blue"> 青 </label>
  <input type="radio" name="color" value="red" id="red">
  <label for="red"> 赤 </label>
  <input type="radio" name="color" value="green" id="green">
  <label for="green"> 緑 </label>
</div>

以下は JavaScript 側でのイベント設定です。

  • ラジオボタンやチェックボックスでは、同じ name を持つ要素群を「グループ」として扱います。
  • JavaScript では、group[0] === elem の条件によって1グループにつき1回だけイベント登録を行い、そのグループ全体に change イベントを設定しています。
  • これにより、どの選択肢が操作されても正しくバリデーションを実行でき、冗長な処理も防げます。
if (elem.type === "radio" || elem.type === "checkbox") {
  // 同じ name を持つ要素を1つのグループとして取得(ラジオボタン・チェックボックスのセット)
  const group = validationForm.querySelectorAll(`[name="${elem.name}"]`);

  // グループ内で1回だけイベント登録処理を行うための条件。
  // group[0] === elem のときのみ処理を実行する(同じグループに対して重複登録を防止)
  if (group.length && group[0] === elem) {
    // 実際のイベントリスナー(change)は、グループ内の全ての要素に対して登録される。
    // これにより、どの選択肢を操作してもグループ全体のバリデーションが行われる。
    group.forEach((el) => {
      el.addEventListener(eventType, () => {
        group.forEach((target) => isValueMissing(target));
      });
    });
  }
}

.pattern 要素へのイベント登録

patternElems.forEach((elem) =>
  elem.addEventListener("input", () => isPatternMismatch(elem))
);

正規表現検証対象(data-pattern を持つ .pattern 要素)には input イベントでリアルタイムにパターンをチェックします。

.equal-to 要素へのイベント登録

equalToElems.forEach((elem) => {
  elem.addEventListener("input", () => isNotEqualTo(elem));
  const target = document.getElementById(elem.getAttribute("data-equal-to"));
  if (target) target.addEventListener("input", () => isNotEqualTo(elem));
});

2つのフィールドが一致しているかを検証する .equal-to 要素に対しては、

  • 自分自身の変更
  • 比較対象要素の変更

のどちらでも検証が発動するよう、両方に input イベントを登録します。

.minlength / .maxlength 要素へのイベント登録

minlengthElems.forEach((elem) =>
  elem.addEventListener("input", () => isTooShort(elem))
);

maxlengthElems.forEach((elem) =>
  elem.addEventListener("input", () => isTooLong(elem))
);

最小/最大文字数の制限チェックは、input イベントで文字が入力されるたびに検証されます。

すべての検証を実行:validateAll()

const validateAll = () => {
  let hasError = false;
  requiredElems.forEach((elem) => {
    if (isValueMissing(elem)) hasError = true;
  });
  patternElems.forEach((elem) => {
    if (isPatternMismatch(elem)) hasError = true;
  });
  equalToElems.forEach((elem) => {
    if (isNotEqualTo(elem)) hasError = true;
  });
  minlengthElems.forEach((elem) => {
    if (isTooShort(elem)) hasError = true;
  });
  maxlengthElems.forEach((elem) => {
    if (isTooLong(elem)) hasError = true;
  });
  return hasError;
};

この関数は、対象フォーム内のすべてのバリデーション対象要素に対して個別の検証関数を呼び出し、1つでもエラーがあれば true を返す関数です。

処理の流れ

  1. hasError フラグを初期化
  2. 各種バリデーション関数を実行(すべて elem を引数に取り、エラーなら true を返す)
  3. どれか1つでも true があれば hasError = true に
  4. 最終的に hasError を返す

submit イベントでの検証実行 & エラースクロール

validationForm.addEventListener("submit", (e) => {
  hasSubmittedOnce = true;  // 以降エラー表示を有効にする
  const hasError = validateAll(); // 全ての検証を実行
  if (hasError) {
    // エラーがあるため送信をキャンセル
    e.preventDefault();
    // 最初のエラー要素
    const errorElem = validationForm.querySelector(
      `.${settings.errorClassName}`
    );
    // form 要素の data-error-offset を数値として取得(スクロールのオフセット調整)
    const offset = parseInt(validationForm.dataset.errorOffset, 10) || settings.scroll.offset;
    if (errorElem) {
      window.scrollTo({
        top: errorElem.offsetTop - offset,
        behavior: settings.scroll.behavior,
      });
    }
  }
});
  • hasSubmittedOnce = true
    • フォームが一度送信されたことを記録。
    • 以降、リアルタイム検証を有効化するトリガーとして機能します。
  • validateAll() で検証実行
    • すべてのルールを適用し、エラーがあるかを判定。
  • hasError が true の場合
    • e.preventDefault() で送信を中止。
    • 最初のエラー要素(.error-js)を探して、自動的にスクロール移動。

submit イベントでは validateAll() を使って送信可否を判断し、ユーザーがエラー箇所をすぐ確認できるようスクロール誘導します。

スクロール位置の調整

  • data-error-offset があればその値を使い、なければ settings.scroll.offset の値分だけ(デフォルトは 40px)上にオフセット。
  • settings.scroll.behavior で指定されたスクロール動作(デフォルトはスムーズスクロール)でユーザーに視覚的フィードバックを提供。

多言語対応版

必要な言語別のエラーメッセージを定義し、ページ(html 要素)の lang 属性の値で自動的にメッセージの言語を切り替えられるようにします。必要に応じて form 要素の lang 属性や data-lang 属性に言語を指定して、フォームごとに言語を切り替えることもできます。

以下はエラーメッセージを日本語と英語で切り替えられるようにする例です。

多言語対応のしくみ

settings.messages に各言語ごとのエラーメッセージを定義し、fallbackLang でデフォルト言語を設定します。必要に応じて言語を追加します。

messages: {
  // 日本語(キー名には W3C の言語コード(BCP 47)に基づく "ja" を使用)
  ja: {
    required: "入力は必須です",
    requiredSelect: "選択は必須です",
    pattern: "入力された値が正しくないようです",
    equalTo: "入力された値が一致しません",
    minlength: (min) => `${min}文字以上で入力してください`,
    maxlength: (max) => `${max}文字以内で入力してください`,
  },
  // 英語
  en: {
    required: "This field is required.",
    requiredSelect: "Please make a selection.",
    pattern: "The entered value appears to be invalid.",
    equalTo: "The values do not match.",
    minlength: (min) => `Please enter at least ${min} characters.`,
    maxlength: (max) => `Please enter no more than ${max} characters.`,
  },
},
fallbackLang: "ja", // デフォルト(フォールバック)言語を指定

settings.messages に設定する言語のキー名は、W3C の言語コード(BCP 47)に基づく言語コードを指定します(ja や en など)。例えば、日本語の場合、jp は無効なキー名です。

言語コードは、大文字・小文字は区別されませんが、慣例的に国コードは大文字で書きます(en-us でも en-US と解釈される)。

特定の地域の指定が必要ない場合は、2文字の言語コードのみにします(例:en, fr)。2文字の言語コードのみにすることで、例えば、lang 属性の言語コードが en-US や en-GB、en-AU でも en のメッセージが使用されます。

言語設定の取得

言語設定の取得では、各フォームの data-lang 属性による明示的な指定を最優先します。

data-lang 属性の指定がなければ、form 要素や html 要素の lang 属性を自動的に参照し、最終的に fallbackLang(デフォルト言語)にフォールバックします。

言語コードを一貫して扱うために、小文字化と基本言語コードを抽出する前処理をして、優先順位に従ってメッセージを取得します。

不正な言語コード(settings.messages に存在しないキー)が指定されていた場合には、コンソールに警告を出力するようにしています。

// 言語設定をフォームの data-lang 属性 → lang 属性 → HTML の lang 属性 → fallbackLang の順で取得
const currentLang =
  validationForm.dataset.lang || // ① data-lang 属性(最優先)
  validationForm.getAttribute("lang") || // ② form 要素の lang 属性
  document.documentElement.lang || // ③ <html lang="..."> の値
  settings.fallbackLang; // ④ fallback(デフォルト)

// 大文字・小文字の違いによる不一致を防ぐ
const lang = currentLang.toLowerCase();
// 基本言語(ベース言語)を抽出
const baseLang = lang.split("-")[0]; // 例: "en-us" → "en"

// 優先順位にしたがってメッセージを取得(存在しなければ fallbackLang を使用)
const messages =
  settings.messages[currentLang] ||  // 元の値そのまま(例: "en-US", "ja-JP")
  settings.messages[lang] ||         // 小文字化した値(例: "en-us", "ja-jp")
  settings.messages[baseLang] ||     // ベース言語(例: "en", "ja")
  settings.messages[settings.fallbackLang];   // fallback(デフォルト)

//不正な言語コード(settings.messages に存在しないキー)が指定されていた場合にコンソールに警告を出力
if (!settings.messages[lang] && !settings.messages[baseLang]) {
  console.warn(
    `Unsupported language "${currentLang}". Falling back to "${settings.fallbackLang}".`
  );
}

エラーメッセージの表示

各検証関数では、対応する言語のメッセージを使用してエラーを表示します。

addError(elem, className, messages.requiredSelect);

多言語対応版のコード

以下は多言語(英語・日本語)対応版のコード全体です。

document.addEventListener("DOMContentLoaded", () => {
  const settings = {
    formSelector: ".js-form-validation",
    errorClassName: "error-js",
    errorContainerClass: ".error-container",

    messages: {
      // 日本語(キー名には W3C の言語コード(BCP 47)に基づく "ja" を使用)
      ja: {
        required: "入力は必須です",
        requiredSelect: "選択は必須です",
        pattern: "入力された値が正しくないようです",
        equalTo: "入力された値が一致しません",
        minlength: (min) => `${min}文字以上で入力してください`,
        maxlength: (max) => `${max}文字以内で入力してください`,
      },
      // 英語
      en: {
        required: "This field is required.",
        requiredSelect: "Please make a selection.",
        pattern: "The entered value appears to be invalid.",
        equalTo: "The values do not match.",
        minlength: (min) => `Please enter at least ${min} characters.`,
        maxlength: (max) => `Please enter no more than ${max} characters.`,
      },
    },
    // デフォルト(フォールバック)言語を指定
    fallbackLang: "ja",

    counter: {
      wrapperClass: "count-span-wrapper",
      countClass: "count-span",
      overLimitClass: "over-max-count",
      overLimitColor: "red",
    },
    scroll: {
      offset: 40,
      behavior: "smooth",
    },
  };

  const validationForms = document.querySelectorAll(settings.formSelector);

  if (!validationForms.length) return;

  validationForms.forEach((validationForm) => {
    let hasSubmittedOnce = false;

    if (validationForm.dataset.realtimeValidation === "true") {
      hasSubmittedOnce = true;
    }

    // 言語設定をフォームの data-lang 属性 → lang 属性 → HTML の lang 属性 → fallbackLang の順で取得
    const currentLang =
      validationForm.dataset.lang || // ① data-lang 属性(最優先)
      validationForm.getAttribute("lang") || // ② form 要素の lang 属性
      document.documentElement.lang || // ③ <html lang="..."> の値
      settings.fallbackLang; // ④ fallback(デフォルト)

    // 小文字変換とベース言語抽出
    const lang = currentLang.toLowerCase();
    const baseLang = lang.split("-")[0]; // 例: "en-us" → "en"

    // 優先順位にしたがってメッセージを取得(存在しなければ fallbackLang を使用)
    const messages =
      settings.messages[currentLang] ||  // 元の値そのまま(例: "en-US", "ja-JP")
      settings.messages[lang] ||         // 小文字化した値(例: "en-us", "ja-jp")
      settings.messages[baseLang] ||     // ベース言語(例: "en", "ja")
      settings.messages[settings.fallbackLang];   // fallback(デフォルト)

    //不正な言語コード(settings.messages に存在しないキー)が指定されていた場合にコンソールに警告を出力
    if (!settings.messages[lang] && !settings.messages[baseLang]) {
      console.warn(
        `Unsupported language "${currentLang}". Falling back to "${settings.fallbackLang}".`
      );
    }

    const requiredElems = validationForm.querySelectorAll(".required");
    const patternElems = validationForm.querySelectorAll(".pattern");
    const equalToElems = validationForm.querySelectorAll(".equal-to");
    const minlengthElems = validationForm.querySelectorAll(".minlength");
    const maxlengthElems = validationForm.querySelectorAll(".maxlength");
    const showCountElems = validationForm.querySelectorAll(".show-count");

    const removeError = (elem, className) => {
      const errorContainer =
        elem.closest(settings.errorContainerClass) || elem.parentNode;
      const errorSpan = errorContainer.querySelector(
        `.${settings.errorClassName}.${className}`
      );
      if (errorSpan) errorSpan.remove();
    };

    const addError = (elem, className, defaultMessage) => {
      if (!hasSubmittedOnce) return;
      removeError(elem, className);

      const errorMessage =
        elem.getAttribute(`data-error-${className}`) || defaultMessage;

      const errorSpan = document.createElement("span");
      errorSpan.classList.add(settings.errorClassName, className);
      errorSpan.setAttribute("aria-live", "polite");
      errorSpan.textContent = errorMessage;
      const errorContainer =
        elem.closest(settings.errorContainerClass) || elem.parentNode;
      errorContainer.appendChild(errorSpan);
    };

    const getValueLength = (value) =>
      (value.match(/([\uD800-\uDBFF][\uDC00-\uDFFF]|[\s\S])/g) || []).length;

    const isValueMissing = (elem) => {
      const className = "required";
      if ((elem.type === "radio" || elem.type === "checkbox") && elem.name) {
        const group = validationForm.querySelectorAll(`[name="${elem.name}"]`);
        const checked = [...group].some((el) => el.checked);
        const representative = group[0];
        if (!checked) {
          if (elem === representative) {
            // デフォルトのエラーメッセージには、messagesオブジェクトの該当項目を使用
            addError(elem, className, messages.requiredSelect);
          }
          return true;
        } else {
          if (elem === representative) {
            removeError(elem, className);
          }
          return false;
        }
      }

      if (!elem.value.trim()) {
        // デフォルトのエラーメッセージには、messagesオブジェクトの該当項目を使用
        addError(
          elem,
          className,
          elem.tagName === "SELECT"
            ? messages.requiredSelect
            : messages.required
        );
        return true;
      }
      removeError(elem, className);
      return false;
    };

    const isPatternMismatch = (elem) => {
      const className = "pattern";
      const patternType = elem.getAttribute("data-pattern");
      let value = elem.value;

      const patternRegistry = {
        tel: {
          pattern: /^0\d{9,10}$/,
          preprocess: (v) => v.replace(/-/g, ""),
        },
        email: {
          pattern:
            /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/,
        },
        zip: { pattern: /^\d{3}-\d{4}$/ },
        alphanum: { pattern: /^[a-zA-Z0-9]+$/ },
        kana: { pattern: /^[\u30A0-\u30FFー\s]+$/ },
      };

      const def = patternRegistry[patternType];
      if (!def) return false;

      if (typeof def.preprocess === "function") {
        value = def.preprocess(value);
      }

      if (value && !def.pattern.test(value)) {
        // デフォルトのエラーメッセージには、messagesオブジェクトの該当項目を使用
        addError(elem, className, messages.pattern);
        return true;
      }

      removeError(elem, className);
      return false;
    };

    const isNotEqualTo = (elem) => {
      const className = "equal-to";
      const target = document.getElementById(
        elem.getAttribute("data-equal-to")
      );
      if (target && elem.value && target.value && elem.value !== target.value) {
        // デフォルトのエラーメッセージには、messagesオブジェクトの該当項目を使用
        addError(elem, className, messages.equalTo);
        return true;
      }
      removeError(elem, className);
      return false;
    };

    const isTooShort = (elem) => {
      const className = "minlength";
      const minlength = parseInt(elem.getAttribute("data-minlength"), 10);
      const valueLength = getValueLength(elem.value);
      if (elem.value && valueLength < minlength) {
        // デフォルトのエラーメッセージには、messagesオブジェクトの該当項目を使用
        addError(elem, className, `${messages.minlength(minlength)}`);
        return true;
      }
      removeError(elem, className);
      return false;
    };

    const isTooLong = (elem) => {
      const className = "maxlength";
      const maxlength = parseInt(elem.getAttribute("data-maxlength"), 10);
      const valueLength = getValueLength(elem.value);
      if (elem.value && valueLength > maxlength) {
        // デフォルトのエラーメッセージには、messagesオブジェクトの該当項目を使用
        addError(elem, className, `${messages.maxlength(maxlength)}`);
        return true;
      }
      removeError(elem, className);
      return false;
    };

    const attachCounter = () => {
      showCountElems.forEach((elem) => {
        const max = parseInt(elem.getAttribute("data-maxlength"), 10);
        if (!isNaN(max) && !elem.dataset.hasCounter) {
          elem.dataset.hasCounter = "true";
          const countElem = document.createElement("p");
          countElem.classList.add(settings.counter.wrapperClass);
          const countClass = settings.counter.countClass;
          countElem.innerHTML = `<span class="${countClass}">0</span>/${max}`;
          elem.parentNode.appendChild(countElem);
          const countSpan = countElem.querySelector(`.${countClass}`);

          function updateCharCount() {
            const count = getValueLength(elem.value);
            countSpan.textContent = count;
            countSpan.classList.toggle(
              settings.counter.overLimitClass,
              count > max
            );
            countSpan.style.color =
              count > max ? settings.counter.overLimitColor : "";
          }

          updateCharCount();
          elem.addEventListener("input", updateCharCount);
        }
      });
    };

    const attachValidation = () => {
      requiredElems.forEach((elem) => {
        const eventType =
          elem.type === "radio" || elem.type === "checkbox"
            ? "change"
            : "input";

        if (elem.type === "radio" || elem.type === "checkbox") {
          const group = validationForm.querySelectorAll(
            `[name="${elem.name}"]`
          );
          if (group.length && group[0] === elem) {
            group.forEach((el) => {
              el.addEventListener(eventType, () => {
                group.forEach((target) => isValueMissing(target));
              });
            });
          }
        } else {
          elem.addEventListener(eventType, () => isValueMissing(elem));
        }
      });

      patternElems.forEach((elem) =>
        elem.addEventListener("input", () => isPatternMismatch(elem))
      );

      equalToElems.forEach((elem) => {
        elem.addEventListener("input", () => isNotEqualTo(elem));
        const target = document.getElementById(
          elem.getAttribute("data-equal-to")
        );
        if (target) target.addEventListener("input", () => isNotEqualTo(elem));
      });

      minlengthElems.forEach((elem) =>
        elem.addEventListener("input", () => isTooShort(elem))
      );

      maxlengthElems.forEach((elem) =>
        elem.addEventListener("input", () => isTooLong(elem))
      );
    };

    const validateAll = () => {
      let hasError = false;
      requiredElems.forEach((elem) => {
        if (isValueMissing(elem)) hasError = true;
      });
      patternElems.forEach((elem) => {
        if (isPatternMismatch(elem)) hasError = true;
      });
      equalToElems.forEach((elem) => {
        if (isNotEqualTo(elem)) hasError = true;
      });
      minlengthElems.forEach((elem) => {
        if (isTooShort(elem)) hasError = true;
      });
      maxlengthElems.forEach((elem) => {
        if (isTooLong(elem)) hasError = true;
      });
      return hasError;
    };

    validationForm.addEventListener("submit", (e) => {
      hasSubmittedOnce = true;
      const hasError = validateAll();
      if (hasError) {
        e.preventDefault();
        const errorElem = validationForm.querySelector(
          `.${settings.errorClassName}`
        );
        const offset =
          parseInt(validationForm.dataset.errorOffset, 10) ||
          settings.scroll.offset;
        if (errorElem) {
          window.scrollTo({
            top: errorElem.offsetTop - offset,
            behavior: settings.scroll.behavior,
          });
        }
      }
    });

    attachValidation();
    attachCounter();
  });
});

使い方

何も指定しない場合は、<html lang="ja"> のように HTML 要素の lang 属性で指定された言語コードが自動的に適用されます。ただし、その言語が settings.messages に存在しない場合は、fallbackLang に指定されたデフォルト言語が使用されます。

必要に応じて、form 要素の lang または data-lang 属性に言語を個別指定することもできます。

lang 属性と data-lang 属性の使い分け

  1. HTML 標準の lang 属性の使用を基本とします
    • ブラウザや支援技術(スクリーンリーダーなど)に「このフォームの言語は〇〇である」と伝えることが可能
    • <form lang="en">
  2. 必要に応じて data-lang 属性(カスタムデータ属性)を使用します
    • 動的に切り替えたい場合など、JavaScript 側で処理用に明示的に言語を指定したいときに使用
    • <form lang="en" data-lang="ja">
    • 上記の場合、画面表示や支援技術上は英語(lang="en")で提示されますが、JavaScript バリデーションでは日本語(data-lang="ja")のエラーメッセージが使用されます。
属性 役割と用途
lang HTML 標準の属性。支援技術やブラウザにフォームの言語を伝える目的で使用します。
data-lang JavaScript 側で処理に使用するカスタム属性。スクリプトの言語切り替えを制御したい場合に使います。

以下は参考まで。

よく使う標準的な言語コードの例(BCP 47 準拠)

言語 コード 補足
日本語 ja 日本語(言語)だけを指定
日本語 ja-JP 日本語(日本の地域)を指定
中国語(簡体) zh-CN 中国本土向け
中国語(繁体) zh-TW 台湾向け
韓国語 ko 韓国
英語(汎用) en 地域に依存しない汎用英語
英語(米国) en-US アメリカ英語
英語(英国) en-GB イギリス英語
英語(オーストラリア) en-AU オーストラリア英語
フランス語 fr フランス
フランス語(カナダ) fr-CA カナダの仏語
ドイツ語 de ドイツ
イタリア語 it イタリア
スペイン語 es スペイン・中南米
ポルトガル語 pt ポルトガル
ポルトガル語(ブラジル) pt-BR ブラジル向け
ロシア語 ru ロシア
ヒンディー語 hi インド
アラビア語 ar アラブ諸国

ja と ja-JP の違い

ja-JP と ja の違いは、言語コードの精度(具体性/意味する範囲)の違いです。

  • ja(言語コードのみ)
    • 意味:日本語全体を指す汎用的な言語コード。
    • 用途:国や地域を限定せず「日本語」であれば何でも含む。
    • 通常はこちらで十分です。
  • ja-JP(言語コード + 地域コード)
    • 意味:「日本(JP)で使われる日本語」という地域に特化した表現。
    • 用途:たとえば「同じ言語でも、地域によって表記や書式が異なる」場合に使われる。
    • 例:通貨の表記、日付のフォーマットなどを国別に使い分けたいとき。
使い分け
コード 対象
ja すべての日本語 日本語のWebサイトやHTML文書
ja-JP 日本国内での日本語(明示) ロケール指定や特定アプリで地域特化した処理をする場合