diff --git a/src/components/NumberInput/NumberInput.tsx b/src/components/NumberInput/NumberInput.tsx index 404d79d1ff..6e53cb90d4 100644 --- a/src/components/NumberInput/NumberInput.tsx +++ b/src/components/NumberInput/NumberInput.tsx @@ -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'; @@ -18,6 +20,7 @@ import { getInternalState, getParsedValue, getPossibleNumberSubstring, + truncateExtraDecimalNumbers, updateCursorPosition, } from './utils'; @@ -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 */ @@ -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(function NumberInput( @@ -101,6 +118,7 @@ export const NumberInput = React.forwardRef(f onBlur, onKeyDown, allowDecimal = false, + decimalScale: initialDecimalScale, className, } = props; @@ -111,12 +129,14 @@ export const NumberInput = React.forwardRef(f value: internalValue, defaultValue, shiftMultiplier, + decimalScale, } = getInternalState({ min: externalMin, max: externalMax, step: externalStep, shiftMultiplier: externalShiftMultiplier, allowDecimal, + decimalScale: initialDecimalScale, value: externalValue, defaultValue: externalDefaultValue, }); @@ -127,14 +147,16 @@ export const NumberInput = React.forwardRef(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); @@ -171,7 +193,7 @@ export const NumberInput = React.forwardRef(f direction, }); setValue?.(newValue); - setInputValue(newValue.toString()); + setInputValue(getStringValue(newValue, allowDecimal, decimalScale)); } }; @@ -216,15 +238,19 @@ export const NumberInput = React.forwardRef(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); diff --git a/src/components/NumberInput/__tests__/NumberInput.test.tsx b/src/components/NumberInput/__tests__/NumberInput.test.tsx index 35e6356a22..f6d5135a5e 100644 --- a/src/components/NumberInput/__tests__/NumberInput.test.tsx +++ b/src/components/NumberInput/__tests__/NumberInput.test.tsx @@ -183,6 +183,11 @@ describe('NumberInput input', () => { expect(handleUpdate).not.toHaveBeenCalled(); }); + + it('rounds float number down if not allowDecimal prop', () => { + render(); + expect(getInput()).toHaveValue('100'); + }); }); describe('min/max', () => { @@ -328,6 +333,208 @@ describe('NumberInput input', () => { }); }); + describe('decimalScale', () => { + it('adds decimal places to integer number', () => { + render(); + expect(getInput()).toHaveValue('100.00'); + }); + + it('not adds decimal places to integer number without allowDecimal prop', () => { + render(); + expect(getInput()).toHaveValue('100'); + }); + + it('adds decimal places to float number', () => { + render(); + expect(getInput()).toHaveValue('100.1200'); + }); + + it('not adds decimal places to float number without allowDecimal prop', () => { + render(); + expect(getInput()).toHaveValue('100'); + }); + + it('empty input when not value prop', async () => { + render(); + expect(getInput()).toHaveValue(''); + }); + + it('render correct origin float', async () => { + render(); + expect(getInput()).toHaveValue('100.12'); + }); + + it('shows a correctly rounded number', async () => { + const {rerender} = render( + , + ); + expect(getInput()).toHaveValue('100.12'); + + rerender(); + expect(getInput()).toHaveValue('100.13'); + }); + + it('calls onUpdate on change', async () => { + const handleUpdate = jest.fn(); + render(); + + 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(); + + 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(); + + 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(); + + 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(); + expect(getInput()).toHaveValue('100'); + + rerender(); + expect(getInput()).toHaveValue('100'); + + rerender(); + expect(getInput()).toHaveValue('100'); + + rerender(); + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + await user.click(getDownButton()); + expect(handleUpdate).toHaveBeenLastCalledWith(2.455); + }); + + it('adds decimal places after arrowUp button click', async () => { + const user = userEvent.setup(); + render(); + + await user.click(getUpButton()); + expect(getInput()).toHaveValue('0.00'); + }); + + it('rounds up step value', async () => { + const user = userEvent.setup(); + const handleUpdate = jest.fn(); + render( + , + ); + + await user.click(getUpButton()); + expect(handleUpdate).toHaveBeenLastCalledWith(1.1); + }); + + it('rounds down step value', async () => { + const user = userEvent.setup(); + const handleUpdate = jest.fn(); + render( + , + ); + + await user.click(getUpButton()); + expect(handleUpdate).toHaveBeenLastCalledWith(2); + }); + }); + describe('increment/decrement', () => { it('increments value on arrowUp button click', async () => { const user = userEvent.setup(); @@ -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( +
+ + + , + ); + await userEvent.click(screen.getByTestId('submit')); + expect(handleSubmit).toHaveBeenCalledTimes(1); + expect(value).toEqual([['numeric-field', '123.45']]); + }); }); }); diff --git a/src/components/NumberInput/__tests__/utils.test.ts b/src/components/NumberInput/__tests__/utils.test.ts index 95d5cda68f..d9d52148c8 100644 --- a/src/components/NumberInput/__tests__/utils.test.ts +++ b/src/components/NumberInput/__tests__/utils.test.ts @@ -1,4 +1,9 @@ -import {clampToNearestStepValue, getParsedValue, getPossibleNumberSubstring} from '../utils'; +import { + clampToNearestStepValue, + getParsedValue, + getPossibleNumberSubstring, + truncateExtraDecimalNumbers, +} from '../utils'; describe('NumberInput utils', () => { describe('getPossibleNumberSubstring', () => { @@ -189,4 +194,32 @@ describe('NumberInput utils', () => { ).toBe(1.25); }); }); + describe('truncateExtraDecimalNumbers', () => { + it('no adds missing decimal places', () => { + expect(truncateExtraDecimalNumbers('1.1', 2)).toBe('1.1'); + expect(truncateExtraDecimalNumbers('1.1', 8)).toBe('1.1'); + }); + + it('returns original value when decimalScale is negative', () => { + expect(truncateExtraDecimalNumbers('1.11', -1)).toBe('1.11'); + }); + + it('returns integer part only when decimalScale is 0', () => { + expect(truncateExtraDecimalNumbers('1.11', 0)).toBe('1'); + }); + + it('returns original value', () => { + expect(truncateExtraDecimalNumbers('1.1')).toBe('1.1'); + }); + + it('truncates extra decimal places', () => { + expect(truncateExtraDecimalNumbers('1.1111', 1)).toBe('1.1'); + expect(truncateExtraDecimalNumbers('1.1111', 2)).toBe('1.11'); + expect(truncateExtraDecimalNumbers('1.100', 2)).toBe('1.10'); + }); + + it('not rounds', () => { + expect(truncateExtraDecimalNumbers('1.18', 1)).toBe('1.1'); + }); + }); }); diff --git a/src/components/NumberInput/utils.ts b/src/components/NumberInput/utils.ts index aab043b2c7..fda399796d 100644 --- a/src/components/NumberInput/utils.ts +++ b/src/components/NumberInput/utils.ts @@ -1,3 +1,6 @@ +import isFinite from 'lodash/isFinite'; +import isNumber from 'lodash/isNumber'; + export const INCREMENT_BUTTON_QA = 'increment-button-qa'; export const DECREMENT_BUTTON_QA = 'decrement-button-qa'; export const CONTROL_BUTTONS_QA = 'control-buttons-qa'; @@ -52,8 +55,16 @@ export function getParsedValue(value: string | undefined): {valid: boolean; valu return {valid: isValidValue, value: parsedValue}; } -function roundIfNecessary(value: number, allowDecimal: boolean) { - return allowDecimal ? value : Math.floor(value); +function roundIfNecessary(value: number, allowDecimal: boolean, decimalScale?: number) { + if (!allowDecimal) { + return Math.floor(value); + } + + if (allowDecimal && isNumber(decimalScale)) { + return parseFloat(value.toFixed(decimalScale)); + } + + return value; } interface VariablesProps { @@ -64,6 +75,7 @@ interface VariablesProps { value: number | null | undefined; defaultValue: number | null | undefined; allowDecimal: boolean; + decimalScale?: number; } export function getInternalState(props: VariablesProps): { min: number | undefined; @@ -72,6 +84,7 @@ export function getInternalState(props: VariablesProps): { shiftMultiplier: number; value: number | null | undefined; defaultValue: number | null | undefined; + decimalScale: number | undefined; } { const { min: externalMin, @@ -80,6 +93,7 @@ export function getInternalState(props: VariablesProps): { shiftMultiplier: externalShiftMultiplier, value: externalValue, allowDecimal, + decimalScale: externalDecimalScale, defaultValue: externalDefaultValue, } = props; @@ -96,14 +110,48 @@ export function getInternalState(props: VariablesProps): { const max = rangedMax !== undefined && rangedMax <= Number.MAX_SAFE_INTEGER ? rangedMax : undefined; - const step = roundIfNecessary(Math.abs(externalStep), allowDecimal) || 1; - const shiftMultiplier = roundIfNecessary(externalShiftMultiplier, allowDecimal) || 10; - const value = externalValue ? roundIfNecessary(externalValue, allowDecimal) : externalValue; + const decimalScale = + allowDecimal && typeof externalDecimalScale === 'number' + ? normalizeDecimalScale(externalDecimalScale) + : undefined; + + const step = roundIfNecessary(Math.abs(externalStep), allowDecimal, decimalScale) || 1; + const shiftMultiplier = + roundIfNecessary(externalShiftMultiplier, allowDecimal, decimalScale) || 10; + + const value = externalValue + ? roundIfNecessary(externalValue, allowDecimal, decimalScale) + : externalValue; const defaultValue = externalDefaultValue - ? roundIfNecessary(externalDefaultValue, allowDecimal) + ? roundIfNecessary(externalDefaultValue, allowDecimal, decimalScale) : externalDefaultValue; - return {min, max, step, shiftMultiplier, value, defaultValue}; + return {min, max, step, shiftMultiplier, value, defaultValue, decimalScale}; +} + +function normalizeDecimalScale(decimalScale: number) { + if (!isFinite(decimalScale) || !isNumber(decimalScale)) { + return 0; + } + + return decimalScale > 0 ? decimalScale : 0; +} + +export function truncateExtraDecimalNumbers(value: string, decimalScale?: number) { + if (!isNumber(decimalScale) || decimalScale < 0) { + return value; + } + + const dotIndex = value.indexOf('.'); + if (dotIndex < 0) { + return value; + } + + if (decimalScale === 0) { + return value.substring(0, dotIndex); + } + + return value.substring(0, dotIndex + decimalScale + 1); } export function clampToNearestStepValue({ @@ -121,7 +169,7 @@ export function clampToNearestStepValue({ }) { const base = originalMin || 0; const min = originalMin ?? Number.MIN_SAFE_INTEGER; - let clampedValue = toFixedNumber(value, step); + let clampedValue = value; if (clampedValue > max) { clampedValue = max; @@ -154,7 +202,7 @@ export function clampToNearestStepValue({ } } - return toFixedNumber(clampedValue, step); + return clampedValue; } export function updateCursorPosition( @@ -205,8 +253,3 @@ export function areStringRepresentationOfNumbersEqual(v1: string, v2: string) { } return false; } - -function toFixedNumber(value: number, baseStep: number): number { - const stepDecimalDigits = baseStep.toString().split('.')[1]?.length || 0; - return parseFloat(value.toFixed(stepDecimalDigits)); -}