JavaScript フォームにインラインバリデーションチェックを実装する もりけん塾 JavaScript課題25

JavaScript フォームにインラインバリデーションチェックを実装する もりけん塾 JavaScript課題25

JavaScriptでフォームにインラインバリデーションチェックを実装したので、調べたことや実装方法をまとめました。もりけん塾のJavaScript課題25で実装しました。

課題の仕様

仕様

  • バリデーションチェックを追加します
  • 初回は送信ボタンとチェックボックスはdisabled状態。CSSは画像のように灰色にしてください
  • ユーザー名は16文字未満とし、もしinvalidならバリデーションテキストは 「ユーザー名は15文字以下にしてください。」
  • メールアドレスは一般的なメール形式のバリデーションにしてください。もしinvalidならバリデーションテキストは「メールアドレスの形式になっていません。」
  • パスワードのバリデーションは8文字以上の大小の英数字を交ぜたものとし、もしinvalidならバリデーションテキストは「8文字以上の大小の英数字を交ぜたものにしてください。」
  • 利用規約のスクロール実装に併せて、チェックボックスのdisabledは外し、checkedになる
  • 全ての入力がvalidの場合にのみ送信ボタンは緑色になり押下でき、register-done.htmlに遷移できる。

HTMLのフォームバリデーションチェックと組み込みのバリデーションチェック

最初に、HTMLでJavaScriptを書かずに実装できる、組み込みのバリデーションチェックについて調べました。

通常は、フォームを送信した時点で、 HTMLのフォームバリデーションチェックの機能を使用して、組み込みのバリデーションチェックが走り、invalidイベントが発生した場合、フォーム送信を阻止します。これらは、JavaSciptを書かなくても、チェックすることが可能です。

組み込みのバリデーションチェックには、下記の様なものがあります。

required: フォームを送信する前に、フォームフィールドに入力する必要があるかどうかを指定します。
minlength と maxlength: テキストデータ(文字列)の最小・最大長を指定します。
min と max: 数値入力型の最小値、最大値を指定します。
type: データを数字にするか、メールアドレスにするか、その他のプリセットされた特定の型にするかを指定します。
pattern: データが指定された正規表現に一致するかどうかを指定します。

https://developer.mozilla.org/ja/docs/Learn/Forms/Form_validation

組み込みのバリデーションチェックは JavaScriptでのバリデーションチェックよりもパフォーマンスが良いですが、 JavaScriptほどカスタマイズすることができません。

要素が有効である場合は、CSSの擬似要素:validでスタイルの調整が可能です。要素が不正の場合は、 :invalidで、CSSを調整することが可能です。また、form.checkValidity()ですべてのバリデーションがOKかどうかをチェックすることができます。

HTMLとJavsScriptでのバリデーションチェックについては、こちらのサイトで、詳しく解説がされていました。https://liginc.co.jp/348438

今回の課題では、フォームを送信するためのtype=submitのボタン要素は、e.preventDefault();でデフォルトのイベントをキャンセルしているので、組み込みのバリデーションチェックが実行されません。そのため、バリデーションチェックは、JavaSciriptで独自に実装します。

組み込みのバリデーションチェックを任意のタイミングで呼び出すこともできますが、今回は、利用しませんでした。

ユーザーにとって、使いやすいフォームを作る

これまで、フォームのバリデーションチェックは、サブミットボタンを押下時に一気に、チェックするものしか実装したことがなく、入力欄ごとにバリデーションチェックするものは、実装したことがありませんでした。

リアルタイムにバリデーションチェックすることを、インラインバリデーションというそうです。インラインバリデーションの実装時に注意する点について、良い記事を見つけたので、読みました。 UXの解説しているサイトで、他の記事も勉強になりそうです。

https://baymard.com/blog/inline-form-validation

ユーザーの入力の妨げになるバリデーションとは

  1. 早すぎるバリデーション – ユーザーの入力が完了する前に、エラーメッセージを出す
    ・入力中に突然エラーメッセージが表示されると、エラーメッセージを読んで理解するために、これまで入力した内容に誤りがあると勘違いして探すことになる。
    ・まだ入力が終わってないのに、間違いを指摘されるので、ユーザーはストレスに感じる。
  2. 古いエラーメッセージの表示 – ユーザーが問題を解決したにもかかわらず、古いエラーメッセージが表示されている。
    ・エラーメッセージをライブで削除しないと、ユーザーが新しく修正された入力にまだエラーが含まれていると勘違いする。
    ・エラーメッセージは、ユーザーが有効な入力に到達した瞬間に消えるようにしなければならない(つまり、blurだけではなくキーストロークレベルで行う必要がある)。

もりけん塾で呟いたところ、先生からGoogleはどんな感じか、という質問があったので、調べてみました。試しに、GoogleGoogleフォームも作成して挙動をチェックしてみました。

Googleフォームのバリデーションチェック

  • インラインバリデーション(インプット欄ごとにバリデーションチェックが走る)
  • 送信ボタン押下時と、フォーカスがはずれたときにバリデーションチェック
  • 必須項目のチェックはフォーカスがはずれたときに実行される
  • 入力中に必須項目が空欄になった場合は、「この質問は必須です」というエラーメッセージを表示
  • 必須以外のエラー(制限文字数など)が表示されている場合は、入力中にもチェックする
  • エラーメッセージは、有効な入力内容になった時点で消える

サンプルのGoogleフォームも作ってみました↓ ※Googleフォームではパスワードを入力させることは推奨していませんので、下記はサンプルとしてお試しくdさい。

https://docs.google.com/forms/d/e/1FAIpQLSe5bmuVFZyCLUfblJeSlSYTXoxVx3OdIF8XUp8S-__dl2LMdA/viewform

ユーザーに優しい入力フォームを作ろう

以上の内容をふまえて、バリデーションチェックは下記の様な実装にすることにしました。

  • 必須空欄チェック:フォーカスはずれたとき
  • 名前(文字数制限):入力中にチェック
  • メールアドレス;フォーカスはずれたとき
  • パスワード(文字数+大小英数字):フォーカスはずれたとき
  • エラー表示がある場合:入力中にチェックし、有効な内容になった段階でエラーメッセージを消去

フォームのインラインバリデーションチェックの実装の流れ

実装の流れ

  • フォームのinput要素を取得する
  • バリデーションチェックがinvalidのとき、”field-invalid”クラスをaddし、エラーメッセージを表示する
  • バリデーションチェックがvalidのとき、”field-invalid”クラスをremoveし、エラーメッセージを取り除く
  • “field-invalid”クラスがひとつもない(つまり、すべてのバリデーションがOK)のときは、サブミットボタンを有効化する

HTML

HTMLはこちらです。

      <form method="post" id="member_registration" name="memberRegister">
        <h1>会員登録</h1>
        <div class="mb-6">
          <label for="user_name">お名前<span class="required text-red-500 mx-1" aria-hidden="true">*</span></label>
          <input type="text" name="user_name" id="user_name" placeholder="山田 太郎" required>
        </div>
        <div class="mb-6">
          <label for="email">メールアドレス<span class="required text-red-500 mx-1" aria-hidden="true">*</span></label>
          <input type="email" name="email" id="email" placeholder="sample@sample.com" required>
        </div>
        <div class="mb-6">
          <label for="new-password">パスワード<span class="required text-red-500 mx-1" aria-hidden="true">*</span></label>
          <p class="text-sm text-gray-500 mb-2">8文字以上の大小英数字を含める形で入力してください</p>
          <input type="password" name="new-password" id="new-password" autocomplete="new-password" placeholder="********" required>
        </div>
        <div class="flex items-center mb-6">
          <input type="checkbox" name="agree_check" id="agree_check" data-id="agree_check" value="agreement" disabled>
          <label for="agree_check"><a href="#" data-id="terms-link" data-modal-toggle="popup-modal">利用規約</a>を読み、同意しました</label>
        </div>
        <div class="text-center">
          <button type="submit" name="submit_button" class="submit_btn" data-id="submit_button" disabled>会員登録する</button>
        </div>
      </form>

JavaScirpt

モーダルのスクロールイベント

前回実装済みのモーダル内の同意ボタンのイベントです。モーダルにスクロールイベントを実装しました。利用規約を最後まで読み終えたら、同意ボタンがクリックできるようになり、クリックすると、利用規約に同意するのチェックボックスにチェックが入ります。

import { toggleModal } from "./modal";
import * as validation from "./validation";

const agreeCheckBox = document.querySelector('[data-id="agree_check"]');
const agreeButton   = document.querySelector('[data-id="agree_button"]');
const submitButton  = document.querySelector('[data-id="submit_button"]');

//モーダル内の同意ボタンのイベント
agreeButton.addEventListener('click' , () => {
    agreeCheckBox.removeAttribute('disabled');
    agreeCheckBox.classList.remove("field-invalid");
    agreeCheckBox.checked = true;
    changeDisabledStatusSubmitButton();
});

const options = {
    root: document.querySelector('[data-id="modal-inner"]'),
    threshold: 1
};

const removeDisabledForAgreeButton = ([entry]) => {
    if(entry.isIntersecting){
        agreeButton.removeAttribute('disabled');
    }
};

const observer = new IntersectionObserver(removeDisabledForAgreeButton, options);
observer.observe(document.querySelector('[data-id="last_text"]'));

サブミットボタンの無効化・有効化

サブミットボタンの有効化・無効化の実装です。画面遷移させるために、e.preventDefault();で、デフォルトのイベントをキャンセルします。不正のinput項目には、`”field-invalid”`クラスを付与しているので、その数が0であればボタンを有効化しています(デフォルトは無効)。

//サブミットボタンのイベント
submitButton.addEventListener("click", (e) => {
    e.preventDefault();
    window.location.href = "register-done.html";
});

//サブミットボタンの有効化・無効化
const invalidSelector = document.getElementsByClassName("field-invalid");
const changeDisabledStatusSubmitButton = () => submitButton.disabled = invalidSelector.length > 0;

バリデーションチェック

それぞれの項目のバリデーションチェックは、モジュール化して別ファイルに分けました。エラーメッセージの表示・非表示、制限文字数のチェック、Emailのチェック、パスワードのチェックに分かれています。

export const showErrorMessage = (target,message) => {
    target.classList.add("border-2","border-red-500","bg-red-100");

    const errorMessage = document.createElement("p");
    errorMessage.classList.add("field-invalid","text-sm","my-2","text-rose-600");
	errorMessage.textContent = message;

	if(!target.nextElementSibling){
		target.insertAdjacentElement("afterend",errorMessage);
	}
}

export const removeErrorMessage = (target) => {
    target.classList.remove("field-invalid","border-red-500","bg-red-100");

    const error = target.nextElementSibling;
    if(error && error.classList.contains("field-invalid")){
        error.remove();
    }
}

export const checkLength = (labelName,maxLength,input) => {
    if(input.value.length > maxLength){
        showErrorMessage(input,`${labelName}は、${maxLength}文字以内にしてください`)
    }else{
        removeErrorMessage(input);
    }
}

export const checkEmail = (input) => {
    //ref https://github.com/Octagon-simon/octaValidate/blob/4c09725647a560bb47eb277a9c32df27831f5202/src/validate.js#L226
    if(!(/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+$/.test(input.value))){
        showErrorMessage(input,"メールアドレスの形式にしてください");  
    }else{
        removeErrorMessage(input);  
    }
}

export const checkPassword = (input) => {
    if(input.value.length > 0 && !(/^((?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,})+$/.test(input.value))){
        showErrorMessage(input,"8文字以上の大小の英数字を交ぜたものにしてください");
    }else{
        removeErrorMessage(input);
    }
}

インプット欄にイベントを登録

インプット欄にblurイベント、inputイベント、changeイベントを登録します。フォーカスがはずれたときに、はじめに空欄チェックをします。もし空欄でなければ、それぞれのバリデーションチェックをします。

入力時は、ユーザー名のみ常にチェックします。エラーメッセージの表示があるときのみ、メールアドレスとパスワードもチェックします。

チェックボックスはchangeイベントで、チェックします。

invalid項目が存在しない場合に、サブミットボタンが有効化されます。

//インプット欄にバリデーションチェックのイベント登録
const inputSelector = document.querySelectorAll('input');
inputSelector[0].focus();

for (const input of inputSelector){
    input.classList.add("field-invalid");

    //フォーカスがはずれたとき
    input.addEventListener("blur", () => {
     //空欄チェック
        if(input.hasAttribute("required") && input.value.trim() === ""){
            validation.showErrorMessage(input,"入力してください");
        }else{
            validation.removeErrorMessage(input);
       
       //それぞれの項目のチェック
            input.type === "email" && validation.checkEmail(input);
            input.name === "user_name" && validation.checkLength("ユーザー名",15,input);
            input.type === "password" && validation.checkPassword(input);
        }
        changeDisabledStatusSubmitButton();
    });

  //入力されたとき
    input.addEventListener("input" , () => {
        //ユーザー名は常にチェック
        input.name === "user_name" && validation.checkLength("ユーザー名",15,input);

     //エラー表示がある場合は、メールとパスワードも常にチェック
        if(input.nextElementSibling && input.nextElementSibling.classList.contains("field-invalid")){
            input.type === "email" && validation.checkEmail(input);
            input.type === "password" && validation.checkPassword(input);
        }

     //空欄チェック
        if(input.hasAttribute("required") && input.value.trim() === ""){
            validation.showErrorMessage(input,"入力してください");
        }

        changeDisabledStatusSubmitButton();
    });
   
  //変更されたとき
    input.addEventListener("change",() => {
        //チェックボックス
        if(input.type === "checkbox"){
            input.checked ? input.classList.remove("field-invalid") : input.classList.add("field-invalid");
        }

        changeDisabledStatusSubmitButton();
    });
}

プルリクエスト

プルリクエストを出した時は、バリデーションチェックは下記の様に、バリデーションのオブジェクトを作り、validateForm(input)関数に処理をまとめていました。validateForm(input)関数は、フォーカスがはずれた際と、入力されたときに実行していました。

const validations = {
    user_name: {
        maxLength: 16,
        valid(value){
            return value.length < this.maxLength;
        },
        message: "ユーザー名は15文字以下にしてください"
    },
    email: {
        valid(value){
            //ref https://github.com/Octagon-simon/octaValidate/blob/4c09725647a560bb47eb277a9c32df27831f5202/src/validate.js#L226
            return /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+$/.test(value);
        }, 
       message: "メールアドレスの形式になっていません"
    },
    password: {
        valid(value) {
            return /^((?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,})+$/.test(value);
        },
        message: "8文字以上の大小の英数字を交ぜたものにしてください"
    }
}

const validateForm = (input) => {
    removeErrorMessage(input);

    if(input.type === "checkbox"){
        input.checked ? input.classList.remove("field-invalid") : input.classList.add("field-invalid");
        return;
    }

    if(input.value.trim() === ""){
        showErrorMessage(input, "入力してください");
        return;
    }

    if(!validations[input.id].valid(input.value)){
        showErrorMessage(input, validations[input.id].message);
        return;
  }
}
const inputSelector = document.querySelectorAll('input');
for (const input of inputSelector){
    input.classList.add("field-invalid");

    input.addEventListener("blur", () => {
        validateForm(input);
        changeDisabledStatusSubmitButton();
    });

    input.addEventListener("input" , () => {
        input.classList.remove("field-invalid");
        validateForm(input);
        changeDisabledStatusSubmitButton();
    });
}

コードレビュー

inputイベントのパフォーマンスについて

    input.addEventListener("input" , () => {
        input.classList.remove("field-invalid");
        validateForm(input);
        changeDisabledStatusSubmitButton();
    });

上記のコードについて、inputイベントで、入力ごとにバリデーションチェックを走らせているので、パフォーマンスに問題があるのではないかと議題があがりました。

1文字入力するたびに、このコードが全て実行されていて、パフォーマンスが悪いと感じます。

バリデーションが通ると、バリデーションが通ったかどうかが切り替わるので、入力を見る側(ユーザー側)としては楽だなぁと感じました。
ただ、パフォーマンスは悪いかもしれません。

確かに、常に入力中に、監視されるのは、パフォーマンス性が悪そうです。まだ入力していないのにエラーが表示され、ユーザーがいらだつ、という懸念から、文字数制限のみ、入力中にチェックすることにしました。

個別にバリデーションチェックするために、入力欄ごとにチェックを走らせる様に変更することにしました。

メールアドレスとパスワードについても、エラーが表示されているときは、有効な内容になるまで入力を監視して、有効な内容になった時点でエラーメッセージを消すようにしました。また、入力中に空欄になったときは、必須入力エラーを表示します。

    input.addEventListener("input" , () => {
        input.name === "user_name" && validation.checkLength("ユーザー名",15,input);

        if(input.nextElementSibling && input.nextElementSibling.classList.contains("field-invalid")){
            input.type === "email" && validation.checkEmail(input);
            input.type === "password" && validation.checkPassword(input);
        }
    
        if(input.hasAttribute("required") && input.value.trim() === ""){
            validation.showErrorMessage(input,"入力してください");
        }

        changeDisabledStatusSubmitButton();
    });

このような動きになりました。ユーザーにストレスを与えない、スムーズなフォームになったと思います。レビュアーのみなさん、ありがとうございました!

新しいパスワードには、autocomplete=”new-password”をつける

先生に、Googleのサインアップフォームのベストプラクティスについての記事を教えていただき、そちらを参考にして、パスワードにautocomplete=”new-password”を追加しました。autocomplete属性とは、HTMLのタグの中で設定することができる属性の一つで、入力フォームでブラウザ側の自動補完(オートコンプリート)機能の制御を行うもの、とされています。

新しいパスワード入力欄では、autocomplete=”new-password”としておくと、ブラウザに保存されているパスワードの自動入力を停止することができます。強制ではないとのことですが、多くのブラウザで、自動入力を停止するようになっているとのことです。
reference: https://web.dev/sign-in-form-best-practices/#new-password

        <div class="mb-6">
          <label for="new-password">パスワード<span class="required text-red-500 mx-1" aria-hidden="true">*</span></label>
          <p class="text-sm text-gray-500 mb-2">8文字以上の大小英数字を含める形で入力してください</p>
          <input type="password" name="new-password" id="new-password" autocomplete="new-password" placeholder="********" required>
        </div>

MDNでは下記のように説明されていました。

new-password
新しいパスワード。新しいアカウントを作成したりパスワードを変更したりした場合は、一般的な「現在のパスワードを入力してください」ではなく、「新しいパスワードを入力してください」または「パスワードの確認」欄で使用してください。これは意図せずに既存のパスワードが意図せずに入力されるのを防いだり、安全なパスワードを生成するための助けになったりします (autocomplete=”new-password” による自動補完の防止も参照してください)。

私が所属しているフロントエンドエンジニアを目指す方のための塾 「もりけん塾」の森田賢二先生のTwitterはこちら!

先生のブログ「武骨日記」はこちら!

https://kenjimorita.jp/

まい

フロントエンドエンジニア目指して、勉強中です。

JavaScriptカテゴリの最新記事