Cadenza
EN

一组互斥选项 —— 值住在 group 上、item 只带 value,组自己的名字要手写

一组互斥选项。值住在 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

标签

两件命名的事,只有一件是自动的:

给谁起名怎么接要不要手写
每一项FieldLabel htmlForRadioGroupItem id自动
组本身aria-labelledbyFieldLegend id

每一项是 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 不变:

回调的第二个参数是 ChangeEventDetailsreason 恒为 '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 的状态约定给它所在的 Fielddata-disabled —— 控件的 disabled 只管控件自己的视觉。禁用项在键盘导航里会被跳过,见 键盘交互

readOnly 是另一档:能聚焦、改不动。

无效态

和 Checkbox 同一套:项上挂 aria-invalid 画出 destructive 边框与环,Field 上挂 data-invalid 让那一列文字变色(见 Field)。 整组的错误消息放在组后面的一个 FieldError 里。

泛型

值不会退化成 any 两个部件在封装层里被重新声明成泛型函数,而不是直接转出 —— 转正前的那层包装把 props 写成了 Props<any>,照搬会让每个调用方的 valueonValueChange 都拿到 any。所以下面两种写法都成立,默认 Value = string

<RadioGroup<number> value={minutes} onValueChange={setMinutes}>  // 显式指定
<RadioGroup defaultValue="alto">                                 // 由 defaultValue 推断

RadioGroupItem 各自推断自己 value 的类型 —— 让它和组对上是调用方的事, 类型系统不会替你把两边绑在一起。

状态与 className

两个部件的 className 一律双形态:字符串,或 (state) => string | undefined 的函数 (参数就是各自的状态表)。

RadioGroup

状态出现时机函数 className 里的名字
data-disableddisableddisabled
data-readonlyreadOnlyreadOnly
data-requiredrequiredrequired

RadioGroupItem

状态出现时机函数 className 里的名字
data-checked / data-unchecked是不是当前选中项checked
data-disableddisableddisabled
data-readonlyreadOnlyreadOnly
data-requiredrequiredrequired

RadioGroupStatedisabled / 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。

键盘交互

按键行为
Tab整组只占一个 Tab 停靠点,进来时停在选中项上(没有选中项则第一项)
/ / / 在项之间移动,移到哪就选中哪(原生 radio 的读法);到头绕回另一端
Space选中当前项
Enter不选中 —— 上游按原生 radio 的读法把它拦掉了

禁用项在方向键导航中被跳过(受控那个 demo 里从「45 分钟」按 ↓ 会直接落到 「30 分钟」,越过禁用的「90 分钟」绕回开头)。Home / End 没有接。

Props

顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className

RadioGroup

Prop类型默认值说明
defaultValueValue非受控初始值
valueValue受控值
onValueChange(value: Value, eventDetails: RadioGroupChangeEventDetails) => void选中变化回调;第二参可 cancel()
namestring表单字段名,落到每一项的隐藏 input 上
formstring组在 <form> 外面时,指出它属于哪张表单
disabledbooleanfalse禁用整组
readOnlybooleanfalse只读:能聚焦、改不动
requiredbooleanfalse必填(回显为 aria-required
inputRefRef<HTMLInputElement>拿到当前生效的那个隐藏 input
classNamestring | ((state: RadioGroupState) => string | undefined)类名
其余Base UI RadioGroup 的 props(div 的原生属性 + ref透传 —— aria-labelledby 就是从这里进去的

RadioGroupItem

Prop类型默认值说明
valueValue必填,这一项在组内的标识
idstring落在隐藏 input 上,给 FieldLabel htmlFor
disabledbooleanfalse只禁这一项(组上的 disabled 也会传下来)
readOnlybooleanfalse只读
requiredbooleanfalse必填
inputRefRef<HTMLInputElement>拿到这一项的隐藏 input
nativeButtonbooleanfalserender 换成真 <button> 时才打开
classNamestring | ((state: RadioGroupItemState) => string | undefined)类名
其余Base UI Radio.Root 的 props(span 的原生属性 + ref透传(根元素是 span,隐藏 input 是它的兄弟)

五个类型也一并导出:RadioGroupProps / RadioGroupState / RadioGroupChangeEventDetails / RadioGroupItemProps / RadioGroupItemState