diff --git a/packages/components/number-input/__tests__/number-input.test.tsx b/packages/components/number-input/__tests__/number-input.test.tsx index 7b6cdc793c..a50f6dedac 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 () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + 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 () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + 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 () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + 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 () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + 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/package.json b/packages/components/number-input/package.json index 13c7dac07a..a4e3bd27f1 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 3b3d80db87..ecd17861d2 100644 --- a/packages/components/number-input/src/use-number-input.ts +++ b/packages/components/number-input/src/use-number-input.ts @@ -14,9 +14,12 @@ import {useLocale} from "@react-aria/i18n"; import {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"; +import {useRealTimeInputFormatting} from "./use-real-time-formatting"; + export interface Props extends Omit, keyof NumberInputVariantProps> { /** * Ref to the DOM node. @@ -79,6 +82,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. */ @@ -253,51 +262,85 @@ export function useNumberInput(originalProps: UseNumberInputProps) { ], ); + 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]); + + // 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 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 ( + const {key} = e; + + if (key === "ArrowUp") { + e.preventDefault(); + state.increment(); + } else if (key === "ArrowDown") { + e.preventDefault(); + state.decrement(); + } + + const input = e.currentTarget; + const {selectionStart, selectionEnd, value} = input; + + // Restore original Backspace logic for comma/grouping separator navigation + // This is required for existing tests and proper behavior across all modes if ( - e.key === "Backspace" && + key === "Backspace" && !originalProps.isReadOnly && !originalProps.isDisabled && selectionStart !== null && selectionEnd !== null && selectionStart === selectionEnd && - selectionStart > 0 && - value[selectionStart] === groupChar && - value[selectionStart - 1] !== groupChar + selectionStart > 0 ) { - 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); - } - } + const groupChar = + numberFormatter.formatToParts(1000).find((p) => p.type === "group")?.value ?? ","; - setTimeout(() => { - // set the new cursor position - const pos = Math.max(0, selectionStart - 1); + 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, ""); - inputElement.setSelectionRange(pos, pos); - }, 0); + if (cleanValue === "" || cleanValue === "-") { + state.setInputValue(""); + state.setNumberValue(NaN); + } else { + const numberValue = parseFloat(cleanValue); + + if (!isNaN(numberValue)) { + state.setNumberValue(numberValue); + + setTimeout(() => { + const pos = Math.max(0, selectionStart - 1); + + input.setSelectionRange(pos, pos); + }, 0); + } + } + } } else if ( - e.key === "Escape" && + key === "Escape" && inputValue && (isClearable || onClear) && !originalProps.isReadOnly @@ -306,7 +349,16 @@ export function useNumberInput(originalProps: UseNumberInputProps) { onClear?.(); } }, - [inputValue, state, onClear, isClearable, originalProps.isReadOnly], + [ + inputValue, + state, + onClear, + isClearable, + originalProps.isReadOnly, + originalProps.isRealTimeFormat, + numberParser, + numberFormatter, + ], ); const getBaseProps: PropGetter = useCallback( @@ -392,6 +444,17 @@ export function useNumberInput(originalProps: UseNumberInputProps) { }), props, ), + // 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), @@ -408,9 +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..d24758069f --- /dev/null +++ b/packages/components/number-input/src/use-real-time-formatting.ts @@ -0,0 +1,246 @@ +import type {NumberParser} from "@internationalized/number"; +import type {NumberFieldState} from "@react-stately/numberfield"; + +import {useCallback, 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 = Boolean(isRealTimeFormat); + + const handleCompositionStart = useCallback(() => { + 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; + + 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); + + restoreCursorPosition(input, formattedValue, digitCount); + + if (onChange) { + onChange({ + target: {value: formattedValue}, + currentTarget: {value: formattedValue}, + } as React.ChangeEvent); + } + }, + [shouldFormat, numberParser, numberFormatter, state, onChange, restoreCursorPosition], + ); + + 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; + + restoreCursorPosition(input, formattedValue, digitCount); + + if (onChange) { + onChange({ + target: {value: formattedValue}, + currentTarget: {value: formattedValue}, + } as React.ChangeEvent); + } + }, + [numberParser, numberFormatter, state, domRef, onChange, restoreCursorPosition], + ); + + 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; + + restoreCursorPosition(input, formattedValue, digitCount); + + if (onChange) { + onChange({ + target: {value: formattedValue}, + currentTarget: {value: formattedValue}, + } as React.ChangeEvent); + } + }, + [numberParser, numberFormatter, state, domRef, onChange, restoreCursorPosition], + ); + + const handleBeforeInput = useCallback( + (e: React.FormEvent & {data: string | null}) => { + if (isComposingRef.current || (e.nativeEvent as InputEvent)?.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); + + restoreCursorPosition(input, formattedValue, digitCount); + + if (onChange) { + onChange({ + target: {value: formattedValue}, + currentTarget: {value: formattedValue}, + } as React.ChangeEvent); + } + }, + [numberParser, numberFormatter, state, domRef, onChange, restoreCursorPosition], + ); + + return { + shouldFormat, + handleCompositionStart, + handleCompositionEnd, + handleInput, + handlePaste, + handleCut, + handleBeforeInput, + }; +} diff --git a/packages/components/number-input/stories/number-input.stories.tsx b/packages/components/number-input/stories/number-input.stories.tsx index 7311a50fcd..0c0b91613b 100644 --- a/packages/components/number-input/stories/number-input.stories.tsx +++ b/packages/components/number-input/stories/number-input.stories.tsx @@ -538,6 +538,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 453012c74d..da374c75a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2039,6 +2039,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)