190 lines
6.5 KiB
TypeScript
190 lines
6.5 KiB
TypeScript
/**
|
|
* 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<ButtonTone, string> = {
|
|
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<ButtonSize, string> = {
|
|
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<ButtonElement, ButtonProps>(
|
|
(
|
|
{
|
|
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<ButtonVariant, string> = {
|
|
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
|
|
ref={ref}
|
|
className={computedClass}
|
|
onClick={onClick}
|
|
type={type}
|
|
disabled={disabled}
|
|
>
|
|
{startIcon && <span className="flex items-center">{startIcon}</span>}
|
|
<span className="whitespace-nowrap">{children}</span>
|
|
{endIcon && <span className="flex items-center">{endIcon}</span>}
|
|
</button>
|
|
);
|
|
},
|
|
);
|
|
|
|
Button.displayName = "Button";
|
|
|
|
export default Button;
|