🕥

【TypeScript】【React】input 入力中のカンマ区切り

2024/11/19に公開

はじめに

お金の入力をinputで作ろうと思ったら、カンマ区切りの方が分かりやすいだろうから、入力中に自動でカンマ区切りになっていくようにした。

inputのtype='number'はカンマ区切りができないので、いろいろな人がtype='text'で、カンマ区切りの input を作っているので、その中に混ぜてもらいます。

早速コード

// 以下2つのコードは最後の補足にあります
// 今回のinputに使用できる引数
import { NumberInputProps } from "./InputFieldProps";
// カンマ挿入用の関数
import { priceFormatter } from './priceFormatter'

const PriceInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
  (props, ref) => {
    // 引数の内容は補足にて
    const { value = 0, onChange, onBlur, min, max, style } = props;
    // caretの位置
    let caretPos = 0;
    // デリートで消されたかどうか
    let isDeleteKey = false;

    // 最大最小の確認
    // 範囲外であれば強制的に範囲内の値にする
    const getCheckedMinMaxValue = (targetValue: number) => {
      if (min !== undefined && targetValue < min) return min;
      if (max !== undefined && targetValue > max) return max;
      return targetValue;
    };

    // inputエリア内の値
    const [thisValue, setThisValue] = useState(
      priceFormatter(getCheckedMinMaxValue(value))
    );

    // `value`が変更されたときに`thisValue`を更新
    useEffect(() => {
      setThisValue(priceFormatter(getCheckedMinMaxValue(value)));
    }, [value]);

    // カンマを消去
    const deleteComma = (str: string) => {
      return str.replace(/,/g, "");
    };

    const handleBlur = (e: React.FocusEvent<HTMLInputElement, Element>) => {
      const parsedValue = Number(deleteComma(thisValue));
      // 数値でなければ0を設定
      const checkedValue = getCheckedMinMaxValue(
        isNaN(parsedValue) ? 0 : parsedValue
      );
      setThisValue(priceFormatter(checkedValue));
      e.currentTarget.value = checkedValue.toString();
      onBlur?.(e);
    };

    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
      const newStrValue = e.target.value;
      const newNomValue = Number(deleteComma(newStrValue));

      if (/^[-.]$/.test(newStrValue)) return setThisValue(newStrValue);
      // 数字でなければ元の値を返す
      else if (isNaN(newNomValue)) return;

      const [integer, decimal] = deleteComma(newStrValue).split(".");

      // 整数部はカンマ区切り
      // 空文字またはハイフンのみの場合は、そのまま入れる
      let formattedNewStrValue = /^-?$/g.test(integer)
        ? integer
        : priceFormatter(Number(integer));

      // 小数はドットがあれば加える
      if (typeof decimal === "string") formattedNewStrValue += `.${decimal}`;

      // caret position get
      // フォーマットでカンマが入るとcaretの位置がずれるので
      // ここで調整する
      if (e.target.selectionStart) {
        // caretの現在位置
        caretPos = e.target.selectionStart;

        // caretの現在位置より先にある
        // 数値に使用される文字数を取得
        const pattern = /[0-9.-]/g;
        const matches = newStrValue.slice(0, caretPos).match(pattern);
        let numLength = matches ? matches.length : 0;
        // フォーマット(カンマ挿入)後
        // caretよりも先にあるカンマも含めて位置を調整
        let countPos = 0;
        for (let i = 0; i < formattedNewStrValue.length; i++) {
          if (numLength <= 0) break;
          if (formattedNewStrValue[i].match(pattern)) numLength -= 1;
          countPos++;
        }

        // deleteへの対応
        // カンマは無敵状態としてcaretを移動させるだけにする
        const preCommaNum = thisValue.match(/,/g)?.length || 0;
        const newCommaNum = newStrValue.match(/,/g)?.length || 0;
        if (isDeleteKey) countPos += preCommaNum > newCommaNum ? 1 : 0;

        caretPos = countPos;
      }

      // textfield update
      setThisValue(formattedNewStrValue);

      e.target.value = newNomValue.toString();
      onChange?.(e);

      // caret position change
      // caretの位置を変える動作が若干ちらつくので
      // 一瞬透明にする
      const orgColor = e.target.style.caretColor;
      e.target.style.caretColor = "transparent";

      // caret position set
      // 値更新の後に変更するためにtimeout使用
      setTimeout(() => {
        e.target.focus();
        e.target.setSelectionRange(caretPos, caretPos);

        e.target.style.caretColor = orgColor;
      }, 1);
    };

    return (
      <input
        ref={ref}
        value={thisValue}
        style={style}
        onFocus={(e) => e.target.select()}
        onKeyDown={(e) => {
          isDeleteKey = e.key === "Delete";
        }}
        onChange={handleChange}
        onBlur={handleBlur}
      />
    );
  }
);

補足

カンマ挿入

const priceFormatter = (value: number): string => {
  return new Intl.NumberFormat("en-US", {
    minimumFractionDigits: 0,
    maximumFractionDigits: 20,
  }).format(value);
};

引数

type NumberInputProps = {
  value?: number;
  onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
  onBlur?: (event: React.FocusEvent<HTMLInputElement, Element>) => void;
  min?: number;
  max?: number;
  style?: React.CSSProperties;
};

Discussion