feat(ui): 自研 Celadon 组件族入库与 AntD 消费全量替换 - base(Kbd/StatusBadge/IconButton/ToolButton)+product(EmptyState/Callout/SegmentedControl/Checkbox/Select/Tooltip/Switch), composio 视觉基底, globals.css 覆盖清理 (ADR-0008)

This commit is contained in:
maomaochong-ai
2026-09-06 20:28:51 +08:00
parent 0dcd8e1c72
commit c3e2758736
128 changed files with 2791 additions and 737 deletions

View File

@@ -0,0 +1 @@
export { StatusBadge, statusBadgeTone, statusBadgeVariants, type StatusBadgeProps } from "./status-badge";

View File

@@ -0,0 +1,95 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/**
* StatusBadge — 语义状态徽章。
*
* 两种形态:
* - outline(默认):圆点 + 文本,圆点承担状态色,底色 bg-secondary/60。
* - filled:tone 色块纯文本胶囊(AntD Tag variant="filled" 观感),无圆点。
*
* 用途:展示实体的语义状态(成功/告警/失败/进行中/中性),如任务状态、同步状态、
* 节点状态;替代 AntD Tag 的状态标签场景与各页面私有实现。
* token:outline 圆点取 bg-status-{success|warning|error|loading}、中性取前景色;
* 底色 bg-secondary/60、文本 text-muted-foreground;filled 容器取
* bg-status-{tone}/15 + text-status-{tone}(中性取前景色混合)。
* 对标:AntD Tag(状态色标签,variant="filled" 对应 filled)、BoardUI status-dot(状态点)。
* 明暗:全部颜色走 token,明暗双主题自动适配。
*/
export const statusBadgeTone = cva("shrink-0 rounded-full", {
variants: {
tone: {
neutral: "bg-foreground/50",
success: "bg-status-success",
warning: "bg-status-warning",
error: "bg-status-error",
loading: "bg-status-loading",
},
},
defaultVariants: {
tone: "neutral",
},
});
export const statusBadgeVariants = cva("inline-flex items-center gap-1.5 whitespace-nowrap rounded-full", {
variants: {
variant: {
outline: "bg-secondary/60",
filled: "font-medium",
},
size: {
default: "px-2 py-0.5 text-caption",
sm: "gap-1 px-1.5 py-px text-tiny",
},
},
defaultVariants: {
variant: "outline",
size: "default",
},
});
/** filled 容器底色:tone 色 12–15% 淡底(中性为前景色 8%) */
const filledTintClass: Record<string, string> = {
neutral: "bg-foreground/8",
success: "bg-status-success/15",
warning: "bg-status-warning/15",
error: "bg-status-error/15",
loading: "bg-status-loading/15",
};
/** filled 文本色:tone 原色(中性为前景色 70%) */
const filledInkClass: Record<string, string> = {
neutral: "text-foreground/70",
success: "text-status-success",
warning: "text-status-warning",
error: "text-status-error",
loading: "text-status-loading",
};
export type StatusBadgeProps = HTMLAttributes<HTMLSpanElement> &
VariantProps<typeof statusBadgeTone> &
VariantProps<typeof statusBadgeVariants> & {
/** 徽章文本;状态语义由文本承担(无文本时组件退化为纯圆点/色块) */
label?: ReactNode;
};
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}
{children}
</span>
);
}

View File

@@ -0,0 +1,80 @@
import type { ButtonHTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Loader2, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* IconButton — 纯图标按钮(正方形、尺寸档位、语义变体、loading 态)。
*
* 用途:面板/工具条里的图标触发按钮(新建、删除、收起、关闭等);替代 AntD
* Button `type="text"/"ghost" icon` 小尺寸场景与各页私有 icon-button。
* token:底 bg-surface-hover(ghost 悬停)/bg-surface-active(active 按压)、
* 图标 text-muted-foreground(danger 用 text-status-error)、焦点环 ring-ring;
* hover/active 态全用语义 token,无裸色。
* 图标契约:`icon` 传 lucide 组件引用,尺寸按 `size` 档位由本组件决定;禁止调用方
* 传 icon className 改尺寸。必须传 `aria-label`(纯图标无文本)。
* 对标:dbx ui/button variants(ghost/outline/destructive、icon-xs/sm/lg);
* AntD Button icon-only。
* 明暗:全部颜色走 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]",
],
{
variants: {
variant: {
// 纯透明 + 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",
// 描边
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",
},
size: {
xs: "size-6 [&_svg]:size-3.5",
sm: "size-7 [&_svg]:size-4",
default: "size-8 [&_svg]:size-4",
lg: "size-9 [&_svg]:size-[18px]",
},
},
defaultVariants: {
variant: "ghost",
size: "default",
},
},
);
export type IconButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof iconButtonVariants> & {
/** lucide 图标组件引用 */
icon: LucideIcon;
/** loading:替换为 spinner 并禁用 */
loading?: boolean;
/** data-active 语义高亮(如开关组内当前项),仅装饰,不改变 aria-pressed */
active?: boolean;
};
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}
>
{loading ? <Loader2 aria-hidden className="animate-spin" /> : <Icon aria-hidden />}
</button>
);
}

View File

@@ -0,0 +1,2 @@
export { IconButton, iconButtonVariants, type IconButtonProps } from "./icon-button";
export { ToolButton, toolButtonVariants, type ToolButtonProps } from "./tool-button";

View File

@@ -0,0 +1,114 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* ToolButton — 工具激活态按钮(持久切换,可展开面板)。
*
* 用途:工具类按钮——画布工具 Dock、面板工具组:被按下时保持高亮(aria-pressed),
* 或展开浮层面板(expands → aria-expanded)。active 持久态与 hover/按压态区分
* 明显,配合画布 ToolDefinition(active/expands/disabled 谓词)渲染。
* token:active 态 bg-surface-active + text-foreground,常态 bg-transparent +
* text-muted-foreground,hover bg-surface-hover;焦点环 ring-ring;
* danger 态激活文字用 text-status-error。间距由 size 档位控制。
* 图标契约:`icon` 传 ReactNode(内联 JSX 或组件元素),与画布 tool-registry 的
* ToolDefinition.icon 字段一致(icons 是内联 JSX 如 <Undo2 />);尺寸由本组件
* [&_svg] 规则统一控制。禁传 icon className 改尺寸。
* 对标:接管画布工具 Dock 中按钮渲染(aria-pressed 语义);AntD Tooltip+Button
* 组合的工具按钮场景。参考 BoardUI tool 切换语义。
* 明暗:全部颜色走 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",
],
{
variants: {
variant: {
// 工具条/面板内常规(常态透明,激活常驻底)
ghost: "text-muted-foreground hover:bg-surface-hover hover:text-foreground",
// 常驻浅底(面板内分组工具)
default: "bg-surface-secondary text-muted-foreground hover:bg-surface-hover hover:text-foreground",
},
size: {
xs: "h-6 px-1.5 text-tiny [&_svg]:size-3.5",
sm: "h-7 px-2 text-caption [&_svg]:size-4",
default: "h-8 px-2.5 text-caption [&_svg]:size-4",
lg: "h-9 px-3 text-body [&_svg]:size-[18px]",
},
tone: {
default: "",
danger: "data-[active=true]:text-status-error",
},
active: {
true: "",
false: "",
},
},
compoundVariants: [
{
variant: ["ghost", "default"],
active: true,
className: "bg-surface-active text-foreground hover:text-foreground",
},
],
defaultVariants: {
variant: "ghost",
size: "default",
tone: "default",
active: false,
},
},
);
export type ToolButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof toolButtonVariants> & {
/** 图标内容:ReactNode(内联 JSX,与 tool-registry ToolDefinition.icon 对齐) */
icon?: ReactNode;
/** 持久激活态(映射 aria-pressed=true) */
active?: boolean;
/** 面板展开型工具:active 走 aria-expanded 语义而非 pressed */
expands?: boolean;
loading?: boolean;
/** 显式文本标签(提供时按带标签布局渲染) */
label?: ReactNode;
};
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 (
<button
data-slot="tool-button"
type={type}
disabled={disabled || loading}
aria-pressed={pressed || undefined}
aria-expanded={expanded || undefined}
aria-busy={loading || undefined}
data-active={active || undefined}
className={cn(toolButtonVariants({ variant, size, tone, active }), className)}
{...props}
>
{loading ? <Loader2 aria-hidden className="animate-spin" /> : icon}
{label != null ? <span>{label}</span> : null}
</button>
);
}

View File

@@ -0,0 +1,61 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Checkbox } from "./checkbox";
/**
* CheckboxGroup — 复选框组(自研,原生 input,无依赖)。
*
* - 容器不预设布局:调用方用 className 决定排列(垂直传 `flex flex-col gap-1.5`,
* 网格传 `grid grid-cols-2 gap-2`),与 AntD Checkbox.Group 行为一致。
* - 受控 value: V[] + onChange(values),泛型覆盖枚举/字符串场景;
* 可直接替换 AntD Checkbox.Group(options/value/onChange/disabled)。
* - 对标:AntD Checkbox.Group。
*/
export interface CheckboxGroupOption<V extends string = string> {
value: V;
label: ReactNode;
disabled?: boolean;
}
export interface CheckboxGroupProps<V extends string = string> {
value?: V[];
onChange?: (values: V[]) => void;
options: Array<CheckboxGroupOption<V>>;
/** 组整体禁用 */
disabled?: boolean;
ariaLabel?: string;
className?: string;
}
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);
onChange?.(next);
};
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)}
>
{option.label}
</Checkbox>
))}
</div>
);
}

View File

@@ -0,0 +1,126 @@
import { useLayoutEffect, useRef, type InputHTMLAttributes, type ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* Checkbox — 复选框(自研,原生 input + 自绘 glyph,无依赖)。
*
* 设计约束(对齐 ui/README 与 ADR-0008):
* - 原生 <input type="checkbox"> 承载语义与键盘(简单件不走 react-aria-components);
* hidden input + peer 视觉盒::focus-visible ring 落在盒上,aria 状态由原生保证。
* - 只消费三层 token:box border-border / 选中 bg-foreground + text-background(中性反色),
* hover 加深 border、焦点 ring-ring、禁用 opacity;无裸色。
* - indeterminate:非受控 DOM 属性,useLayoutEffect 同步到 input.indeterminate
* (样式经 data-indeterminate 落在视觉盒上)。
* - API 对齐 AntD Checkbox:checked/defaultChecked/disabled/indeterminate/onChange/children,
* 可直接替换;value 传 string 时随原生 input 提交。
* 对标:AntD Checkbox;boardui base/checkbox。
*/
export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "size"> {
/** 复选框尺寸(视觉盒)。默认 md。 */
size?: "sm" | "md";
/** 半选态(视觉 + input.indeterminate) */
indeterminate?: boolean;
/** 右侧标签 */
children?: ReactNode;
/** bare:不渲染 label 根(外层已由调用方提供 label/点击区时用,避免 label 嵌套) */
bare?: boolean;
/** 类名附加到根元素 */
className?: string;
}
const BOX_SIZE: Record<"sm" | "md", string> = {
sm: "size-3.5 rounded-[3px]",
md: "size-4 rounded",
};
export function Checkbox({
size = "md",
indeterminate = false,
children,
bare = false,
className,
disabled,
checked,
...props
}: CheckboxProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
useLayoutEffect(() => {
if (inputRef.current) {
inputRef.current.indeterminate = indeterminate;
}
}, [indeterminate]);
const inner = (
<>
<input
ref={inputRef}
type="checkbox"
disabled={disabled}
checked={checked}
className="peer sr-only"
{...props}
/>
<span
aria-hidden="true"
data-indeterminate={indeterminate || undefined}
className={cn(
"flex shrink-0 items-center justify-center border transition-colors motion-reduce:transition-none",
"border-border text-background",
"peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-1 peer-focus-visible:ring-offset-background",
"peer-checked:border-foreground peer-checked:bg-foreground",
"peer-disabled:opacity-45",
!checked && !indeterminate && "bg-transparent group-hover/checkbox:border-foreground/50",
BOX_SIZE[size],
)}
>
{/* checked 勾(indeterminate 时隐藏,改显短横线) */}
<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")}
>
<path d="M2.8 6 H9.2" fill="none" strokeLinecap="round" />
</svg>
</span>
</>
);
if (bare) {
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,
)}
>
{inner}
{children !== undefined && (
<span className={cn("text-sm leading-none text-foreground", disabled && "opacity-60")}>{children}</span>
)}
</label>
);
}

View File

@@ -0,0 +1,2 @@
export { Checkbox, type CheckboxProps } from "./checkbox";
export { CheckboxGroup, type CheckboxGroupProps, type CheckboxGroupOption } from "./checkbox-group";

View File

@@ -0,0 +1 @@
export { Kbd, type KbdProps } from "./kbd";

View File

@@ -0,0 +1,32 @@
import type { HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
/**
* Kbd — 快捷键提示键帽。
*
* 用途:展示键盘快捷键(⌘K、Shift+Enter、Esc 等),纯展示,非交互。
* token:字号 `fs-tiny`(text-tiny)、字体 font-mono、背景 bg-background(半透明)、
* 边框 var(--workspace-border)、文本 text-foreground。
* 对标:接管私有实现 workspace-sidebar-nav.tsx / workspace-command-palette.tsx /
* canvas 快捷键弹窗中的 `<kbd>` 重复样式;形态参考 shadcn kbd 与 BoardUI kbd。
* 明暗:背景为背景色半透明 + 主题边框,明暗双主题自动适配,无额外差异。
*/
export interface KbdProps extends HTMLAttributes<HTMLElement> {}
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,
)}
{...props}
>
{children}
</kbd>
);
}

View File

@@ -0,0 +1 @@
export { SegmentedControl, type SegmentedControlProps, type SegmentedOption, type SegmentedSize } from "./segmented-control";

View File

@@ -0,0 +1,191 @@
import { useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
/**
* SegmentedControl — 分段控制器(自研,无依赖)。
*
* 设计约束(对齐 ui/README 与 ADR-0008):
* - 只消费三层 token 工具类(surface-tertiary / surface-strong / foreground / muted-foreground / ring …),
* 不写死任何色值;明暗自适应随主题 token。
* - 选中项 thumb 走 bg-surface-strong(light #fff / dark #2a2a2a 抬升面)。
* - 键盘:radio 组 roving tabindex(←/→/Home/End,自动跳过禁用项)。
* - thumb 位置 useLayoutEffect 测量 + ResizeObserver 兜底;motion-reduce 下不播动画。
* - API 对齐 AntD Segmented(value/onChange/options/block/size/disabled),
* 可直接置于 antd Form.Item(Form 注入 value/onChange,props 同名兼容)。
* - icon 节点尺寸由调用方控制(与 AntD options.icon 行为一致),用 currentColor 跟随文字色。
*/
export type SegmentedSize = "sm" | "md";
export interface SegmentedOption<V extends string = string> {
value: V;
/** 文本标签(icon-only 时省略) */
label?: ReactNode;
/** 图标节点(与 label 同给时渲染在左侧,尺寸自控) */
icon?: ReactNode;
disabled?: boolean;
/** 原生 title */
title?: string;
}
export interface SegmentedControlProps<V extends string = string> {
value?: V;
onChange?: (value: V) => void;
options: Array<SegmentedOption<V>>;
size?: SegmentedSize;
/** 填满父容器宽度(项均分) */
block?: boolean;
/** 整体禁用 */
disabled?: boolean;
ariaLabel?: string;
className?: string;
}
const SIZE_CLASS: Record<SegmentedSize, { track: string; thumb: string; item: string }> = {
sm: {
track: "h-7 rounded-md p-0.5",
thumb: "rounded-sm",
item: "px-2.5 text-xs",
},
md: {
track: "h-8 rounded-lg p-0.5",
thumb: "rounded-md",
item: "px-3 text-sm",
},
};
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);
const selectedIndex = options.findIndex((option) => option.value === value);
/** 无选中项时兜底首个可用项,保证 roving tabindex 下键盘始终可达 */
const focusableIndex =
selectedIndex >= 0 ? selectedIndex : options.findIndex((option) => !option.disabled);
useLayoutEffect(() => {
const track = trackRef.current;
if (!track) return;
const measure = () => {
const target = itemRefs.current[selectedIndex];
if (!target) {
setThumb(null);
return;
}
setThumb({ left: target.offsetLeft, width: target.offsetWidth });
};
measure();
const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null;
observer?.observe(track);
return () => observer?.disconnect();
}, [selectedIndex, options, value]);
const select = (next: V, nextIndex: number) => {
if (disabled || options[nextIndex]?.disabled) return;
onChange?.(next);
itemRefs.current[nextIndex]?.focus();
};
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
if (disabled || !["ArrowRight", "ArrowLeft", "Home", "End"].includes(event.key)) return;
event.preventDefault();
if (event.key === "Home") {
const first = options.findIndex((option) => !option.disabled);
if (first >= 0) select(options[first].value, first);
return;
}
if (event.key === "End") {
let last = -1;
options.forEach((option, optionIndex) => {
if (!option.disabled) last = optionIndex;
});
if (last >= 0) select(options[last].value, last);
return;
}
const isPrev = event.key === "ArrowLeft";
for (let step = 1; step <= options.length; step += 1) {
const candidate = (index + (isPrev ? -step : step) + options.length * 2) % options.length;
if (!options[candidate].disabled) {
select(options[candidate].value, candidate);
return;
}
}
};
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 ";
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(" ")}
>
{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(" ")}
style={{ left: thumb.left, width: thumb.width }}
/>
)}
{options.map((option, index) => {
const selected = index === selectedIndex && !disabled;
return (
<button
key={option.value}
ref={(element) => {
itemRefs.current[index] = element;
}}
type="button"
role="radio"
aria-checked={selected}
aria-label={option.label ? undefined : option.title ?? option.value}
title={option.title}
disabled={disabled || option.disabled}
tabIndex={index === focusableIndex ? 0 : -1}
onClick={() => {
if (option.disabled) return;
onChange?.(option.value);
}}
onKeyDown={(event) => onKeyDown(event, index)}
className={[
itemBase,
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",
].join(" ")}
>
{option.icon}
{option.label}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1 @@
export { Select, type SelectOption, type SelectProps, type SelectSize } from "./select";

View File

@@ -0,0 +1,193 @@
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 { Check, ChevronDown, X } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Select — 下拉选择(自研,react-aria-components 单选壳,无远程/多选)。
*
* - API 对齐 AntD Select 的单选子集:value/onChange(options 值)/options/placeholder/
* disabled/allowClear/size/className,可直接替换纯单选场景;可直接置于 antd Form.Item。
* - 浮层定位、进出场、焦点与类型选择由 RAC 承担(ADR-0008:复杂控件以 RAC 为无障碍地基)。
* - Popover isNonModal:避免 RAC modal 滚动锁把页面滚动条锁死(同 boardui 注释);
* isDismissable 与 isNonModal 互斥,故外部 dismiss 手动处理(全局 pointerdown capture)。
* - 样式只吃语义 token:trigger border-border/bg-background/hover:border-foreground/40、
* popover bg-surface-strong + hairline、item hover:bg-surface-hover、选中 Check 前景色,无裸色。
* - 范围外(本批不做,后续补):mode="multiple"/"tags"、showSearch(需 ComboBox)、
* loading、labelInValue、maxCount、suffixIcon。
* 对标:AntD Select(单选);boardui base/select。
*/
export type SelectSize = "sm" | "md";
export interface SelectOption<V extends string = string> {
value: V;
label: ReactNode;
disabled?: boolean;
/** 原生 title */
title?: string;
}
export interface SelectProps<V extends string = string> {
value?: V;
onChange?: (value: V) => void;
options?: Array<SelectOption<V>>;
placeholder?: string;
disabled?: boolean;
allowClear?: boolean;
size?: SelectSize;
ariaLabel?: string;
className?: string;
}
const TRIGGER_CLASS: Record<SelectSize, string> = {
sm: "h-7 text-xs",
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>) {
const triggerRef = useRef<HTMLButtonElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const [popoverWidth, setPopoverWidth] = useState<number | null>(null);
useLayoutEffect(() => {
const measure = () => {
const width = triggerRef.current?.offsetWidth;
if (width) setPopoverWidth(width + 2);
};
measure();
const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null;
if (observer && triggerRef.current) observer.observe(triggerRef.current);
return () => observer?.disconnect();
}, [open]);
useLayoutEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node;
const insideTrigger = triggerRef.current?.contains(target);
const insidePopover = popoverRef.current?.contains(target);
if (!insideTrigger && !insidePopover) setOpen(false);
};
window.addEventListener("pointerdown", onPointerDown, true);
return () => window.removeEventListener("pointerdown", onPointerDown, true);
}, [open]);
const selected = options.find((option) => option.value === value);
const displayLabel = selected?.label ?? value;
const clear = () => {
setOpen(false);
onChange?.(undefined as unknown as V);
};
return (
<AriaSelect
isDisabled={disabled}
isOpen={open}
onOpenChange={setOpen}
selectedKey={value ?? undefined}
onSelectionChange={(key) => {
setOpen(false);
if (key != null) onChange?.(key as V);
}}
aria-label={ariaLabel}
className={cn("inline-flex w-full", className)}
>
<AriaButton
ref={triggerRef}
className={cn(
"flex w-full cursor-pointer items-center justify-between gap-1.5 rounded-md border border-border bg-background px-2.5 font-normal text-foreground outline-none transition-colors motion-reduce:transition-none",
"hover:border-foreground/40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-55",
open && "border-foreground/60",
TRIGGER_CLASS[size],
)}
>
<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"
tabIndex={-1}
aria-label="清空选择"
className="shrink-0 cursor-pointer rounded p-0.5 text-muted-foreground hover:bg-surface-hover hover:text-foreground"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
clear();
}}
>
<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",
)}
/>
</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",
)}
>
<AriaListBox aria-label={ariaLabel ?? placeholder ?? "选项"} className="max-h-60 overflow-auto outline-none">
{options.map((option) => (
<AriaListBoxItem
key={option.value}
id={option.value}
textValue={typeof option.label === "string" ? option.label : option.value}
isDisabled={option.disabled}
className={({ isFocused, isSelected, isDisabled: itemDisabled }) =>
cn(
"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors motion-reduce:transition-none",
"focus-visible:bg-surface-hover",
(isFocused || isSelected) && "bg-surface-hover",
isSelected ? "font-medium text-foreground" : "text-muted-foreground",
itemDisabled && "cursor-not-allowed opacity-50",
)
}
>
{({ isSelected }) => (
<>
<span className={cn("min-w-0 flex-1 truncate")} title={option.title}>
{option.label}
</span>
{isSelected && <Check className="size-3.5 shrink-0 text-foreground" />}
</>
)}
</AriaListBoxItem>
))}
</AriaListBox>
</AriaPopover>
</AriaSelect>
);
}

View File

@@ -0,0 +1 @@
export { Switch, type SwitchProps } from "./switch";

View File

@@ -0,0 +1,156 @@
import { useState, type ButtonHTMLAttributes, type ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* Switch — 开关(自研,button role=switch,无依赖)。
*
* 设计约束(对齐 ui/README 与 ADR-0008):
* - <button type="button" role="switch" aria-checked> 承载语义与键盘(Space/Enter 原生),
* :focus-visible outline 落自身;无裸色、只消费三层 token。
* - 开 = bg-foreground + 反色 thumb(bg-background 语义对齐 Checkbox 选中态),
* 关 = bg-surface-tertiary + 发丝内描边 + surface-strong 抬升 thumb(同 SegmentedControl 选中面)。
* - thumb 滑动用 translate-x 位移(尺寸档固定行程),motion-reduce 关闭过渡。
* - data-state="on|off" 暴露给上下文 CSS 覆盖(admin 语义绿等保留于页面层,不侵入组件)。
* - API 对齐 AntD Switch:checked/defaultChecked/onChange/disabled/size/checkedChildren/
* unCheckedChildren/loading + aria-* 透传;受控与否由 checked 是否传入决定,
* onChange(checked) 单参。顶层组件接收 checked/onChange → Form.Item valuePropName="checked" 注入兼容。
* 对标:AntD Switch;boardui base/switch。
*/
export interface SwitchProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type" | "size" | "onChange"> {
/** 开关尺寸。默认 md;AntD small → sm 映射。 */
size?: "sm" | "md";
/** 受控:是否开启 */
checked?: boolean;
/** 非受控初始值 */
defaultChecked?: boolean;
/** loading:拇指位换转圈,忽略点击(视觉保留 checked 态) */
loading?: boolean;
/** 开启时轨道内左侧文案(2 字宽度档) */
checkedChildren?: ReactNode;
/** 关闭时轨道内右侧文案 */
unCheckedChildren?: ReactNode;
/** 变化回调(AntD 同签名:仅布尔参) */
onChange?: (checked: boolean) => void;
}
/** 几何档:轨道尺寸与 thumb 行程(thumb 用 left-0.5 起始 + translate-x 档位) */
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",
thumbText: "size-3.5 data-[state=on]:translate-x-3.5",
hasTextRoot: "",
},
md: {
root: "h-6 w-11 rounded-full",
thumbPlain: "size-4 data-[state=on]:translate-x-6",
thumbText: "size-4 data-[state=on]:translate-x-9",
hasTextRoot: "w-14",
},
};
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;
const hasText = checkedChildren !== undefined || unCheckedChildren !== undefined;
const handleClick = () => {
if (disabled || loading) {
return;
}
const next = !checked;
if (!controlled) {
setInnerChecked(next);
}
onChange?.(next);
};
const geometry = SWITCH_SIZE[size];
const thumbClass = hasText ? geometry.thumbText : geometry.thumbPlain;
return (
<button
type="button"
role="switch"
data-slot="switch"
data-state={checked ? "on" : "off"}
aria-checked={checked}
aria-label={ariaLabel}
aria-busy={loading || undefined}
disabled={disabled}
onClick={handleClick}
className={cn(
"relative inline-flex shrink-0 select-none items-center align-middle transition-colors motion-reduce:transition-none",
"data-[state=on]:bg-foreground",
"data-[state=off]:bg-surface-tertiary data-[state=off]:ring-1 data-[state=off]:ring-inset data-[state=off]:ring-foreground/15",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-45",
geometry.root,
hasText && size === "md" && geometry.hasTextRoot,
className,
)}
{...props}
>
{/* 关态文案(右侧) */}
{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",
)}
>
{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",
)}
>
{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(
"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

@@ -0,0 +1 @@
export { Tooltip, type TooltipPlacement, type TooltipProps } from "./tooltip";

View File

@@ -0,0 +1,59 @@
import type { ReactNode } from "react";
import { Tooltip as RACTooltip, TooltipTrigger, type Placement } from "react-aria-components";
import { cn } from "@/lib/utils";
/**
* Tooltip — 气泡提示(自研,react-aria-components overlay)。
*
* - API 对齐 AntD Tooltip(title/placement/children),title 为空时不渲染浮层。
* - 浮层定位、进出场、焦点管理由 RAC 承担(ADR-0008:复杂浮层以 RAC 为无障碍地基)。
* - 样式只吃语义 token:bg-surface-strong(light #fff / dark #2a2a2a)+ text-foreground +
* border-border hairline,无裸色;motion 走 RAC 动画类 + motion-reduce 关停。
* - trigger 包一层 inline-flex span:保证 RAC ref 定位可用(children 可为任意组件)。
* - placement 映射 AntD → RAC(topLeft→top left …)。
* 对标:AntD Tooltip;boardui base/tooltip。
*/
export type TooltipPlacement = "top" | "topLeft" | "topRight" | "bottom" | "bottomLeft" | "bottomRight" | "left" | "right";
const PLACEMENT_MAP: Record<TooltipPlacement, Placement> = {
top: "top",
topLeft: "top left",
topRight: "top right",
bottom: "bottom",
bottomLeft: "bottom left",
bottomRight: "bottom right",
left: "left",
right: "right",
};
export interface TooltipProps {
title?: ReactNode;
placement?: TooltipPlacement;
/** 悬停延迟 ms(默认 350,贴近 AntD mouseEnterDelay) */
delay?: number;
className?: string;
children: ReactNode;
}
export function Tooltip({ title, placement = "top", delay = 350, className, children }: TooltipProps) {
if (!title) return <>{children}</>;
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,
)}
>
{title}
</RACTooltip>
</TooltipTrigger>
);
}