From 4a6a03fbf843a96e7499c5ccc829c8454daf5ea4 Mon Sep 17 00:00:00 2001 From: hasegawa-101 Date: Sat, 22 Nov 2025 22:34:40 +0900 Subject: [PATCH 1/4] feat(number-input): add isRealTimeFormat prop --- packages/components/number-input/package.json | 1 + .../number-input/src/use-number-input.ts | 75 +++++++++++++++++++ .../stories/number-input.stories.tsx | 15 ++++ pnpm-lock.yaml | 3 + 4 files changed, 94 insertions(+) diff --git a/packages/components/number-input/package.json b/packages/components/number-input/package.json index 2a68f0688a..257734881e 100644 --- a/packages/components/number-input/package.json +++ b/packages/components/number-input/package.json @@ -48,6 +48,7 @@ "@heroui/shared-icons": "workspace:*", "@heroui/shared-utils": "workspace:*", "@heroui/use-safe-layout-effect": "workspace:*", + "@internationalized/number": "3.6.5", "@react-aria/focus": "3.21.2", "@react-aria/i18n": "3.12.13", "@react-aria/interactions": "3.25.6", diff --git a/packages/components/number-input/src/use-number-input.ts b/packages/components/number-input/src/use-number-input.ts index 651115046e..f667b5ac57 100644 --- a/packages/components/number-input/src/use-number-input.ts +++ b/packages/components/number-input/src/use-number-input.ts @@ -14,6 +14,7 @@ import {useLocale} from "@react-aria/i18n"; import {clsx, dataAttr, isEmpty, objectToDeps, chain, mergeProps} from "@heroui/shared-utils"; import {useNumberFieldState} from "@react-stately/numberfield"; import {useNumberField as useAriaNumberInput} from "@react-aria/numberfield"; +import {NumberParser} from "@internationalized/number"; import {useMemo, useCallback, useState} from "react"; import {FormContext, useSlottedContext} from "@heroui/form"; @@ -79,6 +80,12 @@ export interface Props extends Omit, keyof NumberInputV * if you pass this prop, the clear button will be shown. */ onClear?: () => void; + /** + * Whether to format the value in real-time as the user types. + * When false, formatting only happens on blur (React Aria default behavior). + * @default false + */ + isRealTimeFormat?: boolean; /** * React aria onChange event. */ @@ -304,6 +311,70 @@ export function useNumberInput(originalProps: UseNumberInputProps) { [inputValue, state, onClear, isClearable, originalProps.isReadOnly], ); + const numberFormatter = useMemo(() => { + return new Intl.NumberFormat(locale, originalProps.formatOptions); + }, [locale, originalProps.formatOptions]); + + const numberParser = useMemo(() => { + return new NumberParser(locale, originalProps.formatOptions); + }, [locale, originalProps.formatOptions]); + + const shouldFormat = useMemo(() => { + // Return false if isRealTimeFormat is not enabled (React Aria default) + if (!originalProps.isRealTimeFormat) return false; + + // Only check useGrouping if isRealTimeFormat is true + const resolved = numberFormatter.resolvedOptions(); + + return resolved.useGrouping !== false; + }, [originalProps.isRealTimeFormat, numberFormatter]); + + const handleBeforeInput = useCallback( + (e: React.FormEvent & {data: string | null}) => { + if (!e.data) return; + + const input = domRef.current; + + if (!input) return; + + const {value, selectionStart, selectionEnd} = input; + const nextValue = + value.slice(0, selectionStart ?? 0) + e.data + value.slice(selectionEnd ?? 0); + + // Use React Aria's NumberParser for validation and parsing + // This handles full-width numbers and locale-specific symbols + if (!numberParser.isValidPartialNumber(nextValue)) { + e.preventDefault(); + + return; + } + + const parsedValue = numberParser.parse(nextValue); + + if (isNaN(parsedValue)) return; + + e.preventDefault(); + + const formattedValue = numberFormatter.format(parsedValue); + + // Call validate like React Aria does + if (!state.validate(formattedValue)) { + return; + } + + state.setInputValue(formattedValue); + state.setNumberValue(parsedValue); + + if (onChange) { + onChange({ + target: {value: formattedValue}, + currentTarget: {value: formattedValue}, + } as React.ChangeEvent); + } + }, + [numberParser, numberFormatter, state, domRef, onChange], + ); + const getBaseProps: PropGetter = useCallback( (props = {}) => { return { @@ -387,6 +458,8 @@ export function useNumberInput(originalProps: UseNumberInputProps) { }), props, ), + // Only override onBeforeInput when isRealTimeFormat is true + ...(shouldFormat ? {onBeforeInput: handleBeforeInput} : {}), "aria-readonly": dataAttr(originalProps.isReadOnly), onChange: chain(inputProps.onChange, onChange), onKeyDown: chain(inputProps.onKeyDown, props.onKeyDown, handleKeyDown), @@ -406,6 +479,8 @@ export function useNumberInput(originalProps: UseNumberInputProps) { originalProps.isRequired, onChange, handleKeyDown, + shouldFormat, + handleBeforeInput, ], ); diff --git a/packages/components/number-input/stories/number-input.stories.tsx b/packages/components/number-input/stories/number-input.stories.tsx index 2f7f9d8b6d..7b3bcceb37 100644 --- a/packages/components/number-input/stories/number-input.stories.tsx +++ b/packages/components/number-input/stories/number-input.stories.tsx @@ -531,6 +531,21 @@ export const CustomWithClassNames = { }, }; +export const WithRealTimeFormat = { + render: ControlledTemplate, + + args: { + ...defaultProps, + label: "Amount (Real-time format)", + isRealTimeFormat: true, + formatOptions: { + style: "decimal", + useGrouping: true, + }, + description: "Number is formatted as you type with isRealTimeFormat enabled", + }, +}; + // export const CustomWithHooks = { // render: CustomWithHooksTemplate, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fff33c8929..d7d8e23d32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2042,6 +2042,9 @@ importers: '@heroui/use-safe-layout-effect': specifier: workspace:* version: link:../../hooks/use-safe-layout-effect + '@internationalized/number': + specifier: 3.6.5 + version: 3.6.5 '@react-aria/focus': specifier: 3.21.2 version: 3.21.2(react-dom@18.3.0(react@18.3.0))(react@18.3.0) From 3862606b1f37484f2b5faa4b041a3750d01f6a7d Mon Sep 17 00:00:00 2001 From: hasegawa-101 Date: Thu, 18 Dec 2025 00:21:03 +0900 Subject: [PATCH 2/4] fix(number-input): implement real-time input formatting logic --- .../__tests__/number-input.test.tsx | 237 +++++++++++++++ .../number-input/src/use-number-input.ts | 194 ++++++------ .../src/use-real-time-formatting.ts | 281 ++++++++++++++++++ 3 files changed, 612 insertions(+), 100 deletions(-) create mode 100644 packages/components/number-input/src/use-real-time-formatting.ts diff --git a/packages/components/number-input/__tests__/number-input.test.tsx b/packages/components/number-input/__tests__/number-input.test.tsx index 7b6cdc793c..5fe25e63b4 100644 --- a/packages/components/number-input/__tests__/number-input.test.tsx +++ b/packages/components/number-input/__tests__/number-input.test.tsx @@ -726,3 +726,240 @@ describe("NumberInput with React Hook Form", () => { }); }); }); + +describe("NumberInput Real-Time Formatting", () => { + let user: ReturnType; + + beforeEach(() => { + user = userEvent.setup(); + }); + + it("should format value in real-time when isRealTimeFormat is true", async () => { + const {container} = render( + , + ); + const input = container.querySelector("input") as HTMLInputElement; + + await user.type(input, "1234"); + expect(input.value).toBe("1,234"); + }); + + it("should format even if useGrouping is false when isRealTimeFormat is true", async () => { + const {container} = render( + , + ); + const input = container.querySelector("input") as HTMLInputElement; + + // Type 1234. Should be formatted as $1234 (no comma) + // Note: Currency symbol depends on locale. Default en-US -> $ + await user.type(input, "1234"); + // Standard currency formatting often adds decimals and currency symbol + // We check that it has some currency formatting but NO commas for thousands + expect(input.value).toMatch(/\$1234(\.00)?/); + expect(input.value).not.toContain(","); + }); + + it("should prevent invalid input via beforeInput", async () => { + const {container} = render(); + const input = container.querySelector("input") as HTMLInputElement; + + // Simulate beforeInput with invalid char + // Note: userEvent.type simulates a sequence of events. + // Ideally we'd use a more direct beforeInput simulation if userEvent passes through 'a' by default in JSDOM, + // but the implementation logic we added (preventDefault) should block it if JSDOM/userEvent fires beforeInput. + await user.type(input, "1a2"); + // 'a' should be blocked. + expect(input.value).toBe("12"); + }); + + it("should handle paste and format", async () => { + const {container} = render(); + const input = container.querySelector("input") as HTMLInputElement; + + await user.click(input); + await user.paste("1234"); + + expect(input.value).toBe("1,234"); + }); + + it("should NOT format in real-time when isRealTimeFormat is false (default)", async () => { + const {container} = render( + , + ); + const input = container.querySelector("input") as HTMLInputElement; + + await user.click(input); + await user.keyboard("1234"); + + // Should remain "1234" (no commas) while typing because real-time formatting is off + // Note: React Aria might format on blur, but here we check immediate value + expect(input.value).toBe("1234"); + }); + + describe("Cursor Restoration", () => { + // Helper to spy on setSelectionRange + let setSelectionRangeSpy: jest.SpyInstance; + + beforeEach(() => { + setSelectionRangeSpy = jest.spyOn(HTMLInputElement.prototype, "setSelectionRange"); + }); + + afterEach(() => { + setSelectionRangeSpy.mockRestore(); + }); + + it("should restore cursor correctly when appending a digit", async () => { + // 1,234 -> type '5' at end -> 12,345 + const {container} = render( + , + ); + const input = container.querySelector("input") as HTMLInputElement; + + await user.click(input); + // Move cursor to end + act(() => { + input.setSelectionRange(5, 5); // after 4 + }); + + await user.keyboard("5"); + + // Original: 1,234 (digits: 1234) + // Type 5 -> Value: 12,345 + // Cursor should be after 5. + // 1 (1) 2 (2) , (x) 3 (3) 4 (4) 5 (5) -> Index 6 + + await act(async () => { + jest.runAllTimers(); + }); + + expect(input.value).toBe("12,345"); + expect(setSelectionRangeSpy).toHaveBeenLastCalledWith(6, 6); + }); + + it("should restore cursor correctly when prepending a digit", async () => { + // 1,234 -> type '5' at start -> 51,234 + const {container} = render( + , + ); + const input = container.querySelector("input") as HTMLInputElement; + + await user.click(input); + // Move cursor to start + act(() => { + input.setSelectionRange(0, 0); + }); + + await user.keyboard("5"); + + // Result: 51,234 + // Cursor should be after 5 (1st digit). + // 5 (1) -> Index 1 + + await act(async () => { + jest.runAllTimers(); + }); + + expect(input.value).toBe("51,234"); + expect(setSelectionRangeSpy).toHaveBeenLastCalledWith(1, 1); + }); + + it("should restore cursor correctly when inserting in middle", async () => { + // 1,234 -> type '5' after '1' -> 15,234 + const {container} = render( + , + ); + const input = container.querySelector("input") as HTMLInputElement; + + await user.click(input); + // Cursor after '1' + act(() => { + input.setSelectionRange(1, 1); // 1|,234 + }); + + await user.keyboard("5"); + + // Result: 15,234 + // Cursor should be after 5. + // Digits before: 1, 5 (2 digits) + // 1 (1) 5 (2) , (x) ... + // Index 2 + + await act(async () => { + jest.runAllTimers(); + }); + + expect(input.value).toBe("15,234"); + expect(setSelectionRangeSpy).toHaveBeenLastCalledWith(2, 2); + }); + + it("should restore cursor correctly with currency symbols", async () => { + // $1,234 -> type '5' after '1' -> $15,234 (Assuming en-US default) + const {container} = render( + , + ); + const input = container.querySelector("input") as HTMLInputElement; + + await user.click(input); + // Move cursor after '1' (index 2) + act(() => { + // Find position of '1' + const idx = input.value.indexOf("1"); + + input.setSelectionRange(idx + 1, idx + 1); + }); + + await user.keyboard("5"); + + // Result: $15,234.00 + // Digits before cursor: 1, 5 (2 digits) + // New string: $ 1 5 , 2 3 4 . 0 0 + // Digits: x 1 2 x 3 4 5 x 6 7 + // 2nd digit is '5'. Cursor should be after it. + // Index of '5' is 2. So cursor at 3. + + await act(async () => { + jest.runAllTimers(); + }); + + expect(input.value).toContain("15,234"); + + // We check if the cursor is placed after the 2nd digit + const matches = input.value.match(/^[^0-9]*\d\d/); // match prefix + 2 digits + const expectedIndex = matches ? matches[0].length : 0; + + expect(setSelectionRangeSpy).toHaveBeenLastCalledWith(expectedIndex, expectedIndex); + }); + }); +}); diff --git a/packages/components/number-input/src/use-number-input.ts b/packages/components/number-input/src/use-number-input.ts index a4743d3b39..ecd17861d2 100644 --- a/packages/components/number-input/src/use-number-input.ts +++ b/packages/components/number-input/src/use-number-input.ts @@ -18,6 +18,8 @@ import {NumberParser} from "@internationalized/number"; import {useMemo, useCallback, useState} from "react"; import {FormContext, useSlottedContext} from "@heroui/form"; +import {useRealTimeInputFormatting} from "./use-real-time-formatting"; + export interface Props extends Omit, keyof NumberInputVariantProps> { /** * Ref to the DOM node. @@ -260,62 +262,6 @@ export function useNumberInput(originalProps: UseNumberInputProps) { ], ); - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - const inputElement = e.currentTarget; - const {selectionStart, selectionEnd, value} = inputElement; - // locale-aware grouping separator - const nf = new Intl.NumberFormat(locale, {useGrouping: true}); - const groupChar = nf.formatToParts(1000).find((p) => p.type === "group")?.value ?? ","; - - // handle backspace when cursor is between a digit and the first group separator - // e.g. 1|,234 (en-US) or 1|.234 (de-DE) -> backspace removes the preceding digit if ( - if ( - e.key === "Backspace" && - !originalProps.isReadOnly && - !originalProps.isDisabled && - selectionStart !== null && - selectionEnd !== null && - selectionStart === selectionEnd && - selectionStart > 0 && - value[selectionStart] === groupChar && - value[selectionStart - 1] !== groupChar - ) { - e.preventDefault(); - // e.g. 1,234 -> ,234 - const newValue = value.slice(0, selectionStart - 1) + value.slice(selectionStart); - // e.g. ,234 -> 234 - const cleanValue = newValue.replace(/[^\d.-]/g, ""); - - if (cleanValue === "" || cleanValue === "-") { - state.setInputValue(""); - } else { - const numberValue = parseFloat(cleanValue); - - if (!isNaN(numberValue)) { - state.setNumberValue(numberValue); - } - } - - setTimeout(() => { - // set the new cursor position - const pos = Math.max(0, selectionStart - 1); - - inputElement.setSelectionRange(pos, pos); - }, 0); - } else if ( - e.key === "Escape" && - inputValue && - (isClearable || onClear) && - !originalProps.isReadOnly - ) { - state.setInputValue(""); - onClear?.(); - } - }, - [inputValue, state, onClear, isClearable, originalProps.isReadOnly], - ); - const numberFormatter = useMemo(() => { return new Intl.NumberFormat(locale, originalProps.formatOptions); }, [locale, originalProps.formatOptions]); @@ -324,60 +270,95 @@ export function useNumberInput(originalProps: UseNumberInputProps) { return new NumberParser(locale, originalProps.formatOptions); }, [locale, originalProps.formatOptions]); - const shouldFormat = useMemo(() => { - // Return false if isRealTimeFormat is not enabled (React Aria default) - if (!originalProps.isRealTimeFormat) return false; - - // Only check useGrouping if isRealTimeFormat is true - const resolved = numberFormatter.resolvedOptions(); - - return resolved.useGrouping !== false; - }, [originalProps.isRealTimeFormat, numberFormatter]); - - const handleBeforeInput = useCallback( - (e: React.FormEvent & {data: string | null}) => { - if (!e.data) return; - - const input = domRef.current; - - if (!input) return; + // Hook to handle real-time formatting logic + const { + shouldFormat, + handleBeforeInput, + handleInput, + handleCompositionStart, + handleCompositionEnd, + handlePaste, + handleCut, + } = useRealTimeInputFormatting({ + isRealTimeFormat: originalProps.isRealTimeFormat ?? false, + numberParser, + numberFormatter, + state, + domRef, + onChange, + }); - const {value, selectionStart, selectionEnd} = input; - const nextValue = - value.slice(0, selectionStart ?? 0) + e.data + value.slice(selectionEnd ?? 0); + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const {key} = e; - // Use React Aria's NumberParser for validation and parsing - // This handles full-width numbers and locale-specific symbols - if (!numberParser.isValidPartialNumber(nextValue)) { + if (key === "ArrowUp") { e.preventDefault(); - - return; + state.increment(); + } else if (key === "ArrowDown") { + e.preventDefault(); + state.decrement(); } - const parsedValue = numberParser.parse(nextValue); + const input = e.currentTarget; + const {selectionStart, selectionEnd, value} = input; - if (isNaN(parsedValue)) return; + // Restore original Backspace logic for comma/grouping separator navigation + // This is required for existing tests and proper behavior across all modes + if ( + key === "Backspace" && + !originalProps.isReadOnly && + !originalProps.isDisabled && + selectionStart !== null && + selectionEnd !== null && + selectionStart === selectionEnd && + selectionStart > 0 + ) { + const groupChar = + numberFormatter.formatToParts(1000).find((p) => p.type === "group")?.value ?? ","; - e.preventDefault(); + if (value[selectionStart] === groupChar && value[selectionStart - 1] !== groupChar) { + e.preventDefault(); + const newValue = value.slice(0, selectionStart - 1) + value.slice(selectionStart); + const cleanValue = newValue.replace(/[^\d.-]/g, ""); - const formattedValue = numberFormatter.format(parsedValue); + if (cleanValue === "" || cleanValue === "-") { + state.setInputValue(""); + state.setNumberValue(NaN); + } else { + const numberValue = parseFloat(cleanValue); - // Call validate like React Aria does - if (!state.validate(formattedValue)) { - return; - } + if (!isNaN(numberValue)) { + state.setNumberValue(numberValue); - state.setInputValue(formattedValue); - state.setNumberValue(parsedValue); + setTimeout(() => { + const pos = Math.max(0, selectionStart - 1); - if (onChange) { - onChange({ - target: {value: formattedValue}, - currentTarget: {value: formattedValue}, - } as React.ChangeEvent); + input.setSelectionRange(pos, pos); + }, 0); + } + } + } + } else if ( + key === "Escape" && + inputValue && + (isClearable || onClear) && + !originalProps.isReadOnly + ) { + state.setInputValue(""); + onClear?.(); } }, - [numberParser, numberFormatter, state, domRef, onChange], + [ + inputValue, + state, + onClear, + isClearable, + originalProps.isReadOnly, + originalProps.isRealTimeFormat, + numberParser, + numberFormatter, + ], ); const getBaseProps: PropGetter = useCallback( @@ -463,8 +444,17 @@ export function useNumberInput(originalProps: UseNumberInputProps) { }), props, ), - // Only override onBeforeInput when isRealTimeFormat is true - ...(shouldFormat ? {onBeforeInput: handleBeforeInput} : {}), + // Only override handlers when isRealTimeFormat is true + ...(shouldFormat + ? { + onBeforeInput: handleBeforeInput, + onInput: handleInput, + onCompositionStart: handleCompositionStart, + onCompositionEnd: handleCompositionEnd, + onPaste: handlePaste, + onCut: handleCut, + } + : {}), "aria-readonly": dataAttr(originalProps.isReadOnly), onChange: chain(inputProps.onChange, onChange), onKeyDown: chain(inputProps.onKeyDown, props.onKeyDown, handleKeyDown), @@ -481,11 +471,15 @@ export function useNumberInput(originalProps: UseNumberInputProps) { endContent, classNames?.input, originalProps.isReadOnly, - originalProps.isRequired, onChange, handleKeyDown, shouldFormat, handleBeforeInput, + handleInput, + handleCompositionStart, + handleCompositionEnd, + handlePaste, + handleCut, ], ); diff --git a/packages/components/number-input/src/use-real-time-formatting.ts b/packages/components/number-input/src/use-real-time-formatting.ts new file mode 100644 index 0000000000..d998cae48f --- /dev/null +++ b/packages/components/number-input/src/use-real-time-formatting.ts @@ -0,0 +1,281 @@ +import type {NumberParser} from "@internationalized/number"; +import type {NumberFieldState} from "@react-stately/numberfield"; + +import {useCallback, useMemo, useRef} from "react"; + +export interface UseRealTimeInputFormattingProps { + isRealTimeFormat: boolean; + numberParser: NumberParser; + numberFormatter: Intl.NumberFormat; + state: NumberFieldState; + domRef: React.RefObject; + onChange?: React.ChangeEventHandler; +} + +export function useRealTimeInputFormatting(props: UseRealTimeInputFormattingProps) { + const {isRealTimeFormat, numberParser, numberFormatter, state, domRef, onChange} = props; + const isComposingRef = useRef(false); + + const shouldFormat = useMemo(() => { + return Boolean(isRealTimeFormat); + }, [isRealTimeFormat]); + + const handleCompositionStart = useCallback(() => { + isComposingRef.current = true; + }, []); + + const handleInput = useCallback( + (e: React.FormEvent) => { + if (isComposingRef.current || !shouldFormat) return; + + const input = e.currentTarget; + const value = input.value; + + if (!numberParser.isValidPartialNumber(value)) return; + + const parsedValue = numberParser.parse(value); + + if (isNaN(parsedValue)) return; + + const formattedValue = numberFormatter.format(parsedValue); + + if (value === formattedValue || !state.validate(formattedValue)) return; + + let digitCount = 0; + + for (let i = 0; i < (input.selectionStart ?? 0); i++) { + if (/\d/.test(value[i])) digitCount++; + } + + state.setInputValue(formattedValue); + state.setNumberValue(parsedValue); + + setTimeout(() => { + if (!input) return; + let currentDigitCount = 0; + let newCursorPos = 0; + + for (let i = 0; i < formattedValue.length; i++) { + if (/\d/.test(formattedValue[i])) currentDigitCount++; + if (currentDigitCount >= digitCount) { + newCursorPos = i + 1; + break; + } + } + input.setSelectionRange(newCursorPos, newCursorPos); + }, 0); + + if (onChange) { + onChange({ + target: {value: formattedValue}, + currentTarget: {value: formattedValue}, + } as React.ChangeEvent); + } + }, + [shouldFormat, numberParser, numberFormatter, state, onChange], + ); + + const handleCompositionEnd = useCallback( + (e: React.CompositionEvent) => { + isComposingRef.current = false; + handleInput(e as unknown as React.FormEvent); + }, + [handleInput], + ); + + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + e.preventDefault(); + const clipboardData = e.clipboardData.getData("text/plain"); + + if (!clipboardData) return; + + const input = domRef.current; + + if (!input) return; + + const {value, selectionStart, selectionEnd} = input; + const nextValue = + value.slice(0, selectionStart ?? 0) + clipboardData + value.slice(selectionEnd ?? 0); + + if (!numberParser.isValidPartialNumber(nextValue)) return; + const parsedValue = numberParser.parse(nextValue); + + if (isNaN(parsedValue)) return; + + const formattedValue = numberFormatter.format(parsedValue); + + if (!state.validate(formattedValue)) return; + + state.setInputValue(formattedValue); + state.setNumberValue(parsedValue); + + const digitCount = + (value.slice(0, selectionStart ?? 0).match(/\d/g) || []).length + + (clipboardData.match(/\d/g) || []).length; + + setTimeout(() => { + if (!input) return; + let currentDigitCount = 0; + let newCursorPos = 0; + + for (let i = 0; i < formattedValue.length; i++) { + if (/\d/.test(formattedValue[i])) currentDigitCount++; + if (currentDigitCount >= digitCount) { + newCursorPos = i + 1; + break; + } + } + input.setSelectionRange(newCursorPos, newCursorPos); + }, 0); + + if (onChange) { + onChange({ + target: {value: formattedValue}, + currentTarget: {value: formattedValue}, + } as React.ChangeEvent); + } + }, + [numberParser, numberFormatter, state, domRef, onChange], + ); + + const handleCut = useCallback( + (e: React.ClipboardEvent) => { + e.preventDefault(); + + const input = domRef.current; + + if (!input) return; + + const {value, selectionStart, selectionEnd} = input; + + if (selectionStart === selectionEnd) return; + + const cutText = value.slice(selectionStart ?? 0, selectionEnd ?? 0); + + e.clipboardData.setData("text/plain", cutText); + + const nextValue = value.slice(0, selectionStart ?? 0) + value.slice(selectionEnd ?? 0); + + if (!numberParser.isValidPartialNumber(nextValue)) return; + const parsedValue = numberParser.parse(nextValue); + + if (isNaN(parsedValue)) { + state.setInputValue(""); + state.setNumberValue(NaN); + + return; + } + + const formattedValue = numberFormatter.format(parsedValue); + + if (!state.validate(formattedValue)) return; + + state.setInputValue(formattedValue); + state.setNumberValue(parsedValue); + + const digitCount = (value.slice(0, selectionStart ?? 0).match(/\d/g) || []).length; + + setTimeout(() => { + if (!input) return; + let currentDigitCount = 0; + let newCursorPos = 0; + + for (let i = 0; i < formattedValue.length; i++) { + if (/\d/.test(formattedValue[i])) currentDigitCount++; + if (currentDigitCount >= digitCount) { + newCursorPos = i + 1; + break; + } + } + input.setSelectionRange(newCursorPos, newCursorPos); + }, 0); + + if (onChange) { + onChange({ + target: {value: formattedValue}, + currentTarget: {value: formattedValue}, + } as React.ChangeEvent); + } + }, + [numberParser, numberFormatter, state, domRef, onChange], + ); + + const handleBeforeInput = useCallback( + (e: React.FormEvent & {data: string | null}) => { + if (isComposingRef.current || (e.nativeEvent as any).isComposing) return; + if (!e.data) return; + + const input = domRef.current; + + if (!input) return; + + const {value, selectionStart, selectionEnd} = input; + const nextValue = + value.slice(0, selectionStart ?? 0) + e.data + value.slice(selectionEnd ?? 0); + + if (!numberParser.isValidPartialNumber(nextValue)) { + e.preventDefault(); + + return; + } + + const parsedValue = numberParser.parse(nextValue); + + if (isNaN(parsedValue)) { + e.preventDefault(); + + return; + } + + e.preventDefault(); + + const formattedValue = numberFormatter.format(parsedValue); + + if (!state.validate(formattedValue)) return; + + // Calculate the position of the cursor after formatting + // We count how many digits are before the cursor position in the unformatted value + the new digit + const digitCount = + (value.slice(0, selectionStart ?? 0).match(/\d/g) || []).length + + (e.data.match(/\d/g) || []).length; + + state.setInputValue(formattedValue); + state.setNumberValue(parsedValue); + + setTimeout(() => { + if (!input) return; + let currentDigitCount = 0; + let newCursorPos = 0; + + // Iterate through the formatted value to find the new cursor position + for (let i = 0; i < formattedValue.length; i++) { + if (/\d/.test(formattedValue[i])) currentDigitCount++; + if (currentDigitCount >= digitCount) { + newCursorPos = i + 1; + break; + } + } + input.setSelectionRange(newCursorPos, newCursorPos); + }, 0); + + if (onChange) { + onChange({ + target: {value: formattedValue}, + currentTarget: {value: formattedValue}, + } as React.ChangeEvent); + } + }, + [numberParser, numberFormatter, state, domRef, onChange], + ); + + return { + shouldFormat, + handleCompositionStart, + handleCompositionEnd, + handleInput, + handlePaste, + handleCut, + handleBeforeInput, + }; +} From 371b2537fd966dd15cae77416c91b279aba75375 Mon Sep 17 00:00:00 2001 From: hasegawa-101 Date: Thu, 18 Dec 2025 00:32:27 +0900 Subject: [PATCH 3/4] refactor(number-input): extract restoreCursorPosition to simplify logic --- .../__tests__/number-input.test.tsx | 2 + .../src/use-real-time-formatting.ts | 89 ++++++------------- 2 files changed, 30 insertions(+), 61 deletions(-) diff --git a/packages/components/number-input/__tests__/number-input.test.tsx b/packages/components/number-input/__tests__/number-input.test.tsx index 5fe25e63b4..29108155b7 100644 --- a/packages/components/number-input/__tests__/number-input.test.tsx +++ b/packages/components/number-input/__tests__/number-input.test.tsx @@ -813,11 +813,13 @@ describe("NumberInput Real-Time Formatting", () => { let setSelectionRangeSpy: jest.SpyInstance; beforeEach(() => { + jest.useFakeTimers(); setSelectionRangeSpy = jest.spyOn(HTMLInputElement.prototype, "setSelectionRange"); }); afterEach(() => { setSelectionRangeSpy.mockRestore(); + jest.useRealTimers(); }); it("should restore cursor correctly when appending a digit", async () => { diff --git a/packages/components/number-input/src/use-real-time-formatting.ts b/packages/components/number-input/src/use-real-time-formatting.ts index d998cae48f..faffcda054 100644 --- a/packages/components/number-input/src/use-real-time-formatting.ts +++ b/packages/components/number-input/src/use-real-time-formatting.ts @@ -24,6 +24,26 @@ export function useRealTimeInputFormatting(props: UseRealTimeInputFormattingProp isComposingRef.current = true; }, []); + const restoreCursorPosition = useCallback( + (input: HTMLInputElement, formattedValue: string, digitCount: number) => { + setTimeout(() => { + if (!input) return; + let currentDigitCount = 0; + let newCursorPos = 0; + + for (let i = 0; i < formattedValue.length; i++) { + if (/\d/.test(formattedValue[i])) currentDigitCount++; + if (currentDigitCount >= digitCount) { + newCursorPos = i + 1; + break; + } + } + input.setSelectionRange(newCursorPos, newCursorPos); + }, 0); + }, + [], + ); + const handleInput = useCallback( (e: React.FormEvent) => { if (isComposingRef.current || !shouldFormat) return; @@ -50,20 +70,7 @@ export function useRealTimeInputFormatting(props: UseRealTimeInputFormattingProp state.setInputValue(formattedValue); state.setNumberValue(parsedValue); - setTimeout(() => { - if (!input) return; - let currentDigitCount = 0; - let newCursorPos = 0; - - for (let i = 0; i < formattedValue.length; i++) { - if (/\d/.test(formattedValue[i])) currentDigitCount++; - if (currentDigitCount >= digitCount) { - newCursorPos = i + 1; - break; - } - } - input.setSelectionRange(newCursorPos, newCursorPos); - }, 0); + restoreCursorPosition(input, formattedValue, digitCount); if (onChange) { onChange({ @@ -72,7 +79,7 @@ export function useRealTimeInputFormatting(props: UseRealTimeInputFormattingProp } as React.ChangeEvent); } }, - [shouldFormat, numberParser, numberFormatter, state, onChange], + [shouldFormat, numberParser, numberFormatter, state, onChange, restoreCursorPosition], ); const handleCompositionEnd = useCallback( @@ -114,20 +121,7 @@ export function useRealTimeInputFormatting(props: UseRealTimeInputFormattingProp (value.slice(0, selectionStart ?? 0).match(/\d/g) || []).length + (clipboardData.match(/\d/g) || []).length; - setTimeout(() => { - if (!input) return; - let currentDigitCount = 0; - let newCursorPos = 0; - - for (let i = 0; i < formattedValue.length; i++) { - if (/\d/.test(formattedValue[i])) currentDigitCount++; - if (currentDigitCount >= digitCount) { - newCursorPos = i + 1; - break; - } - } - input.setSelectionRange(newCursorPos, newCursorPos); - }, 0); + restoreCursorPosition(input, formattedValue, digitCount); if (onChange) { onChange({ @@ -136,7 +130,7 @@ export function useRealTimeInputFormatting(props: UseRealTimeInputFormattingProp } as React.ChangeEvent); } }, - [numberParser, numberFormatter, state, domRef, onChange], + [numberParser, numberFormatter, state, domRef, onChange, restoreCursorPosition], ); const handleCut = useCallback( @@ -176,20 +170,7 @@ export function useRealTimeInputFormatting(props: UseRealTimeInputFormattingProp const digitCount = (value.slice(0, selectionStart ?? 0).match(/\d/g) || []).length; - setTimeout(() => { - if (!input) return; - let currentDigitCount = 0; - let newCursorPos = 0; - - for (let i = 0; i < formattedValue.length; i++) { - if (/\d/.test(formattedValue[i])) currentDigitCount++; - if (currentDigitCount >= digitCount) { - newCursorPos = i + 1; - break; - } - } - input.setSelectionRange(newCursorPos, newCursorPos); - }, 0); + restoreCursorPosition(input, formattedValue, digitCount); if (onChange) { onChange({ @@ -198,7 +179,7 @@ export function useRealTimeInputFormatting(props: UseRealTimeInputFormattingProp } as React.ChangeEvent); } }, - [numberParser, numberFormatter, state, domRef, onChange], + [numberParser, numberFormatter, state, domRef, onChange, restoreCursorPosition], ); const handleBeforeInput = useCallback( @@ -243,21 +224,7 @@ export function useRealTimeInputFormatting(props: UseRealTimeInputFormattingProp state.setInputValue(formattedValue); state.setNumberValue(parsedValue); - setTimeout(() => { - if (!input) return; - let currentDigitCount = 0; - let newCursorPos = 0; - - // Iterate through the formatted value to find the new cursor position - for (let i = 0; i < formattedValue.length; i++) { - if (/\d/.test(formattedValue[i])) currentDigitCount++; - if (currentDigitCount >= digitCount) { - newCursorPos = i + 1; - break; - } - } - input.setSelectionRange(newCursorPos, newCursorPos); - }, 0); + restoreCursorPosition(input, formattedValue, digitCount); if (onChange) { onChange({ @@ -266,7 +233,7 @@ export function useRealTimeInputFormatting(props: UseRealTimeInputFormattingProp } as React.ChangeEvent); } }, - [numberParser, numberFormatter, state, domRef, onChange], + [numberParser, numberFormatter, state, domRef, onChange, restoreCursorPosition], ); return { From c3e41ed9139de609aacf2a0e5a426db5e0091737 Mon Sep 17 00:00:00 2001 From: hasegawa-101 Date: Thu, 18 Dec 2025 01:03:45 +0900 Subject: [PATCH 4/4] refactor(number-input): simplify shouldFormat logic and testing teardown --- .../number-input/__tests__/number-input.test.tsx | 10 ++++------ .../number-input/src/use-real-time-formatting.ts | 8 +++----- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/components/number-input/__tests__/number-input.test.tsx b/packages/components/number-input/__tests__/number-input.test.tsx index 29108155b7..a50f6dedac 100644 --- a/packages/components/number-input/__tests__/number-input.test.tsx +++ b/packages/components/number-input/__tests__/number-input.test.tsx @@ -813,13 +813,11 @@ describe("NumberInput Real-Time Formatting", () => { let setSelectionRangeSpy: jest.SpyInstance; beforeEach(() => { - jest.useFakeTimers(); setSelectionRangeSpy = jest.spyOn(HTMLInputElement.prototype, "setSelectionRange"); }); afterEach(() => { setSelectionRangeSpy.mockRestore(); - jest.useRealTimers(); }); it("should restore cursor correctly when appending a digit", async () => { @@ -848,7 +846,7 @@ describe("NumberInput Real-Time Formatting", () => { // 1 (1) 2 (2) , (x) 3 (3) 4 (4) 5 (5) -> Index 6 await act(async () => { - jest.runAllTimers(); + await new Promise((resolve) => setTimeout(resolve, 0)); }); expect(input.value).toBe("12,345"); @@ -880,7 +878,7 @@ describe("NumberInput Real-Time Formatting", () => { // 5 (1) -> Index 1 await act(async () => { - jest.runAllTimers(); + await new Promise((resolve) => setTimeout(resolve, 0)); }); expect(input.value).toBe("51,234"); @@ -914,7 +912,7 @@ describe("NumberInput Real-Time Formatting", () => { // Index 2 await act(async () => { - jest.runAllTimers(); + await new Promise((resolve) => setTimeout(resolve, 0)); }); expect(input.value).toBe("15,234"); @@ -952,7 +950,7 @@ describe("NumberInput Real-Time Formatting", () => { // Index of '5' is 2. So cursor at 3. await act(async () => { - jest.runAllTimers(); + await new Promise((resolve) => setTimeout(resolve, 0)); }); expect(input.value).toContain("15,234"); diff --git a/packages/components/number-input/src/use-real-time-formatting.ts b/packages/components/number-input/src/use-real-time-formatting.ts index faffcda054..d24758069f 100644 --- a/packages/components/number-input/src/use-real-time-formatting.ts +++ b/packages/components/number-input/src/use-real-time-formatting.ts @@ -1,7 +1,7 @@ import type {NumberParser} from "@internationalized/number"; import type {NumberFieldState} from "@react-stately/numberfield"; -import {useCallback, useMemo, useRef} from "react"; +import {useCallback, useRef} from "react"; export interface UseRealTimeInputFormattingProps { isRealTimeFormat: boolean; @@ -16,9 +16,7 @@ export function useRealTimeInputFormatting(props: UseRealTimeInputFormattingProp const {isRealTimeFormat, numberParser, numberFormatter, state, domRef, onChange} = props; const isComposingRef = useRef(false); - const shouldFormat = useMemo(() => { - return Boolean(isRealTimeFormat); - }, [isRealTimeFormat]); + const shouldFormat = Boolean(isRealTimeFormat); const handleCompositionStart = useCallback(() => { isComposingRef.current = true; @@ -184,7 +182,7 @@ export function useRealTimeInputFormatting(props: UseRealTimeInputFormattingProp const handleBeforeInput = useCallback( (e: React.FormEvent & {data: string | null}) => { - if (isComposingRef.current || (e.nativeEvent as any).isComposing) return; + if (isComposingRef.current || (e.nativeEvent as InputEvent)?.isComposing) return; if (!e.data) return; const input = domRef.current;