VeeValidate に電話番号のバリデーションルールを追加する

先週投稿した [VeeValidate で非同期的なバリデーションを行う記事](https://qiita.com/hamakou108/items/b47f98e1e0175f695e85)に続いて、今回も [VeeValidate](https://logaretm.github.io/vee-validate/) ネタです。

VeeValidate には電話番号のバリデーションルールはデフォルトで用意されていません。そのため [google\-libphonenumber](https://www.npmjs.com/package/google-libphonenumber) を使ってカスタムルールを実装してみたので、そのメモです。

ちなみに google-libphonenumber は電話番号のパースやバリデーションのための国際対応ライブラリです。非常に便利なので是非使ってみてください！

- [Googleの電話番号を扱うライブラリlibphonenumberを使ってみたのでメモ \- t\-miliya612のブログ](http://t-miliya612.hatenablog.com/entry/2018/04/14/191318)
- [Javascriptで電話番号のバリデーションと変換（E\.164）に便利なライブラリの紹介 \- Qiita](https://qiita.com/laughingman/items/f35676c05e7543cc0437)

## TL;DR

- google-libphonenumber を使えば国内・国外どちらでも（あるいは両方について）電話番号のバリデーションが実装できる。
- 入力文字列のフィルタは `watch()` 関数を使えばリアクティブに実装できる。

## 環境

- Vue: 2.x
    - [Composition API](https://composition-api.vuejs.org/) を使用
- VeeValidate: 3.2.5
- TypeScript: 3.7.4

## 実装

以下では VeeValidate のモジュール import は省略しています。

### カスタムルール

まずは VeeValidate のカスタムルールを [extend()](https://logaretm.github.io/vee-validate/api/extend.html) で追加します。


```ts
import { PhoneNumberUtil } from 'google-libphonenumber'

extend('phone', {
  message: 'The {_field_} field format is invalid',
  validate(value) {
    const util = PhoneNumberUtil.getInstance()
    try {
      const phoneNumber = util.parseAndKeepRawInput(value, 'JP')
      return util.isValidNumber(phoneNumber)
    } catch (err) {
      return false
    }
  }
})
```

パースやバリデーションの機能は基本的に `PhoneNumberUtil` から利用できます。
入力値が短すぎるなど、場合によっては `parseAndKeepRawInput` でうまくパースできずに例外がスローされるので、その場合は false となるように try-catch で囲んでいます。

ちなみに国コードを指定せずにバリデーションするメソッドは（探した限りでは）用意されていないようです。
そのため日本の電話番号フォーマットにヒットしなかった場合は国外のフォーマットもチェックしたいといったケースでは以下のように全ての国コードを取得してループ処理する必要がありそうです。

```ts
validate(value) {
  const util = PhoneNumberUtil.getInstance()
  const jpFirstRegions = ['JP'].concat(
    util.getSupportedRegions().filter(regionCode => regionCode !== 'JP')
  )
  const validRegionCode = jpFirstRegions.find((regionCode) => {
    try {
      const phoneNumber = util.parseAndKeepRawInput(value, regionCode)
      return util.isValidNumber(phoneNumber)
    } catch (err) {
      return false
    }
  })
  return validRegionCode !== undefined
}
```

### コンポーネント

次にコンポーネントです。
これは単に ValidationProvider の rules に `phone` を指定するだけです。

```vue
<template>
  <ValidationProvider
    v-slot="{ errors }"
    ref="provider"
    name="Phone Number"
    rules="phone"
  >
    <input
      v-model="value"
      type="text"
    />
    <span>{{ errors[0] }}</span>
  </ValidationProvider>
</template>

<script lang="ts">
import { createComponent, ref } from '@vue/composition-api'

export default createComponent({
  setup() {
    const value = ref<string | null>(null)
    return {
      value
    }
  }
})
</script>
```

ただこれだと数字以外の文字も入力できてしまうため、よりユーザーフレンドリーになるように数字のみ許可する場合は以下のようになります。

```vue
<script lang="ts">
import { createComponent, ref, watch } from '@vue/composition-api'

export default createComponent({
  setup() {
    const value = ref<string | null>(null)
    watch(() => {
      value.value = value.value.replace(/[０-９]/g, (s) => {
        // 全角から半角への変換
        return String.fromCharCode(s.charCodeAt(0) - 0xFEE0)
      }).replace(/[^0-9]/g, '')
    })
    return {
      value
    }
  }
</script>
```

`watch()` メソッドを用いて `value` の更新があった際に数字以外の文字をフィルターアウトするようにしており、ついでに全角数字は半角数字に変換しています[^convert-number]。

ちなみに google-libphonenumber 自体は `+1 202-456-1414` のような記号付きの電話番号フォーマットにも対応しているので、 + や - などを除外対象から外すように上記のルールを書き換えれば記号付きのケースにも対応できます。

#### input タグの type で制御すれば良いのでは？

input タグの type に `number` や `tel` を指定するアプローチだと幾つか問題があります。

まず `number` ですが、こちらはユーザーがコピペで `+1 202-456-1414` のような記号を含む番号を入力した場合に `value` が空で評価されてしまいます！またデフォルトでは[スピンボタン](https://ja.wikipedia.org/wiki/%E3%82%B9%E3%83%94%E3%83%B3%E3%83%9C%E3%82%BF%E3%83%B3)が表示されますが、電話番号の数値の増減は不要であるため消去するのが結構手間です。

次に `tel` ですが、こちらは [pattern](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/tel#pattern) を指定しても入力値をフィルターする機能はないため、許可していない文字も入力できてしまいます。

このような事情もあり、最終的に Vue 側で入力値をコントロールするアプローチに落ち着きました。



[^convert-number]: この変換方法は [YoheiM\.NET さんの記事](https://www.yoheim.net/blog.php?q=20191101)から拝借しました。