267 lines
7.8 KiB
TypeScript
267 lines
7.8 KiB
TypeScript
import React, { useState, useRef } from 'react';
|
|
import { Loader2 } from 'lucide-react';
|
|
|
|
interface InteractiveButtonProps {
|
|
children: React.ReactNode;
|
|
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void | Promise<void>;
|
|
variant?: 'primary' | 'secondary' | 'danger' | 'success' | 'ghost' | 'outline';
|
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
|
disabled?: boolean;
|
|
loading?: boolean;
|
|
icon?: React.ReactNode;
|
|
iconPosition?: 'left' | 'right';
|
|
fullWidth?: boolean;
|
|
ripple?: boolean;
|
|
haptic?: boolean;
|
|
className?: string;
|
|
type?: 'button' | 'submit' | 'reset';
|
|
}
|
|
|
|
/**
|
|
* 增强的交互按钮组件
|
|
* 提供丰富的视觉反馈和微交互效果
|
|
*/
|
|
export const InteractiveButton: React.FC<InteractiveButtonProps> = ({
|
|
children,
|
|
onClick,
|
|
variant = 'primary',
|
|
size = 'md',
|
|
disabled = false,
|
|
loading = false,
|
|
icon,
|
|
iconPosition = 'left',
|
|
fullWidth = false,
|
|
ripple = true,
|
|
haptic = true,
|
|
className = '',
|
|
type = 'button',
|
|
}) => {
|
|
const [isPressed, setIsPressed] = useState(false);
|
|
const [ripples, setRipples] = useState<Array<{ id: number; x: number; y: number }>>([]);
|
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
const rippleIdRef = useRef(0);
|
|
|
|
const getVariantClasses = () => {
|
|
const variants = {
|
|
primary: 'bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 text-white shadow-sm hover:shadow-md focus:ring-primary-500',
|
|
secondary: 'bg-gray-100 hover:bg-gray-200 text-gray-900 shadow-sm hover:shadow focus:ring-gray-500',
|
|
danger: 'bg-gradient-to-r from-red-600 to-red-700 hover:from-red-700 hover:to-red-800 text-white shadow-sm hover:shadow-md focus:ring-red-500',
|
|
success: 'bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800 text-white shadow-sm hover:shadow-md focus:ring-green-500',
|
|
ghost: 'hover:bg-gray-100 text-gray-700 hover:text-gray-900 focus:ring-gray-500',
|
|
outline: 'border border-gray-300 hover:border-gray-400 bg-white hover:bg-gray-50 text-gray-700 hover:text-gray-900 shadow-sm focus:ring-gray-500',
|
|
};
|
|
return variants[variant];
|
|
};
|
|
|
|
const getSizeClasses = () => {
|
|
const sizes = {
|
|
xs: 'px-2 py-1 text-xs',
|
|
sm: 'px-3 py-1.5 text-sm',
|
|
md: 'px-4 py-2 text-sm',
|
|
lg: 'px-5 py-2.5 text-base',
|
|
xl: 'px-6 py-3 text-lg',
|
|
};
|
|
return sizes[size];
|
|
};
|
|
|
|
const getIconSize = () => {
|
|
const iconSizes = {
|
|
xs: 'w-3 h-3',
|
|
sm: 'w-4 h-4',
|
|
md: 'w-4 h-4',
|
|
lg: 'w-5 h-5',
|
|
xl: 'w-6 h-6',
|
|
};
|
|
return iconSizes[size];
|
|
};
|
|
|
|
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
if (disabled || loading) return;
|
|
|
|
// 添加按压效果
|
|
setIsPressed(true);
|
|
setTimeout(() => setIsPressed(false), 150);
|
|
|
|
// 添加涟漪效果
|
|
if (ripple && buttonRef.current) {
|
|
const rect = buttonRef.current.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
const newRipple = { id: rippleIdRef.current++, x, y };
|
|
|
|
setRipples(prev => [...prev, newRipple]);
|
|
|
|
// 移除涟漪效果
|
|
setTimeout(() => {
|
|
setRipples(prev => prev.filter(r => r.id !== newRipple.id));
|
|
}, 600);
|
|
}
|
|
|
|
// 触觉反馈(如果支持)
|
|
if (haptic && 'vibrate' in navigator) {
|
|
navigator.vibrate(10);
|
|
}
|
|
|
|
// 执行点击处理
|
|
if (onClick) {
|
|
await onClick(e);
|
|
}
|
|
};
|
|
|
|
const baseClasses = `
|
|
relative overflow-hidden
|
|
inline-flex items-center justify-center
|
|
font-medium rounded-lg
|
|
transition-all duration-200 ease-out
|
|
focus:outline-none focus:ring-2 focus:ring-offset-2
|
|
disabled:opacity-50 disabled:cursor-not-allowed
|
|
transform hover:scale-105 active:scale-95
|
|
${isPressed ? 'animate-button-press' : ''}
|
|
${fullWidth ? 'w-full' : ''}
|
|
`;
|
|
|
|
return (
|
|
<button
|
|
ref={buttonRef}
|
|
type={type}
|
|
className={`${baseClasses} ${getVariantClasses()} ${getSizeClasses()} ${className}`}
|
|
onClick={handleClick}
|
|
disabled={disabled || loading}
|
|
>
|
|
{/* 涟漪效果 */}
|
|
{ripples.map((ripple) => (
|
|
<span
|
|
key={ripple.id}
|
|
className="absolute bg-white bg-opacity-30 rounded-full animate-ping"
|
|
style={{
|
|
left: ripple.x - 10,
|
|
top: ripple.y - 10,
|
|
width: 20,
|
|
height: 20,
|
|
}}
|
|
/>
|
|
))}
|
|
|
|
{/* 按钮内容 */}
|
|
<span className="relative flex items-center gap-2">
|
|
{loading ? (
|
|
<Loader2 className={`${getIconSize()} animate-spin`} />
|
|
) : (
|
|
icon && iconPosition === 'left' && (
|
|
<span className={getIconSize()}>{icon}</span>
|
|
)
|
|
)}
|
|
|
|
<span>{children}</span>
|
|
|
|
{!loading && icon && iconPosition === 'right' && (
|
|
<span className={getIconSize()}>{icon}</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 浮动操作按钮
|
|
*/
|
|
interface FloatingActionButtonProps {
|
|
onClick?: () => void;
|
|
icon: React.ReactNode;
|
|
tooltip?: string;
|
|
variant?: 'primary' | 'secondary' | 'danger';
|
|
size?: 'sm' | 'md' | 'lg';
|
|
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
|
className?: string;
|
|
}
|
|
|
|
export const FloatingActionButton: React.FC<FloatingActionButtonProps> = ({
|
|
onClick,
|
|
icon,
|
|
tooltip,
|
|
variant = 'primary',
|
|
size = 'md',
|
|
position = 'bottom-right',
|
|
className = '',
|
|
}) => {
|
|
const [showTooltip, setShowTooltip] = useState(false);
|
|
|
|
const getVariantClasses = () => {
|
|
const variants = {
|
|
primary: 'bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 text-white shadow-lg hover:shadow-xl',
|
|
secondary: 'bg-white hover:bg-gray-50 text-gray-700 shadow-lg hover:shadow-xl border border-gray-200',
|
|
danger: 'bg-gradient-to-r from-red-600 to-red-700 hover:from-red-700 hover:to-red-800 text-white shadow-lg hover:shadow-xl',
|
|
};
|
|
return variants[variant];
|
|
};
|
|
|
|
const getSizeClasses = () => {
|
|
const sizes = {
|
|
sm: 'w-12 h-12',
|
|
md: 'w-14 h-14',
|
|
lg: 'w-16 h-16',
|
|
};
|
|
return sizes[size];
|
|
};
|
|
|
|
const getPositionClasses = () => {
|
|
const positions = {
|
|
'bottom-right': 'fixed bottom-6 right-6',
|
|
'bottom-left': 'fixed bottom-6 left-6',
|
|
'top-right': 'fixed top-6 right-6',
|
|
'top-left': 'fixed top-6 left-6',
|
|
};
|
|
return positions[position];
|
|
};
|
|
|
|
const getIconSize = () => {
|
|
const iconSizes = {
|
|
sm: 'w-5 h-5',
|
|
md: 'w-6 h-6',
|
|
lg: 'w-8 h-8',
|
|
};
|
|
return iconSizes[size];
|
|
};
|
|
|
|
return (
|
|
<div className="relative">
|
|
<button
|
|
onClick={onClick}
|
|
onMouseEnter={() => setShowTooltip(true)}
|
|
onMouseLeave={() => setShowTooltip(false)}
|
|
className={`
|
|
${getPositionClasses()}
|
|
${getSizeClasses()}
|
|
${getVariantClasses()}
|
|
rounded-full
|
|
flex items-center justify-center
|
|
transition-all duration-300 ease-out
|
|
transform hover:scale-110 active:scale-95
|
|
focus:outline-none focus:ring-4 focus:ring-primary-500 focus:ring-opacity-50
|
|
z-50
|
|
${className}
|
|
`}
|
|
>
|
|
<span className={getIconSize()}>{icon}</span>
|
|
</button>
|
|
|
|
{/* 工具提示 */}
|
|
{tooltip && showTooltip && (
|
|
<div className={`
|
|
absolute z-50 px-3 py-2 text-sm text-white bg-gray-900 rounded-lg shadow-lg
|
|
whitespace-nowrap animate-fade-in
|
|
${position.includes('right') ? 'right-full mr-3' : 'left-full ml-3'}
|
|
${position.includes('bottom') ? 'bottom-0' : 'top-0'}
|
|
`}>
|
|
{tooltip}
|
|
<div className={`
|
|
absolute w-2 h-2 bg-gray-900 transform rotate-45
|
|
${position.includes('right') ? 'right-0 translate-x-1' : 'left-0 -translate-x-1'}
|
|
${position.includes('bottom') ? 'bottom-3' : 'top-3'}
|
|
`} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|