feat: refine workbench visuals and map controls
Unify the Agent history extension with the header acrylic surface, preserve the full conversation body, and consolidate shared control and status styling. Restore map flow and SCADA controller behavior, remove obsolete rendering paths, and extend regression coverage. Button press coverage now releases outside the target so state assertions cannot accidentally toggle the control.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export const BUTTON_INTERACTION_CLASS_NAME =
|
||||
"action-control relative isolate touch-manipulation select-none transition-[color,background-color,border-color,box-shadow,filter] duration-[120ms] ease-[cubic-bezier(0.2,0,0,1)] focus-visible:z-10 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--action-focus-offset)] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-45"
|
||||
|
||||
export const buttonInteractionVariants = cva(BUTTON_INTERACTION_CLASS_NAME, {
|
||||
variants: {
|
||||
tone: {
|
||||
neutral:
|
||||
"text-slate-700 [@media(hover:hover)]:hover:border-[var(--action-border-hover)] [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)] [&:active:not(:disabled)]:border-[var(--action-border-pressed)] [&:active:not(:disabled)]:bg-[var(--action-soft-pressed)] [&:active:not(:disabled)]:text-[var(--action-blue-pressed)] [&:active:not(:disabled)]:shadow-[var(--action-shadow-secondary-pressed)] data-[state=open]:border-[var(--action-border-hover)] data-[state=open]:bg-[var(--action-soft)] data-[state=open]:text-[var(--action-blue-hover)]",
|
||||
brand:
|
||||
"border-[var(--action-blue)] bg-[var(--action-blue)] text-white [@media(hover:hover)]:hover:border-[var(--action-blue-hover)] [@media(hover:hover)]:hover:bg-[var(--action-blue-hover)] [&:active:not(:disabled)]:border-[var(--action-blue-pressed)] [&:active:not(:disabled)]:bg-[var(--action-blue-pressed)] [&:active:not(:disabled)]:shadow-[var(--action-shadow-primary-pressed)]",
|
||||
danger:
|
||||
"text-[var(--status-danger-foreground)] [@media(hover:hover)]:hover:border-[var(--status-danger-border)] [@media(hover:hover)]:hover:bg-[var(--status-danger-soft)] [&:active:not(:disabled)]:border-[var(--status-danger-border)] [&:active:not(:disabled)]:bg-[var(--status-danger-soft)] [&:active:not(:disabled)]:shadow-[var(--action-shadow-secondary-pressed)]"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
tone: "neutral"
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { Button } from "@/shared/ui/button"
|
||||
|
||||
describe("Button", () => {
|
||||
it("uses the shared primary and interaction states by default", () => {
|
||||
render(<Button>执行调度</Button>)
|
||||
|
||||
const button = screen.getByRole("button", { name: "执行调度" })
|
||||
expect(button).toHaveAttribute("data-slot", "button")
|
||||
expect(button.className).toContain("bg-[var(--action-blue)]")
|
||||
expect(button.className).toContain("focus-visible:ring-[var(--action-focus-ring)]")
|
||||
expect(button.className).toContain("[&:active:not(:disabled)]:bg-[var(--action-blue-pressed)]")
|
||||
expect(button.className).toContain(
|
||||
"[&:active:not(:disabled)]:shadow-[var(--action-shadow-primary-pressed)]"
|
||||
)
|
||||
expect(button.className).not.toContain("translate-y")
|
||||
})
|
||||
|
||||
it("keeps compact controls visually small with an expanded hit target", () => {
|
||||
render(
|
||||
<Button aria-label="刷新" size="icon-sm" variant="ghost">
|
||||
R
|
||||
</Button>
|
||||
)
|
||||
|
||||
const button = screen.getByRole("button", { name: "刷新" })
|
||||
expect(button.className).toContain("h-8")
|
||||
expect(button.className).toContain("w-8")
|
||||
expect(button.className).toContain("after:-inset-1")
|
||||
})
|
||||
|
||||
it("exposes a stable and inaccessible-to-repeat loading state", async () => {
|
||||
const onClick = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<Button loading loadingLabel="正在提交调度" onClick={onClick}>
|
||||
提交调度
|
||||
</Button>
|
||||
)
|
||||
|
||||
const button = screen.getByRole("button", { name: /提交调度/ })
|
||||
expect(button).toBeDisabled()
|
||||
expect(button).toHaveAttribute("aria-busy", "true")
|
||||
expect(button).toHaveAttribute("data-loading", "true")
|
||||
expect(screen.getByText("正在提交调度")).toHaveClass("sr-only")
|
||||
|
||||
await user.click(button)
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("preserves asChild composition", () => {
|
||||
render(
|
||||
<Button asChild variant="link">
|
||||
<a href="/sessions">查看会话</a>
|
||||
</Button>
|
||||
)
|
||||
|
||||
const link = screen.getByRole("link", { name: "查看会话" })
|
||||
expect(link).toHaveAttribute("href", "/sessions")
|
||||
expect(link).toHaveAttribute("data-slot", "button")
|
||||
})
|
||||
|
||||
it("blocks and labels loading asChild links", async () => {
|
||||
const onClick = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Button asChild loading loadingLabel="正在打开会话" onClick={onClick}>
|
||||
<a href="/sessions">查看会话</a>
|
||||
</Button>
|
||||
)
|
||||
|
||||
const link = screen.getByRole("link", { name: "正在打开会话" })
|
||||
expect(link).toHaveAttribute("aria-busy", "true")
|
||||
expect(link).toHaveAttribute("aria-disabled", "true")
|
||||
expect(link).toHaveAttribute("tabindex", "-1")
|
||||
await user.click(link)
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
+80
-17
@@ -2,36 +2,44 @@ import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { BUTTON_INTERACTION_CLASS_NAME } from "@/shared/ui/button-interaction"
|
||||
import { cn } from "@/shared/ui/cn"
|
||||
import { Spinner } from "@/shared/ui/spinner"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
cn(
|
||||
BUTTON_INTERACTION_CLASS_NAME,
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap border border-transparent text-sm font-semibold [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0"
|
||||
),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
"border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)] [@media(hover:hover)]:hover:border-[var(--action-blue-hover)] [@media(hover:hover)]:hover:bg-[var(--action-blue-hover)] [&:active:not(:disabled)]:border-[var(--action-blue-pressed)] [&:active:not(:disabled)]:bg-[var(--action-blue-pressed)] [&:active:not(:disabled)]:shadow-[var(--action-shadow-primary-pressed)]",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
|
||||
"border-[var(--action-danger)] bg-[var(--action-danger)] text-white shadow-[var(--action-shadow-secondary)] [@media(hover:hover)]:hover:border-[var(--action-danger-hover)] [@media(hover:hover)]:hover:bg-[var(--action-danger-hover)] [&:active:not(:disabled)]:border-[var(--action-danger-pressed)] [&:active:not(:disabled)]:bg-[var(--action-danger-pressed)] [&:active:not(:disabled)]:shadow-[var(--action-shadow-secondary-pressed)]",
|
||||
outline:
|
||||
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
"border-[var(--action-border)] bg-[var(--surface-reading)] text-slate-700 shadow-[var(--action-shadow-secondary)] [@media(hover:hover)]:hover:border-[var(--action-border-hover)] [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)] [&:active:not(:disabled)]:border-[var(--action-border-pressed)] [&:active:not(:disabled)]:bg-[var(--action-soft-pressed)] [&:active:not(:disabled)]:text-[var(--action-blue-pressed)] [&:active:not(:disabled)]:shadow-[var(--action-shadow-secondary-pressed)]",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
"border-[var(--action-border)] bg-[var(--surface-control)] text-slate-700 shadow-[var(--action-shadow-secondary)] [@media(hover:hover)]:hover:border-[var(--action-border-hover)] [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)] [&:active:not(:disabled)]:border-[var(--action-border-pressed)] [&:active:not(:disabled)]:bg-[var(--action-soft-pressed)] [&:active:not(:disabled)]:text-[var(--action-blue-pressed)] [&:active:not(:disabled)]:shadow-[var(--action-shadow-secondary-pressed)]",
|
||||
ghost:
|
||||
"bg-transparent text-slate-600 shadow-none [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)] [&:active:not(:disabled)]:bg-[var(--action-soft-pressed)] [&:active:not(:disabled)]:text-[var(--action-blue-pressed)] [&:active:not(:disabled)]:shadow-none",
|
||||
link:
|
||||
"h-auto border-0 bg-transparent p-0 text-[var(--action-blue)] shadow-none underline-offset-4 [@media(hover:hover)]:hover:text-[var(--action-blue-hover)] [@media(hover:hover)]:hover:underline [&:active:not(:disabled)]:text-[var(--action-blue-pressed)] [&:active:not(:disabled)]:shadow-none"
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
"icon-sm": "h-8 w-8",
|
||||
},
|
||||
default: "h-10 rounded-xl px-4 py-2",
|
||||
sm: "h-8 rounded-lg px-3 text-xs after:absolute after:-inset-1 after:content-['']",
|
||||
lg: "h-11 rounded-xl px-6",
|
||||
icon: "h-10 w-10 rounded-xl",
|
||||
"icon-sm":
|
||||
"h-8 w-8 rounded-lg after:absolute after:-inset-1 after:content-['']"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
size: "default"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -39,17 +47,72 @@ export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
loading?: boolean
|
||||
loadingLabel?: string
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
disabled,
|
||||
loading = false,
|
||||
loadingLabel = "处理中",
|
||||
onClick,
|
||||
tabIndex,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const blocked = disabled || loading
|
||||
const childContent =
|
||||
asChild && React.isValidElement<{ children?: React.ReactNode }>(children)
|
||||
? children.props.children
|
||||
: children
|
||||
const loadingContent = loading ? (
|
||||
<>
|
||||
<span aria-hidden="true" className="invisible inline-flex items-center gap-2">
|
||||
{childContent}
|
||||
</span>
|
||||
<Spinner
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||
/>
|
||||
<span className="sr-only">{loadingLabel}</span>
|
||||
</>
|
||||
) : null
|
||||
const renderedChildren =
|
||||
loading && asChild && React.isValidElement(children)
|
||||
? React.cloneElement(children, undefined, loadingContent)
|
||||
: loadingContent ?? children
|
||||
|
||||
return (
|
||||
<Comp
|
||||
aria-busy={loading || undefined}
|
||||
aria-disabled={asChild && blocked ? true : undefined}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
data-loading={loading || undefined}
|
||||
data-slot="button"
|
||||
disabled={asChild ? undefined : blocked}
|
||||
onClick={(event) => {
|
||||
if (asChild && blocked) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
onClick?.(event)
|
||||
}}
|
||||
ref={ref}
|
||||
tabIndex={asChild && blocked ? -1 : tabIndex}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{renderedChildren}
|
||||
</Comp>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -82,11 +82,11 @@ const inputGroupButtonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-sm px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
|
||||
xs: "h-8 gap-1 rounded-lg px-2 after:absolute after:-inset-1 after:content-[''] has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "h-8 gap-1.5 rounded-lg px-2.5 after:absolute after:-inset-1 after:content-[''] has-[>svg]:px-2.5",
|
||||
"icon-xs":
|
||||
"size-6 rounded-sm p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
"size-8 rounded-lg p-0 after:absolute after:-inset-1 after:content-[''] has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 after:absolute after:-inset-1 after:content-[''] has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { StatusBadge, StatusDot, StatusIcon } from "./status";
|
||||
|
||||
describe("semantic status primitives", () => {
|
||||
it.each(["neutral", "info", "success", "warning", "danger"] as const)(
|
||||
"renders the %s tone through the shared semantic class",
|
||||
(tone) => {
|
||||
render(<StatusBadge tone={tone}>{tone}</StatusBadge>);
|
||||
|
||||
expect(screen.getByText(tone)).toHaveClass(`status-tone-${tone}`);
|
||||
expect(screen.getByText(tone)).toHaveAttribute("data-status-tone", tone);
|
||||
}
|
||||
);
|
||||
|
||||
it("keeps a standalone status dot accessible when it has a label", () => {
|
||||
render(<StatusDot tone="danger" label="数据源离线" />);
|
||||
|
||||
expect(screen.getByRole("status", { name: "数据源离线" })).toHaveAttribute(
|
||||
"data-status-tone",
|
||||
"danger"
|
||||
);
|
||||
});
|
||||
|
||||
it("marks decorative dots as hidden from assistive technology", () => {
|
||||
const { container } = render(<StatusDot tone="success" />);
|
||||
|
||||
expect(container.firstChild).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
it("exposes activity modes for reduced-motion-safe CSS", () => {
|
||||
const { container } = render(
|
||||
<StatusIcon tone="info" activity="loading">
|
||||
<Loader2 />
|
||||
</StatusIcon>
|
||||
);
|
||||
|
||||
expect(container.querySelector(".status-glyph")).toHaveAttribute(
|
||||
"data-activity",
|
||||
"loading"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not add a breathing marker when an active badge has no explicit icon", () => {
|
||||
const { container } = render(
|
||||
<StatusBadge tone="info" activity="loading">
|
||||
执行中
|
||||
</StatusBadge>
|
||||
);
|
||||
|
||||
expect(container.querySelector(".status-dot")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
|
||||
export type StatusTone = "neutral" | "info" | "success" | "warning" | "danger";
|
||||
export type StatusActivity = "static" | "live" | "loading";
|
||||
|
||||
const toneClassNames: Record<StatusTone, string> = {
|
||||
neutral: "status-tone-neutral",
|
||||
info: "status-tone-info",
|
||||
success: "status-tone-success",
|
||||
warning: "status-tone-warning",
|
||||
danger: "status-tone-danger"
|
||||
};
|
||||
|
||||
type StatusBadgeProps = ComponentProps<"span"> & {
|
||||
tone: StatusTone;
|
||||
activity?: StatusActivity;
|
||||
icon?: ReactNode;
|
||||
size?: "sm" | "md";
|
||||
};
|
||||
|
||||
export function StatusBadge({
|
||||
tone,
|
||||
activity = "static",
|
||||
icon,
|
||||
size = "sm",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: StatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"status-badge inline-flex shrink-0 items-center rounded-full border font-semibold",
|
||||
size === "sm" ? "min-h-5 gap-1 px-2 py-0.5 text-xs leading-4" : "min-h-6 gap-1.5 px-2.5 py-0.5 text-xs leading-5",
|
||||
toneClassNames[tone],
|
||||
className
|
||||
)}
|
||||
data-status-tone={tone}
|
||||
{...props}
|
||||
>
|
||||
{icon ? (
|
||||
<span
|
||||
className="status-glyph grid shrink-0 place-items-center"
|
||||
data-activity={activity}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type StatusDotProps = Omit<ComponentProps<"span">, "children"> & {
|
||||
tone: StatusTone;
|
||||
activity?: StatusActivity;
|
||||
label?: string;
|
||||
size?: "sm" | "md";
|
||||
};
|
||||
|
||||
export function StatusDot({
|
||||
tone,
|
||||
activity = "static",
|
||||
label,
|
||||
size = "sm",
|
||||
className,
|
||||
...props
|
||||
}: StatusDotProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"status-dot inline-block shrink-0 rounded-full",
|
||||
size === "sm" ? "h-1.5 w-1.5" : "h-2 w-2",
|
||||
toneClassNames[tone],
|
||||
className
|
||||
)}
|
||||
data-activity={activity}
|
||||
data-status-tone={tone}
|
||||
role={label ? "status" : undefined}
|
||||
aria-label={label}
|
||||
aria-hidden={label ? undefined : true}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type StatusIconProps = ComponentProps<"span"> & {
|
||||
tone: StatusTone;
|
||||
activity?: StatusActivity;
|
||||
shape?: "rounded" | "circle";
|
||||
size?: "xs" | "sm" | "md";
|
||||
};
|
||||
|
||||
export function StatusIcon({
|
||||
tone,
|
||||
activity = "static",
|
||||
shape = "rounded",
|
||||
size = "sm",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: StatusIconProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"status-icon inline-grid shrink-0 place-items-center border",
|
||||
shape === "circle" ? "rounded-full" : "rounded-lg",
|
||||
size === "xs" ? "h-3.5 w-3.5" : size === "sm" ? "h-6 w-6" : "h-7 w-7",
|
||||
toneClassNames[tone],
|
||||
className
|
||||
)}
|
||||
data-status-tone={tone}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="status-glyph grid place-items-center"
|
||||
data-activity={activity}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user