diff --git a/src/components/Form/From/NumericInput/NumericInput.test.tsx b/src/components/Form/From/NumericInput/NumericInput.test.tsx index 1771b930..1c0944c2 100644 --- a/src/components/Form/From/NumericInput/NumericInput.test.tsx +++ b/src/components/Form/From/NumericInput/NumericInput.test.tsx @@ -3,6 +3,7 @@ import { UseFormRegisterReturn } from 'react-hook-form'; import { render } from '@testing-library/preact'; import userEvent from '@testing-library/user-event'; import { NumericInput } from '.'; +import { handleOnPasteNumericInput } from './helpers'; const mockRegister: UseFormRegisterReturn = { name: 'testInput', @@ -153,4 +154,88 @@ describe('NumericInput Component', () => { await userEvent.paste('123.4567890123456789abcgdehyu0123456.2746472.93.2.7.3.5.3'); expect(inputElement.value).toBe('123.456789012345'); }); + + it('Should not cut the number if user is trying to type more than one "."', async () => { + const { getByPlaceholderText } = render(); + const inputElement = getByPlaceholderText('0.0') as HTMLInputElement; + + await userEvent.type(inputElement, '0.23'); + await userEvent.keyboard('{arrowleft}.'); + expect(inputElement.value).toBe('0.23'); + }); + + it('Should not cut the number and do not move . position', async () => { + const { getByPlaceholderText } = render(); + const inputElement = getByPlaceholderText('0.0') as HTMLInputElement; + + await userEvent.type(inputElement, '12.34'); + await userEvent.keyboard('{arrowleft}{arrowleft}{arrowleft}{arrowleft}.'); + expect(inputElement.value).toBe('12.34'); + }); + + it('Should not paste the number if more than one .', async () => { + const { getByPlaceholderText } = render(); + const inputElement = getByPlaceholderText('0.0') as HTMLInputElement; + + await userEvent.paste('12.34.56'); + expect(inputElement.value).toBe(''); + }); + + it('should accept only one "."', async () => { + const { getByPlaceholderText } = render(); + const inputElement = getByPlaceholderText('0.0') as HTMLInputElement; + + await userEvent.type(inputElement, '...........'); + expect(inputElement.value).toBe('.'); + }); + + it('should paste properly', async () => { + const { getByPlaceholderText } = render(); + const inputElement = getByPlaceholderText('0.0') as HTMLInputElement; + + await userEvent.type(inputElement, '123'); + await userEvent.keyboard('{arrowleft}{arrowleft}{arrowleft}'); + await userEvent.paste('4'); + expect(inputElement.value).toBe('4123'); + }); +}); + +describe('NumericInput onPaste should sanitize the user input', () => { + const testCases = [ + { input: '1.......4.....2', maxLength: 8, expected: '1.42' }, + { input: '12....34.....56', maxLength: 8, expected: '12.3456' }, + { input: '....56789...', maxLength: 5, expected: '.56789' }, + { input: '1.23..4..56.', maxLength: 6, expected: '1.23456' }, + { input: '1.....2', maxLength: 8, expected: '1.2' }, + { input: '123..4...56.7', maxLength: 7, expected: '123.4567' }, + { input: 'a.b.c.123.4.def56', maxLength: 8, expected: '.123456' }, + { input: '12abc34....def567', maxLength: 2, expected: '1234.56' }, + { input: '.....a.b.c......', maxLength: 8, expected: '.' }, + { input: '12.....3..4..5abc6', maxLength: 7, expected: '12.3456' }, + { input: '1a2b3c4d5e.1234567', maxLength: 4, expected: '12345.1234' }, + { input: '12abc@#34..def$%^567', maxLength: 2, expected: '1234.56' }, + { input: '....!@#$$%^&*((', maxLength: 8, expected: '.' }, + { input: '123....abc.def456ghi789', maxLength: 4, expected: '123.4567' }, + { input: '00.00123...4', maxLength: 4, expected: '00.0012' }, + { input: '.1...2.67.865', maxLength: 3, expected: '.126' }, + { input: '123abc...', maxLength: 6, expected: '123.' }, + ]; + + test.each(testCases)( + 'should sanitize the pasted input with maxLength (decimal)', + ({ input, maxLength, expected }) => { + const mockEvent = { + target: { + setSelectionRange: jest.fn(), + value: '', + }, + preventDefault: jest.fn(), + clipboardData: { + getData: jest.fn().mockReturnValue(input), + }, + } as unknown as ClipboardEvent; + + expect(handleOnPasteNumericInput(mockEvent, maxLength)).toBe(expected); + }, + ); }); diff --git a/src/components/Form/From/NumericInput/helpers.ts b/src/components/Form/From/NumericInput/helpers.ts index 77df3f36..2c7f4514 100644 --- a/src/components/Form/From/NumericInput/helpers.ts +++ b/src/components/Form/From/NumericInput/helpers.ts @@ -2,6 +2,12 @@ import { trimToMaxDecimals } from '../../../../shared/parseNumbers/maxDecimals'; const removeNonNumericCharacters = (value: string): string => value.replace(/[^0-9.]/g, ''); +const removeExtraDots = (value: string): string => value.replace(/(\..*?)\./g, '$1'); + +function sanitizeNumericInput(value: string): string { + return removeExtraDots(removeNonNumericCharacters(value)); +} + const replaceCommasWithDots = (value: string): string => value.replace(/,/g, '.'); /** @@ -16,7 +22,53 @@ export function handleOnChangeNumericInput(e: KeyboardEvent, maxDecimals: number target.value = replaceCommasWithDots(target.value); - target.value = removeNonNumericCharacters(target.value); + target.value = sanitizeNumericInput(target.value); target.value = trimToMaxDecimals(target.value, maxDecimals); } + +/** + * Checks if the input already has a decimal point and prevents the user from entering another one. + * Why onKeyDown? Because it is triggered before the character is processed and added to the input value. + */ + +function alreadyHasDecimal(e: KeyboardEvent) { + const decimalChars = ['.', ',']; + return decimalChars.some((char) => e.key === char && e.target && (e.target as HTMLInputElement).value.includes('.')); +} + +export function handleOnKeyDownNumericInput(e: KeyboardEvent): void { + if (alreadyHasDecimal(e)) { + e.preventDefault(); + } +} + +/** + * Handles the paste event to ensure the value does not exceed the maximum number of decimal places, + * replaces commas with dots, and removes invalid non-numeric characters. + * + * @param e - The clipboard event triggered by the input. + * @param maxDecimals - The maximum number of decimal places allowed. + * @returns The sanitized value after the paste event. + */ + +export function handleOnPasteNumericInput(e: ClipboardEvent, maxDecimals: number): string { + const inputElement = e.target as HTMLInputElement; + const { value, selectionStart, selectionEnd } = inputElement; + + const clipboardData = sanitizeNumericInput(e.clipboardData?.getData('text/plain') || ''); + + const combinedValue = value.slice(0, selectionStart || 0) + clipboardData + value.slice(selectionEnd || 0); + + const [integerPart, ...decimalParts] = combinedValue.split('.'); + const sanitizedValue = integerPart + (decimalParts.length > 0 ? '.' + decimalParts.join('') : ''); + + e.preventDefault(); + inputElement.value = trimToMaxDecimals(sanitizedValue, maxDecimals); + + const newCursorPosition = + (selectionStart || 0) + clipboardData.length - (combinedValue.length - sanitizedValue.length); + inputElement.setSelectionRange(newCursorPosition, newCursorPosition); + + return trimToMaxDecimals(sanitizedValue, maxDecimals); +} diff --git a/src/components/Form/From/NumericInput/index.tsx b/src/components/Form/From/NumericInput/index.tsx index 1b8aacb8..2fd14e29 100644 --- a/src/components/Form/From/NumericInput/index.tsx +++ b/src/components/Form/From/NumericInput/index.tsx @@ -2,7 +2,7 @@ import { Input } from 'react-daisyui'; import { UseFormRegisterReturn } from 'react-hook-form'; import { USER_INPUT_MAX_DECIMALS } from '../../../../shared/parseNumbers/maxDecimals'; -import { handleOnChangeNumericInput } from './helpers'; +import { handleOnChangeNumericInput, handleOnKeyDownNumericInput, handleOnPasteNumericInput } from './helpers'; interface NumericInputProps { register: UseFormRegisterReturn; @@ -26,20 +26,27 @@ export const NumericInput = ({ register.onChange(e); } + function handleOnPaste(e: ClipboardEvent): void { + handleOnPasteNumericInput(e, maxDecimals); + register.onChange(e); + } + return ( -
-
+
+