【JavaScript】リアルタイム数値N桁区切りする際はカーソル位置に気をつけよう

金額の3桁コンマ区切りなど、数値データをフォーマットして画面に出す場面はよくありますよね。「JavaScript 数値 3桁区切り」などで検索すれば実装方法は簡単に出てきます。

しかし、入力項目（input要素）でフォーマットしたいというケースでは、検索してもあまり良い実装を見つけることができませんでした。なので私が実装した内容を、この記事に残そうと思います。

## 入力項目のフォーマットの種類

ざっくり、実装種類としては2つあります。

- フォーカスアウト時にフォーマットし、フォーカスイン時に数値へ戻す
- 入力リアルタイムでフォーマットする

前者のフォーカスイン/アウトであれば実装も楽で、気をつける箇所はあまりありません。入力中はフォーマットされず、その間は桁数ミスなどに気づきにくいという欠点がありますが、少ない桁数を入力する場合はあまり問題にならないでしょう。

では、後者のリアルタイムでフォーマットする案はどうでしょう。inputイベントに合わせて値をフォーマットした内容に差し替えるだけ、と思うかもしれませんが、本当にそれだけで良いのでしょうか。

```js: 実装のイメージ
input.addEventListener('input', () => {
  input.value = format(input.value)
})
```

<p class="codepen" data-height="300" data-default-tab="js,result" data-slug-hash="bNbwpBW" data-pen-title="Untitled" data-user="iMasanari" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/iMasanari/pen/bNbwpBW">
  Untitled</a> by iMasanari (<a href="https://codepen.io/iMasanari">@iMasanari</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

上記を動作確認してみると、ぱっと見はうまく動いていそうです。ですが、カーソルを末尾以外に移動させ、入力してみましょう。ブラウザによって異なるかもしれませんが、1文字入力（もしくは削除）するとカーソルが末尾に移動し、元のカーソル位置に連続入力することができません。

## カーソル位置を調整する

上記の通り、フォーマットした場合はカーソルを移動させる必要があります。
クレジットカード番号のような前からN桁で区切る場合、カーソル前の文字列をフォーマットし、その文字数を数えることでフォーマット後のカーソル位置を算出することができます。

```js
input.addEventListener('input', () => {
  // カーソル前の文字列だけでフォーマットし、フォーマット後のカーソル位置を算出
  const pos = format(input.value.slice(0, input.selectionStart)).length

  input.value = format(input.value)
  // カーソル位置を変更
  input.selectionStart = pos
  input.selectionEnd = pos
})
```

これで、末尾以外にカーソルがあってもその位置に連続して文字入力（もしくは削除）することができるようになります。

## Deleteキー（`forwardDelete`）に対応する

上記の内容で、文字の入力も、BackSpaseキーによる文字削除も、カーソル位置がどこであっても問題なくできるようになりました。しかし、対応できていないキーがあります。Deleteキー（`forwardDelete`）です。

カーソルの後ろ側の文字を削除するキーで、削除する文字が数字の場合は問題なく動作するのですが、フォーマットで挿入する文字（今回は半角スペース）の場合は削除できません。

これは、フォーマット前の文字列の半角スペースを削除 → フォーマットを掛けて半角スペースが復活という流れなので、inputイベントだけでは対応できません。keydownイベントで個別対応します。

```js
input.addEventListener('keydown', e => {
  if (e.key === 'Delete') {
    const pos = input.selectionStart

    if (pos === input.selectionEnd && input.value[pos] === ' ') {
      input.selectionStart = pos + 1
    }
  }
})
```

カーソルの後ろにフォーマット文字（半角スペース）がある状態でDeleteキーが押された場合、カーソル位置を移動（してからDeleteによる文字削除を実行）させることで、今回は解決しました。正直、キー入力系イベントのブラウザ実装差異でうまく動かないことがあるのではないかという懸念もあるのですが、自分で試した範囲では問題なく、また、そもそもDeleteキー使う場面はほとんどないだろうという理由で一旦この実装にしています。


## 数値の3桁コンマ区切りの場合

上記の例は、クレジットカードなど、先頭から区切る場合です。一方で、数値のコンマ区切りは後ろからとなります。

コンマ区切りのカーソル位置を算出する際、カーソルの後ろの文字列を使用して算出するのも実装方法の1つです。ですが、カーソル前の文字列を使用して算出できるようにするのが良いでしょう。理由としては2つあります。

- カーソルの後ろの文字列を使用した場合、前述のDeleteキー処理をBackSpaseキーで行う必要がある
  → BackSpaseキーの方がよく使われるので、個別処理を避けたい
- カーソルよりも前に小数点がある場合、処理分岐が必要になる

というわけで、こんな感じに実装しました。

```js
// コード全体は後述のCodePen参照

input.addEventListener('input', e => {
  const m = input.value.match(/^-?([0-9,]*)(\.[0-9,]*)?$/)

  if (!m) {
    // TODO: フォーマットエラー時の処理
    return
  }

  // 整数部から最初のコンマ位置を算出し、正規表現を作成する
  const firstCommaPos = toNumeric(m[1]).length % 3 || 3
  const reg = new RegExp(`(^-?\\d{${firstCommaPos}}|\\d{3})(?=\\d)`, 'g')

  // 上記正規表現を使用した、小数対応のフォーマット変換
  const format = text => {
    const [a, ...b] = text.split('.')
    return [toNumeric(a).replace(reg, '$1,'), ...b.map(toNumeric)].join('.')
  }

  const pos = format(input.value.slice(0, input.selectionStart)).length

  input.value = format(input.value)
  input.selectionStart = pos
  input.selectionEnd = pos
})
```

無理矢理感のある正規表現&小数点対応なので、もっと良い案があれば教えて下さい。


## その他の制御

今回は説明を省略しますが、数値以外の文字が入力された場合はフォーマットしない、もしくは入力できないようにするといった制御があると良いでしょう。

## 完成品

上記の実装を行ったのが、下記になります。
~~デバッグお願いします。~~ 実際に動作を試してみてください。

<p class="codepen" data-height="300" data-default-tab="js,result" data-slug-hash="OPLRNBj" data-pen-title="Qiita Advent Calendar 2024 - 2" data-user="iMasanari" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/iMasanari/pen/OPLRNBj">
  Qiita Advent Calendar 2024 - 2</a> by iMasanari (<a href="https://codepen.io/iMasanari">@iMasanari</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

カーソル位置など、なるべく通常の文字入力と同じ動作を再現してはいますが、諦めている箇所もあります。
例えば、`ctrl+Z` などの Undo / Redo 機能です。[execCommand][execCommand] の `insertText` 等を使えばできるのですが、このAPIは現在非推奨となっています。

[execCommand]: https://developer.mozilla.org/ja/docs/Web/API/Document/execCommand

また、Deleteキー押下時の処理は、Deleteキー以外の同等の処理（`forwardDelete`）には対応していません。Macでの `ctrl+d` がこれに当たります。おそらく頻度も低く、代替手段もあるので特に問題にはならないと思うので非対応としています。


## 最後に

CodePenには、素のJavaScriptで書いています。もしReactやVue、もしくはjQueryで書く場合は適宜修正してください。Reactの場合、[react-number-format][react-number-format] のような専用のライブラリを使用するのも1つです。

[react-number-format]: https://github.com/s-yadav/react-number-format

この記事を参考に、入力したら急にカーソルが末尾にワープする、なんてことが起こらないように実装していただけますと幸いです。