diff --git a/documentation/v5/docs/customization.md b/documentation/v5/docs/customization.md index 0d3d1729..c1840ae3 100644 --- a/documentation/v5/docs/customization.md +++ b/documentation/v5/docs/customization.md @@ -9,12 +9,11 @@ React Number Format v5 is a complete rewrite with a goal of keeping it fully cus The primary thing which react number format controls is apply formatting in place (in the input) while managing correct caret position. It tries to understand what user is trying to do, add number, cut/paste, delete, and manage cursor position accordingly. -At the core of React number format lies NumberFormatBase, which works on four main props controlled from parent. +At the core of React number format lies NumberFormatBase, which works on three main props controlled from parent. -- **format**: A format function which can turn any numeric string to a formatted string. -- **removeFormatting**: A function to removing formatting from a formatted string and return numeric string. -- **isValidInputCharacter**: A function to tell if a character in the formatted value is a valid typeable character. You don't need to pass it most of the time, as it defaults numeric characters (0-9). But case like additional character is allowed to type, for example decimal separator in currency format. -- **getCaretBoundary**: A function given a formatted string, returns boundaries of valid cursor position. basically an array of boolean, where index of specify caret position. true at a index signifies user can put their caret at the position, false means the caret position is not allowed and the caret will move to closet allowed position. +- **format** `(numStr: string) => string`: A format function which can turn any numeric string to a formatted string. +- **removeFormatting** `(formattedStr: string) => string`: A function to removing formatting from a formatted string and return numeric string. +- **getCaretBoundary** `(formattedStr: string) => boolean[]`: A function given a formatted string, returns boundaries of valid cursor position. basically an array of boolean, where index of specify caret position. true at a index signifies user can put their caret at the position, false means the caret position is not allowed and the caret will move to closet allowed position. Most of the time you don't have to define getCaretBoundary, as the default one is enough, but in case you need to define, it looks something like this. @@ -33,6 +32,23 @@ function caretUnknownFormatBoundary(formattedValue) { } ``` +There are few more props to handle some corner case. + +- **isValidInputCharacter** `(char: sting) => boolean`: A function to tell if a character in the formatted value is a valid typeable character. You don't need to pass it most of the time, as it defaults numeric characters (0-9). But case like additional character is allowed to type, for example decimal separator in currency format. +- **isCharacterSame** `(compareProps: CompareProps) => boolean`: Some time we would like to allow user pressing different key and that being interpreted as different key like custom numerals, or letting user press `.` for decimal separator when custom decimalSeparator is provided. In such case we need to inform the library that the two characters are same. + +```js +type CompareProps = { + currentValue: string, // current value in the input, before applying any formatting + lastValue: string, // last formatted value + formattedValue: string, // current formatted value. + currentValueIndex: number, // character index in currentValue which we are comparing + formattedValueIndex: number, // character index in formattedValue which we are comparing +}; +``` + +Check the usage in [custom numeral example](#custom-numeral-example). + Apart from this prop some key handling are required depending on use case which can be done using native events, onKeyDown/onKeyUp etc. ## Examples @@ -170,7 +186,7 @@ Another example for NumericFormat could be support for custom numerals. const persianNumeral = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']; function CustomNumeralNumericFormat(props) { - const { format, removeFormatting, ...rest } = useNumericFormat(props); + const { format, removeFormatting, isCharacterSame, ...rest } = useNumericFormat(props); const _format = (val) => { const _val = format(val); @@ -185,7 +201,25 @@ function CustomNumeralNumericFormat(props) { return removeFormatting(_val); }; - return ; + const _isCharacterSame = (compareMeta) => { + const isCharSame = isCharacterSame(compareMeta); + const { formattedValue, currentValue, formattedValueIndex, currentValueIndex } = compareMeta; + const curChar = currentValue[currentValueIndex]; + const newChar = formattedValue[formattedValueIndex]; + const curPersianChar = persianNumeral[Number(curChar)] ?? curChar; + const newPersianChar = persianNumeral[Number(newChar)] ?? newChar; + + return isCharSame || curPersianChar || newPersianChar; + }; + + return ( + + ); } ``` diff --git a/example/src/index.js b/example/src/index.js index 17e7402c..707400bd 100644 --- a/example/src/index.js +++ b/example/src/index.js @@ -1,12 +1,51 @@ import React from 'react'; import ReactDOM from 'react-dom'; import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider'; -import NumericFormat from '../../src/numeric_format'; +import NumericFormat, { useNumericFormat } from '../../src/numeric_format'; import PatternFormat from '../../src/pattern_format'; import NumberFormatBase from '../../src/number_format_base'; import TextField from 'material-ui/TextField'; import { cardExpiry } from '../../custom_formatters/card_expiry'; +const persianNumeral = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']; + +function CustomNumeralNumericFormat(props) { + const { format, removeFormatting, isCharacterSame, ...rest } = useNumericFormat(props); + + const _format = (val) => { + const _val = format(val); + return _val.replace(/\d/g, ($1) => persianNumeral[Number($1)]); + }; + + const _removeFormatting = (val, ...rest) => { + const _val = val.replace(new RegExp(persianNumeral.join('|'), 'g'), ($1) => + persianNumeral.indexOf($1), + ); + + return removeFormatting(_val, ...rest); + }; + + const _isCharacterSame = (compareMeta) => { + const isCharSame = isCharacterSame(compareMeta); + const { formattedValue, currentValue, formattedValueIndex, currentValueIndex } = compareMeta; + const curChar = currentValue[currentValueIndex]; + const newChar = formattedValue[formattedValueIndex]; + const curPersianChar = persianNumeral[Number(curChar)] ?? curChar; + const newPersianChar = persianNumeral[Number(newChar)] ?? newChar; + + return isCharSame || curPersianChar || newPersianChar; + }; + + return ( + + ); +} + class App extends React.Component { constructor() { super(); @@ -84,7 +123,8 @@ class App extends React.Component {

Custom thousand separator : Format currency in input

ThousandSeparator: '.', decimalSeparator=','
- + , +

ThousandSeparator: ' ', decimalSeparator='.'
@@ -152,7 +192,12 @@ class App extends React.Component {

Custom Numeral: add support for custom languages

- +
); diff --git a/src/number_format_base.tsx b/src/number_format_base.tsx index 808ec488..f8d1e52b 100644 --- a/src/number_format_base.tsx +++ b/src/number_format_base.tsx @@ -50,6 +50,7 @@ export default function NumberFormatBase( value: propValue, getCaretBoundary = caretUnknownFormatBoundary, isValidInputCharacter = charIsNumber, + isCharacterSame, ...otherProps } = props; @@ -135,6 +136,7 @@ export default function NumberFormatBase( caretPos, caretBoundary, isValidInputCharacter, + isCharacterSame, ); //correct caret position if its outside of editable area @@ -230,7 +232,7 @@ export default function NumberFormatBase( : undefined; // needed to prevent warning with useLayoutEffect on server - const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect + const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; useIsomorphicLayoutEffect(() => { const input = focusedElm.current; diff --git a/src/numeric_format.tsx b/src/numeric_format.tsx index 8dd94d7b..c3e2416e 100644 --- a/src/numeric_format.tsx +++ b/src/numeric_format.tsx @@ -14,6 +14,7 @@ import { toNumericString, charIsNumber, isNotValidValue, + findChangeRange, } from './utils'; import { NumericFormatProps, @@ -23,6 +24,7 @@ import { FormatInputValueFunction, RemoveFormattingFunction, NumberFormatBaseProps, + IsCharacterSame, } from './types'; import NumberFormatBase from './number_format_base'; @@ -334,9 +336,9 @@ export function useNumericFormat( props = validateAndUpdateProps(props); const { - decimalSeparator = '.', /* eslint-disable no-unused-vars */ - allowedDecimalSeparators, + decimalSeparator: _decimalSeparator, + allowedDecimalSeparators: _allowedDecimalSeparators, thousandsGroupStyle, suffix, allowNegative, @@ -355,6 +357,9 @@ export function useNumericFormat( ...restProps } = props; + // get derived decimalSeparator and allowedDecimalSeparators + const { decimalSeparator, allowedDecimalSeparators } = getSeparators(props); + const _format: FormatInputValueFunction = (numStr) => format(numStr, props); const _removeFormatting: RemoveFormattingFunction = (inputValue, changeMeta) => @@ -421,7 +426,6 @@ export function useNumericFormat( } // don't allow user to delete decimal separator when decimalScale and fixedDecimalScale is set - const { decimalSeparator, allowedDecimalSeparators } = getSeparators(props); if ( key === 'Backspace' && value[selectionStart - 1] === decimalSeparator && @@ -492,11 +496,43 @@ export function useNumericFormat( return charIsNumber(inputChar); }; + const isCharacterSame: IsCharacterSame = ({ + currentValue, + lastValue, + formattedValue, + currentValueIndex, + formattedValueIndex, + }) => { + const curChar = currentValue[currentValueIndex]; + const newChar = formattedValue[formattedValueIndex]; + + /** + * NOTE: as thousand separator and allowedDecimalSeparators can be same, we need to check on + * typed range if we have typed any character from allowedDecimalSeparators, in that case we + * consider different characters like , and . same within the range of updated value. + */ + const typedRange = findChangeRange(lastValue, currentValue); + const { to } = typedRange; + + if ( + currentValueIndex >= to.start && + currentValueIndex < to.end && + allowedDecimalSeparators && + allowedDecimalSeparators.includes(curChar) && + newChar === decimalSeparator + ) { + return true; + } + + return curChar === newChar; + }; + return { ...(restProps as NumberFormatBaseProps), value: formattedValue, valueIsNumericString: false, isValidInputCharacter, + isCharacterSame, onValueChange: _onValueChange, format: _format, removeFormatting: _removeFormatting, diff --git a/src/types.ts b/src/types.ts index 3d162e7e..d47d8539 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,6 +57,14 @@ type NumberFormatProps = Props & export type OnValueChange = (values: NumberFormatValues, sourceInfo: SourceInfo) => void; +export type IsCharacterSame = (compareProps: { + currentValue: string; + lastValue: string; + formattedValue: string; + currentValueIndex: number; + formattedValueIndex: number; +}) => boolean; + type NumberFormatBase = { type?: 'text' | 'tel' | 'password'; displayType?: 'input' | 'text'; @@ -77,6 +85,7 @@ type NumberFormatBase = { onBlur?: InputAttributes['onBlur']; getCaretBoundary?: (formattedValue: string) => boolean[]; isValidInputCharacter?: (character: string) => boolean; + isCharacterSame?: IsCharacterSame; }; export type NumberFormatBaseProps = NumberFormatProps< diff --git a/src/utils.tsx b/src/utils.tsx index 6d14fee1..b6ac369b 100644 --- a/src/utils.tsx +++ b/src/utils.tsx @@ -1,5 +1,10 @@ import { useMemo, useRef, useState } from 'react'; -import { NumberFormatBaseProps, FormatInputValueFunction, OnValueChange } from './types'; +import { + NumberFormatBaseProps, + FormatInputValueFunction, + OnValueChange, + IsCharacterSame, +} from './types'; // basic noop function export function noop() {} @@ -7,6 +12,23 @@ export function returnTrue() { return true; } +export function memoizeOnce(cb: (...args: T) => R) { + let lastArgs: T | undefined; + let lastValue: R = undefined; + return (...args: T) => { + if ( + lastArgs && + args.length === lastArgs.length && + args.every((value, index) => value === lastArgs[index]) + ) { + return lastValue; + } + lastArgs = args; + lastValue = cb(...args); + return lastValue; + }; +} + export function charIsNumber(char?: string) { return !!(char || '').match(/\d/); } @@ -243,7 +265,7 @@ export function findChangedIndex(prevValue: string, newValue: string) { return { start: i, end: prevLength - j }; } -export function findChangeRange(prevValue: string, newValue: string) { +export const findChangeRange = memoizeOnce((prevValue: string, newValue: string) => { let i = 0, j = 0; const prevLength = prevValue.length; @@ -263,7 +285,7 @@ export function findChangeRange(prevValue: string, newValue: string) { from: { start: i, end: prevLength - j }, to: { start: i, end: newLength - j }, }; -} +}); /* Returns a number whose value is limited to the given range @@ -306,6 +328,15 @@ export function getMaskAtIndex(mask: string | string[] = ' ', index: number) { return mask[index] || ' '; } +function defaultIsCharacterSame({ + currentValue, + formattedValue, + currentValueIndex, + formattedValueIndex, +}: Parameters[0]) { + return currentValue[currentValueIndex] === formattedValue[formattedValueIndex]; +} + export function getCaretPosition( newFormattedValue: string, lastFormattedValue: string, @@ -313,16 +344,14 @@ export function getCaretPosition( curCaretPos: number, boundary: boolean[], isValidInputCharacter: (char: string) => boolean, + /** + * format function can change the character, the caret engine relies on mapping old value and new value + * In such case if character is changed, parent can tell which chars are equivalent + * Some example, all allowedDecimalCharacters are updated to decimalCharacters, 2nd case if user is coverting + * number to different numeric system. + */ + isCharacterSame: IsCharacterSame = defaultIsCharacterSame, ) { - 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 @@ -330,6 +359,7 @@ export function getCaretPosition( const firstAllowedPosition = boundary.findIndex((b) => b); const prefixFormat = newFormattedValue.slice(0, firstAllowedPosition); if (!lastFormattedValue && !curValue.startsWith(prefixFormat)) { + lastFormattedValue = prefixFormat; curValue = prefixFormat + curValue; curCaretPos = curCaretPos + prefixFormat.length; } @@ -344,7 +374,15 @@ export function getCaretPosition( for (let i = 0; i < curValLn; i++) { indexMap[i] = -1; for (let j = 0, jLn = formattedValueLn; j < jLn; j++) { - if (curValue[i] === newFormattedValue[j] && addedIndexMap[j] !== true) { + const isCharSame = isCharacterSame({ + currentValue: curValue, + lastValue: lastFormattedValue, + formattedValue: newFormattedValue, + currentValueIndex: i, + formattedValueIndex: j, + }); + + if (isCharSame && addedIndexMap[j] !== true) { indexMap[i] = j; addedIndexMap[j] = true; break; diff --git a/test/library/keypress_and_caret.spec.js b/test/library/keypress_and_caret.spec.js index 5af742d6..93af0482 100644 --- a/test/library/keypress_and_caret.spec.js +++ b/test/library/keypress_and_caret.spec.js @@ -133,7 +133,23 @@ describe('Test keypress and caret position changes', () => { 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, '.', 2, 2); + expect(input.selectionStart).toEqual(3); + }); + + it('should handle caret position correctly when suffix starts with space and allowed decimal separator is pressed in empty input. #774', async () => { + const { input } = await render( + { />, ); - simulateNativeKeyInput(input, '.', 1, 1); + simulateNativeKeyInput(input, '.', 0, 0); + expect(input.selectionStart).toEqual(1); + }); + + it('should handle the caret position when prefix is provided and number is entered on empty input', async () => { + const { input } = await render(); + + simulateNativeKeyInput(input, '1', 0, 0); + expect(input.selectionStart).toEqual(2); + }); + + it('should handle the caret position when prefix is provided and allowed decimal separator is entered on empty input', async () => { + const { input } = await render( + , + ); + + simulateNativeKeyInput(input, '.', 0, 0); expect(input.selectionStart).toEqual(2); });