From 6b533e7303cb4cd39e2404f4cb4b42338cbc9ada Mon Sep 17 00:00:00 2001
From: Kacper Szarkiewicz <43585069+Sharqiewicz@users.noreply.github.com>
Date: Mon, 30 Sep 2024 15:36:46 +0100
Subject: [PATCH] Fix/numericinput edge cases (#561)
* add test cases NumericInput
* fix dot handling NumericInput
* add test case NumericInput
* handle onPaste and unify dot placement
* change onPaste NumericInput behaviour
* change test description
* fix cursor position after paste
* change onKeyPress to onKeyDown, keypress has been deprecated
* implement handleOnPaste
---
.../From/NumericInput/NumericInput.test.tsx | 85 +++++++++++++++++++
.../Form/From/NumericInput/helpers.ts | 54 +++++++++++-
.../Form/From/NumericInput/index.tsx | 15 +++-
3 files changed, 149 insertions(+), 5 deletions(-)
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 (
-