1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Ruby on RailsAdvent Calendar 2023

Day 14

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

Last updated at Posted at 2023-12-13

<input type="number"> では物足りない

form.number_fieldでレンダリングされる input タグでは次のような不満があります。

  • 3桁区切りの表示をして欲しい(ことがある)
  • うっかり全角数字で入力しても、入力がなかったことにしないで欲しい1

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

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

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

実装

fruit.gif

サンプル作成

rails new sample
cd sample
rails g scaffold fruit price:float
rails db:create ; rails db:migrate

パーシャルテンプレートを変更します。comma_fieldが今回の目的のヘルパーです。

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

FormBuilder に comma_field を追加する

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

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

上記コードの中心は次の部分です

@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)
  2. token_listは便利なヘルパー
  3. ダミーのエレメントは dummy 、数値のエレメントは numeric というターゲットとする
  4. class 属性や他の data 属性などもオプションで渡せるようにする
  5. POST(PATCH、PUT)時に、数値フィールドだけ submit したいため、ダミーフィールドの name と id は指定しない(name: nil, id: nil)

レンダリング結果はこうなります

(実際には改行・インデントされない)
<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 を承継したクラスを利用する方法もあります。例えばヘルパーを作成して

app/helpers/custom_form_builder.rb
class CustomFormBuilder < ActionView::Helpers::FormBuilder
  ...
end
_form.html.erb
- <%= form_with(model: fruit) do |form| %>
+ <%= form_with(model: fruit, builder: CustomFormBuilder) do |form| %>

のように、form_withごとに柔軟に指定する、など

Stimulus を使ったフロント部分

入力まわりは、Javascript を使わざるを得ないので、Stimulus で対応します。

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(/[A-Za-z0-9]/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. アルファベット・数字の全角を半角に変換する(アルファベットは不要ですが、この辺りはいろんなシーンでコピペしてきたものだからそのまま)
  2. 、。・ の読点、句点、中黒は小数点 . (U+002E)と見做す
  3. 長音やハイフンなどなどマイナスもどきは、マイナス -(U+002D)と見做す
  4. 最後に、数字、マイナス記号、小数点以外を除外する

正規化後の値の処理

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

雑感・所感

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

参考にしてみて回った中に Don't overdo it とあったが、これくらいなら許容範囲でしょう。

  1. ウェブ入力欄で、「全角で(半角で)入力してください」とか、「ひらがなで(カタカナで)入力してください」とかに出会うが、お前が正規化しろよと思っている。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?