feat: add reusable pattern components (PR2)

Create src/components/patterns/ with 6 pure-presentation components
that abstract repeated layout patterns from settings/bridge pages:

- SettingsCard: rounded card with optional title/description
- FieldRow: label+description left, control right, optional separator
- SectionPage: max-width page container with consistent spacing
- StatusBanner: success/warning/error/info inline status messages
- EmptyState: centered empty state with icon, title, description, action
- IconAction: ghost icon button with optional tooltip wrapper

All components are zero-side-effect, only import from React and @/lib/utils
(plus ui/button and ui/tooltip for IconAction).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-11 21:45:32 +08:00
1 parent 2b8c7cba53
commit 6ea323b92f
7 files changed
+171

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
import { type ReactElement, type ReactNode } from "react";
import { cn } from "@/lib/utils";
interface EmptyStateProps {
icon?: ReactElement;
title: string;
description?: string;
action?: ReactNode;
className?: string;
}
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
return (
<div className={cn("flex flex-col items-center justify-center py-12 text-center", className)}>
{icon && <div className="mb-4 text-muted-foreground">{icon}</div>}
<h3 className="text-sm font-medium">{title}</h3>
{description && (
<p className="mt-1 text-xs text-muted-foreground max-w-sm">{description}</p>
)}
{action && <div className="mt-4">{action}</div>}
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { type ReactNode } from "react";
import { cn } from "@/lib/utils";
interface FieldRowProps {
label: string;
description?: string;
children: ReactNode;
separator?: boolean;
className?: string;
}
export function FieldRow({ label, description, children, separator, className }: FieldRowProps) {
return (
<div className={cn(
"flex items-center justify-between gap-4",
separator && "border-t border-border/30 pt-4",
className
)}>
<div className="space-y-0.5 flex-1 min-w-0">
<div className="text-sm font-medium">{label}</div>
{description && (
<div className="text-xs text-muted-foreground">{description}</div>
)}
</div>
<div className="flex-shrink-0">{children}</div>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { type ReactElement, forwardRef, type ComponentPropsWithoutRef } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface IconActionProps extends Omit<ComponentPropsWithoutRef<typeof Button>, "variant" | "size" | "children"> {
icon: ReactElement;
tooltip?: string;
size?: "sm" | "md";
}
export const IconAction = forwardRef<HTMLButtonElement, IconActionProps>(
function IconAction({ icon, tooltip, size = "md", className, ...props }, ref) {
const button = (
<Button
ref={ref}
variant="ghost"
size="icon"
className={cn(
size === "sm" ? "h-7 w-7" : "h-8 w-8",
className
)}
{...props}
>
{icon}
</Button>
);
if (tooltip) {
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);
}
return button;
}
);
+22
View File
@@ -0,0 +1,22 @@
import { type ReactNode } from "react";
import { cn } from "@/lib/utils";
const maxWidthMap = {
sm: "max-w-xl",
md: "max-w-2xl",
lg: "max-w-3xl",
} as const;
interface SectionPageProps {
children: ReactNode;
maxWidth?: "sm" | "md" | "lg";
className?: string;
}
export function SectionPage({ children, maxWidth = "lg", className }: SectionPageProps) {
return (
<div className={cn("mx-auto w-full space-y-6", maxWidthMap[maxWidth], className)}>
{children}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { type ReactNode } from "react";
import { cn } from "@/lib/utils";
interface SettingsCardProps {
title?: string;
description?: string;
children: ReactNode;
className?: string;
}
export function SettingsCard({ title, description, children, className }: SettingsCardProps) {
return (
<div className={cn("rounded-lg border border-border/50 p-4 space-y-4 transition-shadow hover:shadow-sm", className)}>
{(title || description) && (
<div className="space-y-1">
{title && <h3 className="text-sm font-medium">{title}</h3>}
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
)}
{children}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { type ReactElement, type ReactNode } from "react";
import { cn } from "@/lib/utils";
const variantStyles = {
success: "bg-green-500/10 text-green-600 dark:text-green-400",
warning: "bg-orange-500/10 text-orange-600 dark:text-orange-400",
error: "bg-red-500/10 text-red-600 dark:text-red-400",
info: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
} as const;
interface StatusBannerProps {
variant: "success" | "warning" | "error" | "info";
icon?: ReactElement;
children: ReactNode;
className?: string;
}
export function StatusBanner({ variant, icon, children, className }: StatusBannerProps) {
return (
<div className={cn(
"flex items-center gap-2 rounded-md px-3 py-2 text-xs",
variantStyles[variant],
className
)}>
{icon && <div className="flex-shrink-0">{icon}</div>}
<div className="flex-1 min-w-0">{children}</div>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
export { SettingsCard } from "./SettingsCard";
export { FieldRow } from "./FieldRow";
export { SectionPage } from "./SectionPage";
export { StatusBanner } from "./StatusBanner";
export { EmptyState } from "./EmptyState";
export { IconAction } from "./IconAction";