forked from CarrotSkin/carrotskin
Initial commit: CarrotSkin project setup
This commit is contained in:
432
src/components/EnhancedInput.tsx
Normal file
432
src/components/EnhancedInput.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
'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;
|
||||
Reference in New Issue
Block a user