/** * Button Component * * 🔒 STYLE LOCKED - See DESIGN_SYSTEM.md for available variants and sizes. * Do not modify variants or add new ones without updating DESIGN_SYSTEM.md first. */ import { ReactNode, forwardRef } from "react"; import clsx from "clsx"; import { twMerge } from "tailwind-merge"; type ButtonSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl"; type ButtonVariant = "primary" | "secondary" | "outline" | "ghost" | "gradient"; type ButtonTone = "brand" | "success" | "warning" | "danger" | "neutral"; type ButtonShape = "rounded" | "pill"; type ButtonElement = HTMLButtonElement; interface ButtonProps { children: ReactNode; // Button text or content size?: ButtonSize; // Button size variant?: ButtonVariant; // Visual variant tone?: ButtonTone; // Color tone shape?: ButtonShape; // Border radius style startIcon?: ReactNode; // Icon before the text endIcon?: ReactNode; // Icon after the text onClick?: () => void; // Click handler disabled?: boolean; // Disabled state fullWidth?: boolean; // Stretch to parent width className?: string; // Additional classes type?: "button" | "submit" | "reset"; // Button type } const toneMap: Record< ButtonTone, { primary: string; secondary: string; outline: string; ghost: string; ring: string; } > = { brand: { primary: "bg-brand-500 text-white hover:bg-brand-600", secondary: "bg-gray-500 text-white hover:bg-gray-600 dark:bg-gray-500 dark:hover:bg-gray-600", outline: "text-brand-600 ring-1 ring-brand-200 hover:bg-brand-25 dark:ring-brand-500/40 dark:text-brand-300 dark:hover:bg-brand-500/[0.08]", ghost: "text-brand-600 hover:bg-brand-50 dark:text-brand-300 dark:hover:bg-brand-500/[0.08]", ring: "focus-visible:ring-brand-500", }, success: { primary: "bg-success-500 text-white hover:bg-success-600", secondary: "bg-gray-500 text-white hover:bg-gray-600 dark:bg-gray-500 dark:hover:bg-gray-600", outline: "text-success-600 ring-1 ring-success-200 hover:bg-success-25 dark:ring-success-500/40 dark:text-success-300", ghost: "text-success-600 hover:bg-success-50 dark:text-success-300 dark:hover:bg-success-500/[0.08]", ring: "focus-visible:ring-success-500", }, warning: { primary: "bg-warning-500 text-white hover:bg-warning-600", secondary: "bg-gray-500 text-white hover:bg-gray-600 dark:bg-gray-500 dark:hover:bg-gray-600", outline: "text-warning-600 ring-1 ring-warning-200 hover:bg-warning-25 dark:ring-warning-500/40 dark:text-warning-300", ghost: "text-warning-600 hover:bg-warning-50 dark:text-warning-300 dark:hover:bg-warning-500/[0.08]", ring: "focus-visible:ring-warning-500", }, danger: { primary: "bg-error-500 text-white hover:bg-error-600", secondary: "bg-gray-500 text-white hover:bg-gray-600 dark:bg-gray-500 dark:hover:bg-gray-600", outline: "text-error-600 ring-1 ring-error-200 hover:bg-error-25 dark:ring-error-500/40 dark:text-error-300", ghost: "text-error-600 hover:bg-error-50 dark:text-error-300 dark:hover:bg-error-500/[0.08]", ring: "focus-visible:ring-error-500", }, neutral: { primary: "bg-gray-900 text-white hover:bg-gray-800 dark:bg-white/10 dark:hover:bg-white/20", secondary: "bg-gray-500 text-white hover:bg-gray-600 dark:bg-gray-500 dark:hover:bg-gray-600", outline: "text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50 dark:text-gray-200 dark:ring-white/[0.08] dark:hover:bg-white/[0.04]", ghost: "text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/[0.04]", ring: "focus-visible:ring-gray-400", }, }; const gradientTone: Record = { brand: "text-white shadow-[0_20px_45px_-30px_rgba(6,147,227,0.9)] bg-[linear-gradient(135deg,var(--color-primary)_0%,var(--color-primary-dark)_100%)]", success: "text-white shadow-[0_20px_45px_-30px_rgba(11,191,135,0.9)] bg-[linear-gradient(135deg,var(--color-success)_0%,var(--color-success-dark)_100%)]", warning: "text-white shadow-[0_20px_45px_-30px_rgba(255,122,0,0.9)] bg-[linear-gradient(135deg,var(--color-warning)_0%,var(--color-warning-dark)_100%)]", danger: "text-white shadow-[0_20px_45px_-30px_rgba(239,68,68,0.9)] bg-[linear-gradient(135deg,var(--color-danger)_0%,var(--color-danger-dark)_100%)]", neutral: "text-white shadow-theme-lg bg-[linear-gradient(135deg,var(--color-gray-900),var(--color-gray-800))]", }; const sizeClasses: Record = { xs: "h-7 px-2.5 text-xs", sm: "h-9 px-3 text-sm", md: "h-10 px-4 text-sm", lg: "h-12 px-5 text-base", xl: "h-14 px-6 text-lg", "2xl": "h-auto px-8 py-4 text-lg font-semibold", }; const Button = forwardRef( ( { children, size = "md", variant = "primary", tone = "brand", shape = "rounded", startIcon, endIcon, onClick, className = "", disabled = false, fullWidth = false, type = "button", }, ref, ) => { const toneStyles = toneMap[tone]; const variantClasses: Record = { primary: toneStyles.primary, secondary: toneStyles.secondary, outline: clsx( "bg-transparent transition-colors", toneStyles.outline, "dark:bg-transparent", ), ghost: toneStyles.ghost, gradient: gradientTone[tone], }; const baseClasses = "inline-flex items-center justify-center gap-2 font-medium transition duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"; const shapeClasses = shape === "pill" ? "rounded-full" : "rounded-lg"; const ringClass = variant === "gradient" ? "focus-visible:ring-white/70" : toneStyles.ring; const computedClass = twMerge( clsx( baseClasses, sizeClasses[size], shapeClasses, variantClasses[variant], ringClass, { "w-full": fullWidth, }, className, ), ); return ( ); }, ); Button.displayName = "Button"; export default Button;