145 lines
4.6 KiB
TypeScript
145 lines
4.6 KiB
TypeScript
import React, { forwardRef, ButtonHTMLAttributes } from 'react';
|
|
import { clsx } from 'clsx';
|
|
|
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'warning';
|
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
|
isLoading?: boolean;
|
|
isFullWidth?: boolean;
|
|
leftIcon?: React.ReactNode;
|
|
rightIcon?: React.ReactNode;
|
|
loadingText?: string;
|
|
}
|
|
|
|
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
|
variant = 'primary',
|
|
size = 'md',
|
|
isLoading = false,
|
|
isFullWidth = false,
|
|
leftIcon,
|
|
rightIcon,
|
|
loadingText,
|
|
className,
|
|
children,
|
|
disabled,
|
|
...props
|
|
}, ref) => {
|
|
const baseClasses = [
|
|
'inline-flex items-center justify-center font-medium',
|
|
'transition-all duration-200 ease-in-out',
|
|
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
'border rounded-lg'
|
|
];
|
|
|
|
const variantClasses = {
|
|
primary: [
|
|
'bg-[var(--color-primary)] text-[var(--text-inverse)] border-[var(--color-primary)]',
|
|
'hover:bg-[var(--color-primary-dark)] hover:border-[var(--color-primary-dark)]',
|
|
'focus:ring-[var(--color-primary)]/20',
|
|
'active:bg-[var(--color-primary-dark)]'
|
|
],
|
|
secondary: [
|
|
'bg-[var(--color-secondary)] text-[var(--text-inverse)] border-[var(--color-secondary)]',
|
|
'hover:bg-[var(--color-secondary-dark)] hover:border-[var(--color-secondary-dark)]',
|
|
'focus:ring-[var(--color-secondary)]/20',
|
|
'active:bg-[var(--color-secondary-dark)]'
|
|
],
|
|
outline: [
|
|
'bg-transparent text-[var(--color-primary)] border-[var(--color-primary)]',
|
|
'hover:bg-[var(--color-primary)] hover:text-[var(--text-inverse)]',
|
|
'focus:ring-[var(--color-primary)]/20',
|
|
'active:bg-[var(--color-primary-dark)] active:border-[var(--color-primary-dark)]'
|
|
],
|
|
ghost: [
|
|
'bg-transparent text-[var(--color-primary)] border-transparent',
|
|
'hover:bg-[var(--color-primary)]/10 hover:text-[var(--color-primary-dark)]',
|
|
'focus:ring-[var(--color-primary)]/20',
|
|
'active:bg-[var(--color-primary)]/20'
|
|
],
|
|
danger: [
|
|
'bg-[var(--color-error)] text-[var(--text-inverse)] border-[var(--color-error)]',
|
|
'hover:bg-[var(--color-error-dark)] hover:border-[var(--color-error-dark)]',
|
|
'focus:ring-[var(--color-error)]/20',
|
|
'active:bg-[var(--color-error-dark)]'
|
|
],
|
|
success: [
|
|
'bg-[var(--color-success)] text-[var(--text-inverse)] border-[var(--color-success)]',
|
|
'hover:bg-[var(--color-success-dark)] hover:border-[var(--color-success-dark)]',
|
|
'focus:ring-[var(--color-success)]/20',
|
|
'active:bg-[var(--color-success-dark)]'
|
|
],
|
|
warning: [
|
|
'bg-[var(--color-warning)] text-[var(--text-inverse)] border-[var(--color-warning)]',
|
|
'hover:bg-[var(--color-warning-dark)] hover:border-[var(--color-warning-dark)]',
|
|
'focus:ring-[var(--color-warning)]/20',
|
|
'active:bg-[var(--color-warning-dark)]'
|
|
]
|
|
};
|
|
|
|
const sizeClasses = {
|
|
xs: 'px-2 py-1 text-xs gap-1 min-h-6',
|
|
sm: 'px-3 py-1.5 text-sm gap-1.5 min-h-8 sm:min-h-10', // Better touch target on mobile
|
|
md: 'px-4 py-2 text-sm gap-2 min-h-10 sm:min-h-12',
|
|
lg: 'px-6 py-2.5 text-base gap-2 min-h-12 sm:min-h-14',
|
|
xl: 'px-8 py-3 text-lg gap-3 min-h-14 sm:min-h-16'
|
|
};
|
|
|
|
const loadingSpinner = (
|
|
<svg
|
|
className="animate-spin -ml-1 mr-2 h-4 w-4"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="4"
|
|
/>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const classes = clsx(
|
|
baseClasses,
|
|
variantClasses[variant],
|
|
sizeClasses[size],
|
|
{
|
|
'w-full': isFullWidth,
|
|
'cursor-wait': isLoading
|
|
},
|
|
className
|
|
);
|
|
|
|
return (
|
|
<button
|
|
ref={ref}
|
|
className={classes}
|
|
disabled={disabled || isLoading}
|
|
{...props}
|
|
>
|
|
{isLoading && loadingSpinner}
|
|
{!isLoading && leftIcon && (
|
|
<span className="flex-shrink-0">{leftIcon}</span>
|
|
)}
|
|
<span>
|
|
{isLoading && loadingText ? loadingText : children}
|
|
</span>
|
|
{!isLoading && rightIcon && (
|
|
<span className="flex-shrink-0">{rightIcon}</span>
|
|
)}
|
|
</button>
|
|
);
|
|
});
|
|
|
|
Button.displayName = 'Button';
|
|
|
|
export default Button; |