一组互斥选项。值住在 group 上(value / defaultValue / onValueChange),
一个 RadioGroupItem 就是一个选项、只带自己的 value。行为全部由 Base UI 承担:
方向键漫游、移到哪选中哪、隐藏 <input type="radio"> 的原生序列化都是它的事。
使用
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldLabel,
FieldLegend,
FieldSet,
RadioGroup,
RadioGroupItem,
} from '@gedatou/cadenza-ui'<FieldSet>
<FieldLegend id="voice">声部</FieldLegend>
<RadioGroup aria-labelledby="voice" defaultValue="alto" name="voice">
<Field orientation="horizontal">
<RadioGroupItem id="voice-soprano" value="soprano" />
<FieldLabel htmlFor="voice-soprano">女高音</FieldLabel>
</Field>
<Field orientation="horizontal">
<RadioGroupItem id="voice-alto" value="alto" />
<FieldLabel htmlFor="voice-alto">女中音</FieldLabel>
</Field>
</RadioGroup>
</FieldSet>组成
两个对外部件,FieldSet / FieldLegend / Field / FieldLabel 来自
Field 家族:
RadioGroup
├── RadioGroupItem
└── RadioGroupItem标签
两件命名的事,只有一件是自动的:
每一项是 box-only,和 Checkbox / Switch 一模一样:根元素就是那个圆点方框本身,
文字塞进 children 会被挤进方块里,所以走外部 FieldLabel(见
Field)。一个 htmlFor 买两样东西 ——
id 落在隐藏的 <input> 上(可见的框自己留一个生成 id),所以原生
<label for> 把点击转发给那个 input,是它切换选中;同时 Base UI 反查
input.labels、把 label 的 id 镜像成框上的 aria-labelledby,是它给出无障碍名。
不需要第二份文案。
组本身没有这条自动通道。 RadioGroup 渲染的是一个裸的
role="radiogroup" div:Base UI 本来会从它自己的 Field.Root / Fieldset.Legend
context 里把名字接过来,但本库的 FieldSet / FieldLegend 走的是 shadcn 那条
纯 DOM 线(fieldset / legend 元素,不带任何 context)—— 两条线不相通,
所以不写 aria-labelledby 的组就是一个没有名字的组。给 FieldLegend 一个 id、
组上 aria-labelledby 指过去即可;没有可见 legend 时用 aria-label。
描述
某一项要带描述时,用 FieldContent 把「标签 + 描述」装成一列,和
Checkbox 同一个写法:
<Field orientation="horizontal">
<RadioGroupItem id="voice-alto" value="alto" />
<FieldContent>
<FieldLabel htmlFor="voice-alto">女中音</FieldLabel>
<FieldDescription>需要能稳定唱到 F3。</FieldDescription>
</FieldContent>
</Field>整组的描述放在 FieldLegend 之后、RadioGroup 之前的 FieldDescription 里。
受控
value + onValueChange 都在 group 上,item 不变:
回调的第二个参数是 ChangeEventDetails:reason 恒为 'none'(上游没有为 radio
准备第二种理由),cancel() 被尊重 —— 调用它,内部状态就不往前走:
<RadioGroup
value={minutes}
onValueChange={(next, eventDetails) => {
if (locked) {
eventDetails.cancel()
return
}
setMinutes(next)
}}
>非受控就换成 defaultValue,其余照旧。
表单
序列化是原生的:给 group 一个 name,每一项的隐藏 <input type="radio"> 共用它,
选中的那一项按原生规则提交 —— FormData 直接拿得到,不用额外接线。值不是字符串时会被
序列化成字符串写进 DOM(数字 45 提交成 "45")。组渲染在 <form> 外面时,用 form
指出它属于哪张表单。
禁用
整组禁用就在 RadioGroup 上写 disabled,它会传给每一项。
单独禁用一项就在那个 RadioGroupItem 上写 disabled;要让那一行的文字也跟着变灰,
按 Field 的状态约定给它所在的 Field 挂 data-disabled ——
控件的 disabled 只管控件自己的视觉。禁用项在键盘导航里会被跳过,见
键盘交互。
readOnly 是另一档:能聚焦、改不动。
无效态
和 Checkbox 同一套:项上挂 aria-invalid 画出 destructive 边框与环,Field 上挂
data-invalid 让那一列文字变色(见 Field)。
整组的错误消息放在组后面的一个 FieldError 里。
泛型
值不会退化成 any。 两个部件在封装层里被重新声明成泛型函数,而不是直接转出 ——
转正前的那层包装把 props 写成了 Props<any>,照搬会让每个调用方的 value 和
onValueChange 都拿到 any。所以下面两种写法都成立,默认 Value = string:
<RadioGroup<number> value={minutes} onValueChange={setMinutes}> // 显式指定
<RadioGroup defaultValue="alto"> // 由 defaultValue 推断RadioGroupItem 各自推断自己 value 的类型 —— 让它和组对上是调用方的事,
类型系统不会替你把两边绑在一起。
状态与 className
两个部件的 className 一律双形态:字符串,或 (state) => string | undefined 的函数
(参数就是各自的状态表)。
RadioGroup
RadioGroupItem
RadioGroupState 是 disabled / readOnly / required,外加 Field 那一组
(valid / touched / dirty / filled / focused);RadioGroupItemState 在这些
之上多一个 checked。Field 那一组只在 Base UI 自己的 Field.Root 里才会动,
而本库的 Field 不是它,所以它们停在默认值上。
组带 data-slot="radio-group",项带 data-slot="radio-group-item",项里那个圆点带
data-slot="radio-group-indicator" —— 圆点由部件自己渲染,不用写 children。
键盘交互
禁用项在方向键导航中被跳过(受控那个 demo 里从「45 分钟」按 ↓ 会直接落到
「30 分钟」,越过禁用的「90 分钟」绕回开头)。Home / End 没有接。
Props
顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className。
RadioGroup
RadioGroupItem
五个类型也一并导出:RadioGroupProps / RadioGroupState /
RadioGroupChangeEventDetails / RadioGroupItemProps / RadioGroupItemState。