Skip to content

Commit

Permalink
Fix/numericinput edge cases (#561)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Sharqiewicz authored Sep 30, 2024
1 parent 54f0113 commit 6b533e7
Show file tree
Hide file tree
Showing 3 changed files with 149 additions and 5 deletions.
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);
},
);
});
54 changes: 53 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,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);
}
15 changes: 11 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, handleOnKeyDownNumericInput, handleOnPasteNumericInput } from './helpers';

interface NumericInputProps {
register: UseFormRegisterReturn;
Expand All @@ -26,20 +26,27 @@ export const NumericInput = ({
register.onChange(e);
}

function handleOnPaste(e: ClipboardEvent): void {
handleOnPasteNumericInput(e, maxDecimals);
register.onChange(e);
}

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}
onKeyDown={handleOnKeyDownNumericInput}
onPaste={handleOnPaste}
pattern="^[0-9]*[.,]?[0-9]*$"
placeholder="0.0"
readOnly={readOnly}
Expand Down

0 comments on commit 6b533e7

Please sign in to comment.