数値が三桁区切りで表示される input タグをつくる

# `<input type="number">` では物足りない
`form.number_field`でレンダリングされる input タグでは次のような不満があります。
- 3桁区切りの表示をして欲しい（ことがある）
- うっかり全角数字で入力しても、入力がなかったことにしないで欲しい[^1]
[^1]:ウェブ入力欄で、「全角で（半角で）入力してください」とか、「ひらがなで（カタカナで）入力してください」とかに出会うが、お前が正規化しろよと思っている。

HTML の仕様（規格）の範疇だから自分でなんとかするしかない。なので設計方針はつぎのとおりです。

- input 欄に入力中 (focusin) は数値のみで、カンマは入力しない
- 入力は全角でも可能（使用者にできるだけ負担をかけない）
- 欄外に移ったら (focusout) 3桁区切りのカンマが入った表示にする
- POST(PATCH、PUT) の送信時 (submit) は数値を渡す

Stimulus についての概要は把握していることを前提としてます。
検証は、ruby 3.2、Rails 7.1 の環境です。

## 実装
![fruit.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/69840/8b192b05-06f3-c9ea-513f-c4983d38b16d.gif)

## サンプル作成
```bash
rails new sample
cd sample
rails g scaffold fruit price:float
rails db:create ; rails db:migrate
```
パーシャルテンプレートを変更します。`comma_field`が今回の目的のヘルパーです。

```diff_erb:app/views/fruits/_form.html.erb
- <%= form.number_field :price %>
+ <%= form.comma_field :price %>
```

# FormBuilder に comma_field を追加する
表示を担うダミーの input エレメントと値を担う input エレメントをレンダリングするようにします。そのため、ActionView::Helpers::FormBuilder に`comma_field`というメソッドを追加します。

```ruby:config/initializers/form_builder.rb
module ActionView
  module Helpers
    class FormBuilder
      include  TagHelper

      def comma_field(method, options = {})
        actions = token_list(%w[focusin->comma#focusin focusout->comma#focusout], options.dig(:data, :action))
        controllers = token_list(options.dig(:data, :controller))
        options.merge!({data: {comma_target: "dummy", action: actions}, value: @object[method]&.to_fs(:delimited), id: nil, name: nil})
        options[:data].merge!({controller: controllers}) if controllers.present?
        @template.content_tag(:span,
          @template.text_field(nil, nil, objectify_options(options)) +
          @template.hidden_field(@object_name, method, objectify_options({data: {comma_target: "numeric"}})),
                              data: {controller: "comma"}
        )
      end
    end
  end
end
```
## 上記コードの中心は次の部分です
```ruby
@template.content_tag(:span,
  @template.text_field(...) +
  @template.hidden_field(...)
)
```
これにより *.html.erb 中の `form.comma_field(..)`が`<span><input type="text" ... /><input type="hidden" ... /></span>`へとレンダリングされるようになります。`text_field`がダミーのエレメント、`hidden_field`が数値のエレメント。それを Stimulus (後述) で扱うために \<span\>で囲っています。

## Stimulus 用の data 属性を付加する
`actions = `以下の4行は Stimulus のコントローラー、アクション、ターゲットを指示するためのものです。

 1. Stimulus のコントローラー名は comma とする（comma_controller.js）
 1. [`token_list`](https://www.rubydoc.info/github/rails/rails/ActionView/Helpers/TagHelper#token_list-instance_method)は便利なヘルパー
 1. ダミーのエレメントは `dummy` 、数値のエレメントは `numeric` というターゲットとする 
 1. class 属性や他の data 属性などもオプションで渡せるようにする
 1. POST(PATCH、PUT)時に、数値フィールドだけ submit したいため、ダミーフィールドの name と　id は指定しない（name: nil, id: nil)

## レンダリング結果はこうなります
```html:(実際には改行・インデントされない)
<span data-controller="comma">
    <input data-comma-target="dummy" data-action="focusin->comma#focusin focusout->comma#focusout" type="text" />
    <input data-comma-target="numeric" autocomplete="off" type="hidden" name="fruit[price]" id="fruit_price" />
</span>
```
## 他の実装方法
ここでは新たなメソッドを追加して ActionView::Helpers::FormBuilder を拡張しています。そのため、`form_builder.rb`ファイルの配置は`config/initializers/`にしてあります。なので、アプリ全体で使えることになります。

他方、FormBuilder を承継したクラスを利用する方法もあります。例えばヘルパーを作成して
```ruby:app/helpers/custom_form_builder.rb
class CustomFormBuilder < ActionView::Helpers::FormBuilder
  ...
end
```
```diff_erb:_form.html.erb
- <%= form_with(model: fruit) do |form| %>
+ <%= form_with(model: fruit, builder: CustomFormBuilder) do |form| %>
```
のように、`form_with`ごとに柔軟に指定する、など

# Stimulus を使ったフロント部分

入力まわりは、Javascript を使わざるを得ないので、Stimulus で対応します。
```javascript:app/javascript/controllers/comma_controller.js
import { Controller } from "@hotwired/stimulus"

/*** 数字を桁区切りにする処理 ***/
const numberWithComma = new Intl.NumberFormat()
export default class extends Controller {
  static targets = ["dummy", "numeric"]

  focusin() {
    if (this.dummyTarget.readOnly) { return false }
    this.dummyTarget.value = this.numericTarget.value
    this.dummyTarget.select()
  }
  
  focusout() {
    let val = this.dummyTarget.value
    val = val.replace(/[Ａ-Ｚａ-ｚ０-９]/g, (s) => {
      return String.fromCharCode(s.charCodeAt(0) - 65248)
    }).replace(/、|。|・/g, ".").replace(/˗|ᅳ|᭸|‐|‑|‒|–|—|―|⁃|⁻|−|▬|─|━|➖|ㅡ|﹘|﹣|－|𐄐|𐆑|ー/, "-").replace(/[^0-9.-]/g, "")
    this.numericTarget.value = val
    this.dummyTarget.value = numberWithComma.format(val)
  }
}
```
## focusin()
フォーカスが入ったら、数値エレメント(\<input type="hidden" data-comma-target="numeric"\>)の値を表示エレメント(\<input type="text" data-comma-target="dummy"\>)に代入してます。
事実上カンマをなくして数値にしているだけです。

## focusout()
### フォーカスが外れたら、まず、入力値を正規化する
1. アルファベット・数字の全角を半角に変換する（アルファベットは不要ですが、この辺りはいろんなシーンでコピペしてきたものだからそのまま）
1. `、。・` の読点、句点、中黒は小数点 `.` (U+002E)と見做す
1. 長音やハイフンなどなどマイナスもどきは、マイナス `-`(U+002D)と見做す
1. 最後に、数字、マイナス記号、小数点以外を除外する

### 正規化後の値の処理
1. 数値になったので、数値エレメントに代入する
1. `Intl.NumberFormat()` で数値をカンマ付きに変換してダミーエレメントに代入する

# 雑感・所感
いちど作ってしまえば使い回しができるので便利です。

参考にしてみて回った中に [Don't overdo it](https://web-crunch.com/posts/how-to-use-formbuilder-in-ruby-on-rails) とあったが、これくらいなら許容範囲でしょう。