migration wip
This commit is contained in:
224
components/ui/Button.tsx
Normal file
224
components/ui/Button.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user