Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 36 additions & 10 deletions src/components/NumberInput/NumberInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import * as React from 'react';

import isNumber from 'lodash/isNumber';

import {KeyCode} from '../../constants';
import {useControlledState, useForkRef} from '../../hooks';
import {useFormResetHandler} from '../../hooks/private';
Expand All @@ -18,6 +20,7 @@ import {
getInternalState,
getParsedValue,
getPossibleNumberSubstring,
truncateExtraDecimalNumbers,
updateCursorPosition,
} from './utils';

Expand Down Expand Up @@ -69,6 +72,8 @@ export interface NumberInputProps
* @default false
*/
allowDecimal?: boolean;
/** Maximum number of digits allowed after the decimal point */
decimalScale?: number;
/** The control's value */
value?: number | null;
/** The control's default value. Use when the component is not controlled */
Expand All @@ -77,8 +82,20 @@ export interface NumberInputProps
onUpdate?: (value: number | null) => void;
}

function getStringValue(value: number | null) {
return value === null ? '' : String(value);
function getStringValue(value: number | null, isAllowDecimal: boolean, decimalScale?: number) {
if (!isNumber(value)) {
return '';
}

if (!isAllowDecimal) {
return String(Math.floor(value));
}

if (isNumber(decimalScale)) {
return value.toFixed(decimalScale);
}

return String(value);
}

export const NumberInput = React.forwardRef<HTMLSpanElement, NumberInputProps>(function NumberInput(
Expand All @@ -101,6 +118,7 @@ export const NumberInput = React.forwardRef<HTMLSpanElement, NumberInputProps>(f
onBlur,
onKeyDown,
allowDecimal = false,
decimalScale: initialDecimalScale,
className,
} = props;

Expand All @@ -111,12 +129,14 @@ export const NumberInput = React.forwardRef<HTMLSpanElement, NumberInputProps>(f
value: internalValue,
defaultValue,
shiftMultiplier,
decimalScale,
} = getInternalState({
min: externalMin,
max: externalMax,
step: externalStep,
shiftMultiplier: externalShiftMultiplier,
allowDecimal,
decimalScale: initialDecimalScale,
value: externalValue,
defaultValue: externalDefaultValue,
});
Expand All @@ -127,14 +147,16 @@ export const NumberInput = React.forwardRef<HTMLSpanElement, NumberInputProps>(f
externalOnUpdate,
);

const [inputValue, setInputValue] = React.useState(getStringValue(value));
const [inputValue, setInputValue] = React.useState(
getStringValue(value, allowDecimal, decimalScale),
);

React.useEffect(() => {
const stringPropsValue = getStringValue(value);
const stringPropsValue = getStringValue(value, allowDecimal, decimalScale);
if (!areStringRepresentationOfNumbersEqual(inputValue, stringPropsValue)) {
setInputValue(stringPropsValue);
}
}, [value, inputValue]);
}, [value, inputValue, allowDecimal, decimalScale]);

const clamp = !(allowDecimal && !externalStep);

Expand Down Expand Up @@ -171,7 +193,7 @@ export const NumberInput = React.forwardRef<HTMLSpanElement, NumberInputProps>(f
direction,
});
setValue?.(newValue);
setInputValue(newValue.toString());
setInputValue(getStringValue(newValue, allowDecimal, decimalScale));
}
};

Expand Down Expand Up @@ -216,15 +238,19 @@ export const NumberInput = React.forwardRef<HTMLSpanElement, NumberInputProps>(f
if (value !== clampedValue) {
setValue?.(clampedValue);
}
setInputValue(clampedValue.toString());
setInputValue(getStringValue(clampedValue, allowDecimal, decimalScale));
} else if (isNumber(value)) {
setInputValue(getStringValue(value, allowDecimal, decimalScale));
}

onBlur?.(e);
};

const handleUpdate = (v: string) => {
setInputValue(v);
const preparedStringValue = getPossibleNumberSubstring(v, allowDecimal);
updateCursorPosition(innerControlRef, v, preparedStringValue);
const formattedValue = truncateExtraDecimalNumbers(v, decimalScale);
setInputValue(formattedValue);
const preparedStringValue = getPossibleNumberSubstring(formattedValue, allowDecimal);
updateCursorPosition(innerControlRef, formattedValue, preparedStringValue);
const {valid, value: parsedNumberValue} = getParsedValue(preparedStringValue);
if (valid && parsedNumberValue !== value) {
setValue?.(parsedNumberValue);
Expand Down
232 changes: 232 additions & 0 deletions src/components/NumberInput/__tests__/NumberInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,11 @@ describe('NumberInput input', () => {

expect(handleUpdate).not.toHaveBeenCalled();
});

it('rounds float number down if not allowDecimal prop', () => {
render(<NumberInput value={100.8} />);
expect(getInput()).toHaveValue('100');
});
});

describe('min/max', () => {
Expand Down Expand Up @@ -328,6 +333,208 @@ describe('NumberInput input', () => {
});
});

describe('decimalScale', () => {
it('adds decimal places to integer number', () => {
render(<NumberInput allowDecimal decimalScale={2} value={100} />);
expect(getInput()).toHaveValue('100.00');
});

it('not adds decimal places to integer number without allowDecimal prop', () => {
render(<NumberInput decimalScale={2} value={100} />);
expect(getInput()).toHaveValue('100');
});

it('adds decimal places to float number', () => {
render(<NumberInput allowDecimal decimalScale={4} value={100.12} />);
expect(getInput()).toHaveValue('100.1200');
});

it('not adds decimal places to float number without allowDecimal prop', () => {
render(<NumberInput decimalScale={4} value={100.12} />);
expect(getInput()).toHaveValue('100');
});

it('empty input when not value prop', async () => {
render(<NumberInput allowDecimal decimalScale={2} />);
expect(getInput()).toHaveValue('');
});

it('render correct origin float', async () => {
render(<NumberInput allowDecimal decimalScale={2} value={100.12} />);
expect(getInput()).toHaveValue('100.12');
});

it('shows a correctly rounded number', async () => {
const {rerender} = render(
<NumberInput allowDecimal decimalScale={2} value={100.124} />,
);
expect(getInput()).toHaveValue('100.12');

rerender(<NumberInput allowDecimal decimalScale={2} value={100.125} />);
expect(getInput()).toHaveValue('100.13');
});

it('calls onUpdate on change', async () => {
const handleUpdate = jest.fn();
render(<NumberInput allowDecimal decimalScale={2} onUpdate={handleUpdate} />);

fireEvent.change(getInput(), {target: {value: '1.50'}});

expect(handleUpdate).toHaveBeenLastCalledWith(1.5);
expect(getInput()).toHaveValue('1.50');
});

it('calls onUpdate with a truncated number', async () => {
const handleUpdate = jest.fn();

render(<NumberInput allowDecimal decimalScale={2} onUpdate={handleUpdate} />);

fireEvent.change(getInput(), {target: {value: '100.348'}});

expect(handleUpdate).toHaveBeenLastCalledWith(100.34);
});

it('restricts input of extra decimal digits', async () => {
const user = userEvent.setup();
const handleUpdate = jest.fn();

render(<NumberInput allowDecimal decimalScale={2} onUpdate={handleUpdate} />);

await user.type(getInput(), '1.1284');
expect(handleUpdate).toHaveBeenLastCalledWith(1.12);
expect(getInput()).toHaveValue('1.12');
});

it('shows correct value after blur', async () => {
const user = userEvent.setup();
const handleUpdate = jest.fn();

render(<NumberInput allowDecimal decimalScale={2} onUpdate={handleUpdate} />);

await user.type(getInput(), '1.1');
act(() => {
getInput().blur();
});

expect(handleUpdate).toHaveBeenLastCalledWith(1.1);
expect(getInput()).toHaveValue('1.10');
});

it('normalizes invalid decimalScale values to 0', () => {
const {rerender} = render(<NumberInput allowDecimal decimalScale={-2} value={100} />);
expect(getInput()).toHaveValue('100');

rerender(<NumberInput allowDecimal decimalScale={Infinity} value={100} />);
expect(getInput()).toHaveValue('100');

rerender(<NumberInput allowDecimal decimalScale={-Infinity} value={100} />);
expect(getInput()).toHaveValue('100');

rerender(<NumberInput allowDecimal decimalScale={NaN} value={100} />);
expect(getInput()).toHaveValue('100');
});
});

describe('decimalScale and step', () => {
it('increases value by an integer on arrowUp button click', async () => {
const user = userEvent.setup();
const handleUpdate = jest.fn();
render(
<NumberInput value={2.456} allowDecimal decimalScale={2} onUpdate={handleUpdate} />,
);

await user.click(getUpButton());
expect(handleUpdate).toHaveBeenLastCalledWith(3.46);
});

it('decreases value by an integer on arrowDown button click', async () => {
const user = userEvent.setup();
const handleUpdate = jest.fn();
render(
<NumberInput value={2.456} allowDecimal decimalScale={2} onUpdate={handleUpdate} />,
);

await user.click(getDownButton());
expect(handleUpdate).toHaveBeenLastCalledWith(1.46);
});

it('increases value by an float on arrowUp button click', async () => {
const user = userEvent.setup();
const handleUpdate = jest.fn();
render(
<NumberInput
step={0.001}
value={2.456}
allowDecimal
decimalScale={3}
onUpdate={handleUpdate}
/>,
);

await user.click(getUpButton());
expect(handleUpdate).toHaveBeenLastCalledWith(2.457);
});

it('decreases value by an float on arrowUp button click', async () => {
const user = userEvent.setup();
const handleUpdate = jest.fn();
render(
<NumberInput
step={0.001}
value={2.456}
allowDecimal
decimalScale={3}
onUpdate={handleUpdate}
/>,
);

await user.click(getDownButton());
expect(handleUpdate).toHaveBeenLastCalledWith(2.455);
});

it('adds decimal places after arrowUp button click', async () => {
const user = userEvent.setup();
render(<NumberInput step={0.1} defaultValue={-0.1} allowDecimal decimalScale={2} />);

await user.click(getUpButton());
expect(getInput()).toHaveValue('0.00');
});

it('rounds up step value', async () => {
const user = userEvent.setup();
const handleUpdate = jest.fn();
render(
<NumberInput
onUpdate={handleUpdate}
step={0.09}
value={1}
allowDecimal
decimalScale={1}
/>,
);

await user.click(getUpButton());
expect(handleUpdate).toHaveBeenLastCalledWith(1.1);
});

it('rounds down step value', async () => {
const user = userEvent.setup();
const handleUpdate = jest.fn();
render(
<NumberInput
onUpdate={handleUpdate}
step={0.01}
value={1}
allowDecimal
decimalScale={1}
/>,
);

await user.click(getUpButton());
expect(handleUpdate).toHaveBeenLastCalledWith(2);
});
});

describe('increment/decrement', () => {
it('increments value on arrowUp button click', async () => {
const user = userEvent.setup();
Expand Down Expand Up @@ -588,5 +795,30 @@ describe('NumberInput input', () => {
expect(handleSubmit).toHaveBeenCalledTimes(1);
expect(value).toEqual([['numeric-field', '123.45']]);
});

test('should submit decimal value with custom decimal scale', async () => {
let value;
const handleSubmit = jest.fn((e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
value = [...formData.entries()];
});
render(
<form data-qa="form" onSubmit={handleSubmit}>
<NumberInput
allowDecimal
decimalScale={2}
name="numeric-field"
value={123.4512}
/>
<button type="submit" data-qa="submit">
submit
</button>
</form>,
);
await userEvent.click(screen.getByTestId('submit'));
expect(handleSubmit).toHaveBeenCalledTimes(1);
expect(value).toEqual([['numeric-field', '123.45']]);
});
});
});
Loading
Loading