phase 2 and 3
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
"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",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: "slate" | "green" | "amber" | "red" | "blue";
|
||||
}) {
|
||||
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])}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user