HTML5のカスタムデータ属性（data-*）を使ってリアルタイムにバリデーションする。



## **初めに**
**html**のカスタムデータ属性（ **data-*** ）の学習のため、 **data-*** と**TypeScript**を使い、フォームに文字を入力すると、リアルタイムにエラーかどうか判定するプログラムを作成しました。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/195024/1bf56717-19c8-44e6-b54e-12f7962d79e6.png)![キャプチャ.PNG](https://qiita-image-store.s3.amazonaws.com/0/195024/31f633b2-8f38-6bf1-8bb7-8b4d7b906f66.png)


## **カスタムデータ属性**

**html**のタグにつけられる属性には**id**や**class**、**href**など様々なものがありますが、カスタムデータ属性を使用するとオリジナルの属性をつけることができます。
属性名は先頭に**data-**がついていれば自由に記述できます。[^1]

```html
<div id="my" data-foo="bar"></div>
```

カスタムデータ属性は要素の**dataset**プロパティからアクセスすることができます。

```JavaScript
const myDiv = document.getElementById('my');

console.log(myDiv.dataset.foo);  // bar
console.log('foo' in myDiv.dataset);  // true
```

## **カスタムデータ属性を使ったバリデーション**

カスタムデータ属性にバリデーションを記述します。

```html
<input type="password" id="password" class="form-control"
    data-required
    data-pattern="^[0-9A-Za-z]+$"
    data-maxlength="12"
    data-minlength="6">
```

```js
const rules = {
  'required': el => {
    return el.value !== '';
  },
  'pattern': el => {
    return (new RegExp(el.dataset.pattern)).test(el.value);
  },
  // =====================================================
  // 以下略
  // =====================================================
};
```

## **HTML**

**bootstrap**の**CDN**を利用しました。
**input**タグのカスタムデータ属性にバリデーションのルールを記述します。
警告テキストを**li**タグで表示します。

```html:index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet">
    <link href="style.css" rel="stylesheet">
    <title></title>
  </head>
  <body>
    <div class="container">
      <form class="form-horizontal">
        <h3>Real time validation</h3>
        
        <div class="form-group validation-target">
          <label for="name" class="control-label col-xs-4">名前</label>
          <div class="col-xs-8">
            <input type="text"
                   id="name"
                   class="form-control"
                   data-required="true"
                   data-maxlength="8">
            <ul>
              <li class=" text-danger" data-required>名前は必須です。</li>
              <li class="text-danger" data-maxlength>名前は8文字以内で入力してください。</li>
            </ul>
          </div>
        </div>
        
        <div class="form-group validation-target">
          <label for="email" class="control-label col-xs-4">メールアドレス</label>
          <div class="col-xs-8">
            <input type="email"
                   id="email"
                   class="form-control"
                   data-required="true"
                   data-pattern="^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$">
            <ul>
              <li class="text-danger" data-required>メールアドレスは必須です。</li>
              <li class="text-danger" data-pattern>メールアドレスは正しい形式で入力してください。</li>
            </ul>
          </div>
        </div>
        
        <div class="form-group validation-target">
          <label for="password" class="control-label col-xs-4">パスワード</label>
          <div class="col-xs-8">
            <input type="password"
                   id="password"
                   class="form-control"
                   data-pattern="^[0-9A-Za-z]+$"
                   data-maxlength="12"
                   data-minlength="6"
                   data-required="true">
            <ul>
              <li class="text-danger" data-required>パスワードは必須です。</li>
              <li class="text-danger" data-maxlength>パスワードは12文字以内で入力してください。</li>
              <li class="text-danger" data-minlength>パスワードは6文字以上で入力してください。</li>
              <li class="text-danger" data-pattern>パスワードは半角英数字で入力してください。</li>
            </ul>
          </div>
        </div>
        
        <div class="form-group validation-target">
          <label for="age" class="control-label col-xs-4">年齢</label>
          <div class="col-xs-8">
            <input type="number"
                   id="age"
                   class="form-control"
                   data-min="0"
                   data-max="150"
                   data-pattern="^[0-9]*$">
            <ul>
              <li class="text-danger" data-pattern>年齢は整数で入力してください。</li>
              <li class="text-danger" data-min>年齢は0歳以上で入力してください。</li>
              <li class="text-danger" data-max>年齢は150歳以下で入力してください。</li>
            </ul>
          </div>
        </div>
        
        <div class="form-group validation-target">
          <label for="program-language" class="control-label col-xs-4">好きなプログラミング言語</label>
          <div class="col-xs-8">
            <input type="text"
                   id="program-language"
                   class="form-control"
                   list="program-list"
                   data-required="tru"
                   data-pattern="JavaScript|Ruby|Python|PHP|Swift|Java|C#|その他">
            <datalist id="program-list">
              <option>JavaScript</option>
              <option>Ruby</option>
              <option>Python</option>
              <option>PHP</option>
              <option>Swift</option>
              <option>Java</option>
              <option>C#</option>
              <option>その他</option>
            </datalist>
            <ul>
              <li class="text-danger" data-required>プログラミング言語の選択は必須です。</li>
              <li class="text-danger" data-pattern>プログラミング言語は選択肢から選択してください。</li>
            </ul>
          </div>
        </div> 
        
        <div class="form-group">
          <div class="col-xs-offset-4 col-xs-8">
            <div class="checkbox">
              <label>
                <input type="checkbox">remember
              </label>
            </div>
          </div>
        </div>
        
        <div class="form-group">
          <div class="col-xs-offset-4 col-xs-8">
            <button type="submit" class="btn btn-default" formnovalidate>送信</button>
          </div>
        </div>
        
      </form>
    </div>
    <script type="text/javascript" src="script.js"></script>
  </body>
</html>
```

以下はフォントサイズの調整と警告テキストをデフォルトで非表示にするCSSです。

```css:style.css
.container {
  font-size: 12px;
}
.text-danger {
  display: none;
}
```

##**JavaScript**

静的型付けの**AltJS**[^2]である**TypeScript**[^3]を使いました。

####**バリデーションを行うクラスを作る**

```ts:script.ts
class AppModel {

  // 検証対象の要素 --- （※１）
  private _el: HTMLInputElement;

  // 各カスタムデータ属性名をキーにし、対応した検証メソッドを格納したMapオブジェクト --- （※２）
  private _Rules = new Map<string, Function>()
    .set('required', (el: HTMLInputElement): boolean => {
      return el.value.trim() !== '';
    })
    .set('maxlength', (el: HTMLInputElement): boolean => {
      return el.value.length <= Number(el.dataset.maxlength);
    })
    .set('minlength', (el: HTMLInputElement): boolean => {
      return el.value.length >= Number(el.dataset.minlength);
    })
    .set('pattern', (el: HTMLInputElement): boolean => {
      const ptn = el.dataset.pattern;
      return (ptn !== undefined) ? new RegExp(ptn).test(el.value) : true;
    })
    .set('max', (el: HTMLInputElement): boolean => {
      return Number(el.value) <= Number(el.dataset.max);
    })
    .set('min', (el: HTMLInputElement): boolean => {
      return Number(el.value) >= Number(el.dataset.min)
    });

  constructor(el: HTMLInputElement) {
    this._el = el;
  }

  // 要素に対し各検証を実行し、エラーとなったキーの配列を返す。 --- （※３）
  public validate(): string[] {
    const errors: string[] = [];
    for (let [key, func] of Array.from(this._Rules.entries())) {
      if (!(key in this._el.dataset))
        continue;
      if (!func(this._el))
        errors.push(key);
    }
    return errors;
  }
}
```

**_rules**（※２）は**html**側のカスタムデータ属性名をキー、キーに対応した検証用の関数をバリューとした**Map**オブジェクトです。
**validate**メソッド（※３）では対象の要素（※１）に対し検証メソッドを順に実行しエラーとなったキー（カスタムデータ属性名）の配列を返します。検証対象のカスタムデータ属性にないキーは実行されないようにしました。

**_rules**を**Map**でなく連想配列にする場合インターフェイスが必要です。
（**noImplicitAny** / 暗黙の**any**型を許容しない場合）

インターフェイスを定義しないとブラケット表記法（**Object[key]** ← **Object.key**と同じ）のキーに変数を指定した場合、「オブジェクト側のキーの型が定義されていない」とコンパイルエラーとなります。

```ts
// Mapを使わない場合
interface IRules { 
  [index: string]: Function  // キーが文字列、バリューが関数と定義
}

class AppModel {
  private _el: HTMLInputElement;

  private _Rules: IRules = { // IRulesを実装
    'required': (el: HTMLInputElement): boolean => {
      return el.value.trim() !== '';
    },
    // 略
  }

  // 要素に対し各検証を実行し、エラーとなったキーの配列を返す。
  public validate(): string[] {
    return Object.keys(this._Rules).filter(key => {  // filter関数でエラーとなったキーのみ抽出
      return (key in this._el.dataset)  // keyがカスタムデータ属性に含まれる場合検証
        ? !(this._Rules[key](this._el))  // interfaceを定義しない場合ここでエラー
        : false;
    })
  }
}
```

#### **画面表示を操作するクラスを作る**

```ts
class AppView {
  private _model: AppModel
  // ユーザー入力要素
  private _input: HTMLInputElement;
  // 警告テキスト
  private _list: NodeListOf<HTMLLIElement>;

  constructor(
    // クラス名 validation-target の div 要素
    private _group: HTMLDivElement
  ) {
    // グループ内の input 要素と li 要素の NodeList を取得
    this._input = _group.getElementsByTagName('input')[0];
    this._list = this._group.getElementsByTagName('li');

    // input 要素に対し必要なイベントリスナーを登録
    this._input.addEventListener('keyup', () => this.handleEvent(), false);
    this._input.addEventListener('mouseup', () => this.handleEvent(), false);

    this._model = new AppModel(this._input);
  }

  // イベントリスナーで登録されるメソッド
  private handleEvent(): void {
    const errors: string[] = this._model.validate();
    // エラーがない場合に検証成功のメソッド、ある場合に検証エラー時のメソッドを呼び出す
    (errors.length === 0) ? this.onValid() : this.onInvalid(errors);
  }

  // ユーザー入力値が正しいとき
  private onValid(): void {
    // success 表示
    this._group.classList.add('has-success');
    this._group.classList.remove('has-error');
    // 全ての警告テキストを非表示
    Array.from(this._list)
      .forEach(li => li.style.display = 'none');
  }

  // ユーザー入力値エラーがあるとき
  private onInvalid(errorKeys: string[]): void {
    // error 表示
    this._group.classList.add('has-error');
    this._group.classList.remove('has-success');
    // 全ての警告テキストを非表示
    Array.from(this._list)
      .forEach(li => li.style.display = 'none');
    // エラーの警告のみ再表示
    Array.from(this._list)
      .filter(li => errorKeys.some(key => key in li.dataset))
      .forEach(li => li.style.display = 'block');
  }
}
```

#### **メイン処理**

```ts
window.addEventListener('DOMContentLoaded', () => {
  // 検証する要素群を取得
  const groups = <HTMLCollectionOf<HTMLDivElement>>document.getElementsByClassName('validation-target');
  // 画面操作のインスタンスを生成
  Array.from(groups).forEach(group => new AppView(group));
  document.getElementsByTagName('form')[0].addEventListener('submit', (e) => {
    // 送信時の動作、サンプルなので何もしません。
    alert('デモ画面です。')
    e.preventDefault();
  }, false);
}, false);
```

## **デモ**

[mozilla thimbleを使ったデモサイト](https://thimbleprojects.org/sanonosa/380725)

## **参考**

- **[data-* - MDN web docs](https://developer.mozilla.org/ja/docs/Web/HTML/Global_attributes/data-*)**
- **[ブレイクスルーJavaScript - 翔泳社](http://www.shoeisha.co.jp/book/detail/9784798139050)**


[^1]: 名前を **xml** で始めてはならない。名前にコロンを含めてはならない。名前に大文字を含めてはならない。といった規則があります。
[^2]: トランスパイル（変換)すると**JavaScript**になる言語です。
[^3]: 静的型システムを備えた**MicroSoft**発の**altJS[^2]**です。