Cadenza
EN

box-only 的复选框 —— id 落在隐藏 input 上,一条 htmlFor 同时管点击与无障碍名

Base UI 的 Checkbox.Root 装进 shadcn 的 base-nova 盒子。封装层只补了一件事: 半选态的样子

它是 box-only 的:根元素渲染一个 <span role="checkbox">,旁边跟一个隐藏的 <input type="checkbox"> —— 所以根元素就是那个方块,再没有别的。文字得交给 同级的 FieldLabel(见 Field)。

使用

import {
  Checkbox,
  Field,
  FieldContent,
  FieldDescription,
  FieldLabel,
} from '@gedatou/cadenza-ui'
<Field orientation="horizontal">
  <Checkbox id="terms" name="terms" />
  <FieldLabel htmlFor="terms">同意条款</FieldLabel>
</Field>

标签

Field orientation="horizontal" + Checkbox id + FieldLabel htmlFor一条 htmlFor 把两件事都买下了,值得知道为什么 —— 因为 id 并没有落在看上去的 那个地方:

通道管什么
FieldLabel htmlForCheckbox id点文字切换 + 无障碍名

id 落在隐藏的那个 <input> 上(可见的方块自己留一个生成的 id)。于是原生 <label for> 把点击转发给这个 input —— 切换正是它完成的;与此同时 Base UI 反查 input.labels,把 label 的 id 镜像到方块上当 aria-labelledby —— 屏幕阅读器读到的 名字由此而来。不需要第二个 aria-label,封装层也不用补任何点击接线。

压根没有可见文字时(比如表格里每行一个)才给 aria-label

全库四条标签通道的总表在 Field,box-only 是其中一条, SwitchRadioGroupItem 与它同类。

受控

受控三件套是 checked / defaultChecked / onCheckedChange

回调的第二参是真的 ChangeEventDetails(导出名 CheckboxChangeEventDetails):

  • reason 恒为 'none' —— 上游给复选框就没派第二种 reason,别指望靠它分辨来源。
  • cancel() 会被尊重:调了它,组件内部那份状态就不推进。

受控时显示的本来就是你的 checkedcancel() 拦下的是组件内部那份看不见的状态; 非受控(只给 defaultChecked)时,那份状态就是显示的那份,于是 cancel() 直接把这 一下勾选拦住 —— demo 里第二个框就是这样,不写任何外部 state 也取消不掉。

半选

「全选」框的那一档:勾了一部分时既不算勾上、也不算没勾。

indeterminate 是一个显示态,和 checked 正交,不是 checked 的第三个取值 —— 渲染出来是 aria-checked="mixed"。所以「全选」框的写法就是两个值各算各的:全勾时 checked,只勾了一部分时 indeterminate

Base UI 另有一个 parent prop 专做这件事,但它必须待在 CheckboxGroup 里,而本库 没有提升那个部件 —— 上面的 demo 因此自己算这两个值。

⚠️ 没有 group 包着时 parent 不是「不起作用」,而是有害:它会把隐藏 input 的 name 清空(这个字段从 FormData 里整个消失)、跳过 uncheckedValue 那个 input, 并在框上打一个 data-parent=""。别在 group 之外用它。

那条横杠是封装层补的。 base-nova 的盒子只画 data-checked:半选时既不填色、 里面那个对勾还在,看上去和「勾上了」一模一样。所以 Checkbox 在默认 className 里 按 data-indeterminate 填色、藏掉对勾、用 before 画出横杠 —— 盒子里的指示器是部件 自己渲染的,没有可替换的 children,只能走 CSS。这条修复同样落在 DataTable 的表头全选上。

无效态

控件挂 aria-invalid,字段那一列挂 data-invalid —— 两个属性各管一头:

挂在哪效果
Checkboxaria-invalid方块画出 destructive 边框与环
Fielddata-invalid整列文字变 destructive 色,见 Field

勾上之后边框会回到 primary(aria-invalid:aria-checked:border-primary)—— 只有边框: 那圈 ring-destructive/20 没有对应的 aria-checked 规则,所以勾中的无效框是 primary 边框 配 destructive 的环,仍然在提示有问题。错误消息本身交给同一个 Field 里的 FieldError 说。

描述

带描述时用 FieldContent 把「标签 + 描述」装成一列 —— hero 里第一个字段就是这个形态 (那个还带了 defaultChecked):

<Field orientation="horizontal">
  <Checkbox id="newsletter" name="newsletter" />
  <FieldContent>
    <FieldLabel htmlFor="newsletter">乐季简报</FieldLabel>
    <FieldDescription>新的场次开票时给你发一封邮件。</FieldDescription>
  </FieldContent>
</Field>

横排的 Field 就是一行 flex,标签和描述直接摆进去会左右并排;FieldContent 是专门 装这一列文本的弹性列(见 Field)。没有描述就不需要它。

表单

序列化走原生:给了 name,那个隐藏 input 就参与提交 —— 勾着时提交 value(不给 value 就是原生的 "on"),没勾时提交 uncheckedValue,默认什么都不提交,和原生 复选框一致。控件渲染在 <form> 之外时,用 form 指出它属于哪张表单。

禁用

控件自己的 disabled 只管控件的视觉,要让整列文字跟着变灰,按 Field 的状态约定在 Field 上手动挂 data-disabled —— hero 里第二个字段就是这么写的。

readOnly 是另一档:能聚焦、读得到,但勾不动也取消不掉。

分组

一列复选框就是多个 Field 排进一个 FieldGroup;要给整组一个名字,再套一层 FieldSet + FieldLegend(语义是原生 fieldset / legend)—— 半选那个 demo 就是这个结构。分组是布局的事,全部由 Field 家族承担; Base UI 的 CheckboxGroup 本库没有提升。

表格

DataTable 的选择列渲染的就是这个 Checkbox:每行一个, 表头那个「全选」用的正是上面那套 checked + indeterminate 的算法。行里那个没有可见 文字,所以名字走 aria-label

状态与 className

className 通到的是 Base UI 的 slot,所以函数形态成立:(state) => string | undefined。 那个 state 就是 CheckboxState

data-*CheckboxState 里的名字出现时机
data-checked / data-uncheckedchecked勾着 / 没勾。indeterminate 时两个都不出现
data-indeterminateindeterminate半选。封装层的横杠样式挂在这个属性上
data-disableddisableddisabled
data-readonlyreadOnlyreadOnly
data-requiredrequiredrequired

CheckboxState 里还有 Field 那一组(valid / touched / dirty / filled / focused)。它们只在 Base UI 的 Field.Root 里才会动 —— 本库的 Field 不是它 (那是纯 DOM 布局),所以在这里这组值不会变。

根元素带 data-slot="checkbox",里面那个对勾指示器带 data-slot="checkbox-indicator", 需要从外部定位时当选择器用。

Props

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

Prop类型默认值说明
idstring落在隐藏的 <input> 上,FieldLabel htmlFor 指这里
defaultCheckedbooleanfalse非受控初始勾选态
checkedboolean受控勾选态
onCheckedChange(checked: boolean, eventDetails: CheckboxChangeEventDetails) => void勾选变化回调。reason 恒为 'none'cancel() 拒绝这次变更
indeterminatebooleanfalse半选显示态,与 checked 正交
disabledbooleanfalse忽略用户交互
readOnlybooleanfalse只读:不能勾也不能取消
requiredbooleanfalse提交表单前必须勾上
namestring表单字段名,隐藏 input 靠它参与提交
valuestring勾着时提交的值;不给就是原生的 "on"
uncheckedValuestring没勾时提交的值;不给就什么都不提交
formstring拥有这个隐藏 input 的表单 id,控件渲染在表单之外时用
parentbooleanfalseBase UI CheckboxGroup 的能力,本库未提升 —— 在 group 之外打开会清空 name,见半选一节
inputRefRef<HTMLInputElement>拿到那个隐藏的 <input>
nativeButtonbooleanfalserender 换成真 <button> 时才打开;打开后 id 改落在按钮上
renderReactElement | (props, state) => ReactElement换掉渲染的元素
classNamestring | ((state: CheckboxState) => string | undefined)类名。排在封装层那组半选样式之后,冲突时你的赢
其余Base UI Checkbox.Root 的 props(span 的原生属性 + ref透传

三个类型一并导出:CheckboxProps / CheckboxState / CheckboxChangeEventDetails