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 并没有落在看上去的
那个地方:
id 落在隐藏的那个 <input> 上(可见的方块自己留一个生成的 id)。于是原生
<label for> 把点击转发给这个 input —— 切换正是它完成的;与此同时 Base UI 反查
input.labels,把 label 的 id 镜像到方块上当 aria-labelledby —— 屏幕阅读器读到的
名字由此而来。不需要第二个 aria-label,封装层也不用补任何点击接线。
压根没有可见文字时(比如表格里每行一个)才给 aria-label。
全库四条标签通道的总表在 Field,box-only 是其中一条, Switch 和 RadioGroupItem 与它同类。
受控
受控三件套是 checked / defaultChecked / onCheckedChange:
回调的第二参是真的 ChangeEventDetails(导出名 CheckboxChangeEventDetails):
reason恒为'none'—— 上游给复选框就没派第二种 reason,别指望靠它分辨来源。cancel()会被尊重:调了它,组件内部那份状态就不推进。
受控时显示的本来就是你的 checked,cancel() 拦下的是组件内部那份看不见的状态;
非受控(只给 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 —— 两个属性各管一头:
勾上之后边框会回到 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:
CheckboxState 里还有 Field 那一组(valid / touched / dirty / filled /
focused)。它们只在 Base UI 的 Field.Root 里才会动 —— 本库的 Field 不是它
(那是纯 DOM 布局),所以在这里这组值不会变。
根元素带 data-slot="checkbox",里面那个对勾指示器带 data-slot="checkbox-indicator",
需要从外部定位时当选择器用。
Props
顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className。
三个类型一并导出:CheckboxProps / CheckboxState / CheckboxChangeEventDetails。