Skip to content
File

Blob: src/client/components/ui/button.tsx

typescript76 lines
1import type { ComponentProps, ReactNode } from "react";
2 
3type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
4type ButtonSize = "sm" | "md";
5 
6type ButtonBaseProps = {
7 variant?: ButtonVariant;
8 size?: ButtonSize;
9 children: ReactNode;
10 className?: string;
11};
12 
13type ButtonAsButton = ButtonBaseProps &
14 Omit<ComponentProps<"button">, keyof ButtonBaseProps> & {
15 href?: never;
16 };
17 
18type ButtonAsAnchor = ButtonBaseProps &
19 Omit<ComponentProps<"a">, keyof ButtonBaseProps> & {
20 href: string;
21 };
22 
23export type ButtonProps = ButtonAsButton | ButtonAsAnchor;
24 
25const 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.
32const 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 
43const sizes: Record<ButtonSize, string> = {
44 sm: "px-3 py-1.5 text-sm",
45 md: "px-4 py-2.5 text-sm",
46};
47 
48export function buttonClasses(variant: ButtonVariant = "primary", size: ButtonSize = "md"): string {
49 return `${shared} ${variants[variant]} ${sizes[size]}`;
50}
51 
52export 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