feat: adopt kumo green ui system

This commit is contained in:
2026-07-02 07:12:31 -07:00
parent 4bb4312b08
commit 0f0bd8813d
32 changed files with 1453 additions and 796 deletions

View File

@@ -1,31 +1,26 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Badge as KumoBadge } from "@cloudflare/kumo/components/badge";
import { cn } from "@/lib/utils";
type KumoBadgeProps = React.ComponentProps<typeof KumoBadge>;
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
outline: "text-foreground",
success: "border-emerald-200 bg-emerald-50 text-emerald-700",
warning: "border-amber-200 bg-amber-50 text-amber-700",
danger: "border-red-200 bg-red-50 text-red-700",
},
},
defaultVariants: {
variant: "default",
},
},
);
type LegacyBadgeVariant = "default" | "secondary" | "outline" | "success" | "warning" | "danger";
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
const variantMap: Record<LegacyBadgeVariant, KumoBadgeProps["variant"]> = {
default: "green",
secondary: "secondary",
outline: "outline",
success: "success",
warning: "warning",
danger: "error",
};
export function Badge({ className, variant, ...props }: BadgeProps): React.ReactElement {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
export type BadgeProps = Omit<KumoBadgeProps, "variant" | "children"> & {
variant?: LegacyBadgeVariant | KumoBadgeProps["variant"];
children?: React.ReactNode;
};
export function Badge({ variant = "default", ...props }: BadgeProps): React.ReactElement {
const mappedVariant = variant in variantMap ? variantMap[variant as LegacyBadgeVariant] : (variant as KumoBadgeProps["variant"]);
return <KumoBadge variant={mappedVariant} {...(props as KumoBadgeProps)} />;
}

View File

@@ -1,50 +1,59 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import {
Button as KumoButton,
LinkButton as KumoLinkButton,
type ButtonProps as KumoButtonProps,
type LinkButtonProps as KumoLinkButtonProps,
} from "@cloudflare/kumo/components/button";
import { cn } from "@/lib/utils";
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
type ButtonSize = "default" | "sm" | "lg" | "icon";
const buttonVariants = cva(
"inline-flex min-h-11 shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "border bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
},
size: {
default: "h-11 px-4 py-2",
sm: "h-9 min-h-9 px-3",
lg: "h-12 px-6",
icon: "h-11 w-11",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const variantMap: Record<ButtonVariant, KumoButtonProps["variant"]> = {
default: "primary",
secondary: "secondary",
outline: "outline",
ghost: "ghost",
destructive: "destructive",
};
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const sizeMap: Record<ButtonSize, KumoButtonProps["size"]> = {
default: "base",
sm: "sm",
lg: "lg",
icon: "base",
};
export function Button({
className,
variant,
size,
asChild = false,
...props
}: ButtonProps): React.ReactElement {
const Comp = asChild ? Slot : "button";
export type ButtonProps = Omit<KumoButtonProps, "variant" | "size" | "shape"> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export type LinkButtonProps = Omit<KumoLinkButtonProps, "variant" | "size"> & {
variant?: ButtonVariant;
size?: Exclude<ButtonSize, "icon">;
};
export { buttonVariants };
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ variant = "default", size = "default", ...props },
ref,
): React.ReactElement {
const kumoProps = {
ref,
shape: size === "icon" ? "square" : "base",
size: sizeMap[size],
variant: variantMap[variant],
...props,
} as KumoButtonProps & React.RefAttributes<HTMLButtonElement>;
return (
<KumoButton {...kumoProps} />
);
});
export const LinkButton = React.forwardRef<HTMLAnchorElement, LinkButtonProps>(function LinkButton(
{ variant = "default", size = "default", ...props },
ref,
): React.ReactElement {
return <KumoLinkButton ref={ref} size={sizeMap[size]} variant={variantMap[variant]} {...props} />;
});

View File

@@ -1,43 +1,29 @@
import * as React from "react";
import { LayerCard } from "@cloudflare/kumo/components/layer-card";
import { cn } from "@/lib/utils";
export function Card({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>): React.ReactElement {
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>): React.ReactElement {
return (
<div
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
<LayerCard
className={cn("rounded-lg border border-kumo-line bg-kumo-base text-card-foreground shadow-sm", className)}
{...props}
/>
);
}
export function CardHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>): React.ReactElement {
return <div className={cn("flex flex-col gap-1.5 p-5", className)} {...props} />;
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>): React.ReactElement {
return <LayerCard.Secondary className={cn("flex flex-col gap-1.5 p-5", className)} {...props} />;
}
export function CardTitle({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>): React.ReactElement {
return <h3 className={cn("text-base font-semibold leading-none", className)} {...props} />;
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>): React.ReactElement {
return <h3 className={cn("text-base font-semibold leading-none text-kumo-strong", className)} {...props} />;
}
export function CardDescription({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>): React.ReactElement {
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>): React.ReactElement {
return <p className={cn("text-sm text-kumo-subtle", className)} {...props} />;
}
export function CardContent({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>): React.ReactElement {
return <div className={cn("p-5 pt-0", className)} {...props} />;
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>): React.ReactElement {
return <LayerCard.Primary className={cn("p-5 pt-0", className)} {...props} />;
}

View File

@@ -0,0 +1,39 @@
"use client";
import * as React from "react";
import { Checkbox as KumoCheckbox, type CheckboxProps as KumoCheckboxProps } from "@cloudflare/kumo/components/checkbox";
type CheckboxProps = Omit<KumoCheckboxProps, "checked" | "onCheckedChange"> & {
checked?: boolean;
defaultChecked?: boolean;
onCheckedChange?: (checked: boolean) => void;
};
export function Checkbox({
checked,
defaultChecked = false,
name,
onCheckedChange,
...props
}: CheckboxProps): React.ReactElement {
const [uncontrolledChecked, setUncontrolledChecked] = React.useState(defaultChecked);
const isControlled = checked !== undefined;
const currentChecked = isControlled ? checked : uncontrolledChecked;
return (
<>
<KumoCheckbox
checked={currentChecked}
onCheckedChange={(nextChecked) => {
const booleanValue = Boolean(nextChecked);
if (!isControlled) {
setUncontrolledChecked(booleanValue);
}
onCheckedChange?.(booleanValue);
}}
{...props}
/>
{name && currentChecked ? <input name={name} type="hidden" value="on" /> : null}
</>
);
}

View File

@@ -1,6 +1,10 @@
"use client";
import * as React from "react";
import { X } from "lucide-react";
import { Dialog as KumoDialog } from "@cloudflare/kumo/components/dialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type DialogProps = {
@@ -21,38 +25,35 @@ export function Dialog({
footer,
onClose,
className,
}: DialogProps): React.ReactElement | null {
if (!open) {
return null;
}
}: DialogProps): React.ReactElement {
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/32 p-3 backdrop-blur-sm sm:items-center">
<section
aria-modal="true"
<KumoDialog.Root
onOpenChange={(nextOpen) => {
if (!nextOpen) {
onClose();
}
}}
open={open}
>
<KumoDialog
className={cn(
"flex max-h-[92svh] w-full max-w-3xl flex-col overflow-hidden rounded-lg border bg-card shadow-xl",
"flex max-h-[92svh] w-[min(calc(100vw-1.5rem),48rem)] flex-col overflow-hidden rounded-lg border border-kumo-line bg-kumo-base p-0 shadow-xl",
className,
)}
role="dialog"
size="xl"
>
<header className="flex shrink-0 items-start justify-between gap-4 border-b p-5">
<header className="flex shrink-0 items-start justify-between gap-4 border-b border-kumo-line p-5">
<div className="min-w-0">
<h2 className="text-lg font-semibold">{title}</h2>
{description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
<KumoDialog.Title>{title}</KumoDialog.Title>
{description ? <KumoDialog.Description className="mt-1">{description}</KumoDialog.Description> : null}
</div>
<button
aria-label="关闭弹窗"
className="inline-flex size-10 shrink-0 items-center justify-center rounded-md border bg-background text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onClose}
type="button"
>
<Button aria-label="关闭弹窗" onClick={onClose} size="icon" type="button" variant="outline">
<X className="size-4" aria-hidden="true" />
</button>
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-5">{children}</div>
{footer ? <footer className="shrink-0 border-t bg-secondary/35 p-4">{footer}</footer> : null}
</section>
</div>
{footer ? <footer className="shrink-0 border-t border-kumo-line bg-kumo-recessed p-4">{footer}</footer> : null}
</KumoDialog>
</KumoDialog.Root>
);
}

View File

@@ -1,18 +1 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
export function Input({ className, type, ...props }: InputProps): React.ReactElement {
return (
<input
type={type}
className={cn(
"flex h-11 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Input, Textarea, type InputProps, type InputAreaProps as TextareaProps } from "@cloudflare/kumo/components/input";

49
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,49 @@
"use client";
import * as React from "react";
import { Select as KumoSelect } from "@cloudflare/kumo/components/select";
export type SelectOption = {
value: string;
label: React.ReactNode;
disabled?: boolean;
};
type SelectProps = {
"aria-label"?: string;
className?: string;
defaultValue?: string;
disabled?: boolean;
label?: React.ReactNode;
name?: string;
onValueChange?: (value: string) => void;
options: SelectOption[];
placeholder?: string;
required?: boolean;
value?: string;
};
export function Select({
onValueChange,
options,
placeholder,
value,
defaultValue,
...props
}: SelectProps): React.ReactElement {
return (
<KumoSelect<string>
defaultValue={defaultValue}
onValueChange={(nextValue) => onValueChange?.(String(nextValue))}
placeholder={placeholder}
value={value}
{...props}
>
{options.map((option) => (
<KumoSelect.Option key={option.value} disabled={option.disabled} value={option.value}>
{option.label}
</KumoSelect.Option>
))}
</KumoSelect>
);
}

1
components/ui/table.tsx Normal file
View File

@@ -0,0 +1 @@
export { Table, type KumoTableLayout, type KumoTableRowVariant } from "@cloudflare/kumo/components/table";