Ant Design の Input をカスタムして、数値入力用でフォーカスアウト時にカンマ区切りにする方法

## はじめに

React Typescript AntDesign を使用。
入力は数値のみ、フォーカスアウト時にカンマ区切りのフォーマットを適用する。 という実装をすることがあったのでメモを残す。
`Ant Design` には、`InputNumber` コンポーネントという数値入力専用のコンポーネントが存在するが、今回の要件にあるフォーカスアウト時にカンマ区切りにする。といったことができなかった。
デフォルトの `Input` でもこの動作をサポートしていないため、カスタムコンポーネント `CustomInput` を作成


## Ant Design の Input とは？

`Input` は、フォームの入力フィールドとしてよく使われるコンポーネントで、以下のように簡単に導入できる。

```tsx
import React, { useState } from 'react';
import { Input } from 'antd';

const BasicInput: React.FC = () => {
  const [value, setValue] = useState('');

  return <Input value={value} onChange={(e) => setValue(e.target.value)} placeholder="数値を入力" />;
};

export default BasicInput;
```


## CustomInput の要件

✅ **数値のみを入力可能にする**
✅ **最大桁数（maxLength）を設定可能にする**
✅ **最小値・最大値の制限を設定可能にする**
✅ **指定したステップで丸める**
✅ **フォーカス時はカンマなし、フォーカスアウト時にカンマ区切りを適用**
✅ **無効な値（数値以外）は自動クリア**

## 実装コード

以下のコードが `CustomInput` の実装です。

```tsx
import React, { forwardRef, useEffect, useState } from 'react'
import { Input, InputRef } from 'antd'
import { formatWithCommas } from '../../../utils/Format'

// 呼び出し元で渡せる値を定義
export interface CustomInputProps {
  value?: string // 初期値（文字列形式の数値）
  onChange?: (value: string) => void // 値変更時のコールバック関数
  placeholder?: string
  maxLength?: number // 最大桁数
  min?: number // 最小値
  max?: number // 最大値
  step?: number // ステップサイズ
  disabled?: boolean
}

const NumericInput = forwardRef<InputRef, NumericInputProps>((props, ref) => {
  const { value = '', onChange, placeholder, maxLength, min, max, step, onBlur, disabled = false } = props
  const [displayValue, setDisplayValue] = useState(value) // 表示用の値
  const [isFocused, setIsFocused] = useState(false) // フォーカス状態を管理

  // 初期値のフォーマットを適用
  // フォーカスの有無に応じて表示値を切り替える
  useEffect(() => {
    if (isFocused) {
      setDisplayValue(value)
    }
    else {
      setDisplayValue(formatWithCommas(value))
    }
  }, [value, isFocused])

  // 入力値をそのまま更新する（カンマなし）
  const updateInputValue = (value: string) => {
    if (!onChange) {
      return
    }

    onChange(value)
    setDisplayValue(value)
  }

  // カンマ区切りを適用して更新する
  const updateFormattedValue = (value: string | number) => {
    if (!onChange) {
      return null
    }
    const formattedValue = formatWithCommas(value) // カンマ区切り形式に変換
    onChange(String(value))
    setDisplayValue(formattedValue)
  }

  const constrainNumber = (numValue: number) => {
    // 入力値が min より小さい場合、最小値に丸める
    if (min !== undefined && numValue < min) {
      return min
    }

    // 入力値が max より大きい場合、最大値に丸める
    if (max !== undefined && max < numValue) {
      return max
    }

    // 入力値を step の倍数に丸める（浮動小数点誤差を回避）
    if (step) {
      const multiplier = 1 / step
      return Math.floor(numValue * multiplier) / multiplier
    }

    return numValue
  }

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (!onChange) {
      return
    }

    const { value: inputValue } = e.target
    const reg = /^-?\d*(\.\d*)?$/

    // 数値または有効な入力のみを許可
    if (reg.test(inputValue) || inputValue === '' || inputValue === '-') {
      // maxLength の指定がある場合、トリミング
      if (maxLength) {
        updateInputValue(inputValue.slice(0, maxLength))
      }
      else {
        updateInputValue(inputValue)
      }
    }
  }

  const handleBlur = () => {

    setIsFocused(false)
    
    if (!onChange || !value) {
      return
    }

    let numericValue = String(value)
    if (numericValue.charAt(numericValue.length - 1) === '.' || numericValue === '-') {
      numericValue = numericValue.slice(0, -1)
    }

    // 値を数値型に変換
    const numValue = parseFloat(numericValue)

    // 数値が有効かチェック
    if (isNaN(numValue)) {
      updateInputValue('')
      return
    }

    // カンマ区切りの表示値を設定
    updateFormattedValue(constrainNumber(numValue))
  }

  const handleFocus = () => {
    setIsFocused(true)
    // フォーカス時はカンマを除去した値を表示
    updateInputValue(value)
  }

  return (
    <Input
      value={displayValue} // 表示用の値を使用
      placeholder={placeholder}
      onChange={handleChange}
      onFocus={handleFocus}
      ref={ref}
      disabled={disabled}
    />
  )
})

export default CustomInput

```

正直、検証は細部までできていないので改善の余地は多々あると思います。