import React, { useState, isValidElement, useMemo, useEffect, useRef, useCallback, useImperativeHandle } from 'react'; import { View, TextInput as RNTextInput, TouchableOpacity, Platform, TextInputProps as RNTextInputProps } from 'react-native'; import MaterialDesignIcons from '@react-native-vector-icons/material-design-icons'; import { useUnistyles } from 'react-native-unistyles'; import { TextInputProps } from './types'; import { textInputStyles } from './TextInput.styles'; import { getNativeFormAccessibilityProps } from '../utils/accessibility'; import type { TextInputHandle } from '../utils/refTypes'; import Text from '../Text'; // Inner TextInput component that can be memoized to prevent re-renders // for Android secure text entry type InnerTextInputProps = { inputRef: React.ForwardedRef; value: string | undefined; onChangeText: ((text: string) => void) | undefined; isAndroidSecure: boolean; textInputProps: Omit; inputStyle: any; }; const InnerRNTextInput = React.memo( ({ inputRef, value, onChangeText, isAndroidSecure, textInputProps, inputStyle }) => { return ( ); }, (prevProps, nextProps) => { // For Android secure text entry, skip re-renders when only value changes if (nextProps.isAndroidSecure) { // Only re-render if non-value props change const valueChanged = prevProps.value !== nextProps.value; const otherPropsChanged = prevProps.onChangeText !== nextProps.onChangeText || prevProps.isAndroidSecure !== nextProps.isAndroidSecure || prevProps.textInputProps !== nextProps.textInputProps || prevProps.inputStyle !== nextProps.inputStyle; if (valueChanged && !otherPropsChanged) { return true; // Skip re-render } } return false; // Allow re-render } ); const TextInput = React.forwardRef(({ value, onChangeText, onFocus, onBlur, onPress, placeholder, disabled = false, inputMode = 'text', secureTextEntry = false, leftIcon, rightIcon, showPasswordToggle, autoCapitalize = 'sentences', size = 'md', type = 'outlined', hasError = false, error, helperText, label, // Spacing variants from FormInputStyleProps margin, marginVertical, marginHorizontal, style, testID, id, // Accessibility props accessibilityLabel, accessibilityHint, accessibilityDisabled, accessibilityHidden, accessibilityRole, accessibilityRequired, accessibilityInvalid, // Submit handling onSubmitEditing, returnKeyType = 'default', textContentType, }, ref) => { const [isFocused, setIsFocused] = useState(false); const [isPasswordVisible, setIsPasswordVisible] = useState(false); const inputRef = useRef(null); // Expose focus/blur via imperative handle useImperativeHandle(ref, () => ({ focus: () => inputRef.current?.focus(), blur: () => inputRef.current?.blur(), }), []); // Derive hasError from error prop or hasError boolean const computedHasError = Boolean(error) || hasError; // Note: we always render the wrapper structure for tree stability (see below). // Track if this is a secure field that needs Android workaround const isSecureField = inputMode === 'password' || secureTextEntry; const needsAndroidSecureWorkaround = Platform.OS === 'android' && isSecureField && !isPasswordVisible; // For Android secure text entry, we use an internal ref to track value const internalValueRef = useRef(value ?? ''); // Sync external value changes to internal ref (for programmatic updates) useEffect(() => { if (value !== undefined) { internalValueRef.current = value; } }, [value]); // Get theme for icon sizes and colors const { theme } = useUnistyles(); const iconSize = theme.sizes.input[size].iconSize; const iconColor = theme.colors.text.secondary; // Determine if we should show password toggle const isPasswordField = inputMode === 'password' || secureTextEntry; const shouldShowPasswordToggle = isPasswordField && (showPasswordToggle !== false); const getKeyboardType = useCallback((): 'default' | 'email-address' | 'numeric' => { switch (inputMode) { case 'email': return 'email-address'; case 'number': return 'numeric'; case 'password': case 'text': default: return 'default'; } }, [inputMode]); const handleFocus = useCallback(() => { setIsFocused(true); onFocus?.(); }, [onFocus]); const handlePress = useCallback(() => { onPress?.(); }, [onPress]); const handleBlur = useCallback(() => { setIsFocused(false); onBlur?.(); }, [onBlur]); const togglePasswordVisibility = () => { setIsPasswordVisible(!isPasswordVisible); }; const handleSubmitEditing = useCallback(() => { onSubmitEditing?.(); }, [onSubmitEditing]); // Memoized change handler for InnerTextInput const handleChangeText = useCallback((text: string) => { internalValueRef.current = text; onChangeText?.(text); }, [onChangeText]); // Memoized input style const inputStyle = useMemo(() => (textInputStyles.input as any)({}), []); // Generate native accessibility props const nativeA11yProps = useMemo(() => { // Derive invalid state from computedHasError or explicit accessibilityInvalid const isInvalid = accessibilityInvalid ?? computedHasError; const computedLabel = accessibilityLabel ?? label ?? placeholder; return getNativeFormAccessibilityProps({ accessibilityLabel: computedLabel, accessibilityHint: accessibilityHint ?? (error || helperText), accessibilityDisabled: accessibilityDisabled ?? disabled, accessibilityHidden, accessibilityRole: accessibilityRole ?? 'textbox', accessibilityRequired, accessibilityInvalid: isInvalid, }); }, [ accessibilityLabel, label, placeholder, accessibilityHint, error, helperText, accessibilityDisabled, disabled, accessibilityHidden, accessibilityRole, accessibilityRequired, accessibilityInvalid, computedHasError, ]); // Determine the textContentType for iOS AutoFill // If explicitly provided, use that; otherwise default password fields to 'password' const resolvedTextContentType = textContentType ?? (isSecureField ? 'password' : undefined); // Memoized TextInput props (everything except value/onChangeText) const textInputProps = useMemo(() => ({ onPress: handlePress, placeholder, editable: !disabled, keyboardType: getKeyboardType(), secureTextEntry: isSecureField && !isPasswordVisible, autoCapitalize, onFocus: handleFocus, onBlur: handleBlur, onSubmitEditing: handleSubmitEditing, returnKeyType, placeholderTextColor: '#999999', textContentType: resolvedTextContentType, ...nativeA11yProps, }), [ handlePress, placeholder, disabled, getKeyboardType, isSecureField, isPasswordVisible, autoCapitalize, handleFocus, handleBlur, handleSubmitEditing, returnKeyType, resolvedTextContentType, nativeA11yProps, ]); // Apply variants to the stylesheet (for size, type, and spacing) textInputStyles.useVariants({ size, type, focused: isFocused, hasError: computedHasError, disabled, margin, marginVertical, marginHorizontal, }); // Compute dynamic styles - call as functions for theme reactivity const containerStyle = (textInputStyles.container as any)({ type, focused: isFocused, hasError: computedHasError, disabled }); const leftIconContainerStyle = (textInputStyles.leftIconContainer as any)({}); const rightIconContainerStyle = (textInputStyles.rightIconContainer as any)({}); const passwordToggleStyle = (textInputStyles.passwordToggle as any)({}); // Wrapper, label, and footer styles const wrapperStyle = (textInputStyles.wrapper as any)({}); const labelStyle = (textInputStyles.label as any)({ disabled }); const footerStyle = (textInputStyles.footer as any)({}); const helperTextStyle = (textInputStyles.helperText as any)({ hasError: computedHasError }); const showFooter = Boolean(error) || Boolean(helperText); // Helper to render left icon const renderLeftIcon = () => { if (!leftIcon) return null; if (typeof leftIcon === 'string') { return ( ); } else if (isValidElement(leftIcon)) { return leftIcon; } return null; }; // Helper to render right icon (not password toggle) const renderRightIcon = () => { if (!rightIcon) return null; if (typeof rightIcon === 'string') { return ( ); } else if (isValidElement(rightIcon)) { return rightIcon; } return null; }; // Always render a single stable tree so that toggling label/error/helperText // never changes the TextInput position in the React tree, preventing focus loss. return ( {label && ( {label} )} {/* Left Icon */} {leftIcon && ( {renderLeftIcon()} )} {/* Input */} {/* Right Icon or Password Toggle */} {shouldShowPasswordToggle ? ( ) : rightIcon ? ( {renderRightIcon()} ) : null} {showFooter ? ( {error && {error}} {!error && helperText && {helperText}} ) : null} ); }); TextInput.displayName = 'TextInput'; export default TextInput;