AngularでUXを意識したメールアドレス入力欄のバリデーションを実装してみた。

## はじめに
「UXを意識した～」と一丁前に言ってますが、[CodeGrid](https://app.codegrid.net/)というフロントエンド関係を主に扱うサイトの、「[よりよいメールアドレス入力欄を求めて](https://app.codegrid.net/series/2017-mail-address)」という記事の内容を**一部**実装しただけです💦
加えてメールアドレスの形式チェックに関してはあまり厳密に行っていません。

## 今回のポイント
#### 1. メールアドレスチェックのタイミング
(個人的に)多く見かけるのが全てを入力し終え「登録」ボタンを押してから初めてチェックをし、入力の誤りをユーザーに通知するやり方です。これだと再入力へのステップ数が増えるほか、**入力モードから解放され一息ついたユーザーを再度入力モードに戻してしまいストレスになります。**どうせなら入力モードの状態で誤りを直しておきたいものです。
そこでチェックのタイミングは**リアルタイム**で行います。
幸いにもAngularには便利なリアクティブフォームがあるのでリアルタイムなチェック処理は簡単に実装できます。👏

#### 2. メールアドレスのチェック内容
チェック内容にも気を付けます。メールアドレスの形式チェックを行ってしまうと、**正しいメールアドレスを入力しようとしているのに1文字目から形式チェックが走り怒られてしまう**のです。
そこで今回は先の記事にある通り、**メールアドレスの構文で禁止されている文字列が入力値に含まれていないか**をチェックします。
そのため

- 共通チェック
- ローカル部分(@より前)のチェック
- ドメイン部分(@より後)のチェック

この3つに分解してチェックを行うことにしました。

#### 3. 登録ボタン
チェックに引っかからなければメールアドレスの形式で無くても登録ボタンが押せてしまいます。
これに関しては**正しいメールアドレスの形式になるまでボタンを非活性にする**ことで対処します。


以上のポイントを踏まえて実装していきます。

## 実装に移る前に
何をもって正しいメールアドレスの形式なのか？というところで今回はWikipedia先生に力をお借りしました。
[メールアドレス - Wikipedia](https://ja.wikipedia.org/wiki/%E3%83%A1%E3%83%BC%E3%83%AB%E3%82%A2%E3%83%89%E3%83%AC%E3%82%B9)
今回は、

1. 禁則文字
2. 「..」や「@@」
3. 先頭の「.」や「@」
4. 「.@」や「@.」
5. ドメイン部分(「@」以降)に@が出現する

これらを誤った形式として実装していきます。

## 実装してみた
Angularのカスタムバリデーションに関しては以前自分が投稿した「[Ionic3でCustomValidationとエラーメッセージコンポーネントを実装してみる](https://qiita.com/masaks/items/830b0dc050e5e7d7d84f)」という記事に乗っ取って実装していきます。

``` top.html
    <form [formGroup]="myForm" (ngSubmit)="formSubmit()">

        <div class="input-area">
            <input [formControl]="email" placeholder="メールアドレス" (keyup)="isErrorExists()">
            <error-message [control]="email"></error-message>
        </div>

        <button type="submit" [disabled]="isDisabled" ion-button block color="dark">ログイン</button>

    </form>
```

``` top.ts
import { Component } from '@angular/core';
import { FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms";
import { EmailValidator } from "../../services/validator/email.validator";

@IonicPage()
@Component({
    selector: 'page-top',
    templateUrl: 'top.html',
})
export class TopPage {

    isDisabled: boolean = true;

    email = new FormControl('', [
        Validators.required,
        EmailValidator.commonCheck,
        EmailValidator.localCheck,
        EmailValidator.domainCheck
    ]);

    myForm: FormGroup = this.builder.group({
        email: this.email,
    });

    constructor(
        private builder: FormBuilder,
    ) { }

    isErrorExists() {
        // FormGroupのチェック状態を確認
        if (this.myForm.invalid) {
            this.isDisabled = true;
            return;
        }
        // 設定したcheckを通過していたらemailの形式チェックを行う。
        this.isDisabled = !!(!this.myForm.controls[ 'email' ].value.match(/[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/));
    }
}
```
``` email.validator.ts
import { AbstractControl, ValidationErrors } from "@angular/forms";

export class EmailValidator {

    static readonly AT_SIGN = '@';
    static readonly NO_AT_SIGN = 1;
    static readonly VALID_EMAIL = 2;

    static commonCheck(control: AbstractControl): ValidationErrors | null {        
        // 禁則文字を検知
        if (control.value.match(/[<>\"'&|%\\\\]/)) {
            return { 'illegalCharacter': true };
        }
        // ..または@@を検知
        if (control.value.match(/(\.|@)\1+/)) {
            return { 'email': true };
        }
        return null;
    }

    static localCheck(control: AbstractControl): ValidationErrors | null {
        // 先頭の.または@を検知
        if (control.value.match(/(^\.|^@)/)) {
            return { 'email': true };
        }
        // @直前の.を検知
        if (control.value.match(/\.+@/)) {
            return { 'email': true };
        }
        return null;
    }

    static domainCheck(control: AbstractControl): ValidationErrors | null {

        const emailAtSignByDividedArray = control.value.split(EmailValidator.AT_SIGN);
        const thatEmail = emailAtSignByDividedArray.length;

        // @が無いときはドメイン部分のチェックは不要
        if (thatEmail === EmailValidator.NO_AT_SIGN) {
            return null;
        }
        // @が2つあるとき正しいメールアドレスの形式ではない
        if (thatEmail !== EmailValidator.VALID_EMAIL) {
            return { 'email': true };
        }

        const domain = emailAtSignByDividedArray[ 1 ];

        // @直後の.を検知
        if (domain.match(/(^\.)/)) {
            return { 'email': true };
        }
        return null;
    }
}
``` 

## 解説

ボタンに関しては`isDisabled`で活性非活性を管理しました。

``` top.html
<button type="submit" [disabled]="isDisabled" ion-button block color="dark">ログイン</button>
```
これはFormGroup内のバリデーションが全て通りかつメールアドレスの形式が正しいものになった時、活性になるようにしています。
わざわざFormGroupを用いているのは登録画面はほぼほぼ他の項目もあるためです。

``` top.ts
    isErrorExists() {
        // FormGroupのチェック状態を確認
        if (this.myForm.invalid) {
            this.isDisabled = true;
            return;
        }
        // 設定したcheckを通過していたらemailの形式チェックを行う。
        this.isDisabled = !!(!this.myForm.controls[ 'email' ].value.match(/[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/));
    }
```

`isErrorExists()`はメールアドレスが入力されるたびに走ります。
なぜ`(ngModelChange)`ではなく`(keyup)`なのかと言うと、`isErrorExists()`が`email.validator`クラスのバリデーションよりも先に走ってしまうためです。結果、ボタンの活性非活性のタイミングが1テンポ遅れてしまいます。

``` top.html
<input [formControl]="email" placeholder="メールアドレス" (keyup)="isErrorExists()">
```

`email.validator.ts`に関してはソースコードのままです。
`commonCheck()`では全体を通して禁則文字と連続する「..」及び「@@」を検知します。
`localCheck`では先頭の「.」及び「@」、「@」直前の「.」を検知します。
`domainCheck`では「@」が入力されてから初めてチェックが始まり、「@」直後の「.」とドメイン部分の「@」を検知します。

## 実際の動作
![qiita_mail_valid.gif](https://qiita-image-store.s3.amazonaws.com/0/316318/0c4f1163-06c4-def5-f3ef-4d4ade6ae39f.gif)

出来ました👏👏👏

## 最後に
こんなに分割しなくても済むような正規表現の書き方があると思いますが今回はこのレベルで😖
UI/UXって大事だなぁ…(ｼﾐｼﾞﾐ)

## 参考サイト
[よりよいメールアドレス入力欄を求めて](https://app.codegrid.net/series/2017-mail-address)