Files
klz-cables.com/components/ui/Button.tsx
2025-12-29 18:18:48 +01:00

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 };