File
Blob: src/client/components/ui/button.tsx
| 1 | import type { ComponentProps, ReactNode } from "react"; |
| 2 | |
| 3 | type ButtonVariant = "primary" | "secondary" | "danger" | "ghost"; |
| 4 | type ButtonSize = "sm" | "md"; |
| 5 | |
| 6 | type ButtonBaseProps = { |
| 7 | variant?: ButtonVariant; |
| 8 | size?: ButtonSize; |
| 9 | children: ReactNode; |
| 10 | className?: string; |
| 11 | }; |
| 12 | |
| 13 | type ButtonAsButton = ButtonBaseProps & |
| 14 | Omit<ComponentProps<"button">, keyof ButtonBaseProps> & { |
| 15 | href?: never; |
| 16 | }; |
| 17 | |
| 18 | type ButtonAsAnchor = ButtonBaseProps & |
| 19 | Omit<ComponentProps<"a">, keyof ButtonBaseProps> & { |
| 20 | href: string; |
| 21 | }; |
| 22 | |
| 23 | export type ButtonProps = ButtonAsButton | ButtonAsAnchor; |
| 24 | |
| 25 | const shared = |
| 26 | "inline-flex items-center justify-center gap-2 rounded-xl font-medium no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/50 focus-visible:ring-offset-2 focus-visible:ring-offset-canvas disabled:opacity-50 disabled:pointer-events-none"; |
| 27 | |
| 28 | // Anchor-backed buttons still match the global link rules in app.css, so each |
| 29 | // variant declares text colors for hover states instead of relying on cascade. |
| 30 | // Hover color swaps are intentionally instant; animating them can create muddy |
| 31 | // intermediate colors across browsers. Only the active press scale transitions. |
| 32 | const variants: Record<ButtonVariant, string> = { |
| 33 | primary: |
| 34 | "bg-accent-600 text-white hover:bg-accent-500 hover:text-white active:scale-[0.98] transition-transform", |
| 35 | secondary: |
| 36 | "border border-zinc-300 bg-zinc-100 text-zinc-700 hover:bg-zinc-200 hover:text-zinc-900 dark:border-zinc-700/60 dark:bg-zinc-800/60 dark:text-zinc-300 dark:hover:bg-zinc-700/60 dark:hover:text-zinc-100 active:scale-[0.98] transition-transform", |
| 37 | danger: |
| 38 | "border border-red-500/20 bg-red-500/10 text-red-700 hover:bg-red-500/20 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 active:scale-[0.98] transition-transform", |
| 39 | ghost: |
| 40 | "text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800/70 dark:hover:text-zinc-100 active:scale-[0.97] transition-transform", |
| 41 | }; |
| 42 | |
| 43 | const sizes: Record<ButtonSize, string> = { |
| 44 | sm: "px-3 py-1.5 text-sm", |
| 45 | md: "px-4 py-2.5 text-sm", |
| 46 | }; |
| 47 | |
| 48 | export function buttonClasses(variant: ButtonVariant = "primary", size: ButtonSize = "md"): string { |
| 49 | return `${shared} ${variants[variant]} ${sizes[size]}`; |
| 50 | } |
| 51 | |
| 52 | export function Button({ |
| 53 | variant = "primary", |
| 54 | size = "md", |
| 55 | className = "", |
| 56 | children, |
| 57 | ...rest |
| 58 | }: ButtonProps) { |
| 59 | const classes = `${shared} ${variants[variant]} ${sizes[size]} ${className}`.trim(); |
| 60 | |
| 61 | if ("href" in rest && rest.href != null) { |
| 62 | const { href, ...anchorRest } = rest as ButtonAsAnchor; |
| 63 | return ( |
| 64 | <a href={href} className={classes} {...anchorRest}> |
| 65 | {children} |
| 66 | </a> |
| 67 | ); |
| 68 | } |
| 69 | |
| 70 | return ( |
| 71 | <button className={classes} {...(rest as Omit<ButtonAsButton, keyof ButtonBaseProps>)}> |
| 72 | {children} |
| 73 | </button> |
| 74 | ); |
| 75 | } |
| 76 |