diff --git a/.changeset/gentle-owls-arrive.md b/.changeset/gentle-owls-arrive.md new file mode 100644 index 0000000000..25fa7f3adf --- /dev/null +++ b/.changeset/gentle-owls-arrive.md @@ -0,0 +1,5 @@ +--- +"@heroui/number-input": patch +--- + +fix backspace behavior with formatted numbers (#5712) diff --git a/packages/components/number-input/__tests__/number-input.test.tsx b/packages/components/number-input/__tests__/number-input.test.tsx index fb111f6e99..bb7eb18d82 100644 --- a/packages/components/number-input/__tests__/number-input.test.tsx +++ b/packages/components/number-input/__tests__/number-input.test.tsx @@ -592,5 +592,63 @@ describe("NumberInput with React Hook Form", () => { await user.keyboard("1234"); }); }); + + describe("Backspace behavior with formatted numbers", () => { + it("should handle backspace when cursor is between first digit and comma", async () => { + const {container} = render( + , + ); + + const input = container.querySelector("input[type='text']") as HTMLInputElement; + + expect(input.value).toBe("1,234"); + + act(() => { + input.focus(); + input.setSelectionRange(1, 1); + }); + + act(() => { + fireEvent.keyDown(input, {key: "Backspace", code: "Backspace"}); + }); + + expect(input.value).toBe("234"); + }); + + it("should handle backspace for other formatted number scenarios", async () => { + const {container} = render( + , + ); + + const input = container.querySelector("input[type='text']") as HTMLInputElement; + + expect(input.value).toBe("1,234,567"); + + act(() => { + input.focus(); + input.setSelectionRange(5, 5); + }); + + act(() => { + fireEvent.keyDown(input, {key: "Backspace", code: "Backspace"}); + }); + + expect(input.value).toBe("123,567"); + }); + }); }); }); diff --git a/packages/components/number-input/src/use-number-input.ts b/packages/components/number-input/src/use-number-input.ts index fe9be28e00..3c4e5d84b9 100644 --- a/packages/components/number-input/src/use-number-input.ts +++ b/packages/components/number-input/src/use-number-input.ts @@ -242,7 +242,48 @@ 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) && @@ -252,7 +293,7 @@ export function useNumberInput(originalProps: UseNumberInputProps) { onClear?.(); } }, - [inputValue, state.setInputValue, onClear, isClearable, originalProps.isReadOnly], + [inputValue, state, onClear, isClearable, originalProps.isReadOnly], ); const getBaseProps: PropGetter = useCallback(