Enhanced Interactive Components: - Created InteractiveButton with ripple effects, haptic feedback, and multiple variants - Developed InteractiveInput and InteractiveTextarea with real-time validation and status indicators - Added FloatingActionButton for quick actions with elegant tooltips - Implemented comprehensive micro-interactions and animations Advanced Loading & Skeleton States: - Enhanced SkeletonLoader with multiple variants (model, material, template, table-row) - Added specialized skeleton components (ModelCardSkeleton, MaterialCardSkeleton, etc.) - Created EnhancedLoadingState with progress indicators and operation tracking - Implemented BatchOperationLoading for complex workflows Optimized Form Experience: - Upgraded ProjectForm with new interactive components - Added real-time validation feedback and error animations - Implemented smart input states (success, error, loading) - Enhanced user feedback with visual and haptic responses Perfected Empty States: - Redesigned EmptyState with multiple variants and illustrations - Created specialized empty state components (EmptyProjectList, EmptyModelList, etc.) - Added contextual tips and guidance for better user onboarding - Implemented error states and recovery actions Advanced Data Display: - Built comprehensive DataTable with search, sort, filter, and pagination - Created flexible CardGrid with view switching and bulk operations - Added row selection, bulk actions, and advanced filtering - Implemented responsive layouts and mobile optimization Rich Animation System: - Added 20+ new micro-interaction animations - Implemented button press, success pulse, error shake effects - Created smooth slide-in animations for all directions - Added loading dots, heartbeat, and bounce-in animations Key Features: - Ripple effects on button clicks with haptic feedback - Real-time form validation with animated error states - Contextual empty states with actionable guidance - Advanced data tables with full CRUD operations - Responsive card grids with multiple view modes - Comprehensive loading states for better perceived performance All components now provide rich visual feedback, smooth animations, and professional user experience that matches modern design standards.
432 lines
12 KiB
TypeScript
432 lines
12 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
|
import { Eye, EyeOff, AlertCircle, CheckCircle, Search, X } from 'lucide-react';
|
|
|
|
interface InteractiveInputProps {
|
|
type?: 'text' | 'email' | 'password' | 'search' | 'number' | 'tel' | 'url';
|
|
value?: string;
|
|
onChange?: (value: string) => void;
|
|
onFocus?: () => void;
|
|
onBlur?: () => void;
|
|
onEnter?: () => void;
|
|
placeholder?: string;
|
|
label?: string;
|
|
error?: string;
|
|
success?: string;
|
|
hint?: string;
|
|
required?: boolean;
|
|
disabled?: boolean;
|
|
loading?: boolean;
|
|
icon?: React.ReactNode;
|
|
iconPosition?: 'left' | 'right';
|
|
clearable?: boolean;
|
|
autoFocus?: boolean;
|
|
maxLength?: number;
|
|
className?: string;
|
|
inputClassName?: string;
|
|
}
|
|
|
|
/**
|
|
* 增强的交互输入组件
|
|
* 提供丰富的视觉反馈和状态指示
|
|
*/
|
|
export const InteractiveInput: React.FC<InteractiveInputProps> = ({
|
|
type = 'text',
|
|
value = '',
|
|
onChange,
|
|
onFocus,
|
|
onBlur,
|
|
onEnter,
|
|
placeholder,
|
|
label,
|
|
error,
|
|
success,
|
|
hint,
|
|
required = false,
|
|
disabled = false,
|
|
loading = false,
|
|
icon,
|
|
iconPosition = 'left',
|
|
clearable = false,
|
|
autoFocus = false,
|
|
maxLength,
|
|
className = '',
|
|
inputClassName = '',
|
|
}) => {
|
|
const [isFocused, setIsFocused] = useState(false);
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [internalValue, setInternalValue] = useState(value);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
setInternalValue(value);
|
|
}, [value]);
|
|
|
|
useEffect(() => {
|
|
if (autoFocus && inputRef.current) {
|
|
inputRef.current.focus();
|
|
}
|
|
}, [autoFocus]);
|
|
|
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const newValue = e.target.value;
|
|
setInternalValue(newValue);
|
|
onChange?.(newValue);
|
|
};
|
|
|
|
const handleFocus = () => {
|
|
setIsFocused(true);
|
|
onFocus?.();
|
|
};
|
|
|
|
const handleBlur = () => {
|
|
setIsFocused(false);
|
|
onBlur?.();
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Enter') {
|
|
onEnter?.();
|
|
}
|
|
};
|
|
|
|
const handleClear = () => {
|
|
setInternalValue('');
|
|
onChange?.('');
|
|
inputRef.current?.focus();
|
|
};
|
|
|
|
const getInputType = () => {
|
|
if (type === 'password') {
|
|
return showPassword ? 'text' : 'password';
|
|
}
|
|
return type;
|
|
};
|
|
|
|
const getStatusColor = () => {
|
|
if (error) return 'border-red-300 focus:border-red-500 focus:ring-red-500';
|
|
if (success) return 'border-green-300 focus:border-green-500 focus:ring-green-500';
|
|
return 'border-gray-300 focus:border-primary-500 focus:ring-primary-500';
|
|
};
|
|
|
|
const getStatusIcon = () => {
|
|
if (loading) {
|
|
return <div className="w-4 h-4 border-2 border-gray-300 border-t-primary-600 rounded-full animate-spin" />;
|
|
}
|
|
if (error) {
|
|
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
|
}
|
|
if (success) {
|
|
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const showClearButton = clearable && internalValue && !disabled && !loading;
|
|
const showPasswordToggle = type === 'password' && !disabled;
|
|
|
|
return (
|
|
<div className={`space-y-1 ${className}`}>
|
|
{/* 标签 */}
|
|
{label && (
|
|
<label className="block text-sm font-medium text-gray-700">
|
|
{label}
|
|
{required && <span className="text-red-500 ml-1">*</span>}
|
|
</label>
|
|
)}
|
|
|
|
{/* 输入容器 */}
|
|
<div className="relative">
|
|
{/* 左侧图标 */}
|
|
{icon && iconPosition === 'left' && (
|
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<span className="text-gray-400 w-4 h-4">{icon}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 搜索图标(特殊处理) */}
|
|
{type === 'search' && !icon && (
|
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<Search className="w-4 h-4 text-gray-400" />
|
|
</div>
|
|
)}
|
|
|
|
{/* 输入框 */}
|
|
<input
|
|
ref={inputRef}
|
|
type={getInputType()}
|
|
value={internalValue}
|
|
onChange={handleChange}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
maxLength={maxLength}
|
|
className={`
|
|
block w-full rounded-lg border
|
|
${getStatusColor()}
|
|
${icon && iconPosition === 'left' || type === 'search' ? 'pl-10' : 'pl-3'}
|
|
${showClearButton || showPasswordToggle || getStatusIcon() || (icon && iconPosition === 'right') ? 'pr-10' : 'pr-3'}
|
|
py-2.5 text-sm
|
|
placeholder-gray-400
|
|
focus:outline-none
|
|
disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed
|
|
${isFocused ? 'shadow-sm' : ''}
|
|
${error ? 'animate-error-shake' : ''}
|
|
${inputClassName}
|
|
`}
|
|
/>
|
|
|
|
{/* 右侧图标区域 */}
|
|
<div className="absolute inset-y-0 right-0 flex items-center pr-3 space-x-1">
|
|
{/* 清除按钮 */}
|
|
{showClearButton && (
|
|
<button
|
|
type="button"
|
|
onClick={handleClear}
|
|
className="text-gray-400 hover:text-gray-600 transition-colors p-0.5 rounded hover:bg-gray-100"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
)}
|
|
|
|
{/* 密码显示切换 */}
|
|
{showPasswordToggle && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
className="text-gray-400 hover:text-gray-600 transition-colors p-0.5 rounded hover:bg-gray-100"
|
|
>
|
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
</button>
|
|
)}
|
|
|
|
{/* 状态图标 */}
|
|
{getStatusIcon()}
|
|
|
|
{/* 右侧图标 */}
|
|
{icon && iconPosition === 'right' && (
|
|
<span className="text-gray-400 w-4 h-4">{icon}</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 焦点指示器 */}
|
|
{isFocused && (
|
|
<div className="absolute inset-0 rounded-lg border-2 border-primary-500 pointer-events-none animate-pulse" />
|
|
)}
|
|
</div>
|
|
|
|
{/* 底部信息 */}
|
|
<div className="flex justify-between items-start">
|
|
<div className="space-y-1">
|
|
{/* 错误信息 */}
|
|
{error && (
|
|
<p className="text-sm text-red-600 flex items-center gap-1 animate-slide-in-up">
|
|
<AlertCircle className="w-3 h-3" />
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{/* 成功信息 */}
|
|
{success && !error && (
|
|
<p className="text-sm text-green-600 flex items-center gap-1 animate-slide-in-up">
|
|
<CheckCircle className="w-3 h-3" />
|
|
{success}
|
|
</p>
|
|
)}
|
|
|
|
{/* 提示信息 */}
|
|
{hint && !error && !success && (
|
|
<p className="text-sm text-gray-500">{hint}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* 字符计数 */}
|
|
{maxLength && (
|
|
<p className={`text-xs ${internalValue.length > maxLength * 0.8 ? 'text-orange-500' : 'text-gray-400'}`}>
|
|
{internalValue.length}/{maxLength}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 搜索输入组件
|
|
*/
|
|
interface SearchInputProps {
|
|
value?: string;
|
|
onChange?: (value: string) => void;
|
|
onSearch?: (value: string) => void;
|
|
placeholder?: string;
|
|
loading?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
export const SearchInput: React.FC<SearchInputProps> = ({
|
|
value = '',
|
|
onChange,
|
|
onSearch,
|
|
placeholder = '搜索...',
|
|
loading = false,
|
|
className = '',
|
|
}) => {
|
|
return (
|
|
<InteractiveInput
|
|
type="search"
|
|
value={value}
|
|
onChange={onChange}
|
|
onEnter={() => onSearch?.(value)}
|
|
placeholder={placeholder}
|
|
loading={loading}
|
|
clearable
|
|
className={className}
|
|
/>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 交互式文本区域组件
|
|
*/
|
|
interface InteractiveTextareaProps {
|
|
value?: string;
|
|
onChange?: (value: string) => void;
|
|
onFocus?: () => void;
|
|
onBlur?: () => void;
|
|
placeholder?: string;
|
|
label?: string;
|
|
error?: string;
|
|
success?: string;
|
|
hint?: string;
|
|
required?: boolean;
|
|
disabled?: boolean;
|
|
rows?: number;
|
|
maxLength?: number;
|
|
className?: string;
|
|
textareaClassName?: string;
|
|
}
|
|
|
|
export const InteractiveTextarea: React.FC<InteractiveTextareaProps> = ({
|
|
value = '',
|
|
onChange,
|
|
onFocus,
|
|
onBlur,
|
|
placeholder,
|
|
label,
|
|
error,
|
|
success,
|
|
hint,
|
|
required = false,
|
|
disabled = false,
|
|
rows = 4,
|
|
maxLength,
|
|
className = '',
|
|
textareaClassName = '',
|
|
}) => {
|
|
const [isFocused, setIsFocused] = useState(false);
|
|
const [internalValue, setInternalValue] = useState(value);
|
|
|
|
useEffect(() => {
|
|
setInternalValue(value);
|
|
}, [value]);
|
|
|
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const newValue = e.target.value;
|
|
setInternalValue(newValue);
|
|
onChange?.(newValue);
|
|
};
|
|
|
|
const handleFocus = () => {
|
|
setIsFocused(true);
|
|
onFocus?.();
|
|
};
|
|
|
|
const handleBlur = () => {
|
|
setIsFocused(false);
|
|
onBlur?.();
|
|
};
|
|
|
|
const getStatusColor = () => {
|
|
if (error) return 'border-red-300 focus:border-red-500 focus:ring-red-500';
|
|
if (success) return 'border-green-300 focus:border-green-500 focus:ring-green-500';
|
|
return 'border-gray-300 focus:border-primary-500 focus:ring-primary-500';
|
|
};
|
|
|
|
return (
|
|
<div className={`space-y-1 ${className}`}>
|
|
{/* 标签 */}
|
|
{label && (
|
|
<label className="block text-sm font-medium text-gray-700">
|
|
{label}
|
|
{required && <span className="text-red-500 ml-1">*</span>}
|
|
</label>
|
|
)}
|
|
|
|
{/* 文本区域容器 */}
|
|
<div className="relative">
|
|
<textarea
|
|
value={internalValue}
|
|
onChange={handleChange}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
rows={rows}
|
|
maxLength={maxLength}
|
|
className={`
|
|
block w-full rounded-lg border transition-all duration-200
|
|
${getStatusColor()}
|
|
px-3 py-2.5 text-sm
|
|
placeholder-gray-400
|
|
focus:outline-none focus:ring-2 focus:ring-opacity-50
|
|
disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed
|
|
resize-none
|
|
${isFocused ? 'shadow-sm' : ''}
|
|
${error ? 'animate-error-shake' : ''}
|
|
${textareaClassName}
|
|
`}
|
|
/>
|
|
|
|
{/* 焦点指示器 */}
|
|
{isFocused && (
|
|
<div className="absolute inset-0 rounded-lg border-2 border-primary-500 pointer-events-none animate-pulse" />
|
|
)}
|
|
</div>
|
|
|
|
{/* 底部信息 */}
|
|
<div className="flex justify-between items-start">
|
|
<div className="space-y-1">
|
|
{/* 错误信息 */}
|
|
{error && (
|
|
<p className="text-sm text-red-600 flex items-center gap-1 animate-slide-in-up">
|
|
<AlertCircle className="w-3 h-3" />
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{/* 成功信息 */}
|
|
{success && !error && (
|
|
<p className="text-sm text-green-600 flex items-center gap-1 animate-slide-in-up">
|
|
<CheckCircle className="w-3 h-3" />
|
|
{success}
|
|
</p>
|
|
)}
|
|
|
|
{/* 提示信息 */}
|
|
{hint && !error && !success && (
|
|
<p className="text-sm text-gray-500">{hint}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* 字符计数 */}
|
|
{maxLength && (
|
|
<p className={`text-xs ${internalValue.length > maxLength * 0.8 ? 'text-orange-500' : 'text-gray-400'}`}>
|
|
{internalValue.length}/{maxLength}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|