import React, { forwardRef, memo, PropsWithoutRef, ReactNode, RefObject, useCallback, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import type { LayoutChangeEvent, StyleProp, TextInputProps, TextStyle, ViewProps, ViewStyle, } from 'react-native'; import { Animated, I18nManager, Platform, TextInput as NativeTextInput, View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import { useActionState } from '../../hooks/useActionState'; import useControlledValue from '../../hooks/useControlledValue'; import useLatest from '../../hooks/useLatest'; import type { WithElements } from '../../types'; import { BackgroundContext } from '../../utils'; import { createSyntheticEvent, resolveStateVariant } from '../../utils'; import { HelperText } from '../HelperText'; import { StateLayer } from '../StateLayer'; import InputLabel from './InputLabel'; import type { RenderProps, TextInputLabelProp, TextInputSize, TextInputVariant } from './types'; import { getInputMinHeight, styles } from './utils'; const BLUR_ANIMATION_DURATION = 180; const FOCUS_ANIMATION_DURATION = 150; export type ElementProps = { color: string; forceFocus: () => void; focused: boolean; }; type Element = ReactNode | ((props: ElementProps) => ReactNode); export type Props = Omit & WithElements & { ref?: RefObject; /** * Variant of the TextInput. * - `flat` - flat input with an underline. * - `outlined` - input with an outline. * * In `outlined` variant, the background color of the label is derived from `colors?.background` in theme or the `backgroundColor` style. * This component render TextInputOutlined or TextInputFlat based on that props */ variant?: TextInputVariant; /** * If true, user won't be able to interact with the component. */ disabled?: boolean; /** * The text or component to use for the floating label. */ label?: TextInputLabelProp; /** * Placeholder for the input. */ placeholder?: string; /** * Whether to style the TextInput with error style. */ error?: boolean; /** * Callback that is called when the text input's text changes. Changed text is passed as an argument to the callback handler. */ onChangeText?: Function; /** * Selection color of the input */ selectionColor?: string; /** * Inactive underline color of the input. */ underlineColor?: string; /** * Active underline color of the input. */ activeUnderlineColor?: string; /** * Inactive outline color of the input. */ outlineColor?: string; /** * Active outline color of the input. */ activeOutlineColor?: string; /** * Sets min height with densed layout. For `TextInput` in `flat` mode * height is `64dp` or in dense layout - `52dp` with label or `40dp` without label. * For `TextInput` in `outlined` mode * height is `56dp` or in dense layout - `40dp` regardless of label. * When you apply `height` prop in style the `dense` prop affects only `paddingVertical` inside `TextInput` */ size?: TextInputSize; /** * Whether the input can have multiple lines. */ multiline?: boolean; /** * The number of lines to show in the input (Android only). */ numberOfLines?: number; /** * The Supporting Text below the TextInput */ supportingText?: string; /** * To display the required indicator in Supporting Text and in the Label */ required?: boolean; /** * Callback that is called when the text input is focused. */ onFocus?: (args: any) => void; /** * Callback that is called when the text input is blurred. */ onBlur?: (args: any) => void; /** * * Callback to render a custom input component such as `react-native-text-input-mask` * instead of the default `TextInput` component from `react-native`. * * Example: * ```js * * * } * /> * ``` */ render?: (props: RenderProps) => ReactNode; /** * Value of the text input. */ value?: string; /** * Pass `fontSize` prop to modify the font size inside `TextInput`. * Pass `height` prop to set `TextInput` height. * Pass `backgroundColor` prop to set `TextInput` backgroundColor. */ style?: StyleProp; /** * Style of the Input Container */ inputContainerStyle?: StyleProp; /** * Style of the Input */ inputStyle?: StyleProp; /** * Style of the rightElement */ rightElementStyle?: StyleProp; /** * Style of the leftElement */ leftElementStyle?: StyleProp; /** * props for the stateLayer */ stateLayerProps?: PropsWithoutRef; /** * testID to be used on tests. */ testID?: string; }; export type TextInputHandles = Pick< NativeTextInput, 'focus' | 'clear' | 'blur' | 'isFocused' | 'setNativeProps' >; const animationScale = 1; const minimizedLabelFontSize = 12; const maximizedLabelFontSize = 16; const labelWiggleXOffset = 4; const DefaultComponent = (props: RenderProps) => ; const TextInput = forwardRef( ( { variant = 'flat', size = 'md', disabled = false, error: errorProp = false, multiline = false, editable = true, required = false, maxFontSizeMultiplier = 15, supportingText, selectionColor: selectionColorProp, underlineColor: underlineColorProp, activeUnderlineColor: activeUnderlineColorProp, outlineColor: outlineColorProp, activeOutlineColor: activeOutlineColorProp, placeholderTextColor: placeholderTextColorProp, style, inputContainerStyle, inputStyle, stateLayerProps = {}, left, right, render = DefaultComponent, onBlur, leftElementStyle, rightElementStyle, ...rest }: Props, ref, ) => { const { hovered, actionsRef } = useActionState({ actionsToListen: ['hover'] }); const isControlled = rest.value !== undefined; const validInputValue = isControlled ? rest.value : rest.defaultValue; const floatingLabelVerticalOffset = variant === 'flat' ? 16 : 0; const { current: labelAnimation } = useRef( new Animated.Value(validInputValue ? 0 : 1), ); const { current: errorAnimation } = useRef( new Animated.Value(errorProp ? 1 : 0), ); const [focused, setFocused] = useState(false); const [placeholder, setPlaceholder] = useState(''); // Use value from props instead of local state when input is controlled const [value, onChangeValue] = useControlledValue({ value: rest.value, defaultValue: rest.defaultValue, onChange: rest.onChangeText, disabled: !editable || disabled, }); const onBlurRef = useLatest(onBlur); const state = resolveStateVariant({ errorDisabled: errorProp && disabled, disabled, errorFocusedAndHovered: errorProp && hovered && focused, errorFocused: errorProp && focused, errorHovered: errorProp && hovered, hoveredAndFocused: hovered && focused, hovered, focused: focused, error: !!errorProp, }) as any; styles.useVariants({ variant: variant as any, state, size, }); const [labelLayout, setLabelLayout] = useState<{ measured: boolean; width: number; height: number; }>({ measured: false, width: 0, height: 0, }); const [leftElementLayout, setElementLayout] = useState<{ measured: boolean; width: number; height: number; }>({ measured: false, width: 0, height: 0, }); const timer = useRef(undefined); const inputRefLocal = useRef(null); useImperativeHandle(ref, () => inputRefLocal.current!); const { backgroundColor: parentBackground } = useContext(BackgroundContext); const hasActiveOutline = focused || errorProp; useEffect(() => { // When the input has an error, we wiggle the label and apply error styles if (errorProp) { // show error Animated.timing(errorAnimation, { toValue: 1, duration: FOCUS_ANIMATION_DURATION * animationScale, // To prevent this - https://github.com/callstack/react-native-paper/issues/941 useNativeDriver: true, }).start(); return; } // hide error Animated.timing(errorAnimation, { toValue: 0, duration: BLUR_ANIMATION_DURATION * animationScale, // To prevent this - https://github.com/callstack/react-native-paper/issues/941 useNativeDriver: true, }).start(); }, [errorProp, errorAnimation]); useEffect(() => { // Show placeholder text only if the input is focused, or there's no label // We don't show placeholder if there's a label because the label acts as placeholder // When focused, the label moves up, so we can show a placeholder if (focused || !rest.label) { // Set the placeholder in a delay to offset the label animation // If we show it immediately, they'll overlap and look ugly timer.current = setTimeout( () => setPlaceholder(rest.placeholder), 50, ) as unknown as NodeJS.Timeout; } else { // hidePlaceholder setPlaceholder(''); } return () => { if (timer.current) { clearTimeout(timer.current); } }; }, [focused, rest.label, rest.placeholder]); const hasValue = !!value || focused; useEffect(() => { // The label should be minimized if the text input is focused, or has text // In minimized mode, the label moves up and becomes small // workaround for animated regression for react native > 0.61 // https://github.com/callstack/react-native-paper/pull/1440 if (hasValue) { // minimize label Animated.timing(labelAnimation, { toValue: 0, duration: BLUR_ANIMATION_DURATION * animationScale, // To prevent this - https://github.com/callstack/react-native-paper/issues/941 useNativeDriver: true, }).start(); } else { // restore label Animated.timing(labelAnimation, { toValue: 1, duration: FOCUS_ANIMATION_DURATION * animationScale, // To prevent this - https://github.com/callstack/react-native-paper/issues/941 useNativeDriver: true, }).start(); } }, [focused, hasValue, labelAnimation]); const handleFocus = useCallback( (args: any) => { if (disabled || !editable) { return; } setFocused(true); rest.onFocus?.(args); }, [disabled, editable, rest], ); const handleBlur = useCallback( (args: Object) => { if (!editable) { return; } setFocused(false); onBlur?.(args); }, [editable, onBlur], ); const handleLayoutAnimatedText = useCallback((e: LayoutChangeEvent) => { setLabelLayout({ width: e.nativeEvent.layout.width, height: e.nativeEvent.layout.height, measured: true, }); }, []); const handleLayoutLeftElement = useCallback((e: LayoutChangeEvent) => { setElementLayout({ width: e.nativeEvent.layout.width, height: e.nativeEvent.layout.height, measured: true, }); }, []); const forceFocus = useCallback(() => inputRefLocal.current?.focus(), []); const inputMinHeight = getInputMinHeight(variant, size); // This is because of a bug in react 18 doesn't trigger onBlur when the component is unmounted // we can remove it when it's fixed useEffect(() => { const isVersion18 = typeof React.version === 'string' ? +React.version.split('.')[0] >= 18 : false; const _onBlurRef = onBlurRef; const input = inputRefLocal.current; return () => { if (!isVersion18 || !input?.isFocused() || Platform.OS !== 'web') return; const event = new Event('blur', { bubbles: true }); Object.defineProperty(event, 'target', { writable: false, value: input, }); const syntheticEvent = createSyntheticEvent( event, ) as React.ChangeEvent; _onBlurRef.current?.(syntheticEvent); }; }, [onBlurRef]); const componentStyles = styles.root; const labelWidth = labelLayout.width; const labelHeight = labelLayout.height; const labelHalfWidth = labelWidth / 2; const labelScale = minimizedLabelFontSize / (componentStyles.fontSize || maximizedLabelFontSize); const baseLabelTranslateX = (I18nManager.isRTL ? 1 : -1) * (labelScale - 1 + labelHalfWidth - (labelScale * labelWidth) / 2); // const normalizedLeftElementMarginRight = normalizeSpacings( // styles.leftElement, // 'marginRight', // ); const baseLabelTranslateXOutline = baseLabelTranslateX - leftElementLayout.width - (left ? 0 : 0); const backgroundColor = styles.container?.backgroundColor || componentStyles.backgroundColor; // const viableRadiuses = normalizeBorderRadiuses(componentStyles); const finalHeight = (+(componentStyles.height ?? 0) > 0 ? componentStyles.height : +labelHeight) ?? 0; const inputHeight = finalHeight < inputMinHeight ? inputMinHeight : finalHeight; const computedStyles = useMemo( () => ({ activeIndicator: styles.activeIndicator, fontSize: componentStyles.fontSize, fontWeight: componentStyles.fontWeight, height: componentStyles.height, textAlign: componentStyles.textAlign, backgroundColor, labelBackground: variant === 'outlined' ? parentBackground || backgroundColor || (styles as any).root.defaultLabelBackground : 'transparent', activeColor: (styles as any).root.activeColor, baseLabelTranslateX: variant === 'outlined' ? baseLabelTranslateXOutline : baseLabelTranslateX, labelScale, selectionColor: selectionColorProp || (styles as any).root.activeColor, underlineColor: underlineColorProp, activeUnderlineColor: activeUnderlineColorProp, outlineColor: outlineColorProp, activeOutlineColor: activeOutlineColorProp, placeholderTextColor: placeholderTextColorProp || styles.placeholder?.color, floatingLabelVerticalOffset, labelWiggleXOffset, textInputStyle: [ styles.inputText, !multiline || (multiline && componentStyles.height) ? { height: inputHeight || labelHeight } : {}, multiline && variant === 'outlined' && { paddingTop: 12 }, { textAlignVertical: multiline ? 'top' : 'center', textAlign: componentStyles.textAlign ? componentStyles.textAlign : I18nManager.isRTL ? 'right' : 'left', }, Platform.OS === 'ios' && !multiline ? { lineHeight: undefined, textAlign: undefined } : {}, Platform.OS === 'web' && { outline: 'none' }, inputStyle, ], inputContainerStyle: [ styles.labelContainer, { minHeight: componentStyles.height || labelHeight, }, inputContainerStyle, ], underlineStyle: [ styles.underline, styles.activeIndicator, hasActiveOutline && activeOutlineColorProp ? { backgroundColor: hasActiveOutline ? activeUnderlineColorProp : underlineColorProp, } : {}, ], outlineStyle: [ styles.outline, hasActiveOutline && activeOutlineColorProp ? { borderColor: hasActiveOutline ? activeOutlineColorProp : outlineColorProp, } : {}, // viableRadiuses, {}, ], patchContainer: [ StyleSheet.absoluteFill, { backgroundColor, }, styles.patchContainer, ], stateLayerStyle: [styles.stateLayer, stateLayerProps?.style], }), // forcing useMemo to recompute when state, size or variant change // eslint-disable-next-line [ state, size, componentStyles, backgroundColor, variant, parentBackground, baseLabelTranslateXOutline, baseLabelTranslateX, labelScale, selectionColorProp, underlineColorProp, activeUnderlineColorProp, outlineColorProp, activeOutlineColorProp, placeholderTextColorProp, floatingLabelVerticalOffset, multiline, inputHeight, labelHeight, inputStyle, inputContainerStyle, hasActiveOutline, stateLayerProps?.style, ], ); return ( <> {variant === 'flat' && ( <> )} {variant === 'outlined' && ( )} {left && ( {typeof left === 'function' ? left?.({ color: computedStyles.activeColor, forceFocus, focused }) : left} )} {Platform.OS !== 'android' && multiline && !!rest.label && variant === 'flat' && ( // Workaround for: https://github.com/callstack/react-native-paper/issues/2799 // Patch for a multiline TextInput with fixed height, which allow to avoid covering input label with its value. )} {variant !== 'plain' && ( )} {render({ testID: rest.testID, ...rest, style: computedStyles.textInputStyle, ref: inputRefLocal, onChangeText: onChangeValue, placeholder: rest.label ? placeholder : rest.placeholder, placeholderTextColor: computedStyles.placeholderTextColor, editable: !disabled && editable, selectionColor: computedStyles.selectionColor, onFocus: handleFocus, onBlur: handleBlur, underlineColorAndroid: 'transparent', multiline, size, })} {right && ( {typeof right === 'function' ? right?.({ color: computedStyles.activeColor, forceFocus, focused, }) : right} )} {supportingText && ( {supportingText} )} ); }, ); export default memo(TextInput);