diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx index 3c255bb5f482..59260a3cbd78 100644 --- a/src/components/AmountForm.tsx +++ b/src/components/AmountForm.tsx @@ -225,6 +225,8 @@ function AmountForm( }} onKeyPress={textInputKeyPress} isCurrencyPressable={isCurrencyPressable} + style={[styles.iouAmountTextInput]} + containerStyle={[styles.iouAmountTextInputContainer]} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} /> diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx index abdef6707327..e0a494ec6fb1 100644 --- a/src/components/AmountTextInput.tsx +++ b/src/components/AmountTextInput.tsx @@ -1,7 +1,6 @@ import React from 'react'; import type {ForwardedRef} from 'react'; import type {NativeSyntheticEvent, StyleProp, TextInputKeyPressEventData, TextInputSelectionChangeEventData, TextStyle, ViewStyle} from 'react-native'; -import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import type {TextSelection} from './Composer/types'; import TextInput from './TextInput'; @@ -31,21 +30,23 @@ type AmountTextInputProps = { /** Function to call to handle key presses in the text input */ onKeyPress?: (event: NativeSyntheticEvent) => void; + + /** Style for the TextInput container */ + containerStyle?: StyleProp; } & Pick; function AmountTextInput( - {formattedAmount, onChangeAmount, placeholder, selection, onSelectionChange, style, touchableInputWrapperStyle, onKeyPress, ...rest}: AmountTextInputProps, + {formattedAmount, onChangeAmount, placeholder, selection, onSelectionChange, style, touchableInputWrapperStyle, onKeyPress, containerStyle, ...rest}: AmountTextInputProps, ref: ForwardedRef, ) { - const styles = useThemeStyles(); return ( 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; + + /** Style for the container */ + containerStyle?: StyleProp; + + /** Reference to moneyRequestAmountInputRef */ + moneyRequestAmountInputRef?: ForwardedRef; + + /** Character to be shown before the amount */ + prefixCharacter?: string; + + /** Whether to hide the currency symbol */ + hideCurrencySymbol?: boolean; + + /** Style for the prefix */ + prefixStyle?: StyleProp; + + /** Style for the prefix container */ + prefixContainerStyle?: StyleProp; + + /** Style for the touchable input wrapper */ + touchableInputWrapperStyle?: StyleProp; +}; + +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, +) { + const {toLocaleDigit, numberFormat} = useLocalize(); + + const textInput = useRef(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) => { + 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 ( + { + 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) => { + 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}; diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/index.native.tsx index 3039d7327d37..25d91bbabc91 100644 --- a/src/components/TextInput/BaseTextInput/index.native.tsx +++ b/src/components/TextInput/BaseTextInput/index.native.tsx @@ -59,6 +59,8 @@ function BaseTextInput( prefixCharacter = '', inputID, isMarkdownEnabled = false, + prefixContainerStyle = [], + prefixStyle = [], ...props }: BaseTextInputProps, ref: ForwardedRef, @@ -237,21 +239,6 @@ function BaseTextInput( setPasswordHidden((prevPasswordHidden) => !prevPasswordHidden); }, []); - // When adding a new prefix character, adjust this method to add expected character width. - // This is because character width isn't known before it's rendered to the screen, and once it's rendered, - // it's too late to calculate it's width because the change in padding would cause a visible jump. - // Some characters are wider than the others when rendered, e.g. '@' vs '#'. Chosen font-family and font-size - // also have an impact on the width of the character, but as long as there's only one font-family and one font-size, - // this method will produce reliable results. - const getCharacterPadding = (prefix: string): number => { - switch (prefix) { - case CONST.POLICY.ROOM_PREFIX: - return 10; - default: - throw new Error(`Prefix ${prefix} has no padding assigned.`); - } - }; - const hasLabel = Boolean(label?.length); const isReadOnly = inputProps.readOnly ?? inputProps.disabled; // Disabling this line for safeness as nullish coalescing works only if the value is undefined or null, and errorText can be an empty string @@ -275,6 +262,9 @@ function BaseTextInput( role={CONST.ROLE.PRESENTATION} onPress={onPress} tabIndex={-1} + // When autoGrowHeight is true we calculate the width for the textInput, so it will break lines properly + // or if multiline is not supplied we calculate the textinput height, using onLayout. + onLayout={onLayout} accessibilityLabel={label} style={[ autoGrowHeight && styles.autoGrowHeightInputContainer(textInputHeight, variables.componentSizeLarge, typeof maxHeight === 'number' ? maxHeight : 0), @@ -283,9 +273,6 @@ function BaseTextInput( ]} > )} {!!prefixCharacter && ( - + {prefixCharacter} @@ -352,7 +339,7 @@ function BaseTextInput( styles.w100, inputStyle, (!hasLabel || isMultiline) && styles.pv0, - !!prefixCharacter && StyleUtils.getPaddingLeft(getCharacterPadding(prefixCharacter) + styles.pl1.paddingLeft), + !!prefixCharacter && StyleUtils.getPaddingLeft(StyleUtils.getCharacterPadding(prefixCharacter) + styles.pl1.paddingLeft), inputProps.secureTextEntry && styles.secureInput, !isMultiline && {height, lineHeight: undefined}, diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index 519a52fd85ec..9c0ecfbf1c1b 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -61,6 +61,8 @@ function BaseTextInput( prefixCharacter = '', inputID, isMarkdownEnabled = false, + prefixContainerStyle = [], + prefixStyle = [], ...inputProps }: BaseTextInputProps, ref: ForwardedRef, @@ -234,21 +236,6 @@ function BaseTextInput( setPasswordHidden((prevPasswordHidden: boolean | undefined) => !prevPasswordHidden); }, []); - // When adding a new prefix character, adjust this method to add expected character width. - // This is because character width isn't known before it's rendered to the screen, and once it's rendered, - // it's too late to calculate it's width because the change in padding would cause a visible jump. - // Some characters are wider than the others when rendered, e.g. '@' vs '#'. Chosen font-family and font-size - // also have an impact on the width of the character, but as long as there's only one font-family and one font-size, - // this method will produce reliable results. - const getCharacterPadding = (prefix: string): number => { - switch (prefix) { - case CONST.POLICY.ROOM_PREFIX: - return 10; - default: - throw new Error(`Prefix ${prefix} has no padding assigned.`); - } - }; - const hasLabel = Boolean(label?.length); const isReadOnly = inputProps.readOnly ?? inputProps.disabled; // Disabling this line for safeness as nullish coalescing works only if the value is undefined or null, and errorText can be an empty string @@ -296,6 +283,9 @@ function BaseTextInput( onPress={onPress} tabIndex={-1} accessibilityLabel={label} + // When autoGrowHeight is true we calculate the width for the textInput, so it will break lines properly + // or if multiline is not supplied we calculate the textinput height, using onLayout. + onLayout={onLayout} style={[ autoGrowHeight && styles.autoGrowHeightInputContainer(textInputHeight, variables.componentSizeLarge, typeof maxHeight === 'number' ? maxHeight : 0), !isMultiline && styles.componentHeightLarge, @@ -303,9 +293,6 @@ function BaseTextInput( ]} > )} {Boolean(prefixCharacter) && ( - + {prefixCharacter} @@ -372,7 +359,7 @@ function BaseTextInput( styles.w100, inputStyle, (!hasLabel || isMultiline) && styles.pv0, - !!prefixCharacter && StyleUtils.getPaddingLeft(getCharacterPadding(prefixCharacter) + styles.pl1.paddingLeft), + !!prefixCharacter && StyleUtils.getPaddingLeft(StyleUtils.getCharacterPadding(prefixCharacter) + styles.pl1.paddingLeft), inputProps.secureTextEntry && styles.secureInput, // Explicitly remove `lineHeight` from single line inputs so that long text doesn't disappear diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index 1529fbe4c7c6..8271b47c2a43 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -107,6 +107,12 @@ type CustomBaseTextInputProps = { /** Should live markdown be enabled. Changes RNTextInput component to RNMarkdownTextInput */ isMarkdownEnabled?: boolean; + + /** Style for the prefix */ + prefixStyle?: StyleProp; + + /** Style for the prefix container */ + prefixContainerStyle?: StyleProp; }; type BaseTextInputRef = HTMLFormElement | AnimatedTextInputRef; diff --git a/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.tsx b/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.tsx index 5ea8d140c6a0..3e7c5f0bc414 100644 --- a/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.tsx +++ b/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.tsx @@ -22,6 +22,7 @@ function BaseTextInputWithCurrencySymbol( isCurrencyPressable = true, hideCurrencySymbol = false, extraSymbol, + style, ...rest }: TextInputWithCurrencySymbolProps, ref: React.ForwardedRef, @@ -60,7 +61,7 @@ function BaseTextInputWithCurrencySymbol( onSelectionChange(event); }} onKeyPress={onKeyPress} - style={styles.pr1} + style={[styles.pr1, style]} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} /> diff --git a/src/components/TextInputWithCurrencySymbol/types.ts b/src/components/TextInputWithCurrencySymbol/types.ts index 753d957221b2..78d4158cf77f 100644 --- a/src/components/TextInputWithCurrencySymbol/types.ts +++ b/src/components/TextInputWithCurrencySymbol/types.ts @@ -1,4 +1,4 @@ -import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native'; +import type {NativeSyntheticEvent, StyleProp, TextInputSelectionChangeEventData, TextStyle, ViewStyle} from 'react-native'; import type {TextSelection} from '@components/Composer/types'; import type {BaseTextInputProps} from '@components/TextInput/BaseTextInput/types'; @@ -35,6 +35,24 @@ type TextInputWithCurrencySymbolProps = { /** Extra symbol to display */ extraSymbol?: React.ReactNode; + + /** Style for the input */ + style?: StyleProp; + + /** Style for the container */ + containerStyle?: StyleProp; + + /** Character to be shown before the amount */ + prefixCharacter?: string; + + /** Style for the prefix */ + prefixStyle?: StyleProp; + + /** Style for the prefix container */ + prefixContainerStyle?: StyleProp; + + /** Customizes the touchable wrapper of the TextInput component */ + touchableInputWrapperStyle?: StyleProp; } & Pick; export default TextInputWithCurrencySymbolProps; diff --git a/src/components/TimePicker/TimePicker.tsx b/src/components/TimePicker/TimePicker.tsx index 17cd93db432b..6e36c0f824e2 100644 --- a/src/components/TimePicker/TimePicker.tsx +++ b/src/components/TimePicker/TimePicker.tsx @@ -458,7 +458,8 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: Tim onSelectionChange={(e) => { setSelectionHour(e.nativeEvent.selection); }} - style={styles.timePickerInput} + style={[styles.iouAmountTextInput, styles.timePickerInput]} + containerStyle={[styles.iouAmountTextInputContainer]} touchableInputWrapperStyle={styles.timePickerHeight100} selection={selectionHour} /> @@ -484,7 +485,8 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: Tim onSelectionChange={(e) => { setSelectionMinute(e.nativeEvent.selection); }} - style={styles.timePickerInput} + style={[styles.iouAmountTextInput, styles.timePickerInput]} + containerStyle={[styles.iouAmountTextInputContainer]} touchableInputWrapperStyle={styles.timePickerHeight100} selection={selectionMinute} /> diff --git a/src/pages/iou/steps/MoneyRequestAmountForm.tsx b/src/pages/iou/steps/MoneyRequestAmountForm.tsx index 5727351f45da..466619f71e26 100644 --- a/src/pages/iou/steps/MoneyRequestAmountForm.tsx +++ b/src/pages/iou/steps/MoneyRequestAmountForm.tsx @@ -2,22 +2,20 @@ import {useIsFocused} from '@react-navigation/core'; import type {ForwardedRef} from 'react'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; -import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native'; import type {ValueOf} from 'type-fest'; import BigNumberPad from '@components/BigNumberPad'; import Button from '@components/Button'; import FormHelpMessage from '@components/FormHelpMessage'; +import MoneyRequestAmountInput from '@components/MoneyRequestAmountInput'; +import type {MoneyRequestAmountInputRef} from '@components/MoneyRequestAmountInput'; import ScrollView from '@components/ScrollView'; import SettlementButton from '@components/SettlementButton'; -import TextInputWithCurrencySymbol from '@components/TextInputWithCurrencySymbol'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import * as Browser from '@libs/Browser'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; -import getOperatingSystem from '@libs/getOperatingSystem'; import type {MaybePhraseKey} from '@libs/Localize'; import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -68,19 +66,6 @@ type MoneyRequestAmountFormProps = { selectedTab?: SelectedTabRequest; }; -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}; -}; - const isAmountInvalid = (amount: string) => !amount.length || parseFloat(amount) < 0.01; const isTaxAmountInvalid = (currentAmount: string, taxAmount: number, isTaxAmountForm: boolean) => isTaxAmountForm && Number.parseFloat(currentAmount) > CurrencyUtils.convertToFrontendAmount(Math.abs(taxAmount)); @@ -108,28 +93,18 @@ function MoneyRequestAmountForm( ) { const styles = useThemeStyles(); const {isExtraSmallScreenHeight} = useWindowDimensions(); - const {translate, toLocaleDigit, numberFormat} = useLocalize(); + const {translate} = useLocalize(); const textInput = useRef(null); + const moneyRequestAmountInput = useRef(null); const isTaxAmountForm = Navigation.getActiveRoute().includes('taxAmount'); - const decimals = CurrencyUtils.getCurrencyDecimals(currency); - const selectedAmountAsString = amount ? CurrencyUtils.convertToFrontendAmount(amount).toString() : ''; - - const [currentAmount, setCurrentAmount] = useState(selectedAmountAsString); const [formError, setFormError] = useState(''); const [shouldUpdateSelection, setShouldUpdateSelection] = useState(true); const isFocused = useIsFocused(); const wasFocused = usePrevious(isFocused); - const [selection, setSelection] = useState({ - start: selectedAmountAsString.length, - end: selectedAmountAsString.length, - }); - - const forwardDeletePressedRef = useRef(false); - const formattedTaxAmount = CurrencyUtils.convertToDisplayString(Math.abs(taxAmount), currency); /** @@ -141,8 +116,10 @@ function MoneyRequestAmountForm( return; } + const selection = moneyRequestAmountInput.current?.getSelection() ?? {start: 0, end: 0}; + event.preventDefault(); - setSelection({ + moneyRequestAmountInput.current?.changeSelection({ start: selection.end, end: selection.end, }); @@ -155,10 +132,22 @@ function MoneyRequestAmountForm( } }; + useEffect(() => { + if (!isFocused || wasFocused) { + return; + } + const selection = moneyRequestAmountInput.current?.getSelection() ?? {start: 0, end: 0}; + + moneyRequestAmountInput.current?.changeSelection({ + start: selection.end, + end: selection.end, + }); + }, [isFocused, wasFocused]); + const initializeAmount = useCallback((newAmount: number) => { const frontendAmount = newAmount ? CurrencyUtils.convertToFrontendAmount(newAmount).toString() : ''; - setCurrentAmount(frontendAmount); - setSelection({ + moneyRequestAmountInput.current?.changeAmount(frontendAmount); + moneyRequestAmountInput.current?.changeSelection({ start: frontendAmount.length, end: frontendAmount.length, }); @@ -169,70 +158,9 @@ function MoneyRequestAmountForm( return; } initializeAmount(amount); - // we want to re-initialize the state only when the selected tab or amount changes + // we want to re-initialize the state only when the selected tab // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedTab, amount]); - - /** - * 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; - } - if (formError) { - setFormError(''); - } - - // 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)); - } - return strippedAmount; - }); - }, - [decimals, formError], - ); - - // 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]); - - // Removes text selection if user visits currency selector with selection and comes back - useEffect(() => { - if (!isFocused || wasFocused) { - return; - } - - setSelection({ - start: selection.end, - end: selection.end, - }); - }, [selection.end, isFocused, selection, wasFocused]); + }, [selectedTab]); /** * Update amount with number or Backspace pressed for BigNumberPad. @@ -243,19 +171,21 @@ function MoneyRequestAmountForm( if (shouldUpdateSelection && !textInput.current?.isFocused()) { textInput.current?.focus(); } + const currentAmount = moneyRequestAmountInput.current?.getAmount() ?? ''; + const selection = moneyRequestAmountInput.current?.getSelection() ?? {start: 0, end: 0}; // Backspace button is pressed if (key === '<' || key === 'Backspace') { if (currentAmount.length > 0) { const selectionStart = selection.start === selection.end ? selection.start - 1 : selection.start; const newAmount = `${currentAmount.substring(0, selectionStart)}${currentAmount.substring(selection.end)}`; - setNewAmount(MoneyRequestUtils.addLeadingZero(newAmount)); + moneyRequestAmountInput.current?.setNewAmount(MoneyRequestUtils.addLeadingZero(newAmount)); } return; } const newAmount = MoneyRequestUtils.addLeadingZero(`${currentAmount.substring(0, selection.start)}${key}${currentAmount.substring(selection.end)}`); - setNewAmount(newAmount); + moneyRequestAmountInput.current?.setNewAmount(newAmount); }, - [currentAmount, selection, shouldUpdateSelection, setNewAmount], + [shouldUpdateSelection], ); /** @@ -276,6 +206,7 @@ function MoneyRequestAmountForm( const submitAndNavigateToNextPage = useCallback( (iouPaymentType?: PaymentMethodType | undefined) => { // Skip the check for tax amount form as 0 is a valid input + const currentAmount = moneyRequestAmountInput.current?.getAmount() ?? ''; if (!currentAmount.length || (!isTaxAmountForm && isAmountInvalid(currentAmount))) { setFormError('iou.error.invalidAmount'); return; @@ -293,28 +224,11 @@ function MoneyRequestAmountForm( onSubmitButtonPress({amount: currentAmount, currency, paymentMethod: iouPaymentType}); }, - [currentAmount, taxAmount, isTaxAmountForm, onSubmitButtonPress, currency, formattedTaxAmount, initializeAmount], + [taxAmount, isTaxAmountForm, onSubmitButtonPress, currency, formattedTaxAmount, initializeAmount], ); - /** - * Input handler to check for a forward-delete key (or keyboard shortcut) press. - */ - const textInputKeyPress = ({nativeEvent}: NativeSyntheticEvent) => { - 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); const buttonText: string = useMemo(() => { + const currentAmount = moneyRequestAmountInput.current?.getAmount() ?? ''; if (skipConfirmation) { if (currentAmount !== '') { const currencyAmount = CurrencyUtils.convertToDisplayString(CurrencyUtils.convertToBackendAmount(Number.parseFloat(currentAmount)), currency) ?? ''; @@ -324,7 +238,7 @@ function MoneyRequestAmountForm( return iouType === CONST.IOU.TYPE.SPLIT ? translate('iou.splitExpense') : translate('iou.submitExpense'); } return isEditing ? translate('common.save') : translate('common.next'); - }, [skipConfirmation, iouType, currentAmount, currency, isEditing, translate]); + }, [skipConfirmation, iouType, currency, isEditing, translate]); const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); @@ -339,11 +253,18 @@ function MoneyRequestAmountForm( onMouseDown={(event) => onMouseDown(event, [AMOUNT_VIEW_ID])} style={[styles.moneyRequestAmountContainer, styles.flex1, styles.flexRow, styles.w100, styles.alignItemsCenter, styles.justifyContentCenter]} > - { + if (!formError) { + return; + } + setFormError(''); + }} + shouldUpdateSelection={shouldUpdateSelection} ref={(ref) => { if (typeof forwardedRef === 'function') { forwardedRef(ref); @@ -353,19 +274,9 @@ function MoneyRequestAmountForm( } textInput.current = ref; }} - selectedCurrencyCode={currency} - selection={selection} - onSelectionChange={(e: NativeSyntheticEvent) => { - 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} - isCurrencyPressable={isCurrencyPressable} + moneyRequestAmountInputRef={moneyRequestAmountInput} + inputStyle={[styles.iouAmountTextInput]} + containerStyle={[styles.iouAmountTextInputContainer]} /> {!!formError && ( justifyContent: 'center', height: '100%', backgroundColor: 'transparent', + overflow: 'hidden', + borderBottomWidth: 2, + borderColor: theme.border, + }, + + textInputContainerBorder: { borderBottomWidth: 2, borderColor: theme.border, - overflow: 'hidden', }, textInputLabel: { @@ -2958,12 +2963,26 @@ const styles = (theme: ThemeColors) => ...headlineFont, fontSize: variables.iouAmountTextSize, color: theme.heading, - padding: 0, lineHeight: undefined, + paddingHorizontal: 0, + paddingVertical: 0, + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + borderTopRightRadius: 0, + borderBottomRightRadius: 0, }, 0, ), + iouAmountTextInputContainer: { + borderWidth: 0, + borderBottomWidth: 0, + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + }, + moneyRequestConfirmationAmount: { ...headlineFont, fontSize: variables.fontSizeh1, diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 1079e712747d..df16cae2ca96 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -1568,6 +1568,16 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ ...(isDisabled && styles.buttonOpacityDisabled), }), + /** + * When adding a new prefix character, adjust this method to add expected character width. + * This is because character width isn't known before it's rendered to the screen, and once it's rendered, + * it's too late to calculate it's width because the change in padding would cause a visible jump. + * Some characters are wider than the others when rendered, e.g. '@' vs '#'. Chosen font-family and font-size + * also have an impact on the width of the character, but as long as there's only one font-family and one font-size, + * this method will produce reliable results. + */ + getCharacterPadding: (prefix: string): number => prefix.length * 10, + // TODO: remove it when we'll implement the callback to handle this toggle in Expensify/Expensify#368335 getWorkspaceWorkflowsOfflineDescriptionStyle: (descriptionTextStyle: TextStyle | TextStyle[]): StyleProp => ({ ...StyleSheet.flatten(descriptionTextStyle),