Cadenza
EN

共享一个值的一组 toggle —— value 永远是数组,orientation 交回 Base UI,视觉与方向键不再打架

一组相关的 Toggle,共享同一个值。组合就是全部 API: ToggleGroup 里放若干 ToggleGroupItem,每一项的 value 就是它在组里的身份。

value 永远是数组,单选也不例外 —— multiple(默认 false)决定的只是 「同时能有几项在里面」,不是值的形状。所以单选时 defaultValue={['start']}, 拿到的回调参数是 ['center'] 这样的单元素数组;再点一次已经按下的那项会把它松开, 数组变成 [](一项都不选是合法状态)。

根元素是 role="group",所以 aria-label 必给 —— 它是这组按钮的无障碍名; 组里每一项仍然各是一个 <button aria-pressed>,只有图标时照旧各自补 aria-label

使用

import { ToggleGroup, ToggleGroupItem } from '@gedatou/cadenza-ui'
<ToggleGroup aria-label="对齐方式" defaultValue={['start']}>
  <ToggleGroupItem aria-label="左对齐" value="start"><IconAlignLeft /></ToggleGroupItem>
  <ToggleGroupItem aria-label="居中" value="center"><IconAlignCenter /></ToggleGroupItem>
</ToggleGroup>

组成

两个部件,没有第三个:

ToggleGroup
├── ToggleGroupItem
└── ToggleGroupItem

组内请用 ToggleGroupItem 而不是 Toggle:后者也读得到组的 context、功能上照样切换, 但它拿不到分段、间距、圆角合并那一整组样式。

变体与尺寸

variantsize 写在上,经 context 发给每一项,这样整条控件不会长短不一。 优先级是最普通的那种:项 → 组 → 库的默认值。组上设一次,整条控件就统一了; 个别项要与众不同,在那一项上写就行,会盖过组。

(转正之前的 vendored 版本是反的:它读 context.size || size,而项自己的 size 有默认值 'default',所以组一旦设了那个旋钮,项就再也盖不过去。封装层把项的默认值撤到解析那一步, 才换回正常的优先级。)

两档 variantdefault / outline)和三档 size 长什么样,见 Toggle 的变体与尺寸—— 同一套 toggleVariants, 组只负责把它下发下去。

间距

spacing 是项与项之间的间距,单位是 spacing 单位(默认 2,即 gap-2)。 0 是一个开关而不只是「间距为零」:它同时把 data-spacing="0" 写到组和每一项上, 于是中间项去掉圆角、首尾各补回外侧那一边的圆角,outline 变体里相邻的两条边框合并成 一条 —— 一条真正的分段控件。焦点态自带 z-10,焦点环不会被隔壁那项压住。

间距本身是靠一个 --gap CSS 变量下发的,封装层把它合并进你传的 style(函数形态 也认),而不是整份替换 —— 否则组上写一个 style 就会把 gap 抹平,data-spacing 却还 报着旧值。

方向

orientation="vertical" 时组竖排,方向键同时换成上下 —— 这一条现在是一件事, 以前是两件:

vendored 那版自己声明了一个 orientation prop,拿它画 flex 方向、也自己写 data-orientation却从不把它传给 Base UI。于是 Base UI 的 composite 一直按 默认的横向导航:一个看着是竖的组,方向键还是走 ← / →,↑ / ↓ 什么都不做。 封装层这里是自建组合而不是转出,orientation 直接交给 Base UI,data-orientation 由它按自己的 state 写,布局类读的正是同一个属性 —— 视觉和键盘不可能再对不上。

完整按键表见键盘交互

多选

multiple 打开后同时可以按下多项,数组按点击顺序增删。值的形状不变 —— 还是那个数组, 只是里面同时能有几项变了。上面这个 demo 顺带演示了受控写法。

受控

受控三件套是 value / defaultValue / onValueChange。回调第二参是真的 ChangeEventDetails(导出名 ToggleGroupChangeEventDetails):reason 恒为 'none'cancel() 认数 —— 调了它,组内部那份值就不推进。

值的类型是 Value extends string只能是字符串(不像 RadioGroup 还收 number)。受控时 Value 直接从你的 state 推断出来:

type Mark = 'bold' | 'italic' | 'underline'
const [marks, setMarks] = useState<Mark[]>(['bold'])
// value 收 Mark[],onValueChange 交回来的也是 Mark[]

只是别指望它去校验每一项:ToggleGroupItemvalue 是各自推断的,组的泛型管不到 它们 —— 拼错一个字母 TypeScript 不会拦,回调里就多出一个业务代码不认识的值。

什么时候用 ToggleGroup

单选的 ToggleGroup 语义上依然是一组按钮role="group" + 各自的 aria-pressed), 不是 radiogroup:它表达「当前生效的是哪个模式」,而且允许一个都不选。要表达「这道题 必须选一个、而且要跟着表单提交」,用 RadioGroup

几个 toggle 彼此无关(加粗、显示网格线、静音)时不必组队 —— 各写各的 Toggle 就好;组的意义是共享一个值和一次 Tab 停留。

状态与 className

className 在这里函数形态是有效的 —— 和 Toggle 不同。区别在路由:Toggle 把你的 className 交给 cva(内部是 clsx,遇函数静默丢掉), 而这两个部件只用 cva 取变体类,你的 className 是经本库的 cn 合并的 —— cn 遇到函数 会返回一个函数、等 state 到了再解析。所以 (state) => string 照常可用, 下面两张表里的 data-* 也照常可用,两条路都通。

ToggleGroup

data-*出现时机
data-orientation恒在,值是 horizontal / vertical(Base UI 按自己的 state 写,布局类读的就是它)
data-multiplemultiple
data-disabled整组 disabled
data-spacing恒在,值是 spacing(封装层写的,项靠 group-data-* 读它)
data-variant / data-size只在组上设了对应的 prop 时出现(封装层写的)

ToggleGroupItem

data-*出现时机
data-pressed这一项的 value 在组的数组里。同时 aria-pressed="true",默认底色由后者画
data-disabled单项 disabled,或整组 disabled
data-variant / data-size / data-spacing恒在,是最终生效的那一档(封装层写的)

根元素带 data-slot="toggle-group",每一项带 data-slot="toggle-group-item"ToggleGroupStatedisabled / multiple / orientation)与 ToggleGroupItemState 就是两处函数形态 className 各自收到的那个参数。

键盘交互

按键行为
/ 横向(默认)时移动到相邻项;RTL 下左右互换
/ orientation="vertical" 时移动到相邻项
Home / End跳到第一项 / 最后一项(Base UI 给这个组开了 enableHomeAndEndKeys
Tab整组只占一个停留点(roving focus),再按一次就离开这个组
Enter / Space切换当前焦点所在那项

方向键走到头会绕回另一端,这是 Base UI 的 loopFocus(默认 true),不想要就传 false。禁用的项在以上所有导航里都会被跳过 —— 它渲染的是原生 disabled,本来就拿 不到焦点。

Props

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

ToggleGroup

Prop类型默认值说明
aria-labelstring这组按钮的无障碍名,必给(根元素是 role="group"
defaultValuereadonly Value[][]非受控初始值。数组,单选时放一项
valuereadonly Value[]受控值
onValueChange(value: Value[], eventDetails: ToggleGroupChangeEventDetails) => void值变化回调。reason 恒为 'none'cancel() 拒绝这次变更
multiplebooleanfalse同时能否按下多项。不改变值的形状
disabledbooleanfalse整组禁用,下发给每一项
orientation'horizontal' | 'vertical''horizontal'排列方向,同时决定方向键的轴
loopFocusbooleantrue方向键到头是否绕回另一端
variant'default' | 'outline'下发给每一项;不给时项按自己的(默认 default
size'default' | 'sm' | 'lg'同上
spacingnumber2项间距(spacing 单位)。0 融成一条分段控件
renderReactElement | (props, state) => ReactElement换掉渲染的元素
classNamestring | ((state: ToggleGroupState) => string | undefined)类名。函数形态有效(走 cn 不走 cva),见状态与 className
其余Base UI ToggleGroup 的 props(div 的原生属性 + ref透传

ToggleGroupItem

Prop类型默认值说明
valueValue这一项在组 value 数组里的身份。类型上可选,实际必给:组一旦有 value / defaultValue,缺 value 的项会在开发环境报错,Base UI 会拿一个生成的 id 顶上,数组里于是出现看不懂的值
onPressedChange(pressed: boolean, eventDetails: ToggleChangeEventDetails) => void仍然会触发,且先于组的 onValueChange;两者共享同一个 eventDetails,所以在这里 cancel() 连组的值变更一起否决
disabledbooleanfalse单项禁用:原生 disabled + aria-disabled,方向键跳过
pressed / defaultPressedboolean组里不生效:按下与否由组的 value 数组算出来
variant'default' | 'outline''default'只在组上没设 variant 时才生效
size'default' | 'sm' | 'lg''default'只在组上没设 size 时才生效
nativeButtonbooleantrue关掉它才能用 render 换成非 <button> 元素
renderReactElement | (props, state) => ReactElement换掉渲染的元素
childrenReactNode内容,同时就是这一项的无障碍名(纯图标时补 aria-label
classNamestring | ((state: ToggleGroupItemState) => string | undefined)类名。函数形态有效;排在变体类之后,冲突时你的赢
其余Base UI Toggle 的 props(button 的原生属性 + ref透传;type 恒为 "button"

ToggleGroupProps / ToggleGroupItemProps / ToggleGroupState / ToggleGroupItemState / ToggleGroupChangeEventDetails 五个类型一并导出。