style(web): UI kit 组件与画布/后台面板 Prettier 格式化与 migrations.go gofmt 清理

This commit is contained in:
maomaochong-ai
2026-09-06 20:28:52 +08:00
parent d109dddde4
commit 580d76e2ca
25 changed files with 123 additions and 322 deletions

View File

@@ -78,17 +78,9 @@ export type StatusBadgeProps = HTMLAttributes<HTMLSpanElement> &
export function StatusBadge({ tone, variant = "outline", size, label, className, children, ...props }: StatusBadgeProps) {
const resolvedTone = tone ?? "neutral";
return (
<span
data-slot="status-badge"
className={cn(statusBadgeVariants({ variant, size }), variant === "filled" && filledTintClass[resolvedTone], className)}
{...props}
>
{variant === "outline" ? (
<span aria-hidden className={cn(statusBadgeTone({ tone }), "size-1.5", tone === "loading" && "motion-safe:animate-pulse")} />
) : null}
{label != null ? (
<span className={cn("text-muted-foreground", variant === "filled" && filledInkClass[resolvedTone])}>{label}</span>
) : null}
<span data-slot="status-badge" className={cn(statusBadgeVariants({ variant, size }), variant === "filled" && filledTintClass[resolvedTone], className)} {...props}>
{variant === "outline" ? <span aria-hidden className={cn(statusBadgeTone({ tone }), "size-1.5", tone === "loading" && "motion-safe:animate-pulse")} /> : null}
{label != null ? <span className={cn("text-muted-foreground", variant === "filled" && filledInkClass[resolvedTone])}>{label}</span> : null}
{children}
</span>
);

View File

@@ -19,25 +19,20 @@ import { cn } from "@/lib/utils";
* 明暗:全部颜色走 token;active 按压底用 bg-surface-active,明暗自动适配。
*/
export const iconButtonVariants = cva(
[
"inline-flex shrink-0 items-center justify-center rounded-md",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"disabled:pointer-events-none disabled:opacity-45",
"motion-safe:active:scale-[0.96]",
],
["inline-flex shrink-0 items-center justify-center rounded-md", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "disabled:pointer-events-none disabled:opacity-45", "motion-safe:active:scale-[0.96]"],
{
variants: {
variant: {
// 纯透明 + hover 浮底(最常用:面板/工具条)
ghost: "text-muted-foreground hover:bg-surface-hover hover:text-foreground active:bg-surface-active",
// 纯透明 + hover 浮底(最常用:面板/工具条)
ghost: "text-muted-foreground hover:bg-surface-hover hover:text-foreground active:bg-surface-active",
// 常驻浅底
default: "bg-surface-secondary text-muted-foreground hover:bg-surface-hover hover:text-foreground active:bg-surface-active",
default: "bg-surface-secondary text-muted-foreground hover:bg-surface-hover hover:text-foreground active:bg-surface-active",
// 描边
outline: "border border-border bg-transparent text-muted-foreground hover:bg-surface-hover hover:text-foreground",
outline: "border border-border bg-transparent text-muted-foreground hover:bg-surface-hover hover:text-foreground",
// 危险(文本色常驻红)
danger: "text-status-error hover:bg-surface-hover active:bg-surface-active",
// 实心(中性黑底白图标,主操作;active 反转成白底黑字 + 内描边,供选中态使用)
solid: "bg-foreground text-background hover:opacity-85 active:opacity-75 data-[active=true]:bg-background data-[active=true]:text-foreground data-[active=true]:ring-1 data-[active=true]:ring-inset data-[active=true]:ring-border",
danger: "text-status-error hover:bg-surface-hover active:bg-surface-active",
// 实心(中性黑底白图标,主操作;active 反转成白底黑字 + 内描边,供选中态使用)
solid: "bg-foreground text-background hover:opacity-85 active:opacity-75 data-[active=true]:bg-background data-[active=true]:text-foreground data-[active=true]:ring-1 data-[active=true]:ring-inset data-[active=true]:ring-border",
},
size: {
xs: "size-6 [&_svg]:size-3.5",
@@ -65,15 +60,7 @@ export type IconButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
export function IconButton({ variant, size, icon: Icon, loading = false, active = false, className, type = "button", disabled, ...props }: IconButtonProps) {
return (
<button
data-slot="icon-button"
type={type}
disabled={disabled || loading}
aria-busy={loading || undefined}
data-active={active || undefined}
className={cn(iconButtonVariants({ variant, size }), className)}
{...props}
>
<button data-slot="icon-button" type={type} disabled={disabled || loading} aria-busy={loading || undefined} data-active={active || undefined} className={cn(iconButtonVariants({ variant, size }), className)} {...props}>
{loading ? <Loader2 aria-hidden className="animate-spin" /> : <Icon aria-hidden />}
</button>
);

View File

@@ -21,12 +21,7 @@ import { cn } from "@/lib/utils";
* 明暗:全部颜色走 token;active 用按压底语义 token,明暗自动适配。
*/
export const toolButtonVariants = cva(
[
"inline-flex select-none items-center justify-center gap-1.5 rounded-md",
"font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"disabled:pointer-events-none disabled:opacity-45",
],
["inline-flex select-none items-center justify-center gap-1.5 rounded-md", "font-medium transition-colors", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "disabled:pointer-events-none disabled:opacity-45"],
{
variants: {
variant: {
@@ -79,20 +74,7 @@ export type ToolButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
label?: ReactNode;
};
export function ToolButton({
variant,
size,
tone,
icon,
active = false,
expands = false,
loading = false,
label,
className,
type = "button",
disabled,
...props
}: ToolButtonProps) {
export function ToolButton({ variant, size, tone, icon, active = false, expands = false, loading = false, label, className, type = "button", disabled, ...props }: ToolButtonProps) {
const pressed = !expands && active;
const expanded = expands && active;
return (

View File

@@ -30,14 +30,7 @@ export interface CheckboxGroupProps<V extends string = string> {
className?: string;
}
export function CheckboxGroup<V extends string = string>({
value = [],
onChange,
options,
disabled = false,
ariaLabel,
className,
}: CheckboxGroupProps<V>) {
export function CheckboxGroup<V extends string = string>({ value = [], onChange, options, disabled = false, ariaLabel, className }: CheckboxGroupProps<V>) {
const toggle = (optionValue: V, checked: boolean) => {
if (disabled) return;
const next = checked ? [...value, optionValue] : value.filter((item) => item !== optionValue);
@@ -47,12 +40,7 @@ export function CheckboxGroup<V extends string = string>({
return (
<div data-slot="checkbox-group" role="group" aria-label={ariaLabel} className={className}>
{options.map((option) => (
<Checkbox
key={option.value}
checked={value.includes(option.value)}
disabled={disabled || option.disabled}
onChange={(event) => toggle(option.value, event.target.checked)}
>
<Checkbox key={option.value} checked={value.includes(option.value)} disabled={disabled || option.disabled} onChange={(event) => toggle(option.value, event.target.checked)}>
{option.label}
</Checkbox>
))}

View File

@@ -35,16 +35,7 @@ const BOX_SIZE: Record<"sm" | "md", string> = {
md: "size-4 rounded",
};
export function Checkbox({
size = "md",
indeterminate = false,
children,
bare = false,
className,
disabled,
checked,
...props
}: CheckboxProps) {
export function Checkbox({ size = "md", indeterminate = false, children, bare = false, className, disabled, checked, ...props }: CheckboxProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
useLayoutEffect(() => {
@@ -55,14 +46,7 @@ export function Checkbox({
const inner = (
<>
<input
ref={inputRef}
type="checkbox"
disabled={disabled}
checked={checked}
className="peer sr-only"
{...props}
/>
<input ref={inputRef} type="checkbox" disabled={disabled} checked={checked} className="peer sr-only" {...props} />
<span
aria-hidden="true"
data-indeterminate={indeterminate || undefined}
@@ -77,17 +61,11 @@ export function Checkbox({
)}
>
{/* checked 勾(indeterminate 时隐藏,改显短横线) */}
<svg
viewBox="0 0 12 12"
className={cn("size-3 stroke-current stroke-[2.2]", indeterminate ? "hidden" : "block")}
>
<svg viewBox="0 0 12 12" className={cn("size-3 stroke-current stroke-[2.2]", indeterminate ? "hidden" : "block")}>
<path d="M2.5 6.5 L5 9 L9.5 3.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{/* indeterminate 短横线 */}
<svg
viewBox="0 0 12 12"
className={cn("size-3 stroke-current stroke-[2]", indeterminate ? "block" : "hidden")}
>
<svg viewBox="0 0 12 12" className={cn("size-3 stroke-current stroke-[2]", indeterminate ? "block" : "hidden")}>
<path d="M2.8 6 H9.2" fill="none" strokeLinecap="round" />
</svg>
</span>
@@ -95,32 +73,13 @@ export function Checkbox({
);
if (bare) {
return (
<span
className={cn(
"group/checkbox inline-flex items-center",
disabled && "cursor-not-allowed",
className,
)}
>
{inner}
</span>
);
return <span className={cn("group/checkbox inline-flex items-center", disabled && "cursor-not-allowed", className)}>{inner}</span>;
}
return (
<label
data-slot="checkbox"
className={cn(
"group/checkbox inline-flex cursor-pointer select-none items-center gap-2 align-middle",
disabled && "cursor-not-allowed",
className,
)}
>
<label data-slot="checkbox" className={cn("group/checkbox inline-flex cursor-pointer select-none items-center gap-2 align-middle", disabled && "cursor-not-allowed", className)}>
{inner}
{children !== undefined && (
<span className={cn("text-sm leading-none text-foreground", disabled && "opacity-60")}>{children}</span>
)}
{children !== undefined && <span className={cn("text-sm leading-none text-foreground", disabled && "opacity-60")}>{children}</span>}
</label>
);
}

View File

@@ -18,12 +18,7 @@ export function Kbd({ className, children, ...props }: KbdProps) {
return (
<kbd
data-slot="kbd"
className={cn(
"inline-flex h-5 shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-sm",
"border border-[color:var(--workspace-border)] bg-background/60 px-1.5",
"font-mono text-tiny font-medium text-foreground/60",
className,
)}
className={cn("inline-flex h-5 shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-sm", "border border-[color:var(--workspace-border)] bg-background/60 px-1.5", "font-mono text-tiny font-medium text-foreground/60", className)}
{...props}
>
{children}

View File

@@ -53,16 +53,7 @@ const SIZE_CLASS: Record<SegmentedSize, { track: string; thumb: string; item: st
},
};
export function SegmentedControl<V extends string = string>({
value,
onChange,
options,
size = "md",
block = false,
disabled = false,
ariaLabel,
className = "",
}: SegmentedControlProps<V>) {
export function SegmentedControl<V extends string = string>({ value, onChange, options, size = "md", block = false, disabled = false, ariaLabel, className = "" }: SegmentedControlProps<V>) {
const trackRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [thumb, setThumb] = useState<{ left: number; width: number } | null>(null);
@@ -70,8 +61,7 @@ export function SegmentedControl<V extends string = string>({
const selectedIndex = options.findIndex((option) => option.value === value);
/** 无选中项时兜底首个可用项,保证 roving tabindex 下键盘始终可达 */
const focusableIndex =
selectedIndex >= 0 ? selectedIndex : options.findIndex((option) => !option.disabled);
const focusableIndex = selectedIndex >= 0 ? selectedIndex : options.findIndex((option) => !option.disabled);
useLayoutEffect(() => {
const track = trackRef.current;
@@ -123,29 +113,14 @@ export function SegmentedControl<V extends string = string>({
};
const itemBase =
"relative z-10 inline-flex select-none items-center justify-center gap-1.5 whitespace-nowrap font-medium outline-none transition-colors motion-reduce:transition-none " +
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 ";
"relative z-10 inline-flex select-none items-center justify-center gap-1.5 whitespace-nowrap font-medium outline-none transition-colors motion-reduce:transition-none " + "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 ";
return (
<div
ref={trackRef}
role="radiogroup"
aria-label={ariaLabel}
className={[
"relative inline-flex bg-surface-tertiary",
SIZE_CLASS[size].track,
block ? "flex w-full" : "",
disabled ? "opacity-60" : "",
className,
].join(" ")}
>
<div ref={trackRef} role="radiogroup" aria-label={ariaLabel} className={["relative inline-flex bg-surface-tertiary", SIZE_CLASS[size].track, block ? "flex w-full" : "", disabled ? "opacity-60" : "", className].join(" ")}>
{thumb !== null && !disabled && (
<span
aria-hidden="true"
className={[
"pointer-events-none absolute inset-y-0.5 z-0 bg-surface-strong transition-[left,width] duration-200 ease-out motion-reduce:transition-none",
SIZE_CLASS[size].thumb,
].join(" ")}
className={["pointer-events-none absolute inset-y-0.5 z-0 bg-surface-strong transition-[left,width] duration-200 ease-out motion-reduce:transition-none", SIZE_CLASS[size].thumb].join(" ")}
style={{ left: thumb.left, width: thumb.width }}
/>
)}
@@ -160,7 +135,7 @@ export function SegmentedControl<V extends string = string>({
type="button"
role="radio"
aria-checked={selected}
aria-label={option.label ? undefined : option.title ?? option.value}
aria-label={option.label ? undefined : (option.title ?? option.value)}
title={option.title}
disabled={disabled || option.disabled}
tabIndex={index === focusableIndex ? 0 : -1}
@@ -174,11 +149,7 @@ export function SegmentedControl<V extends string = string>({
SIZE_CLASS[size].item,
SIZE_CLASS[size].thumb,
block ? "flex-1" : "",
option.disabled
? "cursor-not-allowed text-muted-foreground/40"
: selected
? "cursor-default text-foreground"
: "text-muted-foreground hover:bg-surface-hover hover:text-foreground",
option.disabled ? "cursor-not-allowed text-muted-foreground/40" : selected ? "cursor-default text-foreground" : "text-muted-foreground hover:bg-surface-hover hover:text-foreground",
].join(" ")}
>
{option.icon}

View File

@@ -1,11 +1,5 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import {
Button as AriaButton,
ListBox as AriaListBox,
ListBoxItem as AriaListBoxItem,
Popover as AriaPopover,
Select as AriaSelect,
} from "react-aria-components";
import { Button as AriaButton, ListBox as AriaListBox, ListBoxItem as AriaListBoxItem, Popover as AriaPopover, Select as AriaSelect } from "react-aria-components";
import { Check, ChevronDown, X } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -52,17 +46,7 @@ const TRIGGER_CLASS: Record<SelectSize, string> = {
md: "h-8 text-sm",
};
export function Select<V extends string = string>({
value,
onChange,
options = [],
placeholder,
disabled = false,
allowClear = false,
size = "md",
ariaLabel,
className,
}: SelectProps<V>) {
export function Select<V extends string = string>({ value, onChange, options = [], placeholder, disabled = false, allowClear = false, size = "md", ariaLabel, className }: SelectProps<V>) {
const triggerRef = useRef<HTMLButtonElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
@@ -122,9 +106,7 @@ export function Select<V extends string = string>({
TRIGGER_CLASS[size],
)}
>
<span className={cn("min-w-0 flex-1 truncate text-left", displayLabel === undefined && "text-muted-foreground")}>
{displayLabel === undefined ? placeholder : displayLabel}
</span>
<span className={cn("min-w-0 flex-1 truncate text-left", displayLabel === undefined && "text-muted-foreground")}>{displayLabel === undefined ? placeholder : displayLabel}</span>
{allowClear && value !== undefined && !disabled && (
<span
role="button"
@@ -140,25 +122,9 @@ export function Select<V extends string = string>({
<X className="size-3.5" />
</span>
)}
<ChevronDown
aria-hidden="true"
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform duration-200 motion-reduce:transition-none",
open && "rotate-180",
)}
/>
<ChevronDown aria-hidden="true" className={cn("size-4 shrink-0 text-muted-foreground transition-transform duration-200 motion-reduce:transition-none", open && "rotate-180")} />
</AriaButton>
<AriaPopover
ref={popoverRef}
isNonModal
placement="bottom"
offset={4}
style={popoverWidth ? { width: popoverWidth } : undefined}
className={cn(
"z-50 rounded-lg border border-border bg-surface-strong p-1 shadow-lg",
"ra-pop-in",
)}
>
<AriaPopover ref={popoverRef} isNonModal placement="bottom" offset={4} style={popoverWidth ? { width: popoverWidth } : undefined} className={cn("z-50 rounded-lg border border-border bg-surface-strong p-1 shadow-lg", "ra-pop-in")}>
<AriaListBox aria-label={ariaLabel ?? placeholder ?? "选项"} className="max-h-60 overflow-auto outline-none">
{options.map((option) => (
<AriaListBoxItem

View File

@@ -36,10 +36,7 @@ export interface SwitchProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement
}
/** 几何档:轨道尺寸与 thumb 行程(thumb 用 left-0.5 起始 + translate-x 档位) */
const SWITCH_SIZE: Record<
"sm" | "md",
{ root: string; thumbPlain: string; thumbText: string; hasTextRoot: string }
> = {
const SWITCH_SIZE: Record<"sm" | "md", { root: string; thumbPlain: string; thumbText: string; hasTextRoot: string }> = {
sm: {
root: "h-5 w-8 rounded-full",
thumbPlain: "size-3.5 data-[state=on]:translate-x-3.5",
@@ -54,19 +51,7 @@ const SWITCH_SIZE: Record<
},
};
export function Switch({
size = "md",
checked: checkedProp,
defaultChecked = false,
loading = false,
checkedChildren,
unCheckedChildren,
onChange,
disabled,
className,
"aria-label": ariaLabel,
...props
}: SwitchProps) {
export function Switch({ size = "md", checked: checkedProp, defaultChecked = false, loading = false, checkedChildren, unCheckedChildren, onChange, disabled, className, "aria-label": ariaLabel, ...props }: SwitchProps) {
const [innerChecked, setInnerChecked] = useState(defaultChecked);
const controlled = checkedProp !== undefined;
const checked = controlled ? checkedProp : innerChecked;
@@ -113,42 +98,24 @@ export function Switch({
{unCheckedChildren !== undefined ? (
<span
aria-hidden="true"
className={cn(
"absolute top-1/2 right-1.5 -translate-y-1/2 text-xs leading-none font-medium transition-opacity motion-reduce:transition-none",
checked ? "text-foreground/55 opacity-0" : "text-foreground/55 opacity-100",
)}
className={cn("absolute top-1/2 right-1.5 -translate-y-1/2 text-xs leading-none font-medium transition-opacity motion-reduce:transition-none", checked ? "text-foreground/55 opacity-0" : "text-foreground/55 opacity-100")}
>
{unCheckedChildren}
</span>
) : null}
{/* 开态文案(左侧,反色) */}
{checkedChildren !== undefined ? (
<span
aria-hidden="true"
className={cn(
"absolute top-1/2 left-1.5 -translate-y-1/2 text-xs leading-none font-medium transition-opacity motion-reduce:transition-none",
checked ? "text-background opacity-100" : "text-background opacity-0",
)}
>
<span aria-hidden="true" className={cn("absolute top-1/2 left-1.5 -translate-y-1/2 text-xs leading-none font-medium transition-opacity motion-reduce:transition-none", checked ? "text-background opacity-100" : "text-background opacity-0")}>
{checkedChildren}
</span>
) : null}
{/* thumb / loading 转圈 */}
{loading ? (
<span
aria-hidden="true"
className={cn(
"absolute inset-0 m-auto size-3.5 animate-spin rounded-full border-[1.5px] border-current border-t-transparent",
checked ? "text-background" : "text-foreground/60",
)}
/>
<span aria-hidden="true" className={cn("absolute inset-0 m-auto size-3.5 animate-spin rounded-full border-[1.5px] border-current border-t-transparent", checked ? "text-background" : "text-foreground/60")} />
) : (
<span
aria-hidden="true"
className={cn(
"sc-switch-thumb absolute left-0.5 top-1/2 -translate-y-1/2 rounded-full bg-surface-strong border border-border/80 transition-transform duration-200 ease-out motion-reduce:transition-none",
thumbClass,
)}
className={cn("sc-switch-thumb absolute left-0.5 top-1/2 -translate-y-1/2 rounded-full bg-surface-strong border border-border/80 transition-transform duration-200 ease-out motion-reduce:transition-none", thumbClass)}
/>
)}
</button>

View File

@@ -43,15 +43,7 @@ export function Tooltip({ title, placement = "top", delay = 350, className, chil
return (
<TooltipTrigger delay={delay}>
<span className="inline-flex">{children}</span>
<RACTooltip
placement={PLACEMENT_MAP[placement]}
offset={6}
className={cn(
"z-50 max-w-64 rounded-md border border-border bg-surface-strong px-2 py-1 text-xs leading-relaxed text-foreground shadow-md",
"ra-pop-in",
className,
)}
>
<RACTooltip placement={PLACEMENT_MAP[placement]} offset={6} className={cn("z-50 max-w-64 rounded-md border border-border bg-surface-strong px-2 py-1 text-xs leading-relaxed text-foreground shadow-md", "ra-pop-in", className)}>
{title}
</RACTooltip>
</TooltipTrigger>