import React, { useMemo, forwardRef, useRef, useEffect, useCallback, } from 'react'; import { StyleSheet, Animated, Easing, Pressable, View } from 'react-native'; import type { TextInputProps as RNTextInputProps, StyleProp, ViewStyle, TextStyle, TextInput as RNTextInput, BlurEvent, FocusEvent, } from 'react-native'; import { hexToRgba, omit, pick } from '../../utils/helpers'; import { StyledTextInputContainer, StyledLabelInsideTextInput, StyledError, StyledTextInput, StyledContainer, StyledMaxLengthMessage, StyledErrorContainer, StyledAsteriskLabelInsideTextInput, StyledInputContentContainer, StyledTextInputAndLabelContainer, StyledLabelContainerInsideTextInput, StyledErrorAndHelpTextContainer, StyledBorderBackDrop, StyledErrorAndMaxLengthContainer, StyledHelperText, } from './StyledTextInput'; import Icon from '../Icon'; import type { Theme } from '../../theme'; import { useTheme } from '../../theme'; import type { State } from './StyledTextInput'; import type { IconName } from '../Icon'; import Typography from '../Typography'; export type TextInputHandles = Pick< RNTextInput, 'focus' | 'clear' | 'blur' | 'isFocused' | 'setNativeProps' >; type TextInputVariant = 'text' | 'textarea'; type NativeTextInputProps = Omit & { onFocus?: (event?: FocusEvent) => void | undefined; onBlur?: (event?: BlurEvent) => void | undefined; }; interface TextInputRef { focus: () => void; blur: () => void; clear: () => void; isFocused: () => boolean; setNativeProps?: (props: RNTextInputProps) => void; } export interface TextInputProps extends NativeTextInputProps { /** * Field label. */ label?: string; /** * Name of Icon or ReactElement to render on the left side of the input, before the user's cursor. */ prefix?: IconName | React.ReactElement; /** * Name of Icon or ReactElement to render on the right side of the input. */ suffix?: IconName | React.ReactElement; /** * Additional wrapper style. */ style?: StyleProp; /** * Input text style. */ textStyle?: StyleProp; /** * Testing id of the component. */ testID?: string; /** * Accessibility label for the input (Android). */ accessibilityLabelledBy?: string; /** * Error message to display. */ error?: string; /** * Whether the input is required, if true, an asterisk will be appended to the label. */ required?: boolean; /** * Placeholder text to display. * */ placeholder?: string; /** * Whether the input is editable. * */ editable?: boolean; /** * Whether the input is disabled. */ disabled?: boolean; /** * Whether the input is loading. */ loading?: boolean; /** * The max length of the input. * If the max length is set, the input will display the current length and the max length. * */ maxLength?: number; /** * Whether to hide the character count. * */ hideCharacterCount?: boolean; /** * The helper text to display. */ helpText?: string; /** * Customise input value renderer */ renderInputValue?: ( inputProps: NativeTextInputProps, ref?: React.ForwardedRef ) => React.ReactNode; /** * Component ref. */ ref?: React.Ref; /** * Component variant. */ variant?: TextInputVariant; } export const getState = ({ disabled, error, editable, loading, isEmptyValue, }: { disabled?: boolean; error?: string; editable?: boolean; loading: boolean; isFocused?: boolean; isEmptyValue?: boolean; }): State => { if (disabled) { return 'disabled'; } if (error) { return 'error'; } if (!editable || loading) { return 'readonly'; } if (!isEmptyValue) { return 'filled'; } return 'default'; }; // Fix issue: Placeholder is not shown on iOS when multiline is true // https://github.com/callstack/react-native-paper/pull/3331 const EMPTY_PLACEHOLDER_VALUE = ' '; export const LABEL_ANIMATION_DURATION = 150; export const renderErrorOrHelpText = ({ error, helpText, }: { error?: string; helpText?: string; }) => { return error ? ( {error} ) : ( !!helpText && {helpText} ); }; export const renderInput = ({ variant, nativeInputProps, renderInputValue, ref, theme, state, }: { variant: TextInputVariant; nativeInputProps: NativeTextInputProps; multiline?: boolean; renderInputValue?: ( inputProps: NativeTextInputProps, ref?: React.Ref ) => React.ReactNode; ref?: React.Ref; theme: Theme; state: State; }) => { const multiline = variant === 'textarea' || nativeInputProps.multiline; // `numberOfLines` must be `1` for single-line inputs to render properly on Android. const numberOfLines = multiline ? nativeInputProps.numberOfLines : 1; return renderInputValue ? ( renderInputValue(nativeInputProps, ref) ) : ( ); }; export const renderSuffix = ({ state, loading, suffix, }: { state: State; loading: boolean; suffix?: IconName | React.ReactElement; }) => { const actualSuffix = loading ? 'loading' : suffix; return typeof actualSuffix === 'string' ? ( ) : ( suffix ); }; export const renderPrefix = ({ state, prefix, }: { state: State; prefix?: IconName | React.ReactElement; }) => { return typeof prefix === 'string' ? ( ) : ( prefix ); }; export const renderMaxLengthMessage = ({ maxLength, state, currentLength, hideCharacterCount, }: { state: State; currentLength: number; maxLength?: number; hideCharacterCount: boolean; }) => { const shouldShowMaxLength = maxLength !== undefined && !hideCharacterCount; return ( shouldShowMaxLength && ( {currentLength}/{maxLength} ) ); }; const getDisplayText = (value?: string, defaultValue?: string) => { return (value !== undefined ? value : defaultValue) ?? ''; }; const TextInput = forwardRef( ( { label, prefix, suffix, style, textStyle, testID, accessibilityLabelledBy, error, required, editable = true, disabled = false, loading = false, maxLength, hideCharacterCount = false, helpText, value, defaultValue, renderInputValue, allowFontScaling = false, variant = 'text', ...nativeProps }: TextInputProps, ref?: React.Ref ) => { const displayText = getDisplayText(value, defaultValue); const isEmptyValue = displayText.length === 0; const [containerHeight, setContainerHeight] = React.useState(0); const [isFocused, setIsFocused] = React.useState(false); const state = getState({ disabled, error, editable, loading, isFocused, isEmptyValue, }); const theme = useTheme(); const focusAnimation = useRef(new Animated.Value(0)).current; useEffect(() => { Animated.timing(focusAnimation, { toValue: isFocused || !isEmptyValue ? 1 : 0, duration: LABEL_ANIMATION_DURATION, easing: Easing.bezier(0.4, 0, 0.2, 1), useNativeDriver: true, }).start(); }, [focusAnimation, isEmptyValue, isFocused]); const innerTextInput = React.useRef(null); const focusInnerTextInput = useCallback( () => innerTextInput.current?.focus(), [] ); const onContentLayout = useCallback( (e: { nativeEvent: { layout: { height: number } } }) => { setContainerHeight(e.nativeEvent.layout.height); }, [] ); React.useImperativeHandle( ref, () => ({ // we don't expose this method, it's for testing https://medium.com/developer-rants/how-to-test-useref-without-mocking-useref-699165f4994e getNativeTextInputRef: () => innerTextInput.current, focus: () => { innerTextInput.current?.focus(); }, clear: () => innerTextInput.current?.clear(), setNativeProps: (args: NativeTextInputProps) => innerTextInput.current?.setNativeProps(args), isFocused: () => innerTextInput.current?.isFocused() || false, blur: () => innerTextInput.current?.blur(), }), [innerTextInput] ); const { borderStyle, textStyleWithoutBorderStyle } = useMemo(() => { if (!textStyle) { return {}; } const flattenTextStyle = StyleSheet.flatten(textStyle); const borderKeys = Object.keys(flattenTextStyle).filter((key) => { return key.startsWith('border'); }) as Array; return { borderStyle: pick(borderKeys, flattenTextStyle), textStyleWithoutBorderStyle: omit(borderKeys, flattenTextStyle), }; }, [textStyle]); const readonlyBackground = hexToRgba( theme.__hd__.textInput.colors.readonlyBackground, 0.7 ); const { backgroundColor, styleWithoutBackgroundColor } = useMemo(() => { const defaultBackground = state === 'readonly' ? readonlyBackground : theme.__hd__.textInput.colors.containerBackground; if (!style) { return { backgroundColor: defaultBackground }; } const flattenTextStyle = StyleSheet.flatten(style); return { backgroundColor: flattenTextStyle.backgroundColor ?? defaultBackground, styleWithoutBackgroundColor: omit( ['backgroundColor'], flattenTextStyle ), }; }, [style, theme, state, readonlyBackground]); const nativeInputTestIDSuffix = testID ? `-${testID}` : ''; const nativeInputProps: NativeTextInputProps = { style: StyleSheet.flatten([ { backgroundColor, color: theme.__hd__.textInput.colors.text[state], }, textStyleWithoutBorderStyle, ]), testID: `text-input${nativeInputTestIDSuffix}`, accessibilityState: { disabled: state === 'disabled' || state === 'readonly', }, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore accessibilityLabelledBy, allowFontScaling, ...nativeProps, onFocus: (event) => { setIsFocused(true); nativeProps.onFocus?.(event); }, onBlur: (event) => { setIsFocused(false); nativeProps.onBlur?.(event); }, editable, maxLength, value, onChangeText: (text) => { nativeProps.onChangeText?.(text); }, defaultValue, placeholder: isFocused || label === undefined ? nativeProps.placeholder : EMPTY_PLACEHOLDER_VALUE, }; return ( {prefix !== undefined && ( {renderPrefix({ state, prefix })} )} {!!label && ( {required && ( * )} {label} )} {renderInput({ variant, nativeInputProps, renderInputValue, ref: (rnTextInputRef) => { innerTextInput.current = rnTextInputRef; }, theme, state, })} {renderSuffix({ state, loading, suffix })} {renderErrorOrHelpText({ error, helpText })} {renderMaxLengthMessage({ state, currentLength: displayText.length, maxLength, hideCharacterCount, })} ); } ); export default TextInput;