feat: add dense console ui primitives

This commit is contained in:
afei A
2026-03-28 00:16:01 +08:00
parent 4ca3ef96b9
commit 025ae31f9f
11 changed files with 236 additions and 9 deletions

View File

@@ -26,9 +26,9 @@ const VARIANT_STYLES: Record<ButtonVariant, string> = {
}; };
const SIZE_STYLES: Record<ButtonSize, string> = { const SIZE_STYLES: Record<ButtonSize, string> = {
sm: "min-h-9 rounded-full px-3.5 text-sm", sm: "h-8 rounded-md px-2.5 text-xs",
md: "min-h-11 rounded-full px-4 text-sm", md: "h-9 rounded-md px-3 text-sm",
lg: "min-h-12 rounded-full px-5 text-base", lg: "h-10 rounded-md px-4 text-sm",
}; };
function joinClasses(...values: Array<string | false | null | undefined>) { function joinClasses(...values: Array<string | false | null | undefined>) {

View File

@@ -19,7 +19,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(
<div <div
ref={ref} ref={ref}
className={joinClasses( className={joinClasses(
"rounded-[28px] border border-[var(--border-soft)] bg-[var(--surface)] shadow-[var(--shadow-card)]", "rounded-[var(--panel-radius)] border border-[var(--border-soft)] bg-[var(--surface)] shadow-[var(--shadow-card)]",
className, className,
)} )}
{...props} {...props}
@@ -36,7 +36,7 @@ export const CardHeader = forwardRef<HTMLDivElement, CardSectionProps>(
<div <div
ref={ref} ref={ref}
className={joinClasses( className={joinClasses(
"flex flex-col gap-2 border-b border-[var(--border-soft)] px-6 py-5", "flex flex-col gap-2 border-b border-[var(--border-soft)] px-4 py-4",
className, className,
)} )}
{...props} {...props}
@@ -53,7 +53,7 @@ export const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(
<h3 <h3
ref={ref} ref={ref}
className={joinClasses( className={joinClasses(
"text-lg font-semibold tracking-[-0.02em] text-[var(--ink-strong)]", "text-base font-semibold tracking-[-0.02em] text-[var(--ink-strong)]",
className, className,
)} )}
{...props} {...props}
@@ -71,7 +71,7 @@ export const CardDescription = forwardRef<
return ( return (
<p <p
ref={ref} ref={ref}
className={joinClasses("text-sm leading-6 text-[var(--ink-muted)]", className)} className={joinClasses("text-sm leading-5 text-[var(--ink-muted)]", className)}
{...props} {...props}
/> />
); );
@@ -99,7 +99,7 @@ export const CardFooter = forwardRef<HTMLDivElement, CardSectionProps>(
<div <div
ref={ref} ref={ref}
className={joinClasses( className={joinClasses(
"flex items-center justify-between gap-3 border-t border-[var(--border-soft)] px-6 py-4", "flex items-center justify-between gap-3 border-t border-[var(--border-soft)] px-4 py-3",
className, className,
)} )}
{...props} {...props}

View File

@@ -0,0 +1,26 @@
import { forwardRef, type InputHTMLAttributes } from "react";
type InputProps = InputHTMLAttributes<HTMLInputElement>;
function joinClasses(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(" ");
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, type = "text", ...props }, ref) => {
return (
<input
ref={ref}
type={type}
className={joinClasses(
"h-9 w-full rounded-md border border-[var(--border-strong)] bg-[var(--surface)] px-3 text-sm text-[var(--ink-strong)] outline-none transition",
"placeholder:text-[var(--ink-faint)] focus:border-[var(--accent-primary)] focus:ring-2 focus:ring-[var(--accent-ring)]",
className,
)}
{...props}
/>
);
},
);
Input.displayName = "Input";

View File

@@ -0,0 +1,32 @@
import type { HTMLAttributes, ReactNode } from "react";
type MetricChipProps = HTMLAttributes<HTMLDivElement> & {
label: string;
value: ReactNode;
};
function joinClasses(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(" ");
}
export function MetricChip({
className,
label,
value,
...props
}: MetricChipProps) {
return (
<div
className={joinClasses(
"inline-flex items-center gap-2 rounded-md border border-[var(--border-soft)] bg-[var(--surface-muted)] px-3 py-1.5",
className,
)}
{...props}
>
<span className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.14em] text-[var(--ink-faint)]">
{label}
</span>
<span className="text-sm font-medium text-[var(--ink-strong)]">{value}</span>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import type { HTMLAttributes, PropsWithChildren } from "react";
function joinClasses(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(" ");
}
export function PageToolbar({
children,
className,
...props
}: PropsWithChildren<HTMLAttributes<HTMLDivElement>>) {
return (
<div
className={joinClasses(
"flex flex-wrap items-center gap-3 border-b border-[var(--border-soft)] pb-4",
className,
)}
{...props}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { forwardRef, type SelectHTMLAttributes } from "react";
type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
function joinClasses(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(" ");
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className, children, ...props }, ref) => {
return (
<select
ref={ref}
className={joinClasses(
"h-9 rounded-md border border-[var(--border-strong)] bg-[var(--surface)] px-3 text-sm text-[var(--ink-strong)] outline-none transition",
"focus:border-[var(--accent-primary)] focus:ring-2 focus:ring-[var(--accent-ring)]",
className,
)}
{...props}
>
{children}
</select>
);
},
);
Select.displayName = "Select";

View File

@@ -0,0 +1,15 @@
import type { HTMLAttributes } from "react";
function joinClasses(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(" ");
}
export function Separator({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
role="separator"
className={joinClasses("h-px w-full bg-[var(--border-soft)]", className)}
{...props}
/>
);
}

View File

@@ -104,7 +104,7 @@ export function StatusBadge({ className, ...props }: StatusBadgeProps) {
<span <span
data-tone={meta.tone} data-tone={meta.tone}
className={joinClasses( className={joinClasses(
"inline-flex items-center rounded-full border px-2.5 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.16em]", "inline-flex items-center rounded-full border px-2 py-0.5 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.14em]",
TONE_STYLES[meta.tone], TONE_STYLES[meta.tone],
className, className,
)} )}

View File

@@ -0,0 +1,78 @@
import { forwardRef, type HTMLAttributes, type ThHTMLAttributes, type TdHTMLAttributes } from "react";
function joinClasses(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(" ");
}
export const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="overflow-hidden rounded-[var(--panel-radius)] border border-[var(--border-soft)] bg-[var(--surface)]">
<table
ref={ref}
className={joinClasses("w-full border-collapse text-sm", className)}
{...props}
/>
</div>
),
);
Table.displayName = "Table";
export const TableHeader = forwardRef<HTMLTableSectionElement, HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<thead
ref={ref}
className={joinClasses("bg-[var(--surface-muted)]", className)}
{...props}
/>
),
);
TableHeader.displayName = "TableHeader";
export const TableBody = forwardRef<HTMLTableSectionElement, HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody ref={ref} className={className} {...props} />
),
);
TableBody.displayName = "TableBody";
export const TableRow = forwardRef<HTMLTableRowElement, HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={joinClasses("border-b border-[var(--border-soft)] last:border-b-0", className)}
{...props}
/>
),
);
TableRow.displayName = "TableRow";
export const TableHead = forwardRef<HTMLTableCellElement, ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={joinClasses(
"px-4 py-2.5 text-left font-[var(--font-mono)] text-[11px] uppercase tracking-[0.16em] text-[var(--ink-faint)]",
className,
)}
{...props}
/>
),
);
TableHead.displayName = "TableHead";
export const TableCell = forwardRef<HTMLTableCellElement, TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td
ref={ref}
className={joinClasses("px-4 py-3 align-middle text-[var(--ink-strong)]", className)}
{...props}
/>
),
);
TableCell.displayName = "TableCell";

View File

@@ -0,0 +1,19 @@
import { render, screen } from "@testing-library/react";
import { PageToolbar } from "@/components/ui/page-toolbar";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
test("renders a dense toolbar row with compact controls", () => {
render(
<PageToolbar>
<Input aria-label="search" />
<Select aria-label="status">
<option value="all"></option>
</Select>
</PageToolbar>,
);
expect(screen.getByLabelText("search").className).toContain("h-9");
expect(screen.getByLabelText("status").className).toContain("h-9");
});

View File

@@ -21,6 +21,13 @@ test("uses order status metadata for the rendered tone", () => {
expect(screen.getByText("失败")).toHaveAttribute("data-tone", "danger"); expect(screen.getByText("失败")).toHaveAttribute("data-tone", "danger");
}); });
test("uses compact dense-console badge sizing", () => {
render(<StatusBadge status="waiting_review" />);
expect(screen.getByText("待审核").className).toContain("px-2");
expect(screen.getByText("待审核").className).toContain("py-0.5");
});
test("can render review decision metadata when a variant is provided", () => { test("can render review decision metadata when a variant is provided", () => {
render(<StatusBadge status="reject" variant="reviewDecision" />); render(<StatusBadge status="reject" variant="reviewDecision" />);