Cadenza
EN

会保持按下的按钮 —— 真的 button + aria-pressed,名字走 children,variant/size 是 shadcn 的 cva 旋钮

Base UI 的 Toggle 装进 shadcn 的 base-nova 皮肤。封装层没有加行为 —— 每个 prop 都是一次普通展开就到底下的部件;它只做两件事:把按下悬停在视觉上分开 (见状态与 className),以及在类型上动两处 —— 把 className 窄化成字符串,并保住 value 那个 Value 泛型不塌成 string

它是一个真的 <button aria-pressed>:根元素就是那颗按钮,键盘激活(Enter / Space)、disabledhover / focus-visible 伪类全部是原生的。

使用

import { Toggle } from '@gedatou/cadenza-ui'
<Toggle aria-label="加粗" defaultPressed>
  <IconBold />
</Toggle>

标签

名字走 children,因为按钮的可访问名就是它自己的内容 —— 所以这里 没有 Checkbox / Switch 那条 id + FieldLabel htmlFor 的通道,封装层也不需要提供:那两个是 box-only 的 (根元素是方块 / 轨道,文字塞不进去,只能挂在同级的 label 上),而 Toggle 的文字本来 就在按钮里。有可见文字就直接写进 children,图标和文字并排也一样(hero 里第三颗 就是)。

只有图标、没有可见文字时补 aria-label —— 和 Buttonicon-* 档同一条规矩。

反过来说,别给它挂 FieldLabel:那会盖掉按钮自己的文字当无障碍名, Field 那页的通道表也因此不收 Toggle。

变体与尺寸

variantsizeshadcn 的 cva 旋钮,不是 Base UI 的 —— Toggle 上没有这两个 prop。三档高度分别是 h-7 / h-8 / h-9,并各配一个同尺寸的 min-w-*,所以纯图标 形态天然是方的。图标自己不用调尺寸:cva 里给未显式指定尺寸的 svg 兜了 size-4size="sm" 时是 size-3.5)。

两个 variant 的差别在没按下的时候:default 是全透明的,outline 则不论按没按都有 一圈边框。

toggleVariants 也一并导出,需要把别的元素画成 toggle 的样子时直接调用。

受控

受控三件套是 pressed / defaultPressed / onPressedChange。回调的第二参是真的 ChangeEventDetails(导出名 ToggleChangeEventDetails):reason 恒为 'none' (上游给 toggle 就只派了这一种,别指望靠它分辨来源),cancel() 是认数的 —— 调了它,组件内部那份状态就不推进:

<Toggle
  aria-label="加粗"
  pressed={bold}
  onPressedChange={(pressed, eventDetails) => {
    if (locked) {
      eventDetails.cancel() // 内部状态不推进,按钮留在原处
      return
    }
    setBold(pressed)
  }}
/>

受控时显示的本来就是你的 pressedcancel() 拦下的是组件内部那份看不见的状态; 只给 defaultPressed 时,那份状态就是显示的那份,于是 cancel() 直接把这一下按压 拦住。这条和 Checkbox / Switch 完全同源。

什么时候用 Toggle

单个 Toggle 是按钮,不是表单字段:它没有 namevalue 也不渲染到 DOM 上, 提交表单时什么都不带;Base UI 还把 type 写死成 "button"typeform 传了 都不生效),所以放进 <form> 里点它也不会误提交。

控件读作典型场景
Toggle立即生效的模式开关,按下 = 这个模式开着工具栏的加粗 / 斜体、显示网格线
Switch立即生效的设置项设置页的「深色模式」
Checkbox待提交的勾选「同意条款」、列表多选

判断方法:这一下改的是当前视图的模式,而且状态不会跟着表单走,用 Toggle;状态属于 一张表单(要序列化、要 required、可能要半选),用 Checkbox;是一条设置开关,用 Switch。

若干个相关的 toggle 共享一个值时用 ToggleGroup —— value 就是在那里派上用场的(它标识这颗 toggle 在组的 value 数组里的身份)。

状态与 className

className 只收字符串:vendored 层把它灌进 cva,函数会被静默丢掉 —— 元素照样 拿到变体类,只有你写的那一份凭空消失。类型因此把这条路封了(和 Button 同一条路、同一处窄化)。

要按状态换样式,用 Base UI 写在按钮上的 data-*,Tailwind 直接当变体写:

<Toggle aria-label="加粗" className="data-pressed:text-primary">
  <IconBold />
</Toggle>
data-*ToggleState 里的名字出现时机
data-pressedpressed按下。同一时刻 aria-pressed="true";cva 的底色挂在 aria-pressed 上,封装层那组挂在 data-pressed 上,两条选择器同时命中
data-disableddisableddisabled

ToggleState 类型照常导出,但因为 className 是字符串,这里拿不到函数形态的出口。)

悬停、按下瞬间、焦点没有 data 属性 —— 原生 <button> 上直接用 hover: / active: / focus-visible: / disabled: 伪类,覆盖全部输入模态。根元素带 data-slot="toggle", 需要从外部定位时当选择器用。

按下和悬停不能长一个样,这是封装层唯一的一笔。 base-nova 两边都画 bg-muted, 而这个主题里 --muted / --accent / --secondary 是同一个值,换 token 分不开。 所以封装层:没按下时的 hover 降成半强度(同变体同 twMerge 组,替换掉 vendored 的 hover:bg-muted 而不是叠加);按下保持实心,并用 data-pressed:hover: 重述一遍, 免得已按下的按钮被那层淡色冲掉;按下另加一圈 inset ring —— 灰阶里单靠填充强度只差 约 1.5% 亮度,这圈环才是一眼能看出来的那一下,而且调用方改掉背景色它也还在。

Props

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

Prop类型默认值说明
defaultPressedbooleanfalse非受控初始按下态
pressedboolean受控按下态
onPressedChange(pressed: boolean, eventDetails: ToggleChangeEventDetails) => void按下态变化回调。reason 恒为 'none'cancel() 拒绝这次变更
disabledbooleanfalse渲染原生 disabled:不响应交互、不可聚焦
valueValue extends string(默认 string只在 ToggleGroup 里有意义:它是这颗 toggle 在组 value 数组里的身份。独立使用时不用给,也不会出现在 DOM 上
variant'default' | 'outline''default'shadcn 的 cva 旋钮
size'default' | 'sm' | 'lg''default'同上;h-8 / h-7 / h-9
nativeButtonbooleantrue关掉它才能用 render 换成非 <button> 元素
renderReactElement | (props, state) => ReactElement换掉渲染的元素
childrenReactNode内容,同时就是它的无障碍名
classNamestring类名。只收字符串,见状态与 className
其余Base UI Toggle 的 props(button 的原生属性 + ref,含 aria-label透传。typeform 除外:Base UI 把它们吞掉,按钮恒为 type="button"

ToggleProps / ToggleState / ToggleChangeEventDetails 三个类型与 toggleVariants 一并导出。