diff --git a/src/components/TextInput/BaseTextInput.js b/src/components/TextInput/BaseTextInput.js index f3d708013d6cb..c9c61207f81e4 100644 --- a/src/components/TextInput/BaseTextInput.js +++ b/src/components/TextInput/BaseTextInput.js @@ -1,5 +1,5 @@ import _ from 'underscore'; -import React, {Component} from 'react'; +import React, {useState, useRef, useEffect, useCallback} from 'react'; import {Animated, View, AppState, Keyboard, StyleSheet} from 'react-native'; import Str from 'expensify-common/lib/str'; import RNTextInput from '../RNTextInput'; @@ -18,128 +18,184 @@ import getSecureEntryKeyboardType from '../../libs/getSecureEntryKeyboardType'; import CONST from '../../CONST'; import FormHelpMessage from '../FormHelpMessage'; import isInputAutoFilled from '../../libs/isInputAutoFilled'; -import * as Pressables from '../Pressable'; +import PressableWithoutFeedback from '../Pressable/PressableWithoutFeedback'; import withLocalize from '../withLocalize'; -const PressableWithoutFeedback = Pressables.PressableWithoutFeedback; -class BaseTextInput extends Component { - constructor(props) { - super(props); - - const value = props.value || props.defaultValue || ''; - const activeLabel = props.forceActiveLabel || value.length > 0 || Boolean(props.prefixCharacter); - - this.state = { - isFocused: false, - labelTranslateY: new Animated.Value(activeLabel ? styleConst.ACTIVE_LABEL_TRANSLATE_Y : styleConst.INACTIVE_LABEL_TRANSLATE_Y), - labelScale: new Animated.Value(activeLabel ? styleConst.ACTIVE_LABEL_SCALE : styleConst.INACTIVE_LABEL_SCALE), - passwordHidden: props.secureTextEntry, - textInputWidth: 0, - textInputHeight: 0, - prefixWidth: 0, - selection: props.selection, - height: variables.componentSizeLarge, - - // Value should be kept in state for the autoGrow feature to work - https://github.com/Expensify/App/pull/8232#issuecomment-1077282006 - value, - }; - - this.input = null; - this.isLabelActive = activeLabel; - this.onPress = this.onPress.bind(this); - this.onFocus = this.onFocus.bind(this); - this.onBlur = this.onBlur.bind(this); - this.setValue = this.setValue.bind(this); - this.togglePasswordVisibility = this.togglePasswordVisibility.bind(this); - this.dismissKeyboardWhenBackgrounded = this.dismissKeyboardWhenBackgrounded.bind(this); - this.storePrefixLayoutDimensions = this.storePrefixLayoutDimensions.bind(this); - } - - componentDidMount() { - if (this.props.disableKeyboard) { - this.appStateSubscription = AppState.addEventListener('change', this.dismissKeyboardWhenBackgrounded); +function BaseTextInput(props) { + const inputValue = props.value || props.defaultValue || ''; + const initialActiveLabel = props.forceActiveLabel || inputValue.length > 0 || Boolean(props.prefixCharacter); + + const [isFocused, setIsFocused] = useState(false); + const [passwordHidden, setPasswordHidden] = useState(props.secureTextEntry); + const [textInputWidth, setTextInputWidth] = useState(0); + const [textInputHeight, setTextInputHeight] = useState(0); + const [prefixWidth, setPrefixWidth] = useState(0); + const [height, setHeight] = useState(variables.componentSizeLarge); + const [width, setWidth] = useState(); + 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); + + useEffect(() => { + if (!props.disableKeyboard) { + return; } + const appStateSubscription = AppState.addEventListener('change', (nextAppState) => { + if (!nextAppState.match(/inactive|background/)) { + return; + } + + Keyboard.dismiss(); + }); + + return () => { + appStateSubscription.remove(); + }; + }, [props.disableKeyboard]); + + // AutoFocus which only works on mount: + useEffect(() => { // We are manually managing focus to prevent this issue: https://github.com/Expensify/App/issues/4514 - if (!this.props.autoFocus || !this.input) { + if (!props.autoFocus || !input.current) { return; } - if (this.props.shouldDelayFocus) { - this.focusTimeout = setTimeout(() => this.input.focus(), CONST.ANIMATED_TRANSITION); + let focusTimeout; + if (props.shouldDelayFocus) { + focusTimeout = setTimeout(() => input.current.focus(), CONST.ANIMATED_TRANSITION); return; } - this.input.focus(); - } + input.current.focus(); - componentDidUpdate(prevProps) { - // Activate or deactivate the label when value is changed programmatically from outside - const inputValue = _.isUndefined(this.props.value) ? this.input.value : this.props.value; - if ((_.isUndefined(inputValue) || this.state.value === inputValue) && _.isEqual(prevProps.selection, this.props.selection)) { + return () => { + if (!focusTimeout) { + return; + } + clearTimeout(focusTimeout); + }; + // We only want this to run on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const animateLabel = useCallback( + (translateY, scale) => { + Animated.parallel([ + Animated.spring(labelTranslateY, { + toValue: translateY, + duration: styleConst.LABEL_ANIMATION_DURATION, + useNativeDriver: true, + }), + Animated.spring(labelScale, { + toValue: scale, + duration: styleConst.LABEL_ANIMATION_DURATION, + useNativeDriver: true, + }), + ]).start(); + }, + [labelScale, labelTranslateY], + ); + + const activateLabel = useCallback(() => { + const value = props.value || ''; + + if (value.length < 0 || isLabelActive.current) { return; } - // eslint-disable-next-line react/no-did-update-set-state - this.setState({value: inputValue, selection: this.props.selection}, () => { - if (this.state.value) { - this.activateLabel(); - } else if (!this.state.isFocused) { - this.deactivateLabel(); - } - }); + animateLabel(styleConst.ACTIVE_LABEL_TRANSLATE_Y, styleConst.ACTIVE_LABEL_SCALE); + isLabelActive.current = true; + }, [animateLabel, props.value]); - // In some cases, When the value prop is empty, it is not properly updated on the TextInput due to its uncontrolled nature, thus manually clearing the TextInput. - if (inputValue === '') { - this.input.clear(); + const deactivateLabel = useCallback(() => { + const value = props.value || ''; + + if (props.forceActiveLabel || value.length !== 0 || props.prefixCharacter) { + return; } - } - componentWillUnmount() { - if (this.focusTimeout) { - clearTimeout(this.focusTimeout); + animateLabel(styleConst.INACTIVE_LABEL_TRANSLATE_Y, styleConst.INACTIVE_LABEL_SCALE); + isLabelActive.current = false; + }, [animateLabel, props.forceActiveLabel, props.prefixCharacter, props.value]); + + const onFocus = (event) => { + if (props.onFocus) { + props.onFocus(event); } + setIsFocused(true); + }; - if (!this.props.disableKeyboard || !this.appStateSubscription) { - return; + const onBlur = (event) => { + if (props.onBlur) { + props.onBlur(event); } + setIsFocused(false); - this.appStateSubscription.remove(); - } + // 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, don't deactivate label. + if (!isInputAutoFilled(input.current)) { + deactivateLabel(); + } + }; - onPress(event) { - if (this.props.disabled) { + const onPress = (event) => { + if (props.disabled) { return; } - if (this.props.onPress) { - this.props.onPress(event); + if (props.onPress) { + props.onPress(event); } if (!event.isDefaultPrevented()) { - this.input.focus(); + input.current.focus(); } - } + }; - onFocus(event) { - if (this.props.onFocus) { - this.props.onFocus(event); - } - this.setState({isFocused: true}); - this.activateLabel(); - } + const onLayout = useCallback( + (event) => { + if (!props.autoGrowHeight && props.multiline) { + return; + } + + const layout = event.nativeEvent.layout; + + setWidth((prevWidth) => (props.autoGrowHeight ? layout.width : prevWidth)); + setHeight((prevHeight) => (!props.multiline ? layout.height : prevHeight)); + }, + [props.autoGrowHeight, props.multiline], + ); + + useEffect(() => { + // Handle side effects when the value gets changed programatically from the outside - onBlur(event) { - if (this.props.onBlur) { - this.props.onBlur(event); + // In some cases, When the value prop is empty, it is not properly updated on the TextInput due to its uncontrolled nature, thus manually clearing the TextInput. + if (inputValue === '') { + input.current.clear(); } - this.setState({isFocused: false}); - // 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, don't deactivate label. - if (!isInputAutoFilled(this.input)) { - this.deactivateLabel(); + if (inputValue) { + activateLabel(); } - } + }, [activateLabel, inputValue]); + + // We capture whether the input has a value or not in a ref. + // It gets updated when the text gets changed. + const hasValueRef = useRef(inputValue.length > 0); + + // Activate or deactivate the label when the focus changes: + useEffect(() => { + // We can't use inputValue here directly, as it might contain + // the defaultValue, which doesn't get updated when the text changes. + // We can't use props.value either, as it might be undefined. + if (hasValueRef.current || isFocused) { + activateLabel(); + } else if (!hasValueRef.current && !isFocused) { + deactivateLabel(); + } + }, [activateLabel, deactivateLabel, inputValue, isFocused]); /** * Set Value & activateLabel @@ -147,258 +203,202 @@ class BaseTextInput extends Component { * @param {String} value * @memberof BaseTextInput */ - setValue(value) { - if (this.props.onInputChange) { - this.props.onInputChange(value); - } - this.setState({value}); - Str.result(this.props.onChangeText, value); - this.activateLabel(); - } - - activateLabel() { - if (this.state.value.length < 0 || this.isLabelActive) { - return; + const setValue = (value) => { + if (props.onInputChange) { + props.onInputChange(value); } - this.animateLabel(styleConst.ACTIVE_LABEL_TRANSLATE_Y, styleConst.ACTIVE_LABEL_SCALE); - this.isLabelActive = true; - } - - deactivateLabel() { - if (this.props.forceActiveLabel || this.state.value.length !== 0 || this.props.prefixCharacter) { - return; - } - - this.animateLabel(styleConst.INACTIVE_LABEL_TRANSLATE_Y, styleConst.INACTIVE_LABEL_SCALE); - this.isLabelActive = false; - } - - dismissKeyboardWhenBackgrounded(nextAppState) { - if (!nextAppState.match(/inactive|background/)) { - return; + Str.result(props.onChangeText, value); + if (value && value.length > 0) { + hasValueRef.current = true; + activateLabel(); + } else { + hasValueRef.current = false; } - - Keyboard.dismiss(); - } - - animateLabel(translateY, scale) { - Animated.parallel([ - Animated.spring(this.state.labelTranslateY, { - toValue: translateY, - duration: styleConst.LABEL_ANIMATION_DURATION, - useNativeDriver: true, - }), - Animated.spring(this.state.labelScale, { - toValue: scale, - duration: styleConst.LABEL_ANIMATION_DURATION, - useNativeDriver: true, - }), - ]).start(); - } - - togglePasswordVisibility() { - this.setState((prevState) => ({passwordHidden: !prevState.passwordHidden})); - } - - storePrefixLayoutDimensions(event) { - this.setState({prefixWidth: Math.abs(event.nativeEvent.layout.width)}); - } - - render() { - // eslint-disable-next-line react/forbid-foreign-prop-types - const inputProps = _.omit(this.props, _.keys(baseTextInputPropTypes.propTypes)); - const hasLabel = Boolean(this.props.label.length); - const isEditable = _.isUndefined(this.props.editable) ? !this.props.disabled : this.props.editable; - const inputHelpText = this.props.errorText || this.props.hint; - const placeholder = this.props.prefixCharacter || this.state.isFocused || !hasLabel || (hasLabel && this.props.forceActiveLabel) ? this.props.placeholder : null; - const maxHeight = StyleSheet.flatten(this.props.containerStyles).maxHeight; - const textInputContainerStyles = _.reduce( - [ - styles.textInputContainer, - ...this.props.textInputContainerStyles, - this.props.autoGrow && StyleUtils.getWidthStyle(this.state.textInputWidth), - !this.props.hideFocusedState && this.state.isFocused && styles.borderColorFocus, - (this.props.hasError || this.props.errorText) && styles.borderColorDanger, - this.props.autoGrowHeight && {scrollPaddingTop: 2 * maxHeight}, - ], - (finalStyles, s) => ({...finalStyles, ...s}), - {}, - ); - const isMultiline = this.props.multiline || this.props.autoGrowHeight; - - return ( - <> - - { + setPasswordHidden((prevState) => !prevState.passwordHidden); + }, []); + + const storePrefixLayoutDimensions = useCallback((event) => { + setPrefixWidth(Math.abs(event.nativeEvent.layout.width)); + }, []); + + // eslint-disable-next-line react/forbid-foreign-prop-types + const inputProps = _.omit(props, _.keys(baseTextInputPropTypes.propTypes)); + const hasLabel = Boolean(props.label.length); + const isEditable = _.isUndefined(props.editable) ? !props.disabled : props.editable; + const inputHelpText = props.errorText || props.hint; + const placeholder = props.prefixCharacter || isFocused || !hasLabel || (hasLabel && props.forceActiveLabel) ? props.placeholder : null; + const maxHeight = StyleSheet.flatten(props.containerStyles).maxHeight; + const textInputContainerStyles = StyleSheet.flatten([ + styles.textInputContainer, + ...props.textInputContainerStyles, + props.autoGrow && StyleUtils.getWidthStyle(textInputWidth), + !props.hideFocusedState && isFocused && styles.borderColorFocus, + (props.hasError || props.errorText) && styles.borderColorDanger, + props.autoGrowHeight && {scrollPaddingTop: 2 * maxHeight}, + ]); + const isMultiline = props.multiline || props.autoGrowHeight; + + return ( + <> + + + - { - if (!this.props.autoGrowHeight && this.props.multiline) { - return; - } - - const layout = event.nativeEvent.layout; - - this.setState((prevState) => ({ - width: this.props.autoGrowHeight ? layout.width : prevState.width, - height: !isMultiline ? layout.height : prevState.height, - })); - }} - style={[ - textInputContainerStyles, - - // When autoGrow is on and minWidth is not supplied, add a minWidth to allow the input to be focusable. - this.props.autoGrow && !textInputContainerStyles.minWidth && styles.mnw2, - ]} - > - {hasLabel ? ( - <> - {/* Adding this background to the label only for multiline text input, - to prevent text overlapping with label when scrolling */} - {isMultiline && ( - - )} - + {/* Adding this background to the label only for multiline text input, + to prevent text overlapping with label when scrolling */} + {isMultiline && ( + - - ) : null} - - {Boolean(this.props.prefixCharacter) && ( - - - {this.props.prefixCharacter} - - )} - { - if (typeof this.props.innerRef === 'function') { - this.props.innerRef(ref); - } else if (this.props.innerRef && _.has(this.props.innerRef, 'current')) { - this.props.innerRef.current = ref; - } - this.input = ref; - }} - // eslint-disable-next-line - {...inputProps} - autoCorrect={this.props.secureTextEntry ? false : this.props.autoCorrect} - placeholder={placeholder} - placeholderTextColor={themeColors.placeholderText} - underlineColorAndroid="transparent" - style={[ - styles.flex1, - styles.w100, - this.props.inputStyle, - (!hasLabel || isMultiline) && styles.pv0, - this.props.prefixCharacter && StyleUtils.getPaddingLeft(this.state.prefixWidth + styles.pl1.paddingLeft), - this.props.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: this.state.height, lineHeight: undefined}, - - // Stop scrollbar flashing when breaking lines with autoGrowHeight enabled. - this.props.autoGrowHeight && StyleUtils.getAutoGrowHeightInputStyle(this.state.textInputHeight, maxHeight), - ]} - multiline={isMultiline} - maxLength={this.props.maxLength} - onFocus={this.onFocus} - onBlur={this.onBlur} - onChangeText={this.setValue} - secureTextEntry={this.state.passwordHidden} - onPressOut={this.props.onPress} - showSoftInputOnFocus={!this.props.disableKeyboard} - keyboardType={getSecureEntryKeyboardType(this.props.keyboardType, this.props.secureTextEntry, this.state.passwordHidden)} - value={this.state.value} - selection={this.state.selection} - editable={isEditable} - // FormSubmit Enter key handler does not have access to direct props. - // `dataset.submitOnEnter` is used to indicate that pressing Enter on this input should call the submit callback. - dataSet={{submitOnEnter: isMultiline && this.props.submitOnEnter}} + - {Boolean(this.props.secureTextEntry) && ( - e.preventDefault()} - accessibilityLabel={this.props.translate('common.visible')} + + ) : null} + + {Boolean(props.prefixCharacter) && ( + + - - - )} - {!this.props.secureTextEntry && Boolean(this.props.icon) && ( - - - - )} - + {props.prefixCharacter} + + + )} + { + if (typeof props.innerRef === 'function') { + props.innerRef(ref); + } else if (props.innerRef && _.has(props.innerRef, 'current')) { + // eslint-disable-next-line no-param-reassign + props.innerRef.current = ref; + } + input.current = ref; + }} + // eslint-disable-next-line + {...inputProps} + autoCorrect={props.secureTextEntry ? false : props.autoCorrect} + placeholder={placeholder} + placeholderTextColor={themeColors.placeholderText} + underlineColorAndroid="transparent" + style={[ + styles.flex1, + styles.w100, + props.inputStyle, + (!hasLabel || isMultiline) && styles.pv0, + props.prefixCharacter && StyleUtils.getPaddingLeft(prefixWidth + styles.pl1.paddingLeft), + props.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: undefined}, + + // Stop scrollbar flashing when breaking lines with autoGrowHeight enabled. + props.autoGrowHeight && StyleUtils.getAutoGrowHeightInputStyle(textInputHeight, maxHeight), + ]} + multiline={isMultiline} + maxLength={props.maxLength} + onFocus={onFocus} + onBlur={onBlur} + onChangeText={setValue} + secureTextEntry={passwordHidden} + onPressOut={props.onPress} + showSoftInputOnFocus={!props.disableKeyboard} + keyboardType={getSecureEntryKeyboardType(props.keyboardType, props.secureTextEntry, passwordHidden)} + value={props.value} + selection={props.selection} + editable={isEditable} + defaultValue={props.defaultValue} + // FormSubmit Enter key handler does not have access to direct props. + // `dataset.submitOnEnter` is used to indicate that pressing Enter on this input should call the submit callback. + dataSet={{submitOnEnter: isMultiline && props.submitOnEnter}} + /> + {Boolean(props.secureTextEntry) && ( + e.preventDefault()} + accessibilityLabel={props.translate('common.visible')} + > + + + )} + {!props.secureTextEntry && Boolean(props.icon) && ( + + + + )} - - {!_.isEmpty(inputHelpText) && ( - - )} - - {/* - 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. - */} - {(this.props.autoGrow || this.props.autoGrowHeight) && ( - // Add +2 to width so that the first digit of amount do not cut off on mWeb - https://github.com/Expensify/App/issues/8158. - this.setState({textInputWidth: e.nativeEvent.layout.width + 2, textInputHeight: e.nativeEvent.layout.height})} - > - {this.state.value || this.props.placeholder} - + + + {!_.isEmpty(inputHelpText) && ( + )} - - ); - } + + {/* + 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. + */} + {(props.autoGrow || props.autoGrowHeight) && ( + // Add +2 to width so that the first digit of amount do not cut off on mWeb - https://github.com/Expensify/App/issues/8158. + { + setTextInputWidth(e.nativeEvent.layout.width + 2); + setTextInputHeight(e.nativeEvent.layout.height); + }} + > + {props.value || props.placeholder} + + )} + + ); } +BaseTextInput.displayName = 'BaseTextInput'; BaseTextInput.propTypes = baseTextInputPropTypes.propTypes; BaseTextInput.defaultProps = baseTextInputPropTypes.defaultProps; diff --git a/src/components/TextInput/baseTextInputPropTypes.js b/src/components/TextInput/baseTextInputPropTypes.js index 2e278bab5d698..8a1b05a628c25 100644 --- a/src/components/TextInput/baseTextInputPropTypes.js +++ b/src/components/TextInput/baseTextInputPropTypes.js @@ -40,10 +40,18 @@ const propTypes = { /** Disable the virtual keyboard */ disableKeyboard: PropTypes.bool, - /** Autogrow input container length based on the entered text */ + /** + * Autogrow input container length based on the entered text. + * Note: If you use this prop, the text input has to be controlled + * by a value prop. + */ autoGrow: PropTypes.bool, - /** Autogrow input container height based on the entered text */ + /** + * Autogrow input container height based on the entered text + * Note: If you use this prop, the text input has to be controlled + * by a value prop. + */ autoGrowHeight: PropTypes.bool, /** Hide the focus styles on TextInput */ diff --git a/src/stories/TextInput.stories.js b/src/stories/TextInput.stories.js index 64329ffed7150..098828c651984 100644 --- a/src/stories/TextInput.stories.js +++ b/src/stories/TextInput.stories.js @@ -60,32 +60,6 @@ PlaceholderInput.args = { placeholder: 'My placeholder text', }; -const AutoGrowInput = Template.bind({}); -AutoGrowInput.args = { - label: 'Autogrow input', - name: 'AutoGrow', - placeholder: 'My placeholder text', - autoGrow: true, - textInputContainerStyles: [ - { - minWidth: 150, - }, - ], -}; - -const AutoGrowHeightInput = Template.bind({}); -AutoGrowHeightInput.args = { - label: 'Autogrowheight input', - name: 'AutoGrowHeight', - placeholder: 'My placeholder text', - autoGrowHeight: true, - textInputContainerStyles: [ - { - maxHeight: 115, - }, - ], -}; - const PrefixedInput = Template.bind({}); PrefixedInput.args = { label: 'Prefixed input', @@ -126,5 +100,50 @@ HintAndErrorInput.args = { hint: 'Type "Oops!" to see the error', }; +// To use autoGrow we need to control the TextInput's value +function AutoGrowSupportInput(args) { + const [value, setValue] = useState(args.value || ''); + React.useEffect(() => { + setValue(args.value || ''); + }, [args.value]); + + return ( + + ); +} + +const AutoGrowInput = AutoGrowSupportInput.bind({}); +AutoGrowInput.args = { + label: 'Autogrow input', + name: 'AutoGrow', + placeholder: 'My placeholder text', + autoGrow: true, + textInputContainerStyles: [ + { + minWidth: 150, + maxWidth: 500, + }, + ], + value: '', +}; + +const AutoGrowHeightInput = AutoGrowSupportInput.bind({}); +AutoGrowHeightInput.args = { + label: 'Autogrowheight input', + name: 'AutoGrowHeight', + placeholder: 'My placeholder text', + autoGrowHeight: true, + textInputContainerStyles: [ + { + maxHeight: 115, + }, + ], +}; + export default story; export {AutoFocus, DefaultInput, DefaultValueInput, ErrorInput, ForceActiveLabel, PlaceholderInput, AutoGrowInput, AutoGrowHeightInput, PrefixedInput, MaxLengthInput, HintAndErrorInput};