diff --git a/src/components/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx index 9ef33900bb00..1e49a730e118 100644 --- a/src/components/MoneyRequestAmountInput.tsx +++ b/src/components/MoneyRequestAmountInput.tsx @@ -91,6 +91,9 @@ type MoneyRequestAmountInputProps = { /** The width of inner content */ contentWidth?: number; + + /** The testID of the input. Used to locate this view in end-to-end tests. */ + testID?: string; }; type Selection = { @@ -127,6 +130,7 @@ function MoneyRequestAmountInput( shouldKeepUserInput = false, autoGrow = true, contentWidth, + testID, ...props }: MoneyRequestAmountInputProps, forwardedRef: ForwardedRef, @@ -337,6 +341,7 @@ function MoneyRequestAmountInput( onMouseDown={handleMouseDown} onMouseUp={handleMouseUp} contentWidth={contentWidth} + testID={testID} /> ); } diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/implementation/index.native.tsx similarity index 100% rename from src/components/TextInput/BaseTextInput/index.native.tsx rename to src/components/TextInput/BaseTextInput/implementation/index.native.tsx diff --git a/src/components/TextInput/BaseTextInput/implementation/index.tsx b/src/components/TextInput/BaseTextInput/implementation/index.tsx new file mode 100644 index 000000000000..e36ae60255fc --- /dev/null +++ b/src/components/TextInput/BaseTextInput/implementation/index.tsx @@ -0,0 +1,534 @@ +import {Str} from 'expensify-common'; +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TextInputFocusEventData, ViewStyle} from 'react-native'; +import {ActivityIndicator, Animated, StyleSheet, View} from 'react-native'; +import Checkbox from '@components/Checkbox'; +import FormHelpMessage from '@components/FormHelpMessage'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; +import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; +import type {AnimatedTextInputRef} from '@components/RNTextInput'; +import RNTextInput from '@components/RNTextInput'; +import SwipeInterceptPanResponder from '@components/SwipeInterceptPanResponder'; +import Text from '@components/Text'; +import * as styleConst from '@components/TextInput/styleConst'; +import TextInputClearButton from '@components/TextInput/TextInputClearButton'; +import TextInputLabel from '@components/TextInput/TextInputLabel'; +import useLocalize from '@hooks/useLocalize'; +import useMarkdownStyle from '@hooks/useMarkdownStyle'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as Browser from '@libs/Browser'; +import isInputAutoFilled from '@libs/isInputAutoFilled'; +import useNativeDriver from '@libs/useNativeDriver'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import type {BaseTextInputProps, BaseTextInputRef} from './types'; + +function BaseTextInput( + { + label = '', + /** + * To be able to function as either controlled or uncontrolled component we should not + * assign a default prop value for `value` or `defaultValue` props + */ + value = undefined, + defaultValue = undefined, + placeholder = '', + errorText = '', + icon = null, + iconLeft = null, + textInputContainerStyles, + touchableInputWrapperStyle, + containerStyles, + inputStyle, + forceActiveLabel = false, + autoFocus = false, + disableKeyboard = false, + autoGrow = false, + autoGrowHeight = false, + maxAutoGrowHeight, + hideFocusedState = false, + maxLength = undefined, + hint = '', + onInputChange = () => {}, + shouldDelayFocus = false, + multiline = false, + shouldInterceptSwipe = false, + autoCorrect = true, + prefixCharacter = '', + suffixCharacter = '', + inputID, + isMarkdownEnabled = false, + excludedMarkdownStyles = [], + shouldShowClearButton = false, + shouldUseDisabledStyles = true, + prefixContainerStyle = [], + prefixStyle = [], + suffixContainerStyle = [], + suffixStyle = [], + contentWidth, + loadingSpinnerStyle, + ...inputProps + }: BaseTextInputProps, + ref: ForwardedRef, +) { + const InputComponent = isMarkdownEnabled ? RNMarkdownTextInput : RNTextInput; + const isAutoGrowHeightMarkdown = isMarkdownEnabled && autoGrowHeight; + + const theme = useTheme(); + const styles = useThemeStyles(); + const markdownStyle = useMarkdownStyle(undefined, excludedMarkdownStyles); + const {hasError = false} = inputProps; + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + + // Disabling this line for saftiness as nullish coalescing works only if value is undefined or null + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const initialValue = value || defaultValue || ''; + const initialActiveLabel = !!forceActiveLabel || initialValue.length > 0 || !!prefixCharacter || !!suffixCharacter; + + const [isFocused, setIsFocused] = useState(false); + const [passwordHidden, setPasswordHidden] = useState(inputProps.secureTextEntry); + const [textInputWidth, setTextInputWidth] = useState(0); + const [textInputHeight, setTextInputHeight] = useState(0); + const [height, setHeight] = useState(variables.componentSizeLarge); + const [width, setWidth] = useState(null); + + const labelScale = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_SCALE : styleConst.INACTIVE_LABEL_SCALE)).current; + const labelTranslateY = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_TRANSLATE_Y : styleConst.INACTIVE_LABEL_TRANSLATE_Y)).current; + const input = useRef(null); + const isLabelActive = useRef(initialActiveLabel); + + // AutoFocus which only works on mount: + useEffect(() => { + // We are manually managing focus to prevent this issue: https://github.com/Expensify/App/issues/4514 + if (!autoFocus || !input.current) { + return; + } + + if (shouldDelayFocus) { + const focusTimeout = setTimeout(() => input?.current?.focus(), CONST.ANIMATED_TRANSITION); + return () => clearTimeout(focusTimeout); + } + input.current.focus(); + // We only want this to run on mount + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, []); + + const animateLabel = useCallback( + (translateY: number, scale: number) => { + Animated.parallel([ + Animated.spring(labelTranslateY, { + toValue: translateY, + useNativeDriver, + }), + Animated.spring(labelScale, { + toValue: scale, + useNativeDriver, + }), + ]).start(); + }, + [labelScale, labelTranslateY], + ); + + const activateLabel = useCallback(() => { + const newValue = value ?? ''; + + if (newValue.length < 0 || isLabelActive.current) { + return; + } + + animateLabel(styleConst.ACTIVE_LABEL_TRANSLATE_Y, styleConst.ACTIVE_LABEL_SCALE); + isLabelActive.current = true; + }, [animateLabel, value]); + + const deactivateLabel = useCallback(() => { + const newValue = value ?? ''; + + if (!!forceActiveLabel || newValue.length !== 0 || prefixCharacter || suffixCharacter) { + return; + } + + animateLabel(styleConst.INACTIVE_LABEL_TRANSLATE_Y, styleConst.INACTIVE_LABEL_SCALE); + isLabelActive.current = false; + }, [animateLabel, forceActiveLabel, prefixCharacter, suffixCharacter, value]); + + const onFocus = (event: NativeSyntheticEvent) => { + inputProps.onFocus?.(event); + setIsFocused(true); + }; + + const onBlur = (event: NativeSyntheticEvent) => { + inputProps.onBlur?.(event); + setIsFocused(false); + }; + + const onPress = (event?: GestureResponderEvent | KeyboardEvent) => { + if (!!inputProps.disabled || !event) { + return; + } + + inputProps.onPress?.(event); + + if ('isDefaultPrevented' in event && !event?.isDefaultPrevented()) { + input.current?.focus(); + } + }; + + const onLayout = useCallback( + (event: LayoutChangeEvent) => { + if (!autoGrowHeight && multiline) { + return; + } + + const layout = event.nativeEvent.layout; + + setWidth((prevWidth: number | null) => (autoGrowHeight ? layout.width : prevWidth)); + setHeight((prevHeight: number) => (!multiline ? layout.height : prevHeight)); + }, + [autoGrowHeight, multiline], + ); + + // The ref is needed when the component is uncontrolled and we don't have a value prop + const hasValueRef = useRef(initialValue.length > 0); + const inputValue = value ?? ''; + const hasValue = inputValue.length > 0 || hasValueRef.current; + + // Activate or deactivate the label when either focus changes, or for controlled + // components when the value prop changes: + useEffect(() => { + if ( + hasValue || + isFocused || + // If the text has been supplied by Chrome autofill, the value state is not synced with the value + // as Chrome doesn't trigger a change event. When there is autofill text, keep the label activated. + isInputAutoFilled(input.current) + ) { + activateLabel(); + } else { + deactivateLabel(); + } + }, [activateLabel, deactivateLabel, hasValue, isFocused]); + + // When the value prop gets cleared externally, we need to keep the ref in sync: + useEffect(() => { + // Return early when component uncontrolled, or we still have a value + if (value === undefined || value) { + return; + } + hasValueRef.current = false; + }, [value]); + + /** + * Set Value & activateLabel + */ + const setValue = (newValue: string) => { + onInputChange?.(newValue); + + if (inputProps.onChangeText) { + Str.result(inputProps.onChangeText, newValue); + } + if (newValue && newValue.length > 0) { + hasValueRef.current = true; + // When the componment is uncontrolled, we need to manually activate the label: + if (value === undefined) { + activateLabel(); + } + } else { + hasValueRef.current = false; + } + }; + + const togglePasswordVisibility = useCallback(() => { + setPasswordHidden((prevPasswordHidden: boolean | undefined) => !prevPasswordHidden); + }, []); + + const hasLabel = !!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 + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const inputHelpText = errorText || hint; + const newPlaceholder = !!prefixCharacter || !!suffixCharacter || isFocused || !hasLabel || (hasLabel && forceActiveLabel) ? placeholder : undefined; + const newTextInputContainerStyles: StyleProp = StyleSheet.flatten([ + styles.textInputContainer, + textInputContainerStyles, + (autoGrow || !!contentWidth) && StyleUtils.getWidthStyle(textInputWidth), + !hideFocusedState && isFocused && styles.borderColorFocus, + (!!hasError || !!errorText) && styles.borderColorDanger, + autoGrowHeight && {scrollPaddingTop: typeof maxAutoGrowHeight === 'number' ? 2 * maxAutoGrowHeight : undefined}, + isAutoGrowHeightMarkdown && styles.pb2, + ]); + const isMultiline = multiline || autoGrowHeight; + + /** + * To prevent text jumping caused by virtual DOM calculations on Safari and mobile Chrome, + * make sure to include the `lineHeight`. + * Reference: https://github.com/Expensify/App/issues/26735 + * For other platforms, explicitly remove `lineHeight` from single-line inputs + * to prevent long text from disappearing once it exceeds the input space. + * See https://github.com/Expensify/App/issues/13802 + */ + const lineHeight = useMemo(() => { + if (Browser.isSafari() || Browser.isMobileChrome()) { + const lineHeightValue = StyleSheet.flatten(inputStyle).lineHeight; + if (lineHeightValue !== undefined) { + return lineHeightValue; + } + } + + return undefined; + }, [inputStyle]); + + const inputPaddingLeft = !!prefixCharacter && StyleUtils.getPaddingLeft(StyleUtils.getCharacterPadding(prefixCharacter) + styles.pl1.paddingLeft); + const inputPaddingRight = !!suffixCharacter && StyleUtils.getPaddingRight(StyleUtils.getCharacterPadding(suffixCharacter) + styles.pr1.paddingRight); + + return ( + <> + + + + {hasLabel ? ( + <> + {/* Adding this background to the label only for multiline text input, + to prevent text overlapping with label when scrolling */} + {isMultiline && } + + + ) : null} + + + {!!iconLeft && ( + + + + )} + {!!prefixCharacter && ( + + + {prefixCharacter} + + + )} + { + const baseTextInputRef = element as BaseTextInputRef | null; + if (typeof ref === 'function') { + ref(baseTextInputRef); + } else if (ref && 'current' in ref) { + // eslint-disable-next-line no-param-reassign + ref.current = baseTextInputRef; + } + + input.current = element as HTMLInputElement | null; + }} + // eslint-disable-next-line + {...inputProps} + autoCorrect={inputProps.secureTextEntry ? false : autoCorrect} + placeholder={newPlaceholder} + placeholderTextColor={theme.placeholderText} + underlineColorAndroid="transparent" + style={[ + styles.flex1, + styles.w100, + inputStyle, + (!hasLabel || isMultiline) && styles.pv0, + inputPaddingLeft, + inputPaddingRight, + inputProps.secureTextEntry && styles.secureInput, + + // Explicitly remove `lineHeight` from single line inputs so that long text doesn't disappear + // once it exceeds the input space (See https://github.com/Expensify/App/issues/13802) + !isMultiline && {height, lineHeight}, + + // Explicitly change boxSizing attribute for mobile chrome in order to apply line-height + // for the issue mentioned here https://github.com/Expensify/App/issues/26735 + // Set overflow property to enable the parent flexbox to shrink its size + // (See https://github.com/Expensify/App/issues/41766) + !isMultiline && Browser.isMobileChrome() && {boxSizing: 'content-box', height: undefined, ...styles.overflowAuto}, + + // Stop scrollbar flashing when breaking lines with autoGrowHeight enabled. + ...(autoGrowHeight && !isAutoGrowHeightMarkdown + ? [StyleUtils.getAutoGrowHeightInputStyle(textInputHeight, typeof maxAutoGrowHeight === 'number' ? maxAutoGrowHeight : 0), styles.verticalAlignTop] + : []), + isAutoGrowHeightMarkdown ? [StyleUtils.getMarkdownMaxHeight(maxAutoGrowHeight), styles.verticalAlignTop] : undefined, + // Add disabled color theme when field is not editable. + inputProps.disabled && shouldUseDisabledStyles && styles.textInputDisabled, + styles.pointerEventsAuto, + ]} + multiline={isMultiline} + maxLength={maxLength} + onFocus={onFocus} + onBlur={onBlur} + onChangeText={setValue} + secureTextEntry={passwordHidden} + onPressOut={inputProps.onPress} + showSoftInputOnFocus={!disableKeyboard} + inputMode={inputProps.inputMode} + value={value} + selection={inputProps.selection} + readOnly={isReadOnly} + defaultValue={defaultValue} + markdownStyle={markdownStyle} + /> + {!!suffixCharacter && ( + + + {suffixCharacter} + + + )} + {isFocused && !isReadOnly && shouldShowClearButton && !!value && setValue('')} />} + {!!inputProps.isLoading && ( + + )} + {!!inputProps.secureTextEntry && ( + { + e.preventDefault(); + }} + accessibilityLabel={translate('common.visible')} + > + + + )} + {!inputProps.secureTextEntry && !!icon && ( + + + + )} + + + + {!!inputHelpText && ( + + )} + + {!!contentWidth && ( + { + if (e.nativeEvent.layout.width === 0 && e.nativeEvent.layout.height === 0) { + return; + } + setTextInputWidth(e.nativeEvent.layout.width); + setTextInputHeight(e.nativeEvent.layout.height); + }} + > + + {/* \u200B added to solve the issue of not expanding the text input enough when the value ends with '\n' (https://github.com/Expensify/App/issues/21271) */} + {value ? `${value}${value.endsWith('\n') ? '\u200B' : ''}` : placeholder} + + + )} + {/* + Text input component doesn't support auto grow by default. + We're using a hidden text input to achieve that. + This text view is used to calculate width or height of the input value given textStyle in this component. + This Text component is intentionally positioned out of the screen. + */} + {(!!autoGrow || autoGrowHeight) && !isAutoGrowHeightMarkdown && ( + // Add +2 to width on Safari browsers so that text is not cut off due to the cursor or when changing the value + // Reference: https://github.com/Expensify/App/issues/8158, https://github.com/Expensify/App/issues/26628 + // For mobile Chrome, ensure proper display of the text selection handle (blue bubble down). + // Reference: https://github.com/Expensify/App/issues/34921 + { + if (e.nativeEvent.layout.width === 0 && e.nativeEvent.layout.height === 0) { + return; + } + let additionalWidth = 0; + if (Browser.isMobileSafari() || Browser.isSafari() || Browser.isMobileChrome()) { + additionalWidth = 2; + } + setTextInputWidth(e.nativeEvent.layout.width + additionalWidth); + setTextInputHeight(e.nativeEvent.layout.height); + }} + > + {/* \u200B added to solve the issue of not expanding the text input enough when the value ends with '\n' (https://github.com/Expensify/App/issues/21271) */} + {value ? `${value}${value.endsWith('\n') ? '\u200B' : ''}` : placeholder} + + )} + + ); +} + +BaseTextInput.displayName = 'BaseTextInput'; + +export default forwardRef(BaseTextInput); diff --git a/src/components/TextInput/BaseTextInput/index.e2e.tsx b/src/components/TextInput/BaseTextInput/index.e2e.tsx new file mode 100644 index 000000000000..c940163a7de6 --- /dev/null +++ b/src/components/TextInput/BaseTextInput/index.e2e.tsx @@ -0,0 +1,26 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useEffect} from 'react'; +import {DeviceEventEmitter} from 'react-native'; +import BaseTextInput from './implementation'; +import type {BaseTextInputProps, BaseTextInputRef} from './types'; + +function BaseTextInputE2E(props: BaseTextInputProps, ref: ForwardedRef) { + useEffect(() => { + const testId = props.testID; + if (!testId) { + return; + } + console.debug(`[E2E] BaseTextInput: text-input with testID: ${testId} changed text to ${props.value}`); + + DeviceEventEmitter.emit('onChangeText', {testID: testId, value: props.value}); + }, [props.value, props.testID]); + + return ( + + ); +} + +export default forwardRef(BaseTextInputE2E); diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index e36ae60255fc..0df586b70057 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -1,534 +1,3 @@ -import {Str} from 'expensify-common'; -import type {ForwardedRef} from 'react'; -import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import type {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TextInputFocusEventData, ViewStyle} from 'react-native'; -import {ActivityIndicator, Animated, StyleSheet, View} from 'react-native'; -import Checkbox from '@components/Checkbox'; -import FormHelpMessage from '@components/FormHelpMessage'; -import Icon from '@components/Icon'; -import * as Expensicons from '@components/Icon/Expensicons'; -import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; -import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; -import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; -import type {AnimatedTextInputRef} from '@components/RNTextInput'; -import RNTextInput from '@components/RNTextInput'; -import SwipeInterceptPanResponder from '@components/SwipeInterceptPanResponder'; -import Text from '@components/Text'; -import * as styleConst from '@components/TextInput/styleConst'; -import TextInputClearButton from '@components/TextInput/TextInputClearButton'; -import TextInputLabel from '@components/TextInput/TextInputLabel'; -import useLocalize from '@hooks/useLocalize'; -import useMarkdownStyle from '@hooks/useMarkdownStyle'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as Browser from '@libs/Browser'; -import isInputAutoFilled from '@libs/isInputAutoFilled'; -import useNativeDriver from '@libs/useNativeDriver'; -import variables from '@styles/variables'; -import CONST from '@src/CONST'; -import type {BaseTextInputProps, BaseTextInputRef} from './types'; +import BaseTextInput from './implementation'; -function BaseTextInput( - { - label = '', - /** - * To be able to function as either controlled or uncontrolled component we should not - * assign a default prop value for `value` or `defaultValue` props - */ - value = undefined, - defaultValue = undefined, - placeholder = '', - errorText = '', - icon = null, - iconLeft = null, - textInputContainerStyles, - touchableInputWrapperStyle, - containerStyles, - inputStyle, - forceActiveLabel = false, - autoFocus = false, - disableKeyboard = false, - autoGrow = false, - autoGrowHeight = false, - maxAutoGrowHeight, - hideFocusedState = false, - maxLength = undefined, - hint = '', - onInputChange = () => {}, - shouldDelayFocus = false, - multiline = false, - shouldInterceptSwipe = false, - autoCorrect = true, - prefixCharacter = '', - suffixCharacter = '', - inputID, - isMarkdownEnabled = false, - excludedMarkdownStyles = [], - shouldShowClearButton = false, - shouldUseDisabledStyles = true, - prefixContainerStyle = [], - prefixStyle = [], - suffixContainerStyle = [], - suffixStyle = [], - contentWidth, - loadingSpinnerStyle, - ...inputProps - }: BaseTextInputProps, - ref: ForwardedRef, -) { - const InputComponent = isMarkdownEnabled ? RNMarkdownTextInput : RNTextInput; - const isAutoGrowHeightMarkdown = isMarkdownEnabled && autoGrowHeight; - - const theme = useTheme(); - const styles = useThemeStyles(); - const markdownStyle = useMarkdownStyle(undefined, excludedMarkdownStyles); - const {hasError = false} = inputProps; - const StyleUtils = useStyleUtils(); - const {translate} = useLocalize(); - - // Disabling this line for saftiness as nullish coalescing works only if value is undefined or null - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const initialValue = value || defaultValue || ''; - const initialActiveLabel = !!forceActiveLabel || initialValue.length > 0 || !!prefixCharacter || !!suffixCharacter; - - const [isFocused, setIsFocused] = useState(false); - const [passwordHidden, setPasswordHidden] = useState(inputProps.secureTextEntry); - const [textInputWidth, setTextInputWidth] = useState(0); - const [textInputHeight, setTextInputHeight] = useState(0); - const [height, setHeight] = useState(variables.componentSizeLarge); - const [width, setWidth] = useState(null); - - const labelScale = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_SCALE : styleConst.INACTIVE_LABEL_SCALE)).current; - const labelTranslateY = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_TRANSLATE_Y : styleConst.INACTIVE_LABEL_TRANSLATE_Y)).current; - const input = useRef(null); - const isLabelActive = useRef(initialActiveLabel); - - // AutoFocus which only works on mount: - useEffect(() => { - // We are manually managing focus to prevent this issue: https://github.com/Expensify/App/issues/4514 - if (!autoFocus || !input.current) { - return; - } - - if (shouldDelayFocus) { - const focusTimeout = setTimeout(() => input?.current?.focus(), CONST.ANIMATED_TRANSITION); - return () => clearTimeout(focusTimeout); - } - input.current.focus(); - // We only want this to run on mount - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, []); - - const animateLabel = useCallback( - (translateY: number, scale: number) => { - Animated.parallel([ - Animated.spring(labelTranslateY, { - toValue: translateY, - useNativeDriver, - }), - Animated.spring(labelScale, { - toValue: scale, - useNativeDriver, - }), - ]).start(); - }, - [labelScale, labelTranslateY], - ); - - const activateLabel = useCallback(() => { - const newValue = value ?? ''; - - if (newValue.length < 0 || isLabelActive.current) { - return; - } - - animateLabel(styleConst.ACTIVE_LABEL_TRANSLATE_Y, styleConst.ACTIVE_LABEL_SCALE); - isLabelActive.current = true; - }, [animateLabel, value]); - - const deactivateLabel = useCallback(() => { - const newValue = value ?? ''; - - if (!!forceActiveLabel || newValue.length !== 0 || prefixCharacter || suffixCharacter) { - return; - } - - animateLabel(styleConst.INACTIVE_LABEL_TRANSLATE_Y, styleConst.INACTIVE_LABEL_SCALE); - isLabelActive.current = false; - }, [animateLabel, forceActiveLabel, prefixCharacter, suffixCharacter, value]); - - const onFocus = (event: NativeSyntheticEvent) => { - inputProps.onFocus?.(event); - setIsFocused(true); - }; - - const onBlur = (event: NativeSyntheticEvent) => { - inputProps.onBlur?.(event); - setIsFocused(false); - }; - - const onPress = (event?: GestureResponderEvent | KeyboardEvent) => { - if (!!inputProps.disabled || !event) { - return; - } - - inputProps.onPress?.(event); - - if ('isDefaultPrevented' in event && !event?.isDefaultPrevented()) { - input.current?.focus(); - } - }; - - const onLayout = useCallback( - (event: LayoutChangeEvent) => { - if (!autoGrowHeight && multiline) { - return; - } - - const layout = event.nativeEvent.layout; - - setWidth((prevWidth: number | null) => (autoGrowHeight ? layout.width : prevWidth)); - setHeight((prevHeight: number) => (!multiline ? layout.height : prevHeight)); - }, - [autoGrowHeight, multiline], - ); - - // The ref is needed when the component is uncontrolled and we don't have a value prop - const hasValueRef = useRef(initialValue.length > 0); - const inputValue = value ?? ''; - const hasValue = inputValue.length > 0 || hasValueRef.current; - - // Activate or deactivate the label when either focus changes, or for controlled - // components when the value prop changes: - useEffect(() => { - if ( - hasValue || - isFocused || - // If the text has been supplied by Chrome autofill, the value state is not synced with the value - // as Chrome doesn't trigger a change event. When there is autofill text, keep the label activated. - isInputAutoFilled(input.current) - ) { - activateLabel(); - } else { - deactivateLabel(); - } - }, [activateLabel, deactivateLabel, hasValue, isFocused]); - - // When the value prop gets cleared externally, we need to keep the ref in sync: - useEffect(() => { - // Return early when component uncontrolled, or we still have a value - if (value === undefined || value) { - return; - } - hasValueRef.current = false; - }, [value]); - - /** - * Set Value & activateLabel - */ - const setValue = (newValue: string) => { - onInputChange?.(newValue); - - if (inputProps.onChangeText) { - Str.result(inputProps.onChangeText, newValue); - } - if (newValue && newValue.length > 0) { - hasValueRef.current = true; - // When the componment is uncontrolled, we need to manually activate the label: - if (value === undefined) { - activateLabel(); - } - } else { - hasValueRef.current = false; - } - }; - - const togglePasswordVisibility = useCallback(() => { - setPasswordHidden((prevPasswordHidden: boolean | undefined) => !prevPasswordHidden); - }, []); - - const hasLabel = !!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 - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const inputHelpText = errorText || hint; - const newPlaceholder = !!prefixCharacter || !!suffixCharacter || isFocused || !hasLabel || (hasLabel && forceActiveLabel) ? placeholder : undefined; - const newTextInputContainerStyles: StyleProp = StyleSheet.flatten([ - styles.textInputContainer, - textInputContainerStyles, - (autoGrow || !!contentWidth) && StyleUtils.getWidthStyle(textInputWidth), - !hideFocusedState && isFocused && styles.borderColorFocus, - (!!hasError || !!errorText) && styles.borderColorDanger, - autoGrowHeight && {scrollPaddingTop: typeof maxAutoGrowHeight === 'number' ? 2 * maxAutoGrowHeight : undefined}, - isAutoGrowHeightMarkdown && styles.pb2, - ]); - const isMultiline = multiline || autoGrowHeight; - - /** - * To prevent text jumping caused by virtual DOM calculations on Safari and mobile Chrome, - * make sure to include the `lineHeight`. - * Reference: https://github.com/Expensify/App/issues/26735 - * For other platforms, explicitly remove `lineHeight` from single-line inputs - * to prevent long text from disappearing once it exceeds the input space. - * See https://github.com/Expensify/App/issues/13802 - */ - const lineHeight = useMemo(() => { - if (Browser.isSafari() || Browser.isMobileChrome()) { - const lineHeightValue = StyleSheet.flatten(inputStyle).lineHeight; - if (lineHeightValue !== undefined) { - return lineHeightValue; - } - } - - return undefined; - }, [inputStyle]); - - const inputPaddingLeft = !!prefixCharacter && StyleUtils.getPaddingLeft(StyleUtils.getCharacterPadding(prefixCharacter) + styles.pl1.paddingLeft); - const inputPaddingRight = !!suffixCharacter && StyleUtils.getPaddingRight(StyleUtils.getCharacterPadding(suffixCharacter) + styles.pr1.paddingRight); - - return ( - <> - - - - {hasLabel ? ( - <> - {/* Adding this background to the label only for multiline text input, - to prevent text overlapping with label when scrolling */} - {isMultiline && } - - - ) : null} - - - {!!iconLeft && ( - - - - )} - {!!prefixCharacter && ( - - - {prefixCharacter} - - - )} - { - const baseTextInputRef = element as BaseTextInputRef | null; - if (typeof ref === 'function') { - ref(baseTextInputRef); - } else if (ref && 'current' in ref) { - // eslint-disable-next-line no-param-reassign - ref.current = baseTextInputRef; - } - - input.current = element as HTMLInputElement | null; - }} - // eslint-disable-next-line - {...inputProps} - autoCorrect={inputProps.secureTextEntry ? false : autoCorrect} - placeholder={newPlaceholder} - placeholderTextColor={theme.placeholderText} - underlineColorAndroid="transparent" - style={[ - styles.flex1, - styles.w100, - inputStyle, - (!hasLabel || isMultiline) && styles.pv0, - inputPaddingLeft, - inputPaddingRight, - inputProps.secureTextEntry && styles.secureInput, - - // Explicitly remove `lineHeight` from single line inputs so that long text doesn't disappear - // once it exceeds the input space (See https://github.com/Expensify/App/issues/13802) - !isMultiline && {height, lineHeight}, - - // Explicitly change boxSizing attribute for mobile chrome in order to apply line-height - // for the issue mentioned here https://github.com/Expensify/App/issues/26735 - // Set overflow property to enable the parent flexbox to shrink its size - // (See https://github.com/Expensify/App/issues/41766) - !isMultiline && Browser.isMobileChrome() && {boxSizing: 'content-box', height: undefined, ...styles.overflowAuto}, - - // Stop scrollbar flashing when breaking lines with autoGrowHeight enabled. - ...(autoGrowHeight && !isAutoGrowHeightMarkdown - ? [StyleUtils.getAutoGrowHeightInputStyle(textInputHeight, typeof maxAutoGrowHeight === 'number' ? maxAutoGrowHeight : 0), styles.verticalAlignTop] - : []), - isAutoGrowHeightMarkdown ? [StyleUtils.getMarkdownMaxHeight(maxAutoGrowHeight), styles.verticalAlignTop] : undefined, - // Add disabled color theme when field is not editable. - inputProps.disabled && shouldUseDisabledStyles && styles.textInputDisabled, - styles.pointerEventsAuto, - ]} - multiline={isMultiline} - maxLength={maxLength} - onFocus={onFocus} - onBlur={onBlur} - onChangeText={setValue} - secureTextEntry={passwordHidden} - onPressOut={inputProps.onPress} - showSoftInputOnFocus={!disableKeyboard} - inputMode={inputProps.inputMode} - value={value} - selection={inputProps.selection} - readOnly={isReadOnly} - defaultValue={defaultValue} - markdownStyle={markdownStyle} - /> - {!!suffixCharacter && ( - - - {suffixCharacter} - - - )} - {isFocused && !isReadOnly && shouldShowClearButton && !!value && setValue('')} />} - {!!inputProps.isLoading && ( - - )} - {!!inputProps.secureTextEntry && ( - { - e.preventDefault(); - }} - accessibilityLabel={translate('common.visible')} - > - - - )} - {!inputProps.secureTextEntry && !!icon && ( - - - - )} - - - - {!!inputHelpText && ( - - )} - - {!!contentWidth && ( - { - if (e.nativeEvent.layout.width === 0 && e.nativeEvent.layout.height === 0) { - return; - } - setTextInputWidth(e.nativeEvent.layout.width); - setTextInputHeight(e.nativeEvent.layout.height); - }} - > - - {/* \u200B added to solve the issue of not expanding the text input enough when the value ends with '\n' (https://github.com/Expensify/App/issues/21271) */} - {value ? `${value}${value.endsWith('\n') ? '\u200B' : ''}` : placeholder} - - - )} - {/* - Text input component doesn't support auto grow by default. - We're using a hidden text input to achieve that. - This text view is used to calculate width or height of the input value given textStyle in this component. - This Text component is intentionally positioned out of the screen. - */} - {(!!autoGrow || autoGrowHeight) && !isAutoGrowHeightMarkdown && ( - // Add +2 to width on Safari browsers so that text is not cut off due to the cursor or when changing the value - // Reference: https://github.com/Expensify/App/issues/8158, https://github.com/Expensify/App/issues/26628 - // For mobile Chrome, ensure proper display of the text selection handle (blue bubble down). - // Reference: https://github.com/Expensify/App/issues/34921 - { - if (e.nativeEvent.layout.width === 0 && e.nativeEvent.layout.height === 0) { - return; - } - let additionalWidth = 0; - if (Browser.isMobileSafari() || Browser.isSafari() || Browser.isMobileChrome()) { - additionalWidth = 2; - } - setTextInputWidth(e.nativeEvent.layout.width + additionalWidth); - setTextInputHeight(e.nativeEvent.layout.height); - }} - > - {/* \u200B added to solve the issue of not expanding the text input enough when the value ends with '\n' (https://github.com/Expensify/App/issues/21271) */} - {value ? `${value}${value.endsWith('\n') ? '\u200B' : ''}` : placeholder} - - )} - - ); -} - -BaseTextInput.displayName = 'BaseTextInput'; - -export default forwardRef(BaseTextInput); +export default BaseTextInput; diff --git a/src/libs/E2E/interactions/index.ts b/src/libs/E2E/interactions/index.ts index 5a1b11b411f2..e9ad35388ed7 100644 --- a/src/libs/E2E/interactions/index.ts +++ b/src/libs/E2E/interactions/index.ts @@ -15,6 +15,19 @@ const waitForElement = (testID: string) => { }); }; +const waitForTextInputValue = (text: string, _testID: string): Promise => { + return new Promise((resolve) => { + const subscription = DeviceEventEmitter.addListener('onChangeText', ({testID, value}) => { + if (_testID !== testID || value !== text) { + return; + } + + subscription.remove(); + resolve(undefined); + }); + }); +}; + const waitForEvent = (eventName: string): Promise => { return new Promise((resolve) => { Performance.subscribeToMeasurements((entry) => { @@ -31,4 +44,4 @@ const tap = (testID: string) => { E2EGenericPressableWrapper.getPressableProps(testID)?.onPress?.(); }; -export {waitForElement, tap, waitForEvent}; +export {waitForElement, tap, waitForEvent, waitForTextInputValue}; diff --git a/src/libs/E2E/tests/moneyRequestTest.e2e.ts b/src/libs/E2E/tests/moneyRequestTest.e2e.ts index 306a13a1745a..cd456c496101 100644 --- a/src/libs/E2E/tests/moneyRequestTest.e2e.ts +++ b/src/libs/E2E/tests/moneyRequestTest.e2e.ts @@ -3,7 +3,7 @@ import type {NativeConfig} from 'react-native-config'; import E2ELogin from '@libs/E2E/actions/e2eLogin'; import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded'; import E2EClient from '@libs/E2E/client'; -import {tap, waitForElement, waitForEvent} from '@libs/E2E/interactions'; +import {tap, waitForElement, waitForEvent, waitForTextInputValue} from '@libs/E2E/interactions'; import getConfigValueOrThrow from '@libs/E2E/utils/getConfigValueOrThrow'; import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; @@ -48,10 +48,11 @@ const test = (config: NativeConfig) => { .then(() => E2EClient.sendNativeCommand(NativeCommands.makeClearCommand())) .then(() => { tap('button_2'); - setTimeout(() => { - Performance.markStart(CONST.TIMING.OPEN_SUBMIT_EXPENSE_CONTACT); - tap('next-button'); - }, 1000); + }) + .then(() => waitForTextInputValue('2', 'moneyRequestAmountInput')) + .then(() => { + Performance.markStart(CONST.TIMING.OPEN_SUBMIT_EXPENSE_CONTACT); + tap('next-button'); }) .then(() => waitForEvent(CONST.TIMING.OPEN_SUBMIT_EXPENSE_CONTACT)) .then((entry) => { diff --git a/src/pages/iou/MoneyRequestAmountForm.tsx b/src/pages/iou/MoneyRequestAmountForm.tsx index 0d37a6777e64..c5ea1c8c17ee 100644 --- a/src/pages/iou/MoneyRequestAmountForm.tsx +++ b/src/pages/iou/MoneyRequestAmountForm.tsx @@ -282,6 +282,7 @@ function MoneyRequestAmountForm( moneyRequestAmountInputRef={moneyRequestAmountInput} inputStyle={[styles.iouAmountTextInput]} containerStyle={[styles.iouAmountTextInputContainer]} + testID="moneyRequestAmountInput" /> {!!formError && (