Files
carrotskin/src/components/EnhancedInput.tsx

433 lines
14 KiB
TypeScript

'use client';
import { motion, AnimatePresence } from 'framer-motion';
import { ReactNode, useState, useRef, useEffect, forwardRef } from 'react';
import { EyeIcon, EyeSlashIcon, ExclamationCircleIcon, CheckCircleIcon } from '@heroicons/react/24/outline';
interface EnhancedInputProps {
type?: 'text' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'search';
placeholder?: string;
value?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
label?: string;
error?: string;
success?: string;
hint?: string;
disabled?: boolean;
required?: boolean;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
className?: string;
containerClassName?: string;
autoFocus?: boolean;
autoComplete?: string;
name?: string;
id?: string;
showPasswordToggle?: boolean;
showStrengthIndicator?: boolean;
showCharCount?: boolean;
maxLength?: number;
minLength?: number;
pattern?: string;
validate?: (value: string) => string | null;
onValidationChange?: (isValid: boolean) => void;
}
const EnhancedInput = forwardRef<HTMLInputElement, EnhancedInputProps>(({
type = 'text',
placeholder,
value = '',
onChange,
onFocus,
onBlur,
label,
error,
success,
hint,
disabled = false,
required = false,
leftIcon,
rightIcon,
className = '',
containerClassName = '',
autoFocus = false,
autoComplete,
name,
id,
showPasswordToggle = false,
showStrengthIndicator = false,
showCharCount = false,
maxLength,
minLength,
pattern,
validate,
onValidationChange,
...props
}, ref) => {
const [isFocused, setIsFocused] = useState(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [internalValue, setInternalValue] = useState(value);
const [validationMessage, setValidationMessage] = useState<string | null>(null);
const [isValidating, setIsValidating] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// 同步外部值
useEffect(() => {
setInternalValue(value);
}, [value]);
// 验证逻辑
useEffect(() => {
if (validate && internalValue) {
setIsValidating(true);
const message = validate(internalValue);
setValidationMessage(message);
setIsValidating(false);
if (onValidationChange) {
onValidationChange(!message);
}
}
}, [internalValue, validate, onValidationChange]);
// 密码强度计算
const getPasswordStrength = (password: string) => {
let strength = 0;
if (password.length >= 8) strength++;
if (/[a-z]/.test(password)) strength++;
if (/[A-Z]/.test(password)) strength++;
if (/[0-9]/.test(password)) strength++;
if (/[^A-Za-z0-9]/.test(password)) strength++;
return strength;
};
const passwordStrength = type === 'password' && showStrengthIndicator ? getPasswordStrength(internalValue) : 0;
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
setIsFocused(true);
onFocus?.(e);
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
setIsFocused(false);
onBlur?.(e);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInternalValue(newValue);
onChange?.(e);
};
const togglePasswordVisibility = () => {
setIsPasswordVisible(!isPasswordVisible);
};
const getInputType = () => {
if (type === 'password' && showPasswordToggle) {
return isPasswordVisible ? 'text' : 'password';
}
return type;
};
const getBorderColor = () => {
if (error || validationMessage) return 'border-red-500 focus:border-red-500 focus:ring-red-500';
if (success || (validationMessage === null && internalValue && validate)) return 'border-green-500 focus:border-green-500 focus:ring-green-500';
if (isFocused) return 'border-orange-500 focus:border-orange-500 focus:ring-orange-500';
return 'border-gray-300 dark:border-gray-600 focus:border-orange-500 focus:ring-orange-500';
};
const getLabelColor = () => {
if (error || validationMessage) return 'text-red-600 dark:text-red-400';
if (success || (validationMessage === null && internalValue && validate)) return 'text-green-600 dark:text-green-400';
if (isFocused) return 'text-orange-600 dark:text-orange-400';
return 'text-gray-700 dark:text-gray-300';
};
const inputVariants = {
initial: { scale: 1 },
focus: {
scale: 1.02,
transition: { duration: 0.2, ease: "easeOut" }
},
blur: {
scale: 1,
transition: { duration: 0.2, ease: "easeOut" }
}
};
const labelVariants = {
initial: { y: 0, scale: 1 },
focus: {
y: -20,
scale: 0.85,
transition: { duration: 0.2, ease: "easeOut" }
},
blur: {
y: internalValue ? -20 : 0,
scale: internalValue ? 0.85 : 1,
transition: { duration: 0.2, ease: "easeOut" }
}
};
return (
<motion.div
ref={containerRef}
className={`relative ${containerClassName}`}
initial="initial"
animate={isFocused ? "focus" : "blur"}
variants={inputVariants}
>
{/* 标签 */}
{label && (
<motion.label
htmlFor={id}
className={`absolute left-3 z-10 bg-white dark:bg-gray-800 px-1 text-sm font-medium transition-all duration-200 ${
isFocused || internalValue ? 'text-xs' : 'text-base'
} ${getLabelColor()}`}
initial="initial"
animate={isFocused ? "focus" : "blur"}
variants={labelVariants}
>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</motion.label>
)}
{/* 输入框容器 */}
<motion.div
className={`relative flex items-center bg-white dark:bg-gray-800 border-2 rounded-lg transition-all duration-200 ${
getBorderColor()
} ${disabled ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : ''}`}
whileHover={!disabled ? { scale: 1.01 } : {}}
animate={{
boxShadow: isFocused ? '0 0 0 3px rgba(249, 115, 22, 0.1)' : '0 1px 3px 0 rgba(0, 0, 0, 0.1)'
}}
transition={{ duration: 0.2 }}
>
{/* 左侧图标 */}
{leftIcon && (
<motion.div
className="absolute left-3 text-gray-400 dark:text-gray-500"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }}
>
{leftIcon}
</motion.div>
)}
{/* 输入框 */}
<input
ref={ref || inputRef}
type={getInputType()}
placeholder={!label ? placeholder : ''}
value={internalValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
required={required}
autoFocus={autoFocus}
autoComplete={autoComplete}
name={name}
id={id}
maxLength={maxLength}
minLength={minLength}
pattern={pattern}
className={`w-full bg-transparent border-0 outline-none focus:ring-0 transition-all duration-200 ${
leftIcon ? 'pl-10' : 'pl-4'
} ${
rightIcon || (type === 'password' && showPasswordToggle) ? 'pr-10' : 'pr-4'
} ${
size === 'sm' ? 'py-2 text-sm' : size === 'lg' ? 'py-3 text-lg' : 'py-2.5 text-base'
} ${className}`}
{...props}
/>
{/* 验证状态图标 */}
<AnimatePresence>
{isValidating && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="absolute right-3 text-orange-500"
>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="w-4 h-4 border-2 border-orange-500 border-t-transparent rounded-full"
/>
</motion.div>
)}
{!isValidating && validationMessage === null && internalValue && validate && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="absolute right-3 text-green-500"
>
<CheckCircleIcon className="w-5 h-5" />
</motion.div>
)}
{(error || validationMessage) && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="absolute right-3 text-red-500"
>
<ExclamationCircleIcon className="w-5 h-5" />
</motion.div>
)}
</AnimatePresence>
{/* 右侧图标和密码切换 */}
<div className="absolute right-3 flex items-center space-x-2">
{type === 'password' && showPasswordToggle && (
<motion.button
type="button"
onClick={togglePasswordVisibility}
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors duration-200"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
{isPasswordVisible ? <EyeSlashIcon className="w-5 h-5" /> : <EyeIcon className="w-5 h-5" />}
</motion.button>
)}
{rightIcon && (
<motion.div
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }}
>
{rightIcon}
</motion.div>
)}
</div>
</motion.div>
{/* 密码强度指示器 */}
{type === 'password' && showStrengthIndicator && internalValue && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mt-2"
>
<div className="flex space-x-1">
{[1, 2, 3, 4, 5].map((level) => (
<motion.div
key={level}
className={`h-1 flex-1 rounded-full transition-all duration-300 ${
passwordStrength >= level
? level <= 2
? 'bg-red-500'
: level <= 3
? 'bg-yellow-500'
: 'bg-green-500'
: 'bg-gray-200 dark:bg-gray-700'
}`}
initial={{ scaleX: 0 }}
animate={{ scaleX: passwordStrength >= level ? 1 : 0.3 }}
transition={{ delay: level * 0.1 }}
/>
))}
</div>
<motion.p
className="text-xs mt-1 text-gray-600 dark:text-gray-400"
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
{passwordStrength <= 2 && '密码强度:弱'}
{passwordStrength === 3 && '密码强度:中等'}
{passwordStrength >= 4 && '密码强度:强'}
</motion.p>
</motion.div>
)}
{/* 字符计数 */}
{showCharCount && maxLength && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex justify-between items-center mt-1"
>
<div />
<motion.span
className={`text-xs ${
internalValue.length > maxLength
? 'text-red-500'
: internalValue.length > maxLength * 0.8
? 'text-yellow-500'
: 'text-gray-500'
}`}
animate={{
scale: internalValue.length > maxLength ? 1.1 : 1,
color: internalValue.length > maxLength ? '#ef4444' : internalValue.length > maxLength * 0.8 ? '#f59e0b' : '#6b7280'
}}
>
{internalValue.length}/{maxLength}
</motion.span>
</motion.div>
)}
{/* 错误信息 */}
<AnimatePresence>
{(error || validationMessage) && (
<motion.div
initial={{ opacity: 0, height: 0, y: -10 }}
animate={{ opacity: 1, height: 'auto', y: 0 }}
exit={{ opacity: 0, height: 0, y: -10 }}
className="mt-1 text-sm text-red-600 dark:text-red-400 flex items-center space-x-1"
>
<ExclamationCircleIcon className="w-4 h-4 flex-shrink-0" />
<span>{error || validationMessage}</span>
</motion.div>
)}
</AnimatePresence>
{/* 成功信息 */}
<AnimatePresence>
{success && (
<motion.div
initial={{ opacity: 0, height: 0, y: -10 }}
animate={{ opacity: 1, height: 'auto', y: 0 }}
exit={{ opacity: 0, height: 0, y: -10 }}
className="mt-1 text-sm text-green-600 dark:text-green-400 flex items-center space-x-1"
>
<CheckCircleIcon className="w-4 h-4 flex-shrink-0" />
<span>{success}</span>
</motion.div>
)}
</AnimatePresence>
{/* 提示信息 */}
<AnimatePresence>
{hint && !error && !validationMessage && !success && (
<motion.div
initial={{ opacity: 0, height: 0, y: -10 }}
animate={{ opacity: 1, height: 'auto', y: 0 }}
exit={{ opacity: 0, height: 0, y: -10 }}
className="mt-1 text-sm text-gray-600 dark:text-gray-400"
>
{hint}
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
});
EnhancedInput.displayName = 'EnhancedInput';
export default EnhancedInput;