File
Blob: src/components/ui.tsx
| 1 | import type { ButtonHTMLAttributes, ReactNode } from "react"; |
| 2 | import { cn } from "../lib/cn"; |
| 3 | |
| 4 | type Variant = "primary" | "subtle" | "ghost" | "danger"; |
| 5 | type Size = "sm" | "md"; |
| 6 | |
| 7 | const VARIANTS: Record<Variant, string> = { |
| 8 | primary: "bg-accent-600 text-white hover:bg-accent-500", |
| 9 | subtle: "border border-zinc-700/60 bg-zinc-800/60 text-zinc-300 hover:bg-zinc-700/60", |
| 10 | ghost: "text-zinc-400 hover:bg-zinc-800/70 hover:text-zinc-100", |
| 11 | danger: "border border-red-500/20 bg-red-500/10 text-red-400 hover:bg-red-500/20", |
| 12 | }; |
| 13 | |
| 14 | const SIZES: Record<Size, string> = { |
| 15 | sm: "h-8 px-2.5 text-xs gap-1.5", |
| 16 | md: "h-10 px-4 text-sm gap-2", |
| 17 | }; |
| 18 | |
| 19 | interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { |
| 20 | variant?: Variant; |
| 21 | size?: Size; |
| 22 | active?: boolean; |
| 23 | } |
| 24 | |
| 25 | export function Button({ |
| 26 | variant = "subtle", |
| 27 | size = "md", |
| 28 | active = false, |
| 29 | className, |
| 30 | children, |
| 31 | ...props |
| 32 | }: ButtonProps) { |
| 33 | return ( |
| 34 | <button |
| 35 | className={cn( |
| 36 | "inline-flex select-none items-center justify-center rounded-lg font-medium transition-transform active:scale-[0.98]", |
| 37 | "disabled:cursor-not-allowed disabled:opacity-60", |
| 38 | SIZES[size], |
| 39 | VARIANTS[variant], |
| 40 | active && "bg-zinc-700 text-zinc-100 ring-2 ring-accent-500/70", |
| 41 | className, |
| 42 | )} |
| 43 | {...props} |
| 44 | > |
| 45 | {children} |
| 46 | </button> |
| 47 | ); |
| 48 | } |
| 49 | |
| 50 | interface SectionProps { |
| 51 | title: string; |
| 52 | icon?: ReactNode; |
| 53 | action?: ReactNode; |
| 54 | children: ReactNode; |
| 55 | } |
| 56 | |
| 57 | export function Section({ title, icon, action, children }: SectionProps) { |
| 58 | return ( |
| 59 | <section className="border-b border-zinc-800/60 px-4 py-4 last:border-b-0"> |
| 60 | <header className="mb-3 flex items-center justify-between"> |
| 61 | <h2 className="flex items-center gap-2 text-[0.7rem] font-semibold uppercase tracking-wider text-zinc-400"> |
| 62 | {icon} |
| 63 | {title} |
| 64 | </h2> |
| 65 | {action} |
| 66 | </header> |
| 67 | {children} |
| 68 | </section> |
| 69 | ); |
| 70 | } |
| 71 | |
| 72 | interface SegmentedOption<T extends string> { |
| 73 | value: T; |
| 74 | label: string; |
| 75 | } |
| 76 | |
| 77 | interface SegmentedProps<T extends string> { |
| 78 | options: SegmentedOption<T>[]; |
| 79 | value: T; |
| 80 | onChange: (value: T) => void; |
| 81 | ariaLabel?: string; |
| 82 | } |
| 83 | |
| 84 | export function Segmented<T extends string>({ options, value, onChange, ariaLabel }: SegmentedProps<T>) { |
| 85 | return ( |
| 86 | <div |
| 87 | role="tablist" |
| 88 | aria-label={ariaLabel} |
| 89 | className="flex gap-0.5 rounded-lg border border-zinc-700/60 bg-zinc-800 p-0.5" |
| 90 | > |
| 91 | {options.map((opt) => ( |
| 92 | <button |
| 93 | key={opt.value} |
| 94 | role="tab" |
| 95 | aria-selected={value === opt.value} |
| 96 | onClick={() => onChange(opt.value)} |
| 97 | className={cn( |
| 98 | "flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition-transform", |
| 99 | value === opt.value ? "bg-zinc-700 text-zinc-100 shadow-sm" : "text-zinc-400 hover:text-zinc-100", |
| 100 | )} |
| 101 | > |
| 102 | {opt.label} |
| 103 | </button> |
| 104 | ))} |
| 105 | </div> |
| 106 | ); |
| 107 | } |