diff --git a/documentation/v5/docs/numeric_format.md b/documentation/v5/docs/numeric_format.md index abc573b9..a5eb369a 100644 --- a/documentation/v5/docs/numeric_format.md +++ b/documentation/v5/docs/numeric_format.md @@ -260,3 +260,61 @@ import { NumericFormat } from 'react-number-format'; - [See Common Props](/docs/props) **Other than this it accepts all the props which can be given to a input or span based on displayType you selected.** + +## Other exports + +With v5.0 we expose some more utils/hooks which can be used for customization or other utilities + +### numericFormatter `(numString: string, props: NumericFormatProps) => string` + +In some places we need to just format the number before we pass it down as value, or in general just to render it. In such cases `numericFormatter` can be used directly. + +**Parameters** + +1st. `numString`(non formatted number string) + +2nd. `props` (the format props applicable on numeric format) + +**Return** +`formattedString` returns the formatted number. + +### removeNumericFormat `(inputValue: string, changeMeta: ChangeMeta, props: NumericFormatProps) => string` + +Most of the time you might not need this, but in some customization case you might wan't to write a patched version on top of removeNumericFormat. + +However for customization case its recommended to use `useNumericFormat` and patch the methods it returns, as lot of other handling is done in the hook. + +**Parameters** + +1st. `inputValue`: the value after user has typed, this will be formatted value with the additional character typed by user. + +2nd. `changeMeta`: This is the change information rnf sends internally, its basically the change information from the last formatted value and the current typed input value. + +The type is following + +```js +{ + from: {start: number, end: number}, + to: {start: number, end: number}, + lastValue: string +} +``` + +3rd. `props`: all the numeric format props + +**Return** +`numString` returns the number in string format. + +### getNumericCaretBoundary `(formattedValue: string, props: NumericFormatProps) => Array` + +This method returns information about what all position in formatted value where caret can be places, it returns n+1 length array of booleans(where n is the length of formattedValue). + +Most of time you don't need this, but in case if you very specific usecase you can patch the function to handle your case. + +See more details on [Concept](https://s-yadav.github.io/react-number-format/docs/customization/#concept) + +### useNumericFormat: `(props: NumericFormatProps) => NumberFormatBaseProps` + +The whole numeric format logic is inside useNumericFormat hook, this returns all the required props which can be passed to `NumberFormatBase`. For customization you can use to patch methods returned by `useNumericFormat` and pass to `NumberFormatBase`. + +See more details in [Customization](https://s-yadav.github.io/react-number-format/docs/customization/) diff --git a/documentation/v5/docs/pattern_format.md b/documentation/v5/docs/pattern_format.md index db8aa449..8100a4e1 100644 --- a/documentation/v5/docs/pattern_format.md +++ b/documentation/v5/docs/pattern_format.md @@ -106,3 +106,61 @@ Demo - [See Common Props](/docs/props) **Other than this it accepts all the props which can be given to a input or span based on displayType you selected.** + +## Other exports + +With v5.0 we expose some more utils/hooks which can be used for customization or other utilities + +### patternFormatter `(numString: string, props: PatternFormatProps) => string` + +In some places we need to just format the number before we pass it down as value, or in general just to render it. In such cases `patternFormatter` can be used directly. + +**Parameters** + +1st. `numString`(non formatted number string) + +2nd. `props` (the format props applicable on numeric format) + +**Return** +`formattedString` returns the formatted number. + +### removePatternFormat `(inputValue: string, changeMeta: ChangeMeta, props: PatternFormatProps) => string` + +Most of the time you might not need this, but in some customization case you might wan't to write a patched version on top of removePatternFormat. + +However for customization case its recommended to use `usePatternFormat` and patch the methods it returns, as lot of other handling is done in the hook. + +**Parameters** + +1st. `inputValue`: the value after user has typed, this will be formatted value with the additional character typed by user. + +2nd. `changeMeta`: This is the change information rnf sends internally, its basically the change information from the last formatted value and the current typed input value. + +The type is following + +```js +{ + from: {start: number, end: number}, + to: {start: number, end: number}, + lastValue: string +} +``` + +3rd. `props`: all the numeric format props + +**Return** +`numString` returns the number in string format. + +### getPatternCaretBoundary `(formattedValue: string, props: PatternFormatProps) => Array` + +This method returns information about what all position in formatted value where caret can be places, it returns n+1 length array of booleans(where n is the length of formattedValue). + +Most of time you don't need this, but in case if you very specific usecase you can patch the function to handle your case. + +See more details on [Concept](https://s-yadav.github.io/react-number-format/docs/customization/#concept) + +### usePatternFormat: `(props: PatternFormatProps) => NumberFormatBaseProps` + +The whole numeric format logic is inside usePatternFormat hook, this returns all the required props which can be passed to `NumberFormatBase`. For customization you can use to patch methods returned by `usePatternFormat` and pass to `NumberFormatBase`. + +See more details in [Customization](https://s-yadav.github.io/react-number-format/docs/customization/) diff --git a/documentation/v5/docs/props.md b/documentation/v5/docs/props.md index f213b8a2..759d5742 100644 --- a/documentation/v5/docs/props.md +++ b/documentation/v5/docs/props.md @@ -160,18 +160,14 @@ const MAX_LIMIT = 1000; **default**: false -If value is passed as string representation of numbers (unformatted) then this should be passed as `true`. +If value is passed as string representation of numbers (unformatted) and number is used in any format props like in prefix or suffix in numeric format and format prop in pattern format then this should be passed as `true`. + +**Note**: Prior to 5.2.0 its was always required to be passed as true when value is passed as string representation of numbers (unformatted). ```js -import { NumericFormat } from 'react-number-format'; +import { PatternFormat } from 'react-number-format'; -; +; ```
@@ -194,7 +190,7 @@ import { NumericFormat } from 'react-number-format'; This handler provides access to any values changes in the input field and is triggered only when a prop changes or the user input changes. It provides two arguments namely the [valueObject](quirks#values-object) as the first and the [sourceInfo](quirks#sourceInfo) as the second. The [valueObject](quirks#values-object) parameter contains the `formattedValue`, `value` and the `floatValue` of the given input field. The [sourceInfo](quirks#sourceInfo) contains the `event` Object and a `source` key which indicates whether the triggered change is due to an event or a prop change. This is particularly useful in identify whether the change is user driven or is an uncontrolled change due to any prop value being updated. :::info -If you are using `values.value` which is non formatted value as numeric string. Make sure to pass valueIsNumericString to be true. +If you are using `values.value` which is non formatted value as numeric string. Make sure to pass valueIsNumericString to be true if any of the format prop as number on it. See [valueIsNumericString](#valueisnumericstring-boolean) for more details. ::: ```js diff --git a/package.json b/package.json index 5e280bd9..b584c232 100755 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "react-number-format", "description": "React component to format number in an input or as a text.", - "version": "5.1.4", + "version": "5.2.0", "main": "dist/react-number-format.cjs.js", "module": "dist/react-number-format.es.js", "types": "types/index.d.ts", diff --git a/src/number_format_base.tsx b/src/number_format_base.tsx index 212556ec..f839cbf8 100644 --- a/src/number_format_base.tsx +++ b/src/number_format_base.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState, useRef, useLayoutEffect } from 'react'; import { FormatInputValueFunction, NumberFormatBaseProps, @@ -62,35 +62,13 @@ export default function NumberFormatBase( onValueChange, ); - const lastUpdatedValue = useRef(); + const lastUpdatedValue = useRef({ formattedValue, numAsString }); const _onValueChange: NumberFormatBaseProps['onValueChange'] = (values, source) => { - lastUpdatedValue.current = values.formattedValue; + lastUpdatedValue.current = { formattedValue: values.formattedValue, numAsString: values.value }; onFormattedValueChange(values, source); }; - // check if there is any change in the value due to props change - useEffect(() => { - const newFormattedValue = (format as FormatInputValueFunction)(numAsString); - - // if the formatted value is not synced to parent, or if the formatted value is different - if (lastUpdatedValue.current === undefined || newFormattedValue !== lastUpdatedValue.current) { - const input = focusedElm.current; - - // formatting can remove some of the number chars, so we need to fine number string again - const _numAsString = removeFormatting(newFormattedValue, undefined); - - updateValue({ - formattedValue: newFormattedValue, - numAsString: _numAsString, - input, - setCaretPosition: true, - source: SourceType.props, - event: undefined, - }); - } - }); - const [mounted, setMounted] = useState(false); const focusedElm = useRef(null); @@ -128,12 +106,18 @@ export default function NumberFormatBase( caretPos: number, currentValue: string, ) => { + // don't reset the caret position when the whole input content is selected + if (el.selectionStart === 0 && el.selectionEnd === el.value.length) return; + /* setting caret position within timeout of 0ms is required for mobile chrome, otherwise browser resets the caret position after we set it We are also setting it without timeout so that in normal browser we don't see the flickering */ setCaretPosition(el, caretPos); + timeout.current.setCaretTimeout = setTimeout(() => { - if (el.value === currentValue) setCaretPosition(el, caretPos); + if (el.value === currentValue && el.selectionStart !== el.selectionEnd) { + setCaretPosition(el, caretPos); + } }, 0); }; @@ -159,7 +143,7 @@ export default function NumberFormatBase( return updatedCaretPos; }; - const updateValue = (params: { + const updateValueAndCaretPosition = (params: { formattedValue?: string; numAsString: string; inputValue?: string; @@ -219,6 +203,49 @@ export default function NumberFormatBase( } }; + /** + * if the formatted value is not synced to parent, or if the formatted value is different from last synced value sync it + * we also don't need to sync to the parent if no formatting is applied + * if the formatting props is removed, in which case last formatted value will be different from the numeric string value + * in such case we need to inform the parent. + */ + useEffect(() => { + const { formattedValue: lastFormattedValue, numAsString: lastNumAsString } = + lastUpdatedValue.current; + if ( + formattedValue !== lastFormattedValue && + (formattedValue !== numAsString || lastFormattedValue !== lastNumAsString) + ) { + _onValueChange(getValueObject(formattedValue, numAsString), { + event: undefined, + source: SourceType.props, + }); + } + }, [formattedValue, numAsString]); + + // also if formatted value is changed from the props, we need to update the caret position + // keep the last caret position if element is focused + const currentCaretPosition = focusedElm.current + ? geInputCaretPosition(focusedElm.current) + : undefined; + + useLayoutEffect(() => { + const input = focusedElm.current; + if (formattedValue !== lastUpdatedValue.current.formattedValue && input) { + const caretPos = getNewCaretPosition( + lastUpdatedValue.current.formattedValue, + formattedValue, + currentCaretPosition, + ); + /** + * set the value imperatively, as we set the caret position as well imperatively. + * This is to keep value and caret position in sync + */ + input.value = formattedValue; + setPatchedCaretPosition(input, caretPos, formattedValue); + } + }, [formattedValue]); + const formatInputValue = ( inputValue: string, event: @@ -244,11 +271,12 @@ export default function NumberFormatBase( const currentCaretPosition = geInputCaretPosition(input); const caretPos = getNewCaretPosition(inputValue, formattedValue, currentCaretPosition); + input.value = formattedValue; setPatchedCaretPosition(input, caretPos, formattedValue); return false; } - updateValue({ + updateValueAndCaretPosition({ formattedValue: _formattedValue, numAsString: _numAsString, inputValue, @@ -298,6 +326,10 @@ export default function NumberFormatBase( if (key === 'ArrowLeft' || key === 'ArrowRight') { const direction = key === 'ArrowLeft' ? 'left' : 'right'; newCaretPosition = correctCaretPosition(value, expectedCaretPosition, direction); + // arrow left or right only moves the caret, so no need to handle the event, if we are handling it manually + if (newCaretPosition !== expectedCaretPosition) { + e.preventDefault(); + } } else if (key === 'Delete' && !isValidInputCharacter(value[expectedCaretPosition])) { // in case of delete go to closest caret boundary on the right side newCaretPosition = correctCaretPosition(value, expectedCaretPosition, 'right'); diff --git a/src/numeric_format.tsx b/src/numeric_format.tsx index 07c5485e..8dd94d7b 100644 --- a/src/numeric_format.tsx +++ b/src/numeric_format.tsx @@ -10,10 +10,10 @@ import { useInternalValues, isNil, roundToPrecision, - isNanValue, setCaretPosition, toNumericString, charIsNumber, + isNotValidValue, } from './utils'; import { NumericFormatProps, @@ -118,6 +118,19 @@ function getNumberRegex(decimalSeparator: string, global: boolean) { return new RegExp(`(^-)|[0-9]|${escapeRegExp(decimalSeparator)}`, global ? 'g' : undefined); } +function isNumericString( + val: string | number | undefined | null, + prefix?: string, + suffix?: string, +) { + // for empty value we can always treat it as numeric string + if (val === '') return true; + + return ( + !prefix?.match(/\d/) && !suffix?.match(/\d/) && typeof val === 'string' && !isNaN(Number(val)) + ); +} + export function removeFormatting( value: string, changeMeta: ChangeMeta = getDefaultChangeMeta(value), @@ -347,16 +360,19 @@ export function useNumericFormat( const _removeFormatting: RemoveFormattingFunction = (inputValue, changeMeta) => removeFormatting(inputValue, changeMeta, props); - let _valueIsNumericString = valueIsNumericString; + const _value = isNil(value) ? defaultValue : value; + + // try to figure out isValueNumericString based on format prop and value + let _valueIsNumericString = valueIsNumericString ?? isNumericString(_value, prefix, suffix); if (!isNil(value)) { - _valueIsNumericString = valueIsNumericString ?? typeof value === 'number'; + _valueIsNumericString = valueIsNumericString || typeof value === 'number'; } else if (!isNil(defaultValue)) { - _valueIsNumericString = valueIsNumericString ?? typeof defaultValue === 'number'; + _valueIsNumericString = valueIsNumericString || typeof defaultValue === 'number'; } const roundIncomingValueToPrecision = (value: string | number | null | undefined) => { - if (isNil(value) || isNanValue(value)) return value; + if (isNotValidValue(value)) return value; if (typeof value === 'number') { value = toNumericString(value); diff --git a/src/pattern_format.tsx b/src/pattern_format.tsx index f35599fd..2e74cbc5 100644 --- a/src/pattern_format.tsx +++ b/src/pattern_format.tsx @@ -5,6 +5,7 @@ import { getCaretPosInBoundary, getDefaultChangeMeta, getMaskAtIndex, + isNil, noop, setCaretPosition, } from './utils'; @@ -158,6 +159,13 @@ function validateProps(props: PatternFormatProps( props: PatternFormatProps, ): NumberFormatBaseProps { @@ -170,6 +178,9 @@ export function usePatternFormat( inputMode = 'numeric', onKeyDown = noop, patternChar = '#', + value, + defaultValue, + valueIsNumericString, ...restProps } = props; @@ -229,12 +240,21 @@ export function usePatternFormat( onKeyDown(e); }; + // try to figure out isValueNumericString based on format prop and value + const _value = isNil(value) ? defaultValue : value; + const isValueNumericString = valueIsNumericString ?? isNumericString(_value, formatProp); + + const _props = { ...props, valueIsNumericString: isValueNumericString }; + return { ...(restProps as NumberFormatBaseProps), + value, + defaultValue, + valueIsNumericString: isValueNumericString, inputMode, - format: (numStr: string) => format(numStr, props), + format: (numStr: string) => format(numStr, _props), removeFormatting: (inputValue: string, changeMeta: ChangeMeta) => - removeFormatting(inputValue, changeMeta, props), + removeFormatting(inputValue, changeMeta, _props), getCaretBoundary: _getCaretBoundary, onKeyDown: _onKeyDown, }; diff --git a/src/types.ts b/src/types.ts index 8abef042..3d162e7e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,8 +62,8 @@ type NumberFormatBase = { displayType?: 'input' | 'text'; inputMode?: InputAttributes['inputMode']; renderText?: (formattedValue: string, otherProps: Partial) => React.ReactNode; - format: FormatInputValueFunction; - removeFormatting: RemoveFormattingFunction; + format?: FormatInputValueFunction; + removeFormatting?: RemoveFormattingFunction; getInputRef?: ((el: HTMLInputElement) => void) | React.Ref; value?: number | string | null; defaultValue?: number | string | null; @@ -75,7 +75,7 @@ type NumberFormatBase = { onChange?: InputAttributes['onChange']; onFocus?: InputAttributes['onFocus']; onBlur?: InputAttributes['onBlur']; - getCaretBoundary: (formattedValue: string) => boolean[]; + getCaretBoundary?: (formattedValue: string) => boolean[]; isValidInputCharacter?: (character: string) => boolean; }; diff --git a/src/utils.tsx b/src/utils.tsx index e826a019..85cda3ab 100644 --- a/src/utils.tsx +++ b/src/utils.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { NumberFormatBaseProps, FormatInputValueFunction, OnValueChange } from './types'; // basic noop function @@ -19,6 +19,10 @@ export function isNanValue(val: string | number) { return typeof val === 'number' && isNaN(val); } +export function isNotValidValue(val: string | number | null | undefined) { + return isNil(val) || isNanValue(val) || (typeof val === 'number' && !isFinite(val)); +} + export function escapeRegExp(str: string) { return str.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); } @@ -310,6 +314,15 @@ export function getCaretPosition( boundary: boolean[], isValidInputCharacter: (char: string) => boolean, ) { + const changeRange = findChangeRange(curValue, newFormattedValue); + const { from, to } = changeRange; + + // if only last typed character is changed in the + if (from.end - from.start === 1 && from.end === to.end && to.end === curCaretPos) { + // don't do anything + return curCaretPos; + } + /** * if something got inserted on empty value, add the formatted character before the current value, * This is to avoid the case where typed character is present on format characters @@ -423,46 +436,55 @@ export function useInternalValues( ): [{ formattedValue: string; numAsString: string }, OnValueChange] { type Values = { formattedValue: string; numAsString: string }; - const propValues = useRef(); - - const getValues = usePersistentCallback((value: string | number | null | undefined) => { - let formattedValue, numAsString; - if (isNil(value) || isNanValue(value)) { - numAsString = ''; - formattedValue = ''; - } else if (typeof value === 'number' || valueIsNumericString) { - numAsString = typeof value === 'number' ? toNumericString(value) : value; - formattedValue = format(numAsString); - } else { - numAsString = removeFormatting(value, undefined); - formattedValue = value; - } + const getValues = usePersistentCallback( + (value: string | number | null | undefined, valueIsNumericString: boolean) => { + let formattedValue, numAsString; + if (isNotValidValue(value)) { + numAsString = ''; + formattedValue = ''; + } else if (typeof value === 'number' || valueIsNumericString) { + numAsString = typeof value === 'number' ? toNumericString(value) : value; + formattedValue = format(numAsString); + } else { + numAsString = removeFormatting(value, undefined); + formattedValue = value; + } - return { formattedValue, numAsString }; - }); + return { formattedValue, numAsString }; + }, + ); const [values, setValues] = useState(() => { - return getValues(defaultValue); + return getValues(isNil(value) ? defaultValue : value, valueIsNumericString); }); + const lastPropBasedValue = useRef(values); + + const _onValueChange: typeof onValueChange = (newValues, sourceInfo) => { + if (newValues.formattedValue !== values.formattedValue) { + setValues({ + formattedValue: newValues.formattedValue, + numAsString: newValues.value, + }); + } - const _onValueChange: typeof onValueChange = (values, sourceInfo) => { - setValues({ - formattedValue: values.formattedValue, - numAsString: values.value, - }); - - onValueChange(values, sourceInfo); + // call parent on value change if only if formatted value is changed + onValueChange(newValues, sourceInfo); }; - useMemo(() => { - //if element is moved to uncontrolled mode, don't reset the value - if (!isNil(value)) { - propValues.current = getValues(value); - setValues(propValues.current); - } else { - propValues.current = undefined; - } - }, [value, getValues]); + // if value is switch from controlled to uncontrolled, use the internal state's value to format with new props + let _value = value; + let _valueIsNumericString = valueIsNumericString; + if (isNil(value)) { + _value = values.numAsString; + _valueIsNumericString = true; + } + + const newValues = getValues(_value, _valueIsNumericString); + + if (newValues.formattedValue !== lastPropBasedValue.current.formattedValue) { + lastPropBasedValue.current = newValues; + setValues(newValues); + } return [values, _onValueChange]; } diff --git a/test/library/input.spec.js b/test/library/input.spec.js index f5a6b13b..9d32f57d 100644 --- a/test/library/input.spec.js +++ b/test/library/input.spec.js @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useRef, useEffect, useMemo } from 'react'; import { renderHook } from '@testing-library/react-hooks/dom'; import TextField from 'material-ui/TextField'; @@ -463,15 +463,29 @@ describe('NumberFormat as input', () => { expect(getInputValue(wrapper)).toEqual('1.2'); }); - it('should call onValueChange in change caused by prop change', () => { + it('should call onValueChange in change caused by prop change', async (done) => { const spy = jasmine.createSpy(); - const wrapper = mount(); - wrapper.setProps({ thousandSeparator: true }); + const { rerender } = await render( + , + ); + await rerender( + , + ); + + await wait(100); + expect(spy.calls.argsFor(0)[0]).toEqual({ formattedValue: '1,234', value: '1234', floatValue: 1234, }); + + done(); }); it('should call onValueChange with the right source information', () => { @@ -513,6 +527,22 @@ describe('NumberFormat as input', () => { }, 0); }); + it('should not reset the selection when manually focused on mount', async () => { + function Test() { + const localInputRef = useRef(); + useEffect(() => { + // eslint-disable-next-line no-unused-expressions + localInputRef.current?.select(); + }, []); + + return (localInputRef.current = elm)} value="12345" />; + } + + const { input } = await render(); + expect(input.selectionStart).toEqual(0); + expect(input.selectionEnd).toEqual(5); + }); + it('should not call onFocus prop when focused then blurred in the same event loop', (done) => { const spy = jasmine.createSpy('onFocus'); const wrapper = mount(); diff --git a/test/library/input_numeric_format.spec.js b/test/library/input_numeric_format.spec.js index 7d565178..04d09b55 100644 --- a/test/library/input_numeric_format.spec.js +++ b/test/library/input_numeric_format.spec.js @@ -687,6 +687,13 @@ describe('Test NumberFormat as input with numeric format options', () => { expect(wrapper.find('input').prop('value')).toEqual('0.00000001'); }); + it('should handle formatting correctly when valueIsNumericString is set to false and float value is provided, #741', async () => { + const { input } = await render( + , + ); + expect(input.value).toEqual('12,345'); + }); + describe('should allow typing number if prefix or suffix is just an number #691', () => { it('when prefix is number', async () => { const { input } = await render(); diff --git a/test/library/keypress_and_caret.spec.js b/test/library/keypress_and_caret.spec.js index d369483c..5af742d6 100644 --- a/test/library/keypress_and_caret.spec.js +++ b/test/library/keypress_and_caret.spec.js @@ -1,20 +1,18 @@ -import React from 'react'; +import React, { useState } from 'react'; import NumericFormat from '../../src/numeric_format'; import PatternFormat from '../../src/pattern_format'; import NumberFormatBase from '../../src/number_format_base'; -import ReactDOM from 'react-dom'; -import { cleanup } from '@testing-library/react'; +import { cleanup, fireEvent } from '@testing-library/react'; import { - simulateKeyInput, - simulateMousUpEvent, simulateFocusEvent, mount, persist, - getInputValue, render, simulateNativeKeyInput, wait, + simulatePaste, + simulateNativeMouseUpEvent, } from '../test_util'; import { cardExpiry } from '../../custom_formatters/card_expiry'; @@ -34,7 +32,7 @@ describe('Test keypress and caret position changes', () => { cleanup(); }); - it('should maintain caret position if suffix/prefix is updated while typing #249', () => { + it('should maintain caret position if suffix/prefix is updated while typing #249', async () => { class TestComp extends React.Component { constructor() { super(); @@ -58,15 +56,15 @@ describe('Test keypress and caret position changes', () => { } } - const wrapper = mount(); - simulateFocusEvent(wrapper.find('input'), 0, 0, setSelectionRange); - simulateKeyInput(wrapper.find('input'), '4', 2, 2, setSelectionRange); - expect(ReactDOM.findDOMNode(wrapper.instance()).value).toEqual('$$1423'); - expect(caretPos).toEqual(4); + const { input } = await render(); + simulateNativeKeyInput(input, '4', 2, 2); + expect(input.value).toEqual('$$1423'); + expect(input.selectionStart).toEqual(4); - simulateKeyInput(wrapper.find('input'), 'Backspace', 4, 4, setSelectionRange); - expect(ReactDOM.findDOMNode(wrapper.instance()).value).toEqual('$123'); - expect(caretPos).toEqual(2); + simulateNativeKeyInput(input, '{backspace}', 4, 4); + + expect(input.value).toEqual('$123'); + expect(input.selectionStart).toEqual(2); }); it('should maintain caret position when isAllowed returns false', async () => { @@ -106,91 +104,126 @@ describe('Test keypress and caret position changes', () => { expect(input.selectionStart).toEqual(3); }); - describe('Test character insertion', () => { - it('should add any number properly when input is empty without format prop passed', () => { - const wrapper = mount(); + it('should not break the cursor position when format prop is updated', async () => { + const Test = () => { + const [val, setValue] = useState(); + return ( + { + setValue(v.floatValue); + }} + prefix={val > 0 ? '+' : undefined} + /> + ); + }; + + const { input } = await render(); + simulateNativeKeyInput(input, '1', 0, 0); + expect(input.value).toEqual('+1,00'); + expect(input.selectionStart).toEqual(2); + }); - simulateKeyInput(wrapper.find('input'), '1', 0); + it('should handle caret position correctly when suffix starts with space and allowed decimal separator is pressed. #725', async () => { + const { input } = await render( + , + ); + + simulateNativeKeyInput(input, '.', 1, 1); + expect(input.selectionStart).toEqual(2); + }); - expect(getInputValue(wrapper)).toEqual('$1'); + describe('Test character insertion', () => { + it('should add any number properly when input is empty without format prop passed', async () => { + const { input } = await render(); - wrapper.setProps({ value: '' }); - wrapper.update(); + simulateNativeKeyInput(input, '1', 0, 0); + expect(input.value).toEqual('$1'); - simulateKeyInput(wrapper.find('input'), '2456789', 0); + input.value = ''; - expect(getInputValue(wrapper)).toEqual('$2,456,789'); + simulateNativeKeyInput(input, '2456789', 0, 0); + expect(input.value).toEqual('$2,456,789'); }); - it('should add any number properly when input is empty with format prop passed', () => { + it('should add any number properly when input is empty with format prop passed', async () => { //case 1: Enter first number - const wrapper = mount(); - simulateKeyInput(wrapper.find('input'), '1', 0); - expect(getInputValue(wrapper)).toEqual('1___ ____ ____ ____'); + const { input, rerender } = await render( + , + ); + simulateNativeKeyInput(input, '1', 0, 0); + expect(input.value).toEqual('1___ ____ ____ ____'); //case 2: if nun numeric character got added - wrapper.setProps({ value: '' }); - wrapper.update(); - simulateKeyInput(wrapper.find('input'), 'b', 0); - expect(getInputValue(wrapper)).toEqual(''); + input.value = ''; + simulateNativeKeyInput(input, 'b', 0, 0); + expect(input.value).toEqual(''); //case 3: Enter first multiple number - wrapper.setProps({ value: undefined }); - wrapper.setProps({ value: '' }); - wrapper.update(); - simulateKeyInput(wrapper.find('input'), '2456789', 0); - expect(getInputValue(wrapper)).toEqual('2456 789_ ____ ____'); + input.value = ''; + simulatePaste(input, '2456789', 0, 0); + expect(input.value).toEqual('2456 789_ ____ ____'); //case 4: When alpha numeric character got added - wrapper.setProps({ value: undefined }); - wrapper.setProps({ value: '' }); - wrapper.update(); - simulateKeyInput(wrapper.find('input'), '245sf6789', 0); - expect(getInputValue(wrapper)).toEqual('2456 789_ ____ ____'); + input.value = ''; + simulatePaste(input, '245sf6789', 0, 0); + expect(input.value).toEqual('2456 789_ ____ ____'); //case 5: Similiar to case 4 but a formatted value got added - wrapper.setProps({ value: undefined }); - wrapper.setProps({ value: '' }); - wrapper.update(); - simulateKeyInput(wrapper.find('input'), '1234 56', 0); - expect(getInputValue(wrapper)).toEqual('1234 56__ ____ ____'); + input.value = ''; + simulatePaste(input, '1234 56', 0, 0); + expect(input.value).toEqual('1234 56__ ____ ____'); //case 6: If format has numbers - wrapper.setProps({ value: undefined }); - wrapper.setProps({ value: '', format: '+1 (###) ### # ##' }); - wrapper.update(); - simulateKeyInput(wrapper.find('input'), '123456', 0); - expect(getInputValue(wrapper)).toEqual('+1 (123) 456 _ __'); + rerender(); + simulatePaste(input, '123456', 0, 0); + expect(input.value).toEqual('+1 (123) 456 _ __'); //case 7: If format has numbers and and formatted value is inserted - wrapper.setProps({ value: undefined }); - wrapper.setProps({ value: '' }); - wrapper.update(); - simulateKeyInput(wrapper.find('input'), '+1 (965) 432 1 19', 0); - expect(getInputValue(wrapper)).toEqual('+1 (965) 432 1 19'); + input.value = ''; + simulatePaste(input, '+1 (965) 432 1 19', 0, 0); + expect(input.value).toEqual('+1 (965) 432 1 19'); }); - it('should handle addition of characters at a cursor position', () => { - let wrapper = mount(); + it('should handle addition of characters at a cursor position for numeric format', async () => { + const { input } = await render( + , + ); - simulateKeyInput(wrapper.find('input'), '8', 2, 2, setSelectionRange); - expect(getInputValue(wrapper)).toEqual('$182,345'); - expect(caretPos).toEqual(3); + simulateNativeKeyInput(input, '8', 2, 2); + expect(input.value).toEqual('$182,345'); + expect(input.selectionStart).toEqual(3); - simulateKeyInput(wrapper.find('input'), '67', 3, 3, setSelectionRange); - expect(getInputValue(wrapper)).toEqual('$18,672,345'); - expect(caretPos).toEqual(6); + simulateNativeKeyInput(input, '67', 3, 3); + expect(input.value).toEqual('$18,672,345'); + expect(input.selectionStart).toEqual(6); + }); - wrapper = mount(); - wrapper.setProps({ format: '### ### ###', value: '123 456 789' }); - wrapper.update(); - simulateKeyInput(wrapper.find('input'), '8', 3, 3, setSelectionRange); - expect(getInputValue(wrapper)).toEqual('123 845 678'); - expect(caretPos).toEqual(5); + it('should handle addition of characters at a cursor position for patter format', async () => { + const { input, rerender } = await render( + , + ); - simulateKeyInput(wrapper.find('input'), '999', 4, 4, setSelectionRange); - expect(getInputValue(wrapper)).toEqual('123 999 845'); - expect(caretPos).toEqual(7); + rerender(); + simulateNativeKeyInput(input, '8', 3, 3); + expect(input.value).toEqual('123 845 678'); + expect(input.selectionStart).toEqual(5); + + simulateNativeKeyInput(input, '999', 4, 4); + expect(input.value).toEqual('123 999 845'); + expect(input.selectionStart).toEqual(7); }); it('after typing decimal cursor position should go after the . when suffix is provided. #673', async () => { @@ -377,8 +410,8 @@ describe('Test keypress and caret position changes', () => { }); describe('Test arrow keys', () => { - it('should keep caret position between the prefix and suffix', () => { - const wrapper = mount( + it('should keep caret position between the prefix and suffix', async () => { + const { input } = await render( { value="Rs. 12,345.50 /sq.feet" />, ); - simulateKeyInput(wrapper.find('input'), 'ArrowLeft', 4, 4, setSelectionRange); - expect(caretPos).toEqual(4); + simulateNativeKeyInput(input, '{arrowleft}', 4, 4); + expect(input.selectionStart).toEqual(4); - simulateKeyInput(wrapper.find('input'), 'ArrowRight', 13, 13, setSelectionRange); - expect(caretPos).toEqual(13); + simulateNativeKeyInput(input, '{arrowright}', 13, 13); + expect(input.selectionStart).toEqual(13); }); - it('should keep caret position within typable area', () => { - const wrapper = mount( + it('should keep caret position within typable area', async () => { + const { input } = await render( , ); - simulateKeyInput(wrapper.find('input'), 'ArrowLeft', 4, 4, setSelectionRange); - expect(caretPos).toEqual(4); + simulateNativeKeyInput(input, '{arrowleft}', 4, 4); + expect(input.selectionStart).toEqual(4); - simulateKeyInput(wrapper.find('input'), 'ArrowRight', 17, 17, setSelectionRange); - expect(caretPos).toEqual(17); + simulateNativeKeyInput(input, '{arrowright}', 17, 17); + expect(input.selectionStart).toEqual(17); - simulateKeyInput(wrapper.find('input'), 'ArrowRight', 7, 7, setSelectionRange); - expect(caretPos).toEqual(9); + simulateNativeKeyInput(input, '{arrowright}', 7, 7); + expect(input.selectionStart).toEqual(9); - simulateKeyInput(wrapper.find('input'), 'ArrowLeft', 9, 9, setSelectionRange); - expect(caretPos).toEqual(7); + simulateNativeKeyInput(input, '{arrowleft}', 9, 9); + expect(input.selectionStart).toEqual(7); - caretPos = undefined; - simulateKeyInput(wrapper.find('input'), 'ArrowRight', 12, 12, setSelectionRange); - expect(caretPos).toEqual(13); + simulateNativeKeyInput(input, '{arrowright}', 12, 12); + expect(input.selectionStart).toEqual(13); - caretPos = undefined; - simulateKeyInput(wrapper.find('input'), 'ArrowLeft', 13, 13, setSelectionRange); - expect(caretPos).toEqual(12); + simulateNativeKeyInput(input, '{arrowleft}', 13, 13); + expect(input.selectionStart).toEqual(12); }); - it('should not move caret positon from left most to right most if left key pressed. #154', () => { - const wrapper = mount(); - caretPos = undefined; - simulateKeyInput(wrapper.find('input'), 'ArrowLeft', 0, 0, setSelectionRange); - expect(caretPos).toEqual(0); + it('should not move caret positon from left most to right most if left key pressed. #154', async () => { + const { input } = await render(); + + input.setSelectionRange(0, 0); + fireEvent.keyDown(input, { key: 'ArrowLeft' }); + + expect(input.selectionStart).toEqual(0); }); }); @@ -431,42 +464,41 @@ describe('Test keypress and caret position changes', () => { jasmine.clock().uninstall(); }); - it('should always keep caret on typable area when we click on the input', () => { - const wrapper = mount( + it('should always keep caret on typable area when we click on the input', async () => { + const { input } = await render( , ); - simulateMousUpEvent(wrapper.find('input'), 0, setSelectionRange); - expect(caretPos).toEqual(4); + simulateNativeMouseUpEvent(input, 0); + expect(input.selectionStart).toEqual(4); - simulateMousUpEvent(wrapper.find('input'), 8, setSelectionRange); - expect([7, 9]).toContain(caretPos); + simulateNativeMouseUpEvent(input, 8); + expect(input.selectionStart).toEqual(9); - simulateMousUpEvent(wrapper.find('input'), 19, setSelectionRange); - expect(caretPos).toEqual(17); + simulateNativeMouseUpEvent(input, 19); + expect(input.selectionStart).toEqual(17); }); - it('should limit the caret position to the next position of the typed number', () => { - const wrapper = mount(); + it('should limit the caret position to the next position of the typed number', async () => { + const { input, rerender } = await render(); - simulateKeyInput(wrapper.find('input'), '1', 0); - expect(getInputValue(wrapper)).toEqual('1 / / '); + simulateNativeKeyInput(input, '1', 0, 0); + expect(input.value).toEqual('1 / / '); - simulateMousUpEvent(wrapper.find('input'), 4, setSelectionRange); - expect(caretPos).toEqual(1); + simulateNativeMouseUpEvent(input, 4); + expect(input.selectionStart).toEqual(1); - wrapper.setProps({ - mask: ['D', 'D', 'M', 'M', 'Y', 'Y', 'Y', 'Y'], - }); - wrapper.update(); + rerender( + , + ); - expect(getInputValue(wrapper)).toEqual('1D/MM/YYYY'); - simulateMousUpEvent(wrapper.find('input'), 4, setSelectionRange); - expect(caretPos).toEqual(1); + expect(input.value).toEqual('1D/MM/YYYY'); + simulateNativeMouseUpEvent(input, 4); + expect(input.selectionStart).toEqual(1); }); - it('should always keep caret position between suffix and prefix', () => { - const wrapper = mount( + it('should always keep caret position between suffix and prefix', async () => { + const { input } = await render( { />, ); - simulateMousUpEvent(wrapper.find('input'), 0, setSelectionRange); - expect(caretPos).toEqual(4); + simulateNativeMouseUpEvent(input, 0); + expect(input.selectionStart).toEqual(4); - simulateMousUpEvent(wrapper.find('input'), 17, setSelectionRange); - expect(caretPos).toEqual(13); + simulateNativeMouseUpEvent(input, 17); + expect(input.selectionStart).toEqual(13); }); it('should correct wrong caret position on focus', () => { @@ -512,7 +544,7 @@ describe('Test keypress and caret position changes', () => { expect(onFocus).toHaveBeenCalledTimes(0); }); - it('should correct wrong caret positon on focus when allowEmptyFormatting is set', () => { + it('should correct wrong caret position on focus when allowEmptyFormatting is set', () => { jasmine.clock().install(); const wrapper = mount( + value} /> ); } diff --git a/yarn.lock b/yarn.lock index ee58fced..0cb14dbb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1945,13 +1945,13 @@ defer-to-connect "^1.0.1" "@testing-library/dom@^8.0.0": - version "8.18.1" - resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.18.1.tgz#80f91be02bc171fe5a3a7003f88207be31ac2cf3" - integrity sha512-oEvsm2B/WtcHKE+IcEeeCqNU/ltFGaVyGbpcm4g/2ytuT49jrlH9x5qRKL/H3A6yfM4YAbSbC0ceT5+9CEXnLg== + version "8.20.0" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.20.0.tgz#914aa862cef0f5e89b98cc48e3445c4c921010f6" + integrity sha512-d9ULIT+a4EXLX3UU8FBjauG9NnsZHkHztXoIcTsOKoOw030fyjheN9svkTULjJxtYag9DZz5Jz5qkWZDPxTFwA== dependencies: "@babel/code-frame" "^7.10.4" "@babel/runtime" "^7.12.5" - "@types/aria-query" "^4.2.0" + "@types/aria-query" "^5.0.1" aria-query "^5.0.0" chalk "^4.1.0" dom-accessibility-api "^0.5.9" @@ -1987,10 +1987,10 @@ resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== -"@types/aria-query@^4.2.0": - version "4.2.2" - resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.2.tgz#ed4e0ad92306a704f9fb132a0cfcf77486dbe2bc" - integrity sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig== +"@types/aria-query@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.1.tgz#3286741fb8f1e1580ac28784add4c7a1d49bdfbc" + integrity sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q== "@types/body-parser@*": version "1.19.2" @@ -2161,9 +2161,9 @@ integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== "@types/react-dom@<18.0.0": - version "17.0.17" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.17.tgz#2e3743277a793a96a99f1bf87614598289da68a1" - integrity sha512-VjnqEmqGnasQKV0CWLevqMTXBYG9GbwuE6x3VetERLh0cq2LTptFE73MrQi2S7GkKXCf2GgwItB/melLnxfnsg== + version "17.0.20" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.20.tgz#e0c8901469d732b36d8473b40b679ad899da1b53" + integrity sha512-4pzIjSxDueZZ90F52mU3aPoogkHIoSIDG+oQ+wQK7Cy2B9S+MvOqY0uEA/qawKz381qrEDkvpwyt8Bm31I8sbA== dependencies: "@types/react" "^17" @@ -2203,9 +2203,9 @@ csstype "^3.0.2" "@types/react@^17": - version "17.0.50" - resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.50.tgz#39abb4f7098f546cfcd6b51207c90c4295ee81fc" - integrity sha512-ZCBHzpDb5skMnc1zFXAXnL3l1FAdi+xZvwxK+PkglMmBrwjpp9nKaWuEvrGnSifCJmBFGxZOOFuwC6KH/s0NuA== + version "17.0.59" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.59.tgz#5aa4e161a356fcb824d81f166e01bad9e82243bb" + integrity sha512-gSON5zWYIGyoBcycCE75E9+r6dCC2dHdsrVkOEiIYNU5+Q28HcBAuqvDuxHcCbMfHBHdeT5Tva/AFn3rnMKE4g== dependencies: "@types/prop-types" "*" "@types/scheduler" "*" @@ -6662,9 +6662,9 @@ lunr-languages@^1.4.0: integrity sha512-Be5vFuc8NAheOIjviCRms3ZqFFBlzns3u9DXpPSZvALetgnydAN0poV71pVLFn0keYy/s4VblMMkqewTLe+KPg== lz-string@^1.4.4: - version "1.4.4" - resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" - integrity sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ== + version "1.5.0" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== magic-string@0.25.3: version "0.25.3"