forked from CarrotSkin/carrotskin
433 lines
14 KiB
TypeScript
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;
|