0️⃣

JavaScriptで数字のみの入力ボックスを作る(全角対応)

2023/11/09に公開

今回説明するのは電話番号や郵便番号といった、半角数字だけが入力できる数字入力ボックスの作り方になります。
応用すればハイフンを含む場合や半角英数字だけの入力ボックスも簡単に作れます。

本記事の途中までは半角数字だけの話で、途中からもし全角数字も許容するならの話になります。

全角許容

完成品はこちらに置いています。
https://glitch.com/~jito-example-number-only-input-jp

type="number"を使わない

まず、数字を入力するときにやりがちな間違いは、次のようにtype="number"を使ってしまうことです。

<input type="number" name="zip"> // Bad

type="number"はあくまで『数値』を入力するためのものだからです。
数字を入力するケースとして電話番号の場合はtype="tel"を使って、それ以外の場合はtype="text"inputmode属性を一緒に使います。

<input type="tel" name="tel"> // Good
<input type="text" name="id" inputmode="numeric"> // Good
<input type="text" name="zip" inputmode="tel"> // Good

inputmode属性を使えばブラウザに対して数値と誤解されることなく、数字のみのインターフェースで入力ができます。

数字以外の入力を制限する

スマホでは数字のみが入力できるようになりましたが、物理キーボードがあるとどんな文字でも入力できてしまいます。また、スマホのスクリーンキーボードでもカンマやハイフンが入力できる場合もあります。
これらの文字は入力値を送信するときや、入力直後にバリデーションが動くことで、ユーザーに修正をもとめることになります。それでもOKですが、より良いUIにしたいなら、そもそも入力できないようにするほうが良さそうです。

そこで、JavaScriptを使ってinputイベントを受け取って数字以外の文字を削除します。

inputイベントを扱う方法は、利用しているフレームワークに依存します。本記事ではJitoを使って説明しますが、VueやReact等でも書き方が異なるだけでやることは同じです。

inputイベントを受信して半角の0~9以外の文字を削除して再設定するコンポーネントはこちらです。

JS
import { compact, mount } from 'https://cdn.jsdelivr.net/gh/ittedev/jito@1.4.0/jito.js'

let main = () => {
  function input(event) {
    event.target.value = event.target.value
      .replace(/[^0-9]/g, '')
  }

  return { input }
}

let html = /* html */`
  <input
    type="text"
    inputmode="numeric"
    oninput="input(event)"
  >

  <style>
    /* 略 */
  </style>
`

export default compact(html, main)

IMEが有効のときはinputイベントを発火させない

さて、先の方法では、replace()メソッドで0~9以外の文字を削除していました。
英語圏ではこれで問題ないと思いますが、ここ日本だと、日本語入力モードのときに何も入力できないという問題が生じます。
日本語入力モードでは数字のキーを押すとまず変換前の全角数字が入力されますので、半角数字ではないため入力できないのは当たり前です。

inputmode属性を適切に設定していれば、フォーカスが当たったときに自動的にIMEがOFFになりますので大きな問題とは言えませんが、もしユーザーがついついIMEを切り替えてしまったら、数字のキーを押しているのに画面に何も表示されないという状態になり、ユーザーからすると「?」です。離脱に繋がる恐れもあります。

まー、実は私が潜在的にIMEは基本ONになっているものと認識しているのか、半角で入力する場面で無意識に変換キーを押してしまうときがあるんですよね。
・・・私だけ?であればこの記事はここでおしまいです。読んでいただきありがとうございました。

さて私のような人が居ると仮定すると、日本語入力モード(IMEが有効)のときに、先のinputイベントが反応しないようにして、変換が確定したときに初めてinputイベントを発火させるようにしたいです。

inputイベントにはisComposingフラグというのがあって、IMEが有効のときはこのフラグが立つのでIMEが有効かどうかを判定できます。このフラグはinputイベントがcompositionstartイベントより後でcompositionendイベントより前の場合にisComposingフラグがtrueになります。
しかし、変換確定時のinputイベントは、Firefoxはcompositionendイベントより前に発生し、
Chromeはcompositionendイベントより後に発生します。そのためブラウザごとにisComposingフラグの値にばらつきがあります。しかも、Safariは2023年3月と最近対応したばかりです。

この問題を解消し、少し古いSafariにも対応する方法は、長いですが次のようなイベントセットを作ります(詳細は後で説明します)。

function createThroughEvents() {
  let isComposing = false
  let isFirefox = window.navigator.userAgent.toLowerCase().includes('firefox')

  return {
    oncompositionstart() {
      isComposing = true
    },

    oncompositionend(event) {
      if (isComposing) {
        isComposing = false
        if (!isFirefox) {
          event.target.dispatchEvent(new InputEvent('input', {
            inputType: 'insertText',
            data: event.data,
            isComposing: false
          }))
        }
      }
    },
  
    onkeydown(event) {
      if (isComposing && event.code === 'Enter') {
        isComposing = false
      }
    },

    oninput(event) {
      if (event.inputType === 'insertFromComposition') {
        isComposing = false
      }
      if (isComposing) {
        event.stopImmediatePropagation()
      }
    },
  }
}

実装手順

このイベントセットは次の手順で作りました。VueやReact、JQueryなどでやる場合もこの手順で作ってみてください。

手順1 オリジナルのisComposingフラグ

compositionstartイベントでONになり、compositionendイベントでOFFになる、オリジナルのisComposingフラグを作ります。
この時点でisComposingフラグはinputイベントのものと全く同じ挙動になります。

  let isComposing = false
    oncompositionstart() {
      isComposing = true
    },

    oncompositionend(event) {
      isComposing = false
    },

手順2 isComposingフラグでinputイベントを無効化

inputイベントでisComposingフラグがONのときに、stopImmediatePropagation()メソッドで後続のinputイベントが実行されないようにします。
この時点でFirefoxは対応完了になります。

    oninput(event) {
      if (isComposing) {
        event.stopImmediatePropagation()
      }
    },

手順3 Safariへの対応

Safariでは変換確定時にinputTypeプロパティが'insertFromComposition'になっているinputイベントが発行されます。
手順2の直前にinputTypeプロパティが'insertFromComposition'だった場合はisComposingフラグをOFFにします。Safariへの対応完了です。

    oninput(event) {
+     if (event.inputType === 'insertFromComposition') {
+       isComposing = false
+     }
      if (isComposing) {
        event.stopImmediatePropagation()
      }
    }

手順4 Chromeへの対応

ChromeやEdgeのようなChromiumベースのブラウザは、compositionendイベントの前に変換確定のinputイベントが動く上に、Safariのような他に変換確定を判定するためのプロパティがありません。そのため、compositionendイベントのときにあえてもう一回inputイベントを発火させます。これでChromeも対応完了です。

    oncompositionend(event) {
      isComposing = false;
+     event.target.dispatchEvent(new InputEvent('input', {
+       inputType: 'insertText',
+       data: event.data,
+       isComposing: false
+     }))
    },

手順5 無駄なイベント発生を減らす①

以上で完了なのですが、手順4のcompositionendイベントでinputイベントを発火させるときに、Safariは変換完了のinputイベントが既に発火しており、Firefoxはわざわざ発火しなくてもこのあとで自然発火します。そのためSafariとFirefoxでは無駄なinputイベントが発火することになってしまいます。これを防ぐためにChromiumベースのブラウザだけで発火させたいです。
そこでisComposingフラグがOFFでFirefox以外のときに発火させることにします。

  let isFirefox = window.navigator.userAgent.toLowerCase().includes('firefox')
    oncompositionend(event) {
+     if (isComposing) {
        isComposing = false;
+       if (!isFirefox) {
          event.target.dispatchEvent(new InputEvent('input', {
            inputType: 'insertText',
            data: event.data,
            isComposing: false
          }))
+       }
+     }
    },

手順6 無駄なイベント発生を減らす②

更に無駄なinputイベントを抑えたければ、ChromiumベースのブラウザでEnterキーを押して変換確定する場合は、Keydownイベントを受け取ってisComposingフラグをOFFにします。

    onkeydown(event) {
      if (isComposing && event.code === 'Enter') {
        isComposing = false;
      }
    },

カスタム要素に設定

今回はJitoで作りましたので1~6の各手順で作ったイベントセットを、先の<input>要素にチャンクとして付与します。inputイベントの実行順序が重要ですので、oninputよりも前に書く必要があります。

let main = () => {
  function input(event) {
    event.target.value = event.target.value
      .replace(/[-]/g, c => String.fromCharCode(c.charCodeAt(0) - 0xFEE0))
      .replace(/[^0-9]/g, '')
  }

+ let throughEvents = createThroughEvents()

  return { input, throughEvents }
}

let html = /* html */`
  <input
    type="text"
    inputmode="numeric"
+   @chunk="throughEvents"
    oninput="input(event)"
  >

  <style>
    /* 略 */
  </style>
`

export default compact(html, main)

これで日本語入力中はinputイベントが実行されず、入力確定時にinputイベントが発火するinputボックスができました。

全角数字も入力できるようにする

IMEの問題が解消されましたので、全角数字が入力されたら自動的に半角数字に変換されるようにしてみます。
これは単純にinputイベントで変換すればできます。

  function input(event) {
    event.target.value = event.target.value
+     .replace(/[-]/g, c => String.fromCharCode(c.charCodeAt(0) - 0xFEE0))
      .replace(/[^0-9]/g, '')
  }

応用

郵便番号

応用で、数字の途中でハイフンやカンマを入れたい場合は、inputイベントで変換を工夫すれば可能です。

郵便番号

  function input(event) {
    event.target.value = event.target.value
      .replace(/[-]/g, c => String.fromCharCode(c.charCodeAt(0) - 0xFEE0))
      .replace(/[^0-9]/g, '')
+     .slice(0, 7)
+     .split('')
+     .reduce((r, c, i, s) => r + c + (i === 2 && (s.length > 3 || event.target.value[3] === '-') ? '-' : ''), '')  
  }

半角英数字だけの入力ボックス

他にも、半角英数字だけを許可する場合はこんな感じです。

  function input(event) {
    event.target.value = event.target.value
      .replace(/[---]/g, c => String.fromCharCode(c.charCodeAt(0) - 0xFEE0))
      .replace(/[^0-9a-zA-Z]/g, '')  
  }

いい感じの入力ボックスができました。
IMEを含めてUIを考えるとなかなか骨が折れますね。

Discussion