-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Reusable and stylable money request amount input #40390
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f799c08
aa5c56d
3aeb011
727a554
9f6d574
dcafc98
4e8cd5e
2031e39
85e809c
3c62252
9880bb8
26b7b00
143dd89
7825357
43b951b
1fc3799
c7b250f
c3ff30f
06b51b1
a0176b5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,251 @@ | ||
| import type {ForwardedRef} from 'react'; | ||
| import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; | ||
| import type {NativeSyntheticEvent, StyleProp, TextInputSelectionChangeEventData, TextStyle, ViewStyle} from 'react-native'; | ||
| import useLocalize from '@hooks/useLocalize'; | ||
| import * as Browser from '@libs/Browser'; | ||
| import * as CurrencyUtils from '@libs/CurrencyUtils'; | ||
| import getOperatingSystem from '@libs/getOperatingSystem'; | ||
| import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; | ||
| import CONST from '@src/CONST'; | ||
| import type {BaseTextInputRef} from './TextInput/BaseTextInput/types'; | ||
| import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol'; | ||
|
|
||
| type CurrentMoney = {amount: string; currency: string}; | ||
|
|
||
| type MoneyRequestAmountInputRef = { | ||
| setNewAmount: (amountValue: string) => void; | ||
| changeSelection: (newSelection: Selection) => void; | ||
| changeAmount: (newAmount: string) => void; | ||
| getAmount: () => string; | ||
| getSelection: () => Selection; | ||
| }; | ||
|
|
||
| type MoneyRequestAmountInputProps = { | ||
| /** IOU amount saved in Onyx */ | ||
| amount?: number; | ||
|
|
||
| /** Currency chosen by user or saved in Onyx */ | ||
| currency?: string; | ||
|
|
||
| /** Whether the currency symbol is pressable */ | ||
| isCurrencyPressable?: boolean; | ||
|
|
||
| /** Fired when back button pressed, navigates to currency selection page */ | ||
| onCurrencyButtonPress?: () => void; | ||
|
|
||
| /** Function to call when the amount changes */ | ||
| onAmountChange?: (amount: string) => void; | ||
|
|
||
| /** Whether to update the selection */ | ||
| shouldUpdateSelection?: boolean; | ||
|
|
||
| /** Style for the input */ | ||
| inputStyle?: StyleProp<TextStyle>; | ||
|
|
||
| /** Style for the container */ | ||
| containerStyle?: StyleProp<ViewStyle>; | ||
|
|
||
| /** Reference to moneyRequestAmountInputRef */ | ||
| moneyRequestAmountInputRef?: ForwardedRef<MoneyRequestAmountInputRef>; | ||
|
|
||
| /** Character to be shown before the amount */ | ||
| prefixCharacter?: string; | ||
|
|
||
| /** Whether to hide the currency symbol */ | ||
| hideCurrencySymbol?: boolean; | ||
|
|
||
| /** Style for the prefix */ | ||
| prefixStyle?: StyleProp<TextStyle>; | ||
|
|
||
| /** Style for the prefix container */ | ||
| prefixContainerStyle?: StyleProp<ViewStyle>; | ||
|
|
||
| /** Style for the touchable input wrapper */ | ||
| touchableInputWrapperStyle?: StyleProp<ViewStyle>; | ||
| }; | ||
|
|
||
| type Selection = { | ||
| start: number; | ||
| end: number; | ||
| }; | ||
|
|
||
| /** | ||
| * Returns the new selection object based on the updated amount's length | ||
| */ | ||
| const getNewSelection = (oldSelection: Selection, prevLength: number, newLength: number): Selection => { | ||
| const cursorPosition = oldSelection.end + (newLength - prevLength); | ||
| return {start: cursorPosition, end: cursorPosition}; | ||
| }; | ||
|
|
||
| function MoneyRequestAmountInput( | ||
| { | ||
| amount = 0, | ||
| currency = CONST.CURRENCY.USD, | ||
| isCurrencyPressable = true, | ||
| onCurrencyButtonPress, | ||
| onAmountChange, | ||
| prefixCharacter = '', | ||
| hideCurrencySymbol = false, | ||
| shouldUpdateSelection = true, | ||
| moneyRequestAmountInputRef, | ||
| ...props | ||
| }: MoneyRequestAmountInputProps, | ||
| forwardedRef: ForwardedRef<BaseTextInputRef>, | ||
| ) { | ||
| const {toLocaleDigit, numberFormat} = useLocalize(); | ||
|
|
||
| const textInput = useRef<BaseTextInputRef | null>(null); | ||
|
|
||
| const decimals = CurrencyUtils.getCurrencyDecimals(currency); | ||
| const selectedAmountAsString = amount ? CurrencyUtils.convertToFrontendAmount(amount).toString() : ''; | ||
|
|
||
| const [currentAmount, setCurrentAmount] = useState(selectedAmountAsString); | ||
|
|
||
| const [selection, setSelection] = useState({ | ||
| start: selectedAmountAsString.length, | ||
| end: selectedAmountAsString.length, | ||
| }); | ||
|
|
||
| const forwardDeletePressedRef = useRef(false); | ||
|
|
||
| /** | ||
| * Sets the selection and the amount accordingly to the value passed to the input | ||
| * @param {String} newAmount - Changed amount from user input | ||
| */ | ||
| const setNewAmount = useCallback( | ||
| (newAmount: string) => { | ||
| // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value | ||
| // More info: https://github.com/Expensify/App/issues/16974 | ||
| const newAmountWithoutSpaces = MoneyRequestUtils.stripSpacesFromAmount(newAmount); | ||
| // Use a shallow copy of selection to trigger setSelection | ||
| // More info: https://github.com/Expensify/App/issues/16385 | ||
| if (!MoneyRequestUtils.validateAmount(newAmountWithoutSpaces, decimals)) { | ||
| setSelection((prevSelection) => ({...prevSelection})); | ||
| return; | ||
| } | ||
|
|
||
| // setCurrentAmount contains another setState(setSelection) making it error-prone since it is leading to setSelection being called twice for a single setCurrentAmount call. This solution introducing the hasSelectionBeenSet flag was chosen for its simplicity and lower risk of future errors https://github.com/Expensify/App/issues/23300#issuecomment-1766314724. | ||
|
|
||
| let hasSelectionBeenSet = false; | ||
| setCurrentAmount((prevAmount) => { | ||
| const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces); | ||
| const isForwardDelete = prevAmount.length > strippedAmount.length && forwardDeletePressedRef.current; | ||
| if (!hasSelectionBeenSet) { | ||
| hasSelectionBeenSet = true; | ||
| setSelection((prevSelection) => getNewSelection(prevSelection, isForwardDelete ? strippedAmount.length : prevAmount.length, strippedAmount.length)); | ||
| } | ||
| onAmountChange?.(strippedAmount); | ||
| return strippedAmount; | ||
| }); | ||
| }, | ||
| [decimals, onAmountChange], | ||
| ); | ||
|
|
||
| useImperativeHandle(moneyRequestAmountInputRef, () => ({ | ||
| setNewAmount(amountValue: string) { | ||
| setNewAmount(amountValue); | ||
| }, | ||
| changeSelection(newSelection: Selection) { | ||
| setSelection(newSelection); | ||
| }, | ||
| changeAmount(newAmount: string) { | ||
| setCurrentAmount(newAmount); | ||
| }, | ||
| getAmount() { | ||
| return currentAmount; | ||
| }, | ||
| getSelection() { | ||
| return selection; | ||
| }, | ||
| })); | ||
|
|
||
| useEffect(() => { | ||
| if (!currency || typeof amount !== 'number') { | ||
| return; | ||
| } | ||
| const frontendAmount = amount ? CurrencyUtils.convertToFrontendAmount(amount).toString() : ''; | ||
| setCurrentAmount(frontendAmount); | ||
| setSelection({ | ||
| start: frontendAmount.length, | ||
| end: frontendAmount.length, | ||
| }); | ||
| // we want to re-initialize the state only when the amount changes | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [amount]); | ||
|
|
||
| // Modifies the amount to match the decimals for changed currency. | ||
| useEffect(() => { | ||
| // If the changed currency supports decimals, we can return | ||
| if (MoneyRequestUtils.validateAmount(currentAmount, decimals)) { | ||
| return; | ||
| } | ||
|
|
||
| // If the changed currency doesn't support decimals, we can strip the decimals | ||
| setNewAmount(MoneyRequestUtils.stripDecimalsFromAmount(currentAmount)); | ||
|
|
||
| // we want to update only when decimals change (setNewAmount also changes when decimals change). | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [setNewAmount]); | ||
|
|
||
| /** | ||
| * Input handler to check for a forward-delete key (or keyboard shortcut) press. | ||
| */ | ||
| const textInputKeyPress = ({nativeEvent}: NativeSyntheticEvent<KeyboardEvent>) => { | ||
| const key = nativeEvent?.key.toLowerCase(); | ||
| if (Browser.isMobileSafari() && key === CONST.PLATFORM_SPECIFIC_KEYS.CTRL.DEFAULT) { | ||
| // Optimistically anticipate forward-delete on iOS Safari (in cases where the Mac Accessiblity keyboard is being | ||
| // used for input). If the Control-D shortcut doesn't get sent, the ref will still be reset on the next key press. | ||
| forwardDeletePressedRef.current = true; | ||
| return; | ||
| } | ||
| // Control-D on Mac is a keyboard shortcut for forward-delete. See https://support.apple.com/en-us/HT201236 for Mac keyboard shortcuts. | ||
| // Also check for the keyboard shortcut on iOS in cases where a hardware keyboard may be connected to the device. | ||
| const operatingSystem = getOperatingSystem(); | ||
| forwardDeletePressedRef.current = key === 'delete' || ((operatingSystem === CONST.OS.MAC_OS || operatingSystem === CONST.OS.IOS) && nativeEvent?.ctrlKey && key === 'd'); | ||
| }; | ||
|
|
||
| const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit); | ||
|
|
||
| return ( | ||
| <TextInputWithCurrencySymbol | ||
| formattedAmount={formattedAmount} | ||
| onChangeAmount={setNewAmount} | ||
| onCurrencyButtonPress={onCurrencyButtonPress} | ||
| placeholder={numberFormat(0)} | ||
| ref={(ref) => { | ||
| if (typeof forwardedRef === 'function') { | ||
| forwardedRef(ref); | ||
| } else if (forwardedRef?.current) { | ||
| // eslint-disable-next-line no-param-reassign | ||
| forwardedRef.current = ref; | ||
| } | ||
| textInput.current = ref; | ||
| }} | ||
| selectedCurrencyCode={currency} | ||
| selection={selection} | ||
| onSelectionChange={(e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Coming from this issue #41762 , we are encountering an edge case on Android. When we select all the number and then add a number, the cursor is misplaced and appears at the start of the number. The reason for this is that on Android, when we select all the text and add a number, the selection update from onSelectionChange is triggered earlier than the manual selection update. As a result, the prevSelection already contains the new selection, leading to an incorrect selection calculation more context on the proposal #41762 (comment)
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FYI, this caused the following issue: #50886 |
||
| if (!shouldUpdateSelection) { | ||
| return; | ||
| } | ||
| const maxSelection = formattedAmount.length; | ||
| const start = Math.min(e.nativeEvent.selection.start, maxSelection); | ||
| const end = Math.min(e.nativeEvent.selection.end, maxSelection); | ||
| setSelection({start, end}); | ||
| }} | ||
| onKeyPress={textInputKeyPress} | ||
| hideCurrencySymbol={hideCurrencySymbol} | ||
| prefixCharacter={prefixCharacter} | ||
| isCurrencyPressable={isCurrencyPressable} | ||
| style={props.inputStyle} | ||
| containerStyle={props.containerStyle} | ||
| prefixStyle={props.prefixStyle} | ||
| prefixContainerStyle={props.prefixContainerStyle} | ||
| touchableInputWrapperStyle={props.touchableInputWrapperStyle} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| MoneyRequestAmountInput.displayName = 'MoneyRequestAmountInput'; | ||
|
|
||
| export default React.forwardRef(MoneyRequestAmountInput); | ||
| export type {CurrentMoney, MoneyRequestAmountInputRef}; | ||
Uh oh!
There was an error while loading. Please reload this page.