import { useTheme } from '../../../context/ThemeContext' import Block from '../../Primitives/Block/Block' import PrimitiveInput from '../../Primitives/PrimitiveInput/PrimitiveInput' import PrimitiveButton from '../../Primitives/PrimitiveButton/PrimitiveButton' import { useRef, useState, useEffect, useId, useCallback } from 'react' import InputLabels from '../utils/InputLabels/InputLabels' import InputFooter from '../utils/InputFooter/InputFooter' import { TextInputSize, type TextInputProps } from './types' import type { TextInputTokensType } from './textInput.tokens' import { toPixels } from '../../../global-utils/GlobalUtils' import { useResponsiveTokens } from '../../../hooks/useResponsiveTokens' import { BREAKPOINTS } from '../../../breakpoints/breakPoints' import { useBreakpoints } from '../../../hooks/useBreakPoints' import FloatingLabels from '../utils/FloatingLabels/FloatingLabels' import { FOUNDATION_THEME } from '../../../tokens' import { useErrorShake } from '../../common/useErrorShake' import { getErrorShakeStyle, errorShakeAnimation, } from '../../common/error.animations' import styled from 'styled-components' import { Eye, EyeOff } from 'lucide-react' import { getTextInputBorderRadius } from './utils' const Wrapper = styled(Block)` ${errorShakeAnimation} ` const TextInput = ({ size = TextInputSize.MEDIUM, leftSlot, rightSlot, error = false, errorMessage, hintText, helpIconHintText, disabled = false, label, placeholder = 'Enter', sublabel, value, onChange, name, required = false, onBlur, onFocus, cursor = 'text', id: providedId, passwordToggle = false, type: providedType, textInputGroupPosition, ...rest }: TextInputProps) => { const textInputTokens = useResponsiveTokens('TEXT_INPUT') const { theme: blendTheme } = useTheme() // primary[50] reads as a near-white halo on a dark surface const focusRingColor = blendTheme === 'dark' ? FOUNDATION_THEME.colors.primary[800] : FOUNDATION_THEME.colors.primary[50] const inputRef = useRef(null) const [isAutofilled, setIsAutofilled] = useState(false) const generatedId = useId() const inputId = providedId || generatedId const errorId = `${inputId}-error` const hintId = `${inputId}-hint` const toggleButtonId = `${inputId}-password-toggle` const [isFocused, setIsFocused] = useState(false) const [showPassword, setShowPassword] = useState(false) const shouldShake = useErrorShake(error) const { breakPointLabel } = useBreakpoints(BREAKPOINTS) const isSmallScreen = breakPointLabel === 'sm' const inputType = passwordToggle ? showPassword ? 'text' : 'password' : providedType || 'text' const inputFocusedOrWithValue = isFocused || value.length > 0 || isAutofilled const isSmallScreenWithLargeSize = isSmallScreen && size === TextInputSize.LARGE const leftSlotRef = useRef(null) const rightSlotRef = useRef(null) const [leftSlotWidth, setLeftSlotWidth] = useState(0) const [rightSlotWidth, setRightSlotWidth] = useState(0) const ariaDescribedBy = [ hintText && !error ? hintId : null, error && errorMessage ? errorId : null, ] .filter(Boolean) .join(' ') || undefined const paddingX = toPixels(textInputTokens.inputContainer.padding.x[size]) const paddingY = toPixels(textInputTokens.inputContainer.padding.y[size]) + (isSmallScreenWithLargeSize ? 0.5 : 1) const GAP = toPixels(textInputTokens.gap) const handleTogglePassword = useCallback(() => { setShowPassword((prev) => !prev) }, []) const handleToggleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() e.stopPropagation() handleTogglePassword() } }, [handleTogglePassword] ) const getToggleButtonColor = () => { if (disabled) return textInputTokens.inputContainer.color.disabled if (error) return textInputTokens.inputContainer.color.error return textInputTokens.inputContainer.color.default } const passwordToggleButton = passwordToggle ? ( { e.preventDefault() e.stopPropagation() handleTogglePassword() }} onKeyDown={handleToggleKeyDown} display="flex" alignItems="center" justifyContent="center" backgroundColor="transparent" border="none" cursor={disabled ? 'not-allowed' : 'pointer'} padding={0} color={getToggleButtonColor()} transition="color 200ms ease-in-out" _hover={{ color: disabled ? textInputTokens.inputContainer.color.disabled : textInputTokens.inputContainer.color.hover, }} _focusVisible={{ outline: `2px solid ${FOUNDATION_THEME.colors.primary[500]}`, outlineOffset: '2px', borderRadius: textInputTokens.inputContainer.borderRadius[size], }} disabled={disabled} tabIndex={disabled ? -1 : 0} > {showPassword ? ( ) : null const combinedRightSlot = rightSlot && passwordToggleButton ? ( {rightSlot} {passwordToggleButton} ) : ( passwordToggleButton || rightSlot ) const paddingInlineStart = leftSlot ? paddingX + leftSlotWidth + GAP : paddingX const paddingInlineEnd = combinedRightSlot ? paddingX + rightSlotWidth + GAP : paddingX useEffect(() => { if (leftSlotRef.current) { setLeftSlotWidth(leftSlotRef.current.offsetWidth) } else { setLeftSlotWidth(0) } if (rightSlotRef.current) { setRightSlotWidth(rightSlotRef.current.offsetWidth) } else { setRightSlotWidth(0) } }, [leftSlot, combinedRightSlot]) useEffect(() => { const input = inputRef.current if (!input) return if (input.value && input.value.length > 0) { setIsAutofilled(true) } const handleAnimationStart = (e: AnimationEvent) => { if (e.animationName === 'blend-autofill-start') { setIsAutofilled(true) } if (e.animationName === 'blend-autofill-cancel') { setIsAutofilled(false) } } input.addEventListener('animationstart', handleAnimationStart) return () => { input.removeEventListener('animationstart', handleAnimationStart) } }, []) return ( {(!isSmallScreen || size !== TextInputSize.LARGE) && ( )} {leftSlot && ( {leftSlot} )} {label && isSmallScreenWithLargeSize && ( )} { setIsFocused(true) onFocus?.(e) }} onBlur={(e) => { setIsFocused(false) onBlur?.(e) }} {...rest} type={ inputType as | 'text' | 'password' | 'email' | 'tel' | 'url' | 'search' } /> {combinedRightSlot && ( {combinedRightSlot} )} ) } export default TextInput