Bootstrap4.5.3で違和感のないパスワード一致バリデーションを行う

Bootstrap は、ver.4 から、入力エラーのメッセージを表示するための **invalid-feedback** クラスが用意されており、簡単にUIからユーザーにバリデーション結果を伝えることができるようになっています。ほとんど CSS を自分で書く必要がありません。

たとえば、こんなように。

|Valid|Invalid|
|---|---|
|![valid.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30945/068643f0-f471-b06a-e5f0-f3cfa37435cf.png)|![invalid.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30945/d1bff19b-c174-27c0-018c-906a337c9627.png)|

実際、ソースコードとして見ると、

```html:index.html
<div class="form-group row">
  <label for="confirm-password" class="col-md-4 col-form-label text-md-right">パスワード（確認）</label>
  <div class="col-md-6">
    <input type="password" id="confirm-password" class="form-control is-valid" name="confirm-password" required>
    <div class="valid-feedback">O.K.</div>
    <div class="invalid-feedback">入力されたパスワードが一致しません。</div>
  </div>
</div>
```

該当する箇所は、`class="form-control is-valid"` です。クラスに、`is-valid` が追加されることで、`<div class="invalid-feedback">` が自動的に表示されるようになっています。逆に、`is-invalid` が追加されると、`<div class="invalid-feedback">` が表示されます。

とはいえ、これは静的な状態です。これでは使い物になりません。**動的**に、つまり入力の値を判定して、こうしたバリデーション表示・非表示を行うには、JavaScript で制御してやる必要が出てきます。文字列入力の状態によって、`is-valid` または、`is-invalid` クラスを追加・削除するのです。

結局のところ、ユーザーがどのような入力をしたかで、どういう挙動になるかは、開発者が仕様を考えて実装しなければなりません。

## パスワードが入力されたときの判定仕様を決める

では、パスワード入力欄と、パスワード確認欄とで、ユーザーがそれぞれで入力をおこなったとき、どういう挙動がユーザーの違和感なくバリデーション表示できるでしょうか。

基本的には、確認パスワードの入力時（KeyUp）イベントに判定を加えると良いでしょう。

```javascript:index.html
var elm_pass = $('#password');
var elm_confirm = $('#confirm-password');
elm_confirm.on('keyup', function() {
  // KeyUpイベント
  // ...
});
```

### まだ「確認」に何も入力されていない場合はバリデーションは非表示

「確認」パスワードに、まだ何も入力されていない場合は、`is-valid` も、`is-invalid` も非表示にするべきでしょう。入力中に、今までの入力内容をすべて削除したときも同じ挙動になります。

これは、これから入力しようというのに、その前からバリデーションエラーが表示されたら違和感があるからです。

```javascript:index.html
var elm_pass = $('#password');
var elm_confirm = $('#confirm-password');
/*
* 確認パスワードのキーボード（KeyUp）イベントリスナー
*/
elm_confirm.on('keyup', function() {
  // まだパスワード（確認）を入力していない
  if (elm_confirm.val() === "") {
    elm_confirm.removeClass("is-valid");
    elm_confirm.removeClass("is-invalid");
    return;
  }
});
```

### 「確認」入力は最後の一文字が入力されるまで判定しない

さらに、先頭から一文字ずつ比較していき、途中で一文字でも異なれば、「パスワードが一致しない」旨を表示します。

よくパスワード入力フォームで、まだ先に入力したパスワードを全部入力し終わってもいないのに「一致しません」とエラー表示されるものがありますが、違和感があります。それを防ぐためのものです。

そして最後の文字まで入力し終えて、先に「入力」したパスワードと同一であると判定されたら、完全一致（Valid）と判定します。

```javascript:index.html
elm_confirm.on('keyup', function() {
  // まだパスワード（確認）を入力していない
  if (elm_confirm.val() === "") {
    elm_confirm.removeClass("is-valid");
    elm_confirm.removeClass("is-invalid");
    return;
  }
  // 先頭から一文字ずつ取り出してチェックし最後まで到達していなくとも「問題無し」と判断
  var array_pass_chars = elm_pass.val().split("");
  var array_confirm_chars = elm_confirm.val().split("");
  $.each(array_confirm_chars, function(index, char) {
    if (array_pass_chars[index] === char){
      // 先頭から一文字ずつ一致している場合には中途でも何も表示しない
      elm_confirm.removeClass("is-valid");
      elm_confirm.removeClass("is-invalid");
    }
    else{
      // 一文字でも異なる場合はInvalid
      elm_confirm.removeClass("is-valid");
      elm_confirm.addClass("is-invalid");
      return false;
    }
  });
  // パスワード文字列が完全一致したらValid
  if (elm_pass.val() === elm_confirm.val()) {
    elm_confirm.addClass("is-valid");
  }
});
```

### 「確認」が最後まで入力されずに、フォーカスを外れたときバリデーション判定をする

入力中は、文字数が先の「入力」に達していなくとも、先頭からの一文字でも合っていれば合致（Valid）と判定してましたが、「確認」パスワード欄からフォーカスが外れたときには、「入力を終えた」と判断し、先に「入力」したパスワードと比較して「Invalid」判定を出します。

もちろん、最後まで入力し終えて一致していれば、「Valid」判定を出します。

```javascript:index.html
/*
* 確認パスワード入力のフォーカスを失ったとき（Blur）のイベントリスナー
*/
elm_confirm.on('blur', function() {
  if (elm_pass.val() === elm_confirm.val()) {
    elm_confirm.removeClass("is-invalid");
    elm_confirm.addClass("is-valid");
  }
  // フォーカスが失われたときパスワードが一致しないと判断（Invalid）
  else {
    elm_confirm.removeClass("is-valid");
    elm_confirm.addClass("is-invalid");
  }
});
```

### デモ

ここまでのものを以下の CodePen に置きました。上で実装した挙動などをお試しになってください。

**[DEMO１](https://codepen.io/hibara/pen/poEvPGJ)**

また、以下のコードはHTML一本で動くので、ローカルで試したい場合は貼り付けてお試しください。

```html:index.html
<!doctype html>
<html lang="en">
<head>
  <!-- Required meta tags -->
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <!-- Bootstrap CSS -->
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css" integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous">

  <title>パスワードのチェック</title>
</head>
<body>

<div class="cotainer">
  <div class="row justify-content-center mt-5">
    <div class="col-md-8">
      <div class="card">
        <div class="card-header">パスワードのチェック</div>
        <div class="card-body">

          <form action="" method="post" onsubmit="">
            <div class="form-group row">
              <label for="password" class="col-md-4 col-form-label text-md-right">パスワード</label>
              <div class="col-md-6">
                <input type="password" id="password" class="form-control" name="password" required>
              </div>
            </div>
            <div class="form-group row">
              <label for="confirm-password" class="col-md-4 col-form-label text-md-right">パスワード（確認）</label>
              <div class="col-md-6">
                <input type="password" id="confirm-password" class="form-control" name="confirm-password" required>
                <div class="valid-feedback">O.K.</div>
                <div class="invalid-feedback">入力されたパスワードが一致しません。</div>
              </div>
            </div>
            <div class="row">
              <div class="col-md-8 offset-md-4">
                <button class="btn btn-primary" type="button" onclick="">
                  登録
                </button>
                <button class="btn btn-secondary ml-3" type='button' onclick="document.location.href='';">
                  戻る
                </button>
              </div>
            </div>
          </form>

        </div><!-- div class="card-body" -->
      </div><!-- div class="card" -->
    </div><!-- div class="col-md-8" -->
  </div><!-- div class="row justify-content-center" -->
</div><!-- div class="cotainer" -->

<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ho+j7jyWK8fNQe+A12Hb8AhRq26LrZ/JpcUGGOn+Y7RsweNrtN/tE3MoK7ZeZDyx" crossorigin="anonymous"></script>

<script type="text/javascript">
  var elm_pass = $('#password');
  var elm_confirm = $('#confirm-password');
  /*
  * 確認パスワードのキーボード（KeyUp）イベントリスナー
  */
  elm_confirm.on('keyup', function() {
    // まだパスワード（確認）を入力していない
    if (elm_confirm.val() === "") {
      elm_confirm.removeClass("is-valid");
      elm_confirm.removeClass("is-invalid");
      return;
    }
    // 先頭から一文字ずつ取り出してチェックし最後まで到達していなくとも「問題無し」と判断
    var array_pass_chars = elm_pass.val().split("");
    var array_confirm_chars = elm_confirm.val().split("");
    $.each(array_confirm_chars, function(index, char) {
      if (array_pass_chars[index] === char){
        // 先頭から一文字ずつ一致している場合には中途でも何も表示しない
        elm_confirm.removeClass("is-valid");
        elm_confirm.removeClass("is-invalid");
      }
      else{
        // 一文字でも異なる場合はInvalid
        elm_confirm.removeClass("is-valid");
        elm_confirm.addClass("is-invalid");
        return false;
      }
    });
    // 完全一致したらValid
    if (elm_pass.val() === elm_confirm.val()) {
      elm_confirm.addClass("is-valid");
    }
    else {
      elm_confirm.addClass("is-invalid");
    }
  });
  /*
  * 確認パスワード入力のフォーカスを失ったとき（Blur）のイベントリスナー
  */
  elm_confirm.on('blur', function() {
    if (elm_pass.val() === elm_confirm.val()) {
      elm_confirm.removeClass("is-invalid");
      elm_confirm.addClass("is-valid");
    }
    // フォーカスが失われたときパスワードが一致しないと判断（Invalid）
    else {
      elm_confirm.removeClass("is-valid");
      elm_confirm.addClass("is-invalid");
    }
  });
</script>

</body>
</html>
```

## おまけ（パスワード文字列の種類を指定する）

ついでにパスワード文字列が、**半角英数字をそれぞれ1種類以上含む8文字以上**のパスワードでない場合は、`Invalid` にしてエラー表示するようにしてみましょう。

まず、先に入力するパスワード欄に、`valid-feedback` と、`invalid-feedback` クラスを持つブロックを追加します。

```html:index.html
<div class="form-group row">
  <label for="password" class="col-md-4 col-form-label text-md-right">パスワード</label>
  <div class="col-md-6">
    <input type="password" id="password" class="form-control" name="password" required>
    <div class="valid-feedback">O.K.</div>
    <div class="invalid-feedback">半角英数字をそれぞれ1種類以上含む8文字以上のパスワードを入力してください。</div>
  </div>
</div>
```

これを実現するには、先に入力する文字列を正規表現で判定する必要があります。正規表現については、以下を参考にさせていただきました。

***言語別：パスワード向けの正規表現 - Qiita***
<https://qiita.com/mpyw/items/886218e7b418dfed254b>

パスワード文字列のチェックも、まだすべて入力し終えていないのに `Invalid` 判定を出されてもウザいですので、これも `KeyUp` イベントではなく、`Blur` イベントでフォーカスが外れたときにチェックすることにします。

```javascript:index.html
/*
* パスワード入力のフォーカスを失ったとき（Blur）のイベントリスナー
* 入力されたパスワードが適切なものかチェックする
*/
elm_pass.on('blur', function() {
  // パスワード入力欄両方が空のときは「確認」の方のバリデーション表示を消す
  if (elm_pass.val() === "" && elm_confirm.val() === "" ) {
    elm_confirm.removeClass('is-valid');
    elm_confirm.removeClass('is-invalid');
  }
  var str = elm_pass.val();
  // 半角英数字をそれぞれ1種類以上含む8文字以上の文字列とマッチするか
  var result = str.match(/^(?=.*?[a-zA-Z])(?=.*?\d)[a-zA-Z\d]{8,}/);
  if (result) {
    // O.K.（Valid）
    elm_pass.removeClass('is-invalid');
    elm_pass.addClass('is-valid');
  }
  else {
    // 入力されたパスワードが一致しません。（Invalid）
    elm_pass.removeClass('is-valid');
    elm_pass.addClass('is-invalid');
  }
});
```

## 入力欄それぞれが空白になったときに違和感

入力欄両方でバリデーションが表示されるようになると、今度は入力欄それぞれが空白になったときにバリデーションが表示されっぱなしになるという違和感があります。

ですので、それぞれでフォーカスを失ったとき、両方の欄をチェックし、**両方とも**空欄になった場合は、バリデーションを非表示する処理を入れましょう。

まずは、最初の入力欄。

```javascript:index.html
/*
* パスワード入力のフォーカスを失ったとき（Blur）のイベントリスナー
* 入力されたパスワードが適切なものかチェックする
*/
elm_pass.on('blur', function() {
  // パスワード入力欄の両方が空のときはバリデーション表示を消す
  if (elm_pass.val() === "" && elm_confirm.val() === "") {
    elm_pass.removeClass('is-valid');
    elm_pass.removeClass('is-invalid');
    elm_confirm.removeClass('is-valid');
    elm_confirm.removeClass('is-invalid');
    return;
  }
  //...
```

次に、確認入力欄。

```javascript:index.html
/*
* 確認パスワード入力のフォーカスを失ったとき（Blur）のイベントリスナー
*/
elm_confirm.on('blur', function() {
  // パスワード入力欄の両方が空のときはバリデーション表示を消す
  if (elm_pass.val() === "" && elm_confirm.val() === "") {
    elm_pass.removeClass('is-valid');
    elm_pass.removeClass('is-invalid');
    elm_confirm.removeClass('is-valid');
    elm_confirm.removeClass('is-invalid');
    return;
  }
  // ...
```

### デモ

以上のことを含めて、**[DEMO２](https://codepen.io/hibara/pen/VwKYzNm)** を用意しました。

また、例によって以下の内容を 一つの `html` ファイルに貼り付ければ動作します。ローカルでも挙動などをお試しください。

```html:index.html
<!doctype html>
<html lang="en">
<head>
  <!-- Required meta tags -->
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <!-- Bootstrap CSS -->
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css" integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous">

  <title>パスワードのチェック</title>
</head>
<body>

<div class="cotainer">
  <div class="row justify-content-center mt-5">
    <div class="col-md-8">
      <div class="card">
        <div class="card-header">パスワードのチェック</div>
        <div class="card-body">

          <form action="" method="post" onsubmit="">
            <div class="form-group row">
              <label for="password" class="col-md-4 col-form-label text-md-right">パスワード</label>
              <div class="col-md-6">
                <input type="password" id="password" class="form-control" name="password" required>
                <div class="valid-feedback">O.K.</div>
                <div class="invalid-feedback">半角英数字をそれぞれ1種類以上含む8文字以上のパスワードを入力してください。</div>
              </div>
            </div>
            <div class="form-group row">
              <label for="confirm-password" class="col-md-4 col-form-label text-md-right">パスワード（確認）</label>
              <div class="col-md-6">
                <input type="password" id="confirm-password" class="form-control" name="confirm-password" required>
                <div class="valid-feedback">O.K.</div>
                <div class="invalid-feedback">入力されたパスワードが一致しません。</div>
              </div>
            </div>
            <div class="row">
              <div class="col-md-8 offset-md-4">
                <button class="btn btn-primary" type="button" onclick="">
                  登録
                </button>
                <button class="btn btn-secondary ml-3" type='button' onclick="document.location.href='';">
                  戻る
                </button>
              </div>
            </div>
          </form>

        </div><!-- div class="card-body" -->
      </div><!-- div class="card" -->
    </div><!-- div class="col-md-8" -->
  </div><!-- div class="row justify-content-center" -->
</div><!-- div class="cotainer" -->

<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ho+j7jyWK8fNQe+A12Hb8AhRq26LrZ/JpcUGGOn+Y7RsweNrtN/tE3MoK7ZeZDyx" crossorigin="anonymous"></script>

<script type="text/javascript">
  var elm_pass = $('#password');
  var elm_confirm = $('#confirm-password');
  /*
  * 確認パスワードのキーボード（KeyUp）イベントリスナー
  */
  elm_confirm.on('keyup', function() {
    // まだパスワード（確認）を入力していない
    if (elm_confirm.val() === "") {
      elm_confirm.removeClass("is-valid");
      elm_confirm.removeClass("is-invalid");
      return;
    }
    // 先頭から一文字ずつ取り出してチェックし最後まで到達していなくとも「問題無し」と判断
    var array_pass_chars = elm_pass.val().split("");
    var array_confirm_chars = elm_confirm.val().split("");
    $.each(array_confirm_chars, function(index, char) {
      if (array_pass_chars[index] === char){
        // 先頭から一文字ずつ一致している場合には中途でも何も表示しない
        elm_confirm.removeClass("is-valid");
        elm_confirm.removeClass("is-invalid");
      }
      else{
        // 一文字でも異なる場合はInvalid
        elm_confirm.removeClass("is-valid");
        elm_confirm.addClass("is-invalid");
        return false;
      }
    });
    // 完全一致したらValid
    if (elm_pass.val() === elm_confirm.val()) {
      elm_confirm.addClass("is-valid");
    }
  });
  /*
  * 確認パスワード入力のフォーカスを失ったとき（Blur）のイベントリスナー
  */
  elm_confirm.on('blur', function() {
    // パスワード入力欄の両方が空のときはバリデーション表示を消す
    if (elm_pass.val() === "" && elm_confirm.val() === "") {
      elm_pass.removeClass('is-valid');
      elm_pass.removeClass('is-invalid');
      elm_confirm.removeClass('is-valid');
      elm_confirm.removeClass('is-invalid');
      return;
    }
    // 完全一致したらValid
    if (elm_pass.val() === elm_confirm.val()) {
      elm_confirm.removeClass('is-invalid');
      elm_confirm.addClass("is-valid");
    }
    else {
      // 一致しなかったらInvalid
      elm_confirm.removeClass('is-valid');
      elm_confirm.addClass("is-invalid");
    }
  });
  /*
  * パスワード入力のフォーカスを失ったとき（Blur）のイベントリスナー
  * 入力されたパスワードが適切なものかチェックする
  */
  elm_pass.on('blur', function() {
    // パスワード入力欄の両方が空のときはバリデーション表示を消す
    if (elm_pass.val() === "" && elm_confirm.val() === "") {
      elm_pass.removeClass('is-valid');
      elm_pass.removeClass('is-invalid');
      elm_confirm.removeClass('is-valid');
      elm_confirm.removeClass('is-invalid');
      return;
    }
    var str = elm_pass.val();
    // 半角英数字をそれぞれ1種類以上含む8文字以上の文字列とマッチするか
    var result = str.match(/^(?=.*?[a-zA-Z])(?=.*?\d)[a-zA-Z\d]{8,}/);
    if (result) {
      // O.K.（Valid）
      elm_pass.removeClass('is-invalid');
      elm_pass.addClass('is-valid');
    }
    else {
      // 入力されたパスワードが一致しません。（Invalid）
      elm_pass.removeClass('is-valid');
      elm_pass.addClass('is-invalid');
    }
  });

</script>

</body>
</html>
```
## さらに発展させる

さらにここへパスワード強度推定を行う処理を入れても良いかもしれません。パスワード強度を判定するライブラリはいろいろありますが、以下の記事で私が **zxcvbn.js** について書いてます。必要あれば、参照なさってください。

***パスワード強度推定メーター（zxcvbn.js）を実装してみる***
<https://qiita.com/hibara/items/fbd26704b8ab7fd74fb1>

以上です。