Files
ttstd-infinite-canvas/web/src/components/ui/base/switch/switch.tsx
Rou 6f7a0674ce fix: 修复请求日志丢失、轮询日志显示和开关动画问题 (#450)
* fix(logs): 请求明细 - 隐藏轮询状态查询

* fix(ui): 开关滑块 - 恢复状态位移与过渡动画

* fix(protocol): 声明式任务 - 创建请求生成幂等 UUID

* fix(logs): 请求日志 - 辅助状态更新失败时仍保留记录
2026-09-09 06:48:26 +08:00

125 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
data-state={checked ? "on" : "off"}
className={cn("sc-switch-thumb absolute left-0.5 top-1/2 -translate-y-1/2 rounded-full border border-border/80 bg-surface-strong transition-transform duration-200 ease-out motion-reduce:transition-none", thumbClass)}
/>
)}
</button>
);
}