🦔

react-number-formatで全角入力とカンマ区切り数値入力を同時に実現させる

に公開

https://www.npmjs.com/package/react-number-format

結論

以下のComposableNumericFormatを使います。

import React, { forwardRef, type InputHTMLAttributes, useState } from "react";
import {
	type InputAttributes,
	NumericFormat,
	type NumericFormatProps,
} from "react-number-format";

const NORMALIZE_MAP: Record<string, string> = {
	"1": "1",
	"2": "2",
	"3": "3",
	"4": "4",
	"5": "5",
	"6": "6",
	"7": "7",
	"8": "8",
	"9": "9",
	"0": "0",

	ー: "-",
	"。": ".",
};

const NORMALIZE_KEY_REGEX = new RegExp(
	`[${Object.keys(NORMALIZE_MAP).join("")}]`,
	"g",
);

const normalizeValue = (value: string): string => {
	return value.replace(NORMALIZE_KEY_REGEX, (match) => NORMALIZE_MAP[match]);
};

const createChangeEvent = (
	target: HTMLInputElement,
): React.ChangeEvent<HTMLInputElement> => ({
	bubbles: true,
	cancelable: true,
	currentTarget: target,
	defaultPrevented: false,
	eventPhase: 0,
	isDefaultPrevented: () => false,
	isPropagationStopped: () => false,
	isTrusted: false,
	nativeEvent: new Event("change"),
	persist: () => {},
	preventDefault: () => {},
	stopPropagation: () => {},
	target,
	timeStamp: Date.now(),
	type: "change",
});

const CustomInput = forwardRef<
	HTMLInputElement,
	InputHTMLAttributes<HTMLInputElement>
>(
	(
		{ value, onChange, onCompositionStart, onCompositionEnd, onBlur, ...props },
		ref,
	) => {
		const [composing, setComposing] = useState(false);
		const [internalValue, setInternalValue] = useState("");

		const displayValue = composing ? internalValue : value;

		const prepareComposition = () => {
			setComposing(true);

			if (typeof value === "string") {
				setInternalValue(value);
			}
		};

		const completeComposition = (target: unknown) => {
			setComposing(false);

			if (target instanceof HTMLInputElement) {
				const { selectionStart, selectionEnd } = target;

				const normalizedValue = normalizeValue(internalValue);
				target.value = normalizedValue;

				target.setSelectionRange(0, 0);

				onChange?.(createChangeEvent(target));

				const diffCount = target.value.length - normalizedValue.length;

				setTimeout(() => {
					target.setSelectionRange(
						(selectionStart ?? 0) + diffCount,
						(selectionEnd ?? 0) + diffCount,
					);
				}, 0);
			}
		};

		const cancelComposition = () => {
			setComposing(false);
		};

		const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
			setInternalValue(event.target.value);

			if (composing) {
				return;
			}

			onChange?.(event);
		};

		const handleCompositionStart = (
			event: React.CompositionEvent<HTMLInputElement>,
		) => {
			prepareComposition();
			onCompositionStart?.(event);
		};

		const handleCompositionEnd = (
			event: React.CompositionEvent<HTMLInputElement>,
		) => {
			completeComposition(event.target);
			onCompositionEnd?.(event);
		};

		const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
			cancelComposition();
			onBlur?.(event);
		};

		return (
			<input
				value={displayValue}
				onChange={handleChange}
				onCompositionStart={handleCompositionStart}
				onCompositionEnd={handleCompositionEnd}
				onBlur={handleBlur}
				{...props}
				inputMode={undefined}
				ref={ref}
			/>
		);
	},
);

export const ComposableNumericFormat = <BaseType extends InputAttributes>(
	props: NumericFormatProps<BaseType>,
) => <NumericFormat {...props} customInput={CustomInput} />;

ComposableNumericFormatは以下のように使います。

function App() {
	const [value, setValue] = useState("");

	return (
		<ComposableNumericFormat
			thousandSeparator
			value={value}
			onValueChange={(values) => setValue(values.value)}
		/>
	);
}

NumericFormatcustomInputの指定

customInputを独自のコンポーネントにすることにより、入力要素のイベントを完全に支配下に置きます。

全角入力中は入力値のフォーマットを行わない

composingというstateを管理し、compositionつまり全角入力中はNumericFormatから渡されるvalueの代わりに自分で管理しているinternalValueinputvalueとして指定します。
全角入力中はカンマがずれますが諦めました。

全角入力が完了したら入力値のフォーマットを再開する

createChangeEventで無理やりReactのChangeEventオブジェクトを作り出し、NumericFormatから渡されるonChangeを呼びます。
全角入力の完了後は必ず文字数が増えるので、onChangeを呼んだ後、フォーマット後の文字列と最後に入力されていた文字列の長さの差をとり、キャレットを自然な位置に戻します。

試したこと

  • 入力値の更新時、フォーカスを一時的にダミーの入力要素に移し、全角入力の入力状態を強制的に解除する
    • Windowsで数字の重複入力になる
  • removeFormatでnormalizeを行う
  • onCompositionUpdateで変更時にnormalizeして挿入する

Discussion