235 lines
6.7 KiB
TypeScript
235 lines
6.7 KiB
TypeScript
"use client";
|
|
|
|
import { forwardRef, type ButtonHTMLAttributes, type InputHTMLAttributes, type SelectHTMLAttributes, type TextareaHTMLAttributes, type ReactNode } from "react";
|
|
|
|
function cx(...parts: Array<string | false | null | undefined>): string {
|
|
return parts.filter(Boolean).join(" ");
|
|
}
|
|
|
|
// -------- Button ---------------------------------------------------------
|
|
|
|
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
|
type ButtonSize = "sm" | "md";
|
|
|
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
variant?: ButtonVariant;
|
|
size?: ButtonSize;
|
|
}
|
|
|
|
const variantClasses: Record<ButtonVariant, string> = {
|
|
primary: "bg-slate-900 text-white hover:bg-slate-800 border-slate-900",
|
|
secondary: "bg-white text-slate-900 border-slate-300 hover:bg-slate-50",
|
|
danger: "bg-red-600 text-white border-red-600 hover:bg-red-500",
|
|
ghost: "bg-transparent text-slate-700 border-transparent hover:bg-slate-100",
|
|
};
|
|
|
|
const sizeClasses: Record<ButtonSize, string> = {
|
|
sm: "px-2.5 py-1.5 text-sm rounded-md",
|
|
md: "px-4 py-2 text-sm rounded-lg",
|
|
};
|
|
|
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|
{ variant = "primary", size = "md", className, ...props },
|
|
ref,
|
|
) {
|
|
return (
|
|
<button
|
|
ref={ref}
|
|
{...props}
|
|
className={cx(
|
|
"inline-flex items-center justify-center gap-1.5 border font-medium transition disabled:opacity-60 disabled:cursor-not-allowed",
|
|
variantClasses[variant],
|
|
sizeClasses[size],
|
|
className,
|
|
)}
|
|
/>
|
|
);
|
|
});
|
|
|
|
// -------- Field label wrapper --------------------------------------------
|
|
|
|
export function Field({
|
|
label,
|
|
hint,
|
|
error,
|
|
children,
|
|
required,
|
|
}: {
|
|
label: string;
|
|
hint?: ReactNode;
|
|
error?: string | null;
|
|
children: ReactNode;
|
|
required?: boolean;
|
|
}) {
|
|
return (
|
|
<label className="block">
|
|
<span className="block text-sm font-medium text-slate-700 mb-1">
|
|
{label}
|
|
{required ? <span className="text-red-600"> *</span> : null}
|
|
</span>
|
|
{children}
|
|
{hint && !error ? <span className="block text-xs text-slate-500 mt-1">{hint}</span> : null}
|
|
{error ? <span className="block text-xs text-red-600 mt-1">{error}</span> : null}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
// -------- Input / Textarea / Select --------------------------------------
|
|
|
|
const inputBase =
|
|
"w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-slate-900 focus:ring-2 focus:ring-slate-200 disabled:bg-slate-50";
|
|
|
|
type InputProps = InputHTMLAttributes<HTMLInputElement>;
|
|
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|
{ className, ...props },
|
|
ref,
|
|
) {
|
|
return <input ref={ref} {...props} className={cx(inputBase, className)} />;
|
|
});
|
|
|
|
type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
|
|
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
|
|
{ className, ...props },
|
|
ref,
|
|
) {
|
|
return (
|
|
<textarea
|
|
ref={ref}
|
|
{...props}
|
|
className={cx(inputBase, "min-h-[5rem] font-mono leading-relaxed", className)}
|
|
/>
|
|
);
|
|
});
|
|
|
|
type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
|
|
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
|
{ className, ...props },
|
|
ref,
|
|
) {
|
|
return <select ref={ref} {...props} className={cx(inputBase, "bg-white pr-8", className)} />;
|
|
});
|
|
|
|
// -------- Card / PageHeader / Table --------------------------------------
|
|
|
|
export function Card({ children, className }: { children: ReactNode; className?: string }) {
|
|
return (
|
|
<div className={cx("rounded-xl bg-white border border-slate-200 shadow-sm", className)}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PageHeader({
|
|
title,
|
|
description,
|
|
actions,
|
|
}: {
|
|
title: ReactNode;
|
|
description?: ReactNode;
|
|
actions?: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
|
{description ? <p className="text-slate-500 mt-1">{description}</p> : null}
|
|
</div>
|
|
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Empty({ title, description }: { title: string; description?: ReactNode }) {
|
|
return (
|
|
<div className="rounded-xl border border-dashed border-slate-300 bg-white p-10 text-center">
|
|
<p className="font-medium text-slate-700">{title}</p>
|
|
{description ? <p className="text-sm text-slate-500 mt-1">{description}</p> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ErrorBanner({ message }: { message: string | null | undefined }) {
|
|
if (!message) return null;
|
|
return (
|
|
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2">
|
|
{message}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
// -------- Modal ----------------------------------------------------------
|
|
|
|
export function Modal({
|
|
open,
|
|
onClose,
|
|
title,
|
|
children,
|
|
footer,
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
title: string;
|
|
children: ReactNode;
|
|
footer?: ReactNode;
|
|
}) {
|
|
if (!open) return null;
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40">
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
className="w-full max-w-lg rounded-2xl bg-white shadow-xl border border-slate-200"
|
|
>
|
|
<div className="flex items-center justify-between p-5 border-b border-slate-200">
|
|
<h2 className="font-semibold">{title}</h2>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="rounded-md p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-900"
|
|
aria-label="Close"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div className="p-5 space-y-4 max-h-[70vh] overflow-y-auto">{children}</div>
|
|
{footer ? (
|
|
<div className="flex justify-end gap-2 p-4 border-t border-slate-200 bg-slate-50 rounded-b-2xl">
|
|
{footer}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// -------- Badge ----------------------------------------------------------
|
|
|
|
export function Badge({
|
|
children,
|
|
tone = "slate",
|
|
className,
|
|
}: {
|
|
children: ReactNode;
|
|
tone?: "slate" | "green" | "amber" | "red" | "blue";
|
|
className?: string;
|
|
}) {
|
|
const tones: Record<string, string> = {
|
|
slate: "bg-slate-100 text-slate-700",
|
|
green: "bg-emerald-100 text-emerald-700",
|
|
amber: "bg-amber-100 text-amber-800",
|
|
red: "bg-red-100 text-red-700",
|
|
blue: "bg-blue-100 text-blue-700",
|
|
};
|
|
return (
|
|
<span
|
|
className={cx(
|
|
"inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium",
|
|
tones[tone],
|
|
className,
|
|
)}
|
|
>
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|