feat(ui): Checkbox/Radio 原子件 + 3 处原生控件采用(P2-5)

- checkboxClass/radioClass(accent-cinnabar + 统一焦点环)+ 同名测试
- Checkbox/Radio 原子件:无 label 只渲染受控原生 input 便于就地替换
- 采用:ChainStarter 链类型单选、GeneratorRunner 入库勾选、CharacterCardItem 角色勾选
This commit is contained in:
Yaojia Wang
2026-07-11 14:04:01 +02:00
parent 45025c36bf
commit 355a2d11cd
7 changed files with 68 additions and 7 deletions

View File

@@ -5,6 +5,7 @@ import { Play } from "lucide-react";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field"; import { Field } from "@/components/ui/Field";
import { Radio } from "@/components/ui/Radio";
import { SectionHeader } from "@/components/ui/SectionHeader"; import { SectionHeader } from "@/components/ui/SectionHeader";
import { TextInput } from "@/components/ui/TextInput"; import { TextInput } from "@/components/ui/TextInput";
import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain"; import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain";
@@ -58,8 +59,7 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
key={kind.key} key={kind.key}
className="flex items-start gap-2 text-sm text-ink" className="flex items-start gap-2 text-sm text-ink"
> >
<input <Radio
type="radio"
name="chain-kind" name="chain-kind"
value={kind.key} value={kind.key}
checked={chainKey === kind.key} checked={chainKey === kind.key}

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { Checkbox } from "@/components/ui/Checkbox";
import { TextInput } from "@/components/ui/TextInput"; import { TextInput } from "@/components/ui/TextInput";
import type { CharacterCardView } from "@/lib/api/types"; import type { CharacterCardView } from "@/lib/api/types";
@@ -25,12 +26,10 @@ export function CharacterCardItem({
}`} }`}
> >
<header className="mb-2 flex items-center gap-2"> <header className="mb-2 flex items-center gap-2">
<input <Checkbox
type="checkbox"
checked={selected} checked={selected}
onChange={onToggle} onChange={onToggle}
aria-label={`选择角色 ${card.name}`} aria-label={`选择角色 ${card.name}`}
className="size-4 accent-cinnabar"
/> />
{onEdit ? ( {onEdit ? (
<TextInput <TextInput

View File

@@ -7,6 +7,7 @@ import { useToast } from "@/components/Toast";
import { ConflictAdjudication } from "@/components/generation/ConflictAdjudication"; import { ConflictAdjudication } from "@/components/generation/ConflictAdjudication";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { Checkbox } from "@/components/ui/Checkbox";
import { Field } from "@/components/ui/Field"; import { Field } from "@/components/ui/Field";
import { StatusNote } from "@/components/ui/StatusNote"; import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea"; import { TextArea } from "@/components/ui/TextArea";
@@ -380,8 +381,7 @@ function PreviewRow({
return ( return (
<li className="flex gap-2 rounded border border-line bg-bg p-3 text-sm"> <li className="flex gap-2 rounded border border-line bg-bg p-3 text-sm">
{selectable ? ( {selectable ? (
<input <Checkbox
type="checkbox"
checked={checked} checked={checked}
onChange={() => onToggle(index)} onChange={() => onToggle(index)}
className="mt-1" className="mt-1"

View File

@@ -0,0 +1,20 @@
import type { InputHTMLAttributes, ReactNode } from "react";
import { checkboxClass } from "@/lib/ui/variants";
interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
label?: ReactNode;
}
// 勾选框:无 label 时只渲染受控原生 input供已有 <label>/布局包裹的调用点直接替换);
// 有 label 时自带 <label> 包裹,保留原生键盘/读屏语义。
export function Checkbox({ label, className, ...props }: CheckboxProps) {
const input = <input type="checkbox" className={checkboxClass(className)} {...props} />;
if (!label) return input;
return (
<label className="inline-flex items-center gap-2 text-sm text-ink">
{input}
<span>{label}</span>
</label>
);
}

View File

@@ -0,0 +1,20 @@
import type { InputHTMLAttributes, ReactNode } from "react";
import { radioClass } from "@/lib/ui/variants";
interface RadioProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
label?: ReactNode;
}
// 单选钮:无 label 时只渲染受控原生 input供已有 <label>/布局包裹的调用点直接替换);
// 有 label 时自带 <label> 包裹,保留原生键盘/读屏语义。
export function Radio({ label, className, ...props }: RadioProps) {
const input = <input type="radio" className={radioClass(className)} {...props} />;
if (!label) return input;
return (
<label className="inline-flex items-center gap-2 text-sm text-ink">
{input}
<span>{label}</span>
</label>
);
}

View File

@@ -4,11 +4,13 @@ import {
badgeClass, badgeClass,
buttonClass, buttonClass,
cardClass, cardClass,
checkboxClass,
cn, cn,
focusRing, focusRing,
inputClass, inputClass,
overlayScrim, overlayScrim,
proseBody, proseBody,
radioClass,
segmentedClass, segmentedClass,
statusNoteClass, statusNoteClass,
transitionUi, transitionUi,
@@ -98,6 +100,14 @@ describe("ui variants", () => {
expect(klass).toContain("px-3"); expect(klass).toContain("px-3");
}); });
it("styles checkbox and radio with accent + shared focus ring", () => {
expect(checkboxClass()).toContain("accent-cinnabar");
expect(checkboxClass()).toContain("rounded-xs");
expect(checkboxClass()).toContain("focus-visible:ring-cinnabar/35");
expect(radioClass()).toContain("rounded-full");
expect(radioClass()).toContain("accent-cinnabar");
});
it("builds segmented control chrome", () => { it("builds segmented control chrome", () => {
const klass = segmentedClass(); const klass = segmentedClass();

View File

@@ -176,3 +176,15 @@ export function statusNoteClass({
export function segmentedClass(className?: string): string { export function segmentedClass(className?: string): string {
return cn("inline-flex rounded border border-line bg-bg p-1", className); return cn("inline-flex rounded border border-line bg-bg p-1", className);
} }
// 勾选/单选控件:原生 input + accent 上色,保留原生键盘/读屏语义,套统一焦点环。
const controlBoxBase =
"h-4 w-4 shrink-0 border-line accent-cinnabar disabled:cursor-not-allowed disabled:opacity-45";
export function checkboxClass(className?: string): string {
return cn(controlBoxBase, "rounded-xs", focusRing, className);
}
export function radioClass(className?: string): string {
return cn(controlBoxBase, "rounded-full", focusRing, className);
}