feat(ui): extend design tokens, add dark mode, add UI primitives
- Add warning/success token roles across light and dark themes - Add Light/Dark/System theme mode (localStorage + Appwrite prefs) - Derive dark token stacks from existing theme seeds - Remove forced light app-shell background; dynamic color-scheme - Add Button/Card/Field/Badge/EmptyState primitives under src/components/ui
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type BadgeTone = "neutral" | "accent" | "success" | "warning" | "danger";
|
||||
|
||||
export type BadgeProps = {
|
||||
tone?: BadgeTone;
|
||||
children: ReactNode;
|
||||
} & HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export function Badge({ tone = "neutral", className, children, ...rest }: BadgeProps) {
|
||||
const classes = ["badge", tone !== "neutral" ? `badge-${tone}` : "", className].filter(Boolean).join(" ");
|
||||
return (
|
||||
<span className={classes} {...rest}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "danger";
|
||||
|
||||
export type ButtonProps = {
|
||||
variant?: ButtonVariant;
|
||||
loading?: boolean;
|
||||
icon?: ReactNode;
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
const BUTTON_VARIANT_CLASSES: Record<ButtonVariant, string> = {
|
||||
primary: "primary-button",
|
||||
secondary: "secondary-button",
|
||||
danger: "danger-button",
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
loading = false,
|
||||
icon,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
type = "button",
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
const classes = [BUTTON_VARIANT_CLASSES[variant], className].filter(Boolean).join(" ");
|
||||
return (
|
||||
<button className={classes} type={type} disabled={disabled || loading} aria-busy={loading || undefined} {...rest}>
|
||||
{loading ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : icon}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type CardProps = {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Card({ title, subtitle, actions, className, children }: CardProps) {
|
||||
const classes = ["app-card p-4 sm:p-5", className].filter(Boolean).join(" ");
|
||||
return (
|
||||
<section className={classes}>
|
||||
{(title || subtitle || actions) && (
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
{title && <h2 className="app-card-title text-xl">{title}</h2>}
|
||||
{subtitle && <p className="app-card-subtitle mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Zap } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type EmptyStateProps = {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
body: string;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function EmptyState({ icon, title, body, action, className }: EmptyStateProps) {
|
||||
const classes = ["empty-state", className].filter(Boolean).join(" ");
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div className="empty-state-icon">{icon ?? <Zap size={22} aria-hidden="true" />}</div>
|
||||
<h3 className="empty-state-title">{title}</h3>
|
||||
<p className="empty-state-copy mt-2 max-w-sm">{body}</p>
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type FieldControlProps = {
|
||||
id: string;
|
||||
"aria-invalid": true | undefined;
|
||||
"aria-describedby": string | undefined;
|
||||
};
|
||||
|
||||
export type FieldProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
error?: string;
|
||||
help?: string;
|
||||
className?: string;
|
||||
children: (controlProps: FieldControlProps) => ReactNode;
|
||||
};
|
||||
|
||||
export function Field({ id, label, error, help, className, children }: FieldProps) {
|
||||
const message = error ?? help;
|
||||
const messageId = message ? `${id}-message` : undefined;
|
||||
return (
|
||||
<div className={className}>
|
||||
<label className="field-label" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
{children({
|
||||
id,
|
||||
"aria-invalid": error ? true : undefined,
|
||||
"aria-describedby": messageId,
|
||||
})}
|
||||
</div>
|
||||
{message && (
|
||||
<p id={messageId} className={error ? "field-error-text" : "field-help-text"}>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { Badge } from "./Badge";
|
||||
export type { BadgeProps, BadgeTone } from "./Badge";
|
||||
export { Button } from "./Button";
|
||||
export type { ButtonProps, ButtonVariant } from "./Button";
|
||||
export { Card } from "./Card";
|
||||
export type { CardProps } from "./Card";
|
||||
export { EmptyState } from "./EmptyState";
|
||||
export type { EmptyStateProps } from "./EmptyState";
|
||||
export { Field } from "./Field";
|
||||
export type { FieldControlProps, FieldProps } from "./Field";
|
||||
Reference in New Issue
Block a user