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:
1
web/src/components/ui/base/badges/index.ts
Normal file
1
web/src/components/ui/base/badges/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { StatusBadge, statusBadgeTone, statusBadgeVariants, type StatusBadgeProps } from "./status-badge";
|
||||
95
web/src/components/ui/base/badges/status-badge.tsx
Normal file
95
web/src/components/ui/base/badges/status-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
web/src/components/ui/base/buttons/icon-button.tsx
Normal file
80
web/src/components/ui/base/buttons/icon-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
web/src/components/ui/base/buttons/index.ts
Normal file
2
web/src/components/ui/base/buttons/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { IconButton, iconButtonVariants, type IconButtonProps } from "./icon-button";
|
||||
export { ToolButton, toolButtonVariants, type ToolButtonProps } from "./tool-button";
|
||||
114
web/src/components/ui/base/buttons/tool-button.tsx
Normal file
114
web/src/components/ui/base/buttons/tool-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
web/src/components/ui/base/checkbox/checkbox-group.tsx
Normal file
61
web/src/components/ui/base/checkbox/checkbox-group.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
web/src/components/ui/base/checkbox/checkbox.tsx
Normal file
126
web/src/components/ui/base/checkbox/checkbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
web/src/components/ui/base/checkbox/index.ts
Normal file
2
web/src/components/ui/base/checkbox/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Checkbox, type CheckboxProps } from "./checkbox";
|
||||
export { CheckboxGroup, type CheckboxGroupProps, type CheckboxGroupOption } from "./checkbox-group";
|
||||
1
web/src/components/ui/base/kbd/index.ts
Normal file
1
web/src/components/ui/base/kbd/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Kbd, type KbdProps } from "./kbd";
|
||||
32
web/src/components/ui/base/kbd/kbd.tsx
Normal file
32
web/src/components/ui/base/kbd/kbd.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
web/src/components/ui/base/segmented-control/index.ts
Normal file
1
web/src/components/ui/base/segmented-control/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SegmentedControl, type SegmentedControlProps, type SegmentedOption, type SegmentedSize } from "./segmented-control";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
1
web/src/components/ui/base/select/index.ts
Normal file
1
web/src/components/ui/base/select/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Select, type SelectOption, type SelectProps, type SelectSize } from "./select";
|
||||
193
web/src/components/ui/base/select/select.tsx
Normal file
193
web/src/components/ui/base/select/select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
web/src/components/ui/base/switch/index.ts
Normal file
1
web/src/components/ui/base/switch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Switch, type SwitchProps } from "./switch";
|
||||
156
web/src/components/ui/base/switch/switch.tsx
Normal file
156
web/src/components/ui/base/switch/switch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
web/src/components/ui/base/tooltip/index.ts
Normal file
1
web/src/components/ui/base/tooltip/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Tooltip, type TooltipPlacement, type TooltipProps } from "./tooltip";
|
||||
59
web/src/components/ui/base/tooltip/tooltip.tsx
Normal file
59
web/src/components/ui/base/tooltip/tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user