feat: add dense console ui primitives
This commit is contained in:
@@ -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>) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
26
src/components/ui/input.tsx
Normal file
26
src/components/ui/input.tsx
Normal 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";
|
||||||
32
src/components/ui/metric-chip.tsx
Normal file
32
src/components/ui/metric-chip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/components/ui/page-toolbar.tsx
Normal file
23
src/components/ui/page-toolbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/components/ui/select.tsx
Normal file
27
src/components/ui/select.tsx
Normal 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";
|
||||||
15
src/components/ui/separator.tsx
Normal file
15
src/components/ui/separator.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
)}
|
)}
|
||||||
|
|||||||
78
src/components/ui/table.tsx
Normal file
78
src/components/ui/table.tsx
Normal 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";
|
||||||
19
tests/ui/page-toolbar.test.tsx
Normal file
19
tests/ui/page-toolbar.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
@@ -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" />);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user