From 631234fe02d41ec87521e36ca4811148da6795e7 Mon Sep 17 00:00:00 2001 From: Caio Quirino Medeiros Date: Sun, 18 Apr 2021 12:14:59 -0300 Subject: [PATCH] chore: v1.0.0 breaking changes: - renaming unit prop to prefix - adding suffix prop - removing ignoreNative prop - adding signPosition and showPositiveSign props --- README.md | 85 +++++++++++++++------------- example/metro.config.js | 3 +- example/src/App.tsx | 37 +++++++------ package.json | 14 ++++- src/CurrencyInput.tsx | 114 +++++++++++++++++++++++++++++++------- src/TextWithCursor.tsx | 8 +-- src/props.ts | 61 ++++++++++++++------ src/utils/formatNumber.ts | 37 +++++++++++-- 8 files changed, 249 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index 86f498d..55adf72 100644 --- a/README.md +++ b/README.md @@ -12,24 +12,24 @@ A simple currency input component for both iOS and Android. -The goal of `react-native-currency-input` is to offer a simple and effective way to handle number inputs with custom format, usually a currency input case, but it can actually be used for other purposes. +The goal of `react-native-currency-input` is to offer a simple and effective way to handle number inputs with custom format, usually a currency input, but it can be used for any number input case.

- +

## Features - A simple and practical component for number inputs - It's just a [``](https://facebook.github.io/react-native/docs/textinput.html) component, so you can use its props and it's easy to customize -- Set precision, delimiter, separator and unit so you can actually have any number format you want -- It handles negative values, in addition to having a prop to disable it -- Set minimun and maximum value +- Handle any number format with these powerful props: `precision`, `delimiter`, `separator`, `prefix` and `suffix`. +- It handles negative values and you can choose the position of the sign with the `signPosition`. +- Set minimun and maximum values with `minValue` and `maxValue`. - Use React Native ES6 and React Hooks **BONUS** -- [``](#fakecurrencyinput): A fake input that hides the real TextInput in order to terminate the [flickering issue](https://reactnative.dev/docs/textinput#value) +- [``](#fakecurrencyinput): A fake input that hides the real TextInput in order to get rid of the [flickering issue](https://reactnative.dev/docs/textinput#value) - [`formatNumber()`](#formatnumbervalue-options): A function that formats number ## Installation @@ -70,20 +70,25 @@ function MyComponent() { ## Props -This component uses the same props as [``](https://facebook.github.io/react-native/docs/textinput.html). Below are the additional props for this component: - -| Prop | Type | Default | Description | -| -------------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| **`value`** | number | | The value for controlled input. **REQUIRED** | -| **`onChangeValue`** | function | | Callback that is called when the input's value changes. **REQUIRED** | -| **`unit`** | string | | Character to be prefixed on the value. | -| **`delimiter`** | string | , | Character for thousands delimiter. | -| **`separator`** | string | . | Decimal separator character. | -| **`precision`** | number | 2 | Decimal precision. | -| **`ignoreNegative`** | boolean | false | Set this to true to disable negative values. | -| **`maxValue`** | number | | Max value allowed. This might cause unexpected behavior if you pass a value higher than this direct to the input. | -| **`minValue`** | number | | Min value allowed. This might cause unexpected behavior if you pass a value lower than this direct to the input. | -| **`onChangeText`** | function | | Callback that is called when the input's text changes. **IMPORTANT**: This does not control the input value, you should use `onChangeValue`. | +| Prop | Type | Default | Description | +| ---------------------- | -------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| **...TextInputProps** | | | Inherit all [props of `TextInput`](https://reactnative.dev/docs/textinput#props). | +| **`value`** | number | | The value for controlled input. **REQUIRED** | +| **`onChangeValue`** | function | | Callback that is called when the input's value changes. **REQUIRED** | +| **`prefix`** | string | | Character to be prefixed on the value. | +| **`suffix`\*** | string | | Character to be suffixed on the value. | +| **`delimiter`** | string | , | Character for thousands delimiter. | +| **`separator`** | string | . | Decimal separator character. | +| **`precision`** | number | 2 | Decimal precision. | +| **`maxValue`** | number | | Max value allowed. Might cause unexpected behavior if you pass a `value` higher than the one defined here. | +| **`minValue`** | number | | Min value allowed. Might cause unexpected behavior if you pass a `value` lower than the one defined here. | +| **`signPosition`** | string | "afterPrefix" | Where the negative/positive sign (+/-) should be placed. | +| **`showPositiveSign`** | boolean | false | Set this to `true` to show the `+` character on positive values. | +| **`onChangeText`** | function | | Callback that is called when the input's text changes. **IMPORTANT**: This does not control the input value, you must use `onChangeValue`. | + +**_\* IMPORTANT:_** Be aware that using the `suffix` implies setting the `selection` property of the `TextInput` internally. You can override the `selection`, but that will cause behavior problems on the component + +**_Tip:_** If you don't want negative values, just use `minValue={0}`. ## Example @@ -100,14 +105,14 @@ yarn android / yarn ios ## `FakeCurrencyInput` -This component hides the real currency input and use a Text to imitate the input, so you won't get the flickering issue but will lost the selection functionality. The cursor is not a real cursor, but a pipe character (|) that will be always at the end of the text. It also have a wrapper View with position 'relative' on which the Text Input is stretched over. +This component hides the `TextInput` and use a `Text` on its place, so you'll lost the cursor, but will get rid of the [flickering issue](https://reactnative.dev/docs/textinput#value). To replace the cursor it's used a pipe character (|) that will be always at the end of the text. It also have a wrapper `View` with position "relative" on which the `TextInput` is stretched over. - Pros - No [flickering issue](https://reactnative.dev/docs/textinput#value) as a controlled input component - The cursor is locked at the end, avoiding the user to mess up with the mask - Cons - - Lost of selection functionality... The user will still be able to copy/paste, but with a bad experience - - The cursor is locked at the end... You may have users who won't like that + - Lost of selection functionality. The user will still be able to copy/paste, but with a bad experience + - The cursor is locked at the end... ### `FakeCurrencyInput` Usage @@ -115,7 +120,7 @@ This component hides the real currency input and use a Text to imitate the input import { FakeCurrencyInput } from 'react-native-currency-input'; function MyComponent() { - const [value, setValue] = ReactuseState(0); // can also be null + const [value, setValue] = React.useState(0); // can also be null return ( @@ -153,24 +159,27 @@ const value = -2375923.3; const formattedValue = formatNumber(value, { separator: ',', - unit: 'R$ ', + prefix: 'R$ ', precision: 2, delimiter: '.', - ignoreNegative: true, + signPosition: 'beforePrefix', }); -console.log(formattedValue); // R$ 2.375.923,30 +console.log(formattedValue); // -R$ 2.375.923,30 ``` ### `options` (optional) -| Name | Type | Default | Description | -| -------------------- | ------- | ------- | -------------------------------------------- | -| **`unit`** | string | | Character to be prefixed on the value. | -| **`delimiter`** | string | , | Character for thousands delimiter. | -| **`separator`** | string | . | Decimal separator character. | -| **`precision`** | number | 2 | Decimal precision. | -| **`ignoreNegative`** | boolean | false | Set this to true to disable negative values. | +| Name | Type | Default | Description | +| ---------------------- | ------- | ------------- | ---------------------------------------------------------------- | +| **`prefix`** | string | | Character to be prefixed on the value. | +| **`suffix`** | string | | Character to be suffixed on the value. | +| **`delimiter`** | string | , | Character for thousands delimiter. | +| **`separator`** | string | . | Decimal separator character. | +| **`precision`** | number | 2 | Decimal precision. | +| **`ignoreNegative`** | boolean | false | Set this to true to disable negative values. | +| **`signPosition`** | string | "afterPrefix" | Where the negative/positive sign (+/-) should be placed. | +| **`showPositiveSign`** | boolean | false | Set this to `true` to show the `+` character on positive values. | ## Contributing diff --git a/example/metro.config.js b/example/metro.config.js index d1f468a..bdbb3fa 100644 --- a/example/metro.config.js +++ b/example/metro.config.js @@ -18,8 +18,7 @@ module.exports = { resolver: { blacklistRE: blacklist( modules.map( - (m) => - new RegExp(`^${escape(path.join(root, 'node_modules', m))}\\/.*$`) + (m) => new RegExp(`^${escape(path.join(root, 'node_modules', m))}\\/.*$`) ) ), diff --git a/example/src/App.tsx b/example/src/App.tsx index 074258a..d72fcc6 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -10,7 +10,7 @@ import { import CurrencyInput, { FakeCurrencyInput } from 'react-native-currency-input'; export default function App() { - const [valor, setValor] = React.useState(null); + const [valor, setValor] = React.useState(0); return ( @@ -18,21 +18,21 @@ export default function App() { contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled" > - CurrencyInput + CurrencyInput Examples - FakeCurrencyInput { - setValor(238551.23); + setValor(123456.78); }} > - 238551.23 + 123456.78 { - setValor(-927.863942); + setValor(-9257.863942); }} > - -927.863942 + -9257.863942 @@ -138,9 +137,11 @@ const styles = StyleSheet.create({ borderWidth: 1, }, label: { - fontSize: 18, + fontSize: 20, + marginBottom: 16, fontWeight: 'bold', marginTop: 24, + textAlign: 'center', }, screenContainer: { flex: 1, diff --git a/package.json b/package.json index 9eda8ac..25f3e64 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-currency-input", - "version": "0.1.3", + "version": "1.0.0", "description": "A simple currency input component for both iOS and Android", "main": "lib/commonjs/index", "module": "lib/module/index", @@ -124,10 +124,14 @@ ] } }, - "eslintIgnore": ["node_modules/", "lib/"], + "eslintIgnore": [ + "node_modules/", + "lib/" + ], "prettier": { "quoteProps": "consistent", "singleQuote": true, + "printWidth": 88, "tabWidth": 2, "trailingComma": "es5", "useTabs": false @@ -135,6 +139,10 @@ "@react-native-community/bob": { "source": "src", "output": "lib", - "targets": ["commonjs", "module", "typescript"] + "targets": [ + "commonjs", + "module", + "typescript" + ] } } diff --git a/src/CurrencyInput.tsx b/src/CurrencyInput.tsx index 8b815c3..2f8f0c4 100644 --- a/src/CurrencyInput.tsx +++ b/src/CurrencyInput.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { TextInput } from 'react-native'; -import formatNumber from './utils/formatNumber'; +import formatNumber, { addSignPrefixAndSuffix } from './utils/formatNumber'; import type { CurrencyInputProps } from './props'; export default React.forwardRef((props, ref) => { @@ -11,29 +11,47 @@ export default React.forwardRef((props, ref) => { onChangeValue, separator, delimiter, - unit = '', + prefix = '', + suffix = '', precision = 2, maxValue, minValue, - ignoreNegative, + signPosition = 'afterPrefix', + showPositiveSign, ...rest } = props; - const [startNegative, setStartNegative] = React.useState(false); + const [startingWithSign, setStartingWithSign] = React.useState<'-' | '+'>(); + + const noNegativeValues = !!minValue && minValue > 0; + const noPositiveValues = !!maxValue && maxValue < 0; const formattedValue = React.useMemo(() => { if (!!value || value === 0 || value === -0) { return formatNumber(value, { separator, - unit, + prefix, + suffix, precision, delimiter, - ignoreNegative: !!ignoreNegative, + ignoreNegative: noNegativeValues, + signPosition, + showPositiveSign, }); } else { return ''; } - }, [delimiter, ignoreNegative, precision, separator, unit, value]); + }, [ + value, + separator, + prefix, + suffix, + precision, + delimiter, + noNegativeValues, + signPosition, + showPositiveSign, + ]); React.useEffect(() => { onChangeText && onChangeText(formattedValue); @@ -41,22 +59,58 @@ export default React.forwardRef((props, ref) => { const handleChangeText = React.useCallback( (text: string) => { - const textWithoutUnit = text.replace(unit, ''); + let textWithoutPrefix = text; + + if (prefix) { + textWithoutPrefix = text.replace(prefix, ''); + if (textWithoutPrefix === text) { + textWithoutPrefix = text.replace(prefix.slice(0, -1), ''); + } + } + + let textWithoutPrefixAndSufix = textWithoutPrefix; + if (suffix) { + const suffixRegex = new RegExp(`${suffix}([^${suffix}]*)$`); + textWithoutPrefixAndSufix = textWithoutPrefix.replace(suffixRegex, ''); - // Allow starting with a minus sign - if (/^(-|-0)$/.test(textWithoutUnit) && !ignoreNegative) { - setStartNegative(true); - onChangeText && onChangeText(unit + '-'); + if (textWithoutPrefixAndSufix === textWithoutPrefix) { + textWithoutPrefixAndSufix = textWithoutPrefix.replace(suffix.slice(1), ''); + } + } + + // Starting with a minus or plus sign + if (/^(-|-0)$/.test(text) && !noNegativeValues) { + setStartingWithSign('-'); + onChangeText && + onChangeText( + addSignPrefixAndSuffix(formattedValue, { + prefix, + suffix, + sign: '-', + signPosition, + }) + ); return; + } else if (/^(\+|\+0)$/.test(text) && !noPositiveValues) { + setStartingWithSign('+'); + onChangeText && + onChangeText( + addSignPrefixAndSuffix(formattedValue, { + prefix, + suffix, + sign: '+', + signPosition, + }) + ); } else { - setStartNegative(false); + setStartingWithSign(undefined); } - const negative = textWithoutUnit.charAt(0) === '-'; + const isNegativeValue = textWithoutPrefixAndSufix.includes('-'); - const textNumericValue = text.replace(/\D+/g, ''); + const textNumericValue = textWithoutPrefixAndSufix.replace(/\D+/g, ''); - const numberValue = Number(textNumericValue) * (negative ? -1 : 1); + const numberValue = Number(textNumericValue) * (isNegativeValue ? -1 : 1); const zerosOnValue = textNumericValue.replace(/[^0]/g, '').length; @@ -78,22 +132,42 @@ export default React.forwardRef((props, ref) => { onChangeValue && onChangeValue(newValue); }, [ - unit, - ignoreNegative, + suffix, + prefix, + noNegativeValues, + noPositiveValues, precision, maxValue, minValue, onChangeValue, onChangeText, + formattedValue, + signPosition, ] ); + const textInputValue = React.useMemo(() => { + return startingWithSign + ? addSignPrefixAndSuffix(formattedValue, { + prefix, + suffix, + sign: startingWithSign, + signPosition, + }) + : formattedValue; + }, [formattedValue, prefix, signPosition, startingWithSign, suffix]); + return ( ); diff --git a/src/TextWithCursor.tsx b/src/TextWithCursor.tsx index 449d0c1..1848162 100644 --- a/src/TextWithCursor.tsx +++ b/src/TextWithCursor.tsx @@ -5,13 +5,7 @@ import type { TextWithCursorProps } from './props'; import useBlink from './hooks/useBlink'; const TextWithCursor = (textWithCursorProps: TextWithCursorProps) => { - const { - children, - cursorVisible, - style, - cursorProps, - ...rest - } = textWithCursorProps; + const { children, cursorVisible, style, cursorProps, ...rest } = textWithCursorProps; const blinkVisible = useBlink(); diff --git a/src/props.ts b/src/props.ts index 83c5372..d9b56ee 100644 --- a/src/props.ts +++ b/src/props.ts @@ -1,9 +1,4 @@ -import type { - TextInputProps, - StyleProp, - ViewStyle, - TextProps, -} from 'react-native'; +import type { TextInputProps, StyleProp, ViewStyle, TextProps } from 'react-native'; export interface FormatNumberOptions { /** @@ -12,7 +7,7 @@ export interface FormatNumberOptions { delimiter?: string; /** - * Set this to true to disable negative values. + * Set this to `true` to disable negative values. */ ignoreNegative?: boolean; @@ -29,7 +24,23 @@ export interface FormatNumberOptions { /** * Character to be prefixed on the value. */ - unit?: string; + prefix?: string; + + /** + * Character to be suffixed on the value. + */ + suffix?: string; + + /** + * Set this to `true` to show the `+` character on positive values. + */ + showPositiveSign?: boolean; + + /** + * Where the negative/positive sign (+/-) should be placed. Defaults to "afterPrefix". + * Use `showPositiveSign` if you want to show the `+` sign. + */ + signPosition?: 'beforePrefix' | 'afterPrefix'; } export interface CurrencyInputProps extends Omit { @@ -38,26 +49,21 @@ export interface CurrencyInputProps extends Omit { */ delimiter?: string; - /** - * Set this to true to disable negative values. - */ - ignoreNegative?: boolean; - /** * Max value allowed on input. - * Notice that this might cause unexpected behavior if you pass a value higher than this direct to the input. In that case, consider do your own validation instead of using this property + * Notice that this might cause unexpected behavior if you pass a value higher than this on input `value`. In that case, consider do your own validation instead of using this property */ maxValue?: number; /** * Min value allowed on input. - * Notice that this might cause unexpected behavior if you pass a value lower than this direct to the input. In that case, consider do your own validation instead of using this property + * Notice that this might cause unexpected behavior if you pass a value lower than this on input `value`. In that case, consider do your own validation instead of using this property */ minValue?: number; /** * Callback that is called when the input's value changes. - * @param value The changed number value. + * @param value The number value. */ onChangeValue?(value: number | null): void; @@ -74,13 +80,34 @@ export interface CurrencyInputProps extends Omit { /** * Character to be prefixed on the value. */ + prefix?: string; + + /** + * Character to be suffixed on the value. + */ + suffix?: string; + + /** + * @deprecated. Use `prefix` instead. + */ unit?: string; /** * The number value of the input. - * IMPORTANT: this is not the input's text value, the input is controlled by it's number value. + * IMPORTANT: This is used to control the component, but keep in mind that this is not the final `value` property of the `TextInput` */ value: number | null; + + /** + * Set this to `true` to show the `+` character on positive values. + */ + showPositiveSign?: boolean; + + /** + * Where the negative/positive sign (+/-) should be placed. Defaults to "afterPrefix". + * Use `showPositiveSign` if you want to show the `+` sign. + */ + signPosition?: 'beforePrefix' | 'afterPrefix'; } export interface FakeCurrencyInputProps extends CurrencyInputProps { diff --git a/src/utils/formatNumber.ts b/src/utils/formatNumber.ts index 3d31c4a..2feeed1 100644 --- a/src/utils/formatNumber.ts +++ b/src/utils/formatNumber.ts @@ -1,16 +1,40 @@ import type { FormatNumberOptions } from '../props'; +interface AddSignPrefixAndSuffixProps { + sign?: '+' | '-' | ''; + prefix?: string; + suffix?: string; + signPosition: 'beforePrefix' | 'afterPrefix'; +} + +export const addSignPrefixAndSuffix = ( + value: any, + options: AddSignPrefixAndSuffixProps +) => { + const { prefix, sign, suffix, signPosition } = options; + + switch (signPosition) { + case 'beforePrefix': + return `${sign}${prefix}${value}${suffix}`; + case 'afterPrefix': + return `${prefix}${sign}${value}${suffix}`; + } +}; + export default (input: number, options?: FormatNumberOptions) => { const { precision, separator = ',', delimiter = '.', - unit = '', + prefix = '', + suffix = '', ignoreNegative, + showPositiveSign, + signPosition = 'afterPrefix', } = options || {}; const negative = ignoreNegative ? false : input < 0; - const sign = negative ? '-' : ''; + const sign = negative ? '-' : showPositiveSign ? '+' : ''; const string = Math.abs(input).toFixed(precision); @@ -31,7 +55,10 @@ export default (input: number, options?: FormatNumberOptions) => { formattedNumber += separator + decimals; } - formattedNumber = `${unit}${sign}${formattedNumber}`; - - return formattedNumber; + return addSignPrefixAndSuffix(formattedNumber, { + prefix, + suffix, + sign, + signPosition, + }); };