224 lines
6.5 KiB
TypeScript
224 lines
6.5 KiB
TypeScript
import React, { forwardRef, ButtonHTMLAttributes, ReactNode } from 'react';
|
|
import { cn } from '../../lib/utils';
|
|
import { getViewport, getTouchTargetSize } from '../../lib/responsive';
|
|
|
|
// Button variants
|
|
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
|
|
|
|
// Button sizes
|
|
type ButtonSize = 'sm' | 'md' | 'lg';
|
|
|
|
// Button props interface
|
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
variant?: ButtonVariant;
|
|
size?: ButtonSize;
|
|
loading?: boolean;
|
|
icon?: ReactNode;
|
|
iconPosition?: 'left' | 'right';
|
|
fullWidth?: boolean;
|
|
responsiveSize?: {
|
|
mobile?: ButtonSize;
|
|
tablet?: ButtonSize;
|
|
desktop?: ButtonSize;
|
|
};
|
|
touchTarget?: boolean;
|
|
}
|
|
|
|
// Helper function to get variant styles
|
|
const getVariantStyles = (variant: ButtonVariant, disabled?: boolean) => {
|
|
const baseStyles = 'transition-all duration-200 ease-in-out font-medium rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2';
|
|
|
|
if (disabled) {
|
|
return `${baseStyles} bg-gray-300 text-gray-500 cursor-not-allowed opacity-60`;
|
|
}
|
|
|
|
switch (variant) {
|
|
case 'primary':
|
|
return `${baseStyles} bg-primary hover:bg-primary-dark text-white focus:ring-primary`;
|
|
case 'secondary':
|
|
return `${baseStyles} bg-secondary hover:bg-secondary-light text-white focus:ring-secondary`;
|
|
case 'outline':
|
|
return `${baseStyles} bg-transparent border-2 border-primary text-primary hover:bg-primary-light hover:border-primary-dark focus:ring-primary`;
|
|
case 'ghost':
|
|
return `${baseStyles} bg-transparent text-primary hover:bg-primary-light focus:ring-primary`;
|
|
default:
|
|
return `${baseStyles} bg-primary hover:bg-primary-dark text-white`;
|
|
}
|
|
};
|
|
|
|
// Helper function to get size styles
|
|
const getSizeStyles = (size: ButtonSize) => {
|
|
switch (size) {
|
|
case 'sm':
|
|
return 'px-3 py-1.5 text-sm';
|
|
case 'md':
|
|
return 'px-4 py-2 text-base';
|
|
case 'lg':
|
|
return 'px-6 py-3 text-lg';
|
|
default:
|
|
return 'px-4 py-2 text-base';
|
|
}
|
|
};
|
|
|
|
// Helper function to get icon spacing
|
|
const getIconSpacing = (size: ButtonSize, iconPosition: 'left' | 'right') => {
|
|
const spacing = {
|
|
sm: iconPosition === 'left' ? 'mr-1.5' : 'ml-1.5',
|
|
md: iconPosition === 'left' ? 'mr-2' : 'ml-2',
|
|
lg: iconPosition === 'left' ? 'mr-2.5' : 'ml-2.5',
|
|
};
|
|
return spacing[size];
|
|
};
|
|
|
|
// Loading spinner component
|
|
const LoadingSpinner = ({ size }: { size: ButtonSize }) => {
|
|
const sizeClasses = {
|
|
sm: 'w-4 h-4',
|
|
md: 'w-5 h-5',
|
|
lg: 'w-6 h-6',
|
|
};
|
|
|
|
return (
|
|
<div className={cn('animate-spin', sizeClasses[size])}>
|
|
<svg
|
|
className="w-full h-full text-current"
|
|
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"
|
|
></circle>
|
|
<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"
|
|
></path>
|
|
</svg>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Main Button component
|
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
(
|
|
{
|
|
variant = 'primary',
|
|
size = 'md',
|
|
loading = false,
|
|
icon,
|
|
iconPosition = 'left',
|
|
fullWidth = false,
|
|
disabled,
|
|
className = '',
|
|
children,
|
|
type = 'button',
|
|
responsiveSize,
|
|
touchTarget = true,
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
const isDisabled = disabled || loading;
|
|
|
|
// Get responsive size if provided
|
|
const getResponsiveSize = () => {
|
|
if (!responsiveSize) return size;
|
|
|
|
if (typeof window === 'undefined') return size;
|
|
|
|
const viewport = getViewport();
|
|
|
|
if (viewport.isMobile && responsiveSize.mobile) {
|
|
return responsiveSize.mobile;
|
|
}
|
|
if (viewport.isTablet && responsiveSize.tablet) {
|
|
return responsiveSize.tablet;
|
|
}
|
|
if (viewport.isDesktop && responsiveSize.desktop) {
|
|
return responsiveSize.desktop;
|
|
}
|
|
|
|
return size;
|
|
};
|
|
|
|
const responsiveSizeValue = getResponsiveSize();
|
|
|
|
// Get touch target size
|
|
const getTouchTargetClasses = () => {
|
|
if (!touchTarget) return '';
|
|
|
|
if (typeof window === 'undefined') return '';
|
|
|
|
const viewport = getViewport();
|
|
const targetSize = getTouchTargetSize(viewport.isMobile, viewport.isLargeDesktop);
|
|
|
|
// Ensure minimum touch target
|
|
return `min-h-[44px] min-w-[44px]`;
|
|
};
|
|
|
|
return (
|
|
<button
|
|
ref={ref}
|
|
type={type}
|
|
disabled={isDisabled}
|
|
className={cn(
|
|
'inline-flex items-center justify-center font-semibold',
|
|
'transition-all duration-200 ease-in-out',
|
|
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
|
// Base styles
|
|
'rounded-lg',
|
|
// Variant styles
|
|
getVariantStyles(variant, isDisabled),
|
|
// Size styles (responsive)
|
|
getSizeStyles(responsiveSizeValue),
|
|
// Touch target optimization
|
|
getTouchTargetClasses(),
|
|
// Full width
|
|
fullWidth ? 'w-full' : '',
|
|
// Mobile-specific optimizations
|
|
'active:scale-95 md:active:scale-100',
|
|
// Custom classes
|
|
className
|
|
)}
|
|
// Add aria-label for accessibility if button has only icon
|
|
aria-label={!children && icon ? 'Button action' : undefined}
|
|
{...props}
|
|
>
|
|
{/* Loading state */}
|
|
{loading && (
|
|
<span className={cn('flex items-center justify-center', getIconSpacing(responsiveSizeValue, 'left'))}>
|
|
<LoadingSpinner size={responsiveSizeValue} />
|
|
</span>
|
|
)}
|
|
|
|
{/* Icon - Left position */}
|
|
{!loading && icon && iconPosition === 'left' && (
|
|
<span className={cn('flex items-center justify-center', getIconSpacing(responsiveSizeValue, 'left'))}>
|
|
{icon}
|
|
</span>
|
|
)}
|
|
|
|
{/* Button content */}
|
|
{children && <span className="leading-none">{children}</span>}
|
|
|
|
{/* Icon - Right position */}
|
|
{!loading && icon && iconPosition === 'right' && (
|
|
<span className={cn('flex items-center justify-center', getIconSpacing(responsiveSizeValue, 'right'))}>
|
|
{icon}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
);
|
|
|
|
Button.displayName = 'Button';
|
|
|
|
// Export types for external use
|
|
export type { ButtonProps, ButtonVariant, ButtonSize }; |