Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix/numericinput edge cases #561

Merged
merged 10 commits into from
Sep 30, 2024
85 changes: 85 additions & 0 deletions src/components/Form/From/NumericInput/NumericInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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(<NumericInput register={mockRegister} />);
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(<NumericInput register={mockRegister} />);
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(<NumericInput register={mockRegister} />);
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(<NumericInput register={mockRegister} />);
const inputElement = getByPlaceholderText('0.0') as HTMLInputElement;

await userEvent.type(inputElement, '...........');
expect(inputElement.value).toBe('.');
});

it('should paste properly', async () => {
const { getByPlaceholderText } = render(<NumericInput register={mockRegister} maxDecimals={3} />);
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);
},
);
});
39 changes: 38 additions & 1 deletion src/components/Form/From/NumericInput/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '.');

/**
Expand All @@ -16,7 +22,38 @@ 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);
}

function alreadyHasDecimal(e: KeyboardEvent) {
const decimalChars = ['.', ','];
return decimalChars.some((char) => e.key === char && e.target && (e.target as HTMLInputElement).value.includes('.'));
}

export function handleOnKeyPressNumericInput(e: KeyboardEvent): void {
if (alreadyHasDecimal(e)) {
e.preventDefault();
}
}

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;
inputElement.setSelectionRange(newCursorPosition, newCursorPosition);

return trimToMaxDecimals(sanitizedValue, maxDecimals);
}
10 changes: 6 additions & 4 deletions src/components/Form/From/NumericInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, handleOnKeyPressNumericInput, handleOnPasteNumericInput } from './helpers';

interface NumericInputProps {
register: UseFormRegisterReturn;
Expand All @@ -27,19 +27,21 @@ export const NumericInput = ({
}

return (
<div className="flex justify-between w-full">
<div className="flex-grow text-4xl text-black font-outfit">
<div className="flex w-full justify-between">
<div className="font-outfit flex-grow text-4xl text-black">
<Input
{...register}
autocomplete="off"
autocorrect="off"
autocapitalize="none"
className={
'input-ghost w-full text-4xl font-outfit pl-0 focus:outline-none focus:text-accent-content text-accent-content ' +
'font-outfit input-ghost w-full pl-0 text-4xl text-accent-content focus:text-accent-content focus:outline-none ' +
additionalStyle
}
minlength="1"
onChange={handleOnChange}
onKeyPress={handleOnKeyPressNumericInput}
onPaste={(e: ClipboardEvent) => handleOnPasteNumericInput(e, maxDecimals)}
pattern="^[0-9]*[.,]?[0-9]*$"
placeholder="0.0"
readOnly={readOnly}
Expand Down
Loading