Cadenza
EN

Base UI Button 的封装 —— cva 变体与尺寸,加上一套 Base UI 没有的 pending 契约

按钮。底下是 Base UI 的 Button,就是一个原生 <button>:事件是 onClick, 禁用是 disabled,键盘激活由浏览器负责。外观是 shadcn 的 buttonVariants(cva): 六个变体(default 是主操作,其余按语义递弱,destructive 只给不可逆操作)、 四档尺寸加四档纯图标尺寸。封装层自己加的只有 pending 那一套(见进行中)。

使用

import { Button, buttonVariants, LinkButton } from '@gedatou/cadenza-ui'
<Button variant="outline">保存</Button>

光标

Tailwind v4 去掉了 preflight 里给按钮的 cursor: pointer,shadcn 跟着换成了默认箭头。本库把它放回去了 ——styles.css 里的全局规则,不用你贴任何 CSS,也没有「初始化时开一下」这种开关。 匹配的是 role 而不只是标签名,因为 Base UI 把非按钮控件渲染成 span (Checkbox / Switch / RadioGroupItem 这三个 box-only 控件的方框就是整个点击区)。

三态是全库统一的约定:

状态光标
可点pointer
禁用not-allowed
进行中wait

进行中是 wait——pending 是「在忙」不是「禁止」,虽然它也带 aria-disabled: styles.css 里 data-pending 的规则排在禁用规则之后、在同一层特异性上专门胜出(全局规则, 无需自己写)。两条规则都连子树一起管,所以禁用容器里的图标不会再单独给出文本光标。

与光标约定配套的还有一条:pending / 禁用状态及其子树内,hover: 反馈一律不触发 ——hover 是「你可以对我做点什么」的 affordance,和 wait / not-allowed 光标同框 就是自相矛盾。:hover 本身拦不住(它沿祖先链一路上走),所以 styles.css 重定义了 hover 变体加守卫,对所有 hover: 工具类生效(包括 vendored 的类和你自己写的), 无需逐处处理。

尺寸

四档高度,加四档只放图标的方形档:

icon-* 是方形档,只放一个图标 —— 必给 aria-label:按钮没有可见文字时, 无障碍名只能从这里来。

带图标

图标和文字同框时,给图标挂 data-icon="inline-start"data-icon="inline-end" ——它那一侧的内边距会收窄一档:

<Button variant="outline">
  <IconPlus data-icon="inline-start" />
  新建
</Button>
 
<Button variant="outline">
  下一步
  <IconArrowRight data-icon="inline-end" />
</Button>

图标自带视觉留白,不收窄就显得偏松(default / lg 收到 2xs / sm 收到 1.5)。 图标的尺寸也跟着档位走:没写 size-* 的 svg 由 cva 定尺寸(默认 size-4xssize-3smsize-3.5),自己写了 size-* 就以你的为准。纯图标按钮走 icon-* 档,不需要这个属性,见尺寸

圆角

className 里加 rounded-full 就是胶囊按钮 —— cn 让后写的圆角赢:

<Button className="rounded-full">保存</Button>

进行中

pending 表示这个按钮触发的动作还在途中(对齐 React 的 useTransition / useFormStatus 词汇)。Base UI 没有这个概念, 所以三件事由封装层自己接上:

做什么怎么做的不做会怎样
保持可聚焦disabled + focusableWhenDisabled,Base UI 于是写 aria-disabled 而不是原生 disabled焦点在动作中途凭空丢失
停止提交pending 期间 type="submit" 改写成 "button"在兄弟输入框里按回车会重复提交同一张表单
说出来aria-busy辅助技术只知道它禁用了,不知道是在忙

按压和键盘激活由 Base UI 的 disabled 一并吞掉(天然防双击)。

比 React Aria 那版少一件事:状态切换时不再有 assertive 补播报。它需要一个 live-region 单例,而且没有新内容可播——本库的 Spinner 是装饰性的,基座也不往 DOM 里 塞任何语言的文案。

默认视觉就是全库同一个 LoadingOverlay真正的覆盖层——标签留在原地被磨砂融开、隐约可见、继续撑宽度、仍是无障碍名, 什么都不替换;Spinnerforeground 色浮在纱上 (装饰性 aria-hidden;不用 currentColor 是因为会和纱下 的标签同色伪装)。按钮的磨砂是内容滤镜而非 backdrop:在 32px 高的小亮元素上, backdrop 模糊核会沿边角涂出晕染(真机放大验证过的物理事实),改为模糊标签本身 + 平面暗纱由宿主裁切成形,磨砂观感相同、边缘与背景像素级贴合;暗纱浓度、过渡、 wait 光标、data-loading 钩子与表格/下拉完全同源。宽度全程不变,邻居不位移; 150ms 交叉淡入进出两向生效(motion-reduce 下豁免)。没有内建防闪延迟——快操作 (比如 200ms 就返回的请求)该不该出 spinner 由你决定,需要就延迟置起 pending

这套结构只在传了 pending 时挂载(传 false 也算——机制留在 DOM 里退出动画才放得完);没用这个特性的按钮渲染裸 children,零开销。 想让宽度真正纹丝不动,pending 期间别换文案——spinner 已经在说话了。

自定义 pending

不传 pending 就什么都不注入,children 完全归你 —— 要自己画进行中的样子,传 disabled,别传 pending。下例是经典的「spinner 排在文案旁 + 换文案」形态; 注意内容变化会改变宽度,这是接管的代价(默认组合的宽度恒定靠的正是覆盖而不替换)。 代价还有一条:wait 光标和 hover 豁免跟着 data-pending 走, 这条路上没有它,拿到的是禁用态的 not-allowed

链接按钮

LinkButton 是穿着按钮衣服的 <a>:有 href、支持 target,变体和尺寸同 Button。 底下是 Base UI 的 Button 走 nativeButton={false},所以禁用时该有的都有: aria-disabledtabIndex={-1}、激活被吞掉。href 还会被整个摘掉 —— 光有 aria-disabled 的链接仍然能从右键菜单打开,而没有 href<a> 既不可聚焦也 不可导航。:disabled 在锚元素上永远不会命中,置灰靠 data-disabled(封装已接好):

它仍然是个链接。 nativeButton={false} 这条路上 Base UI 会给元素挂 role="button",辅助技术就会把它读成按钮、不再收进链接列表 —— shadcn 母版为此 干脆禁掉了 <Button render={<a />} nativeButton={false} /> 这种写法。封装层在 render 元素上传 role={undefined} 把它摘掉了(render 元素的 props 赢下合并), 所以这里拿到的是「链接语义 + 完整禁用契约」,不用二选一。

真要读作按钮(一个不导航、只触发动作的 <a>)就自己传回来:role="button" 排在后面,会盖过封装层这一手。

只要变体外观、其余全部自理时,用 buttonVariants 配普通 <a>

<a className={buttonVariants({ variant: 'outline' })} href="/docs">文档</a>

状态与 className

按钮的交互状态走 CSS 伪类hover: / active: / focus-visible: / :disabledbuttonVariants 自己就是这么写的。原生 <button> 上这些伪类覆盖了全部输入模态, 不需要 render props 那一层。要按状态换内容(不只是样式)就自己拿 state 判断, 自定义 pending 就是这个形态。

className 只收字符串,没有函数形态 —— vendored 层把它灌进 cva,函数会被丢掉, 所以类型上就把这条路封了(见 Props)。

状态属性只有两个,都在根元素上:

状态出现时机谁有
data-disableddisabledButton / LinkButton
data-pendingpending只有 Button

交互态没有 data 属性:Base UI 的 Button 只暴露 disabled,hover / active / focus-visible 直接用 CSS 伪类,在原生 <button> 上它们覆盖了全部输入模态。 光标与 hover 守卫是全库统一的约定,见光标

data-slot是什么
button根元素(ButtonLinkButton 都是这个)
button-labelpending 组合里被磨砂的那层标签壳,只在传了 pending 时存在

覆盖层与 Spinner 各自带着自己的 loading-overlay / spinner,需要从外部定位时 当选择器用。

Props

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

Button

Prop类型默认值说明
onClick(e: MouseEvent) => void原生点击;disabled(含 pending)时不触发
disabledbooleanfalse禁用(渲染原生 disabled 属性)
pendingbooleanfalse进行中:阻断激活但保持可聚焦、打 data-pending / aria-busy。默认视觉是同一个 LoadingOverlay(真覆盖:标签留在暗纱下,Spinner 浮在纱上;按钮尺度不模糊),见进行中
type'button' | 'submit' | 'reset''button'默认不提交表单(Base UI 的默认,封装层不再用 undefined 把它顶掉)。要提交就写 type="submit"pending 期间它会被改写成 'button',免得兄弟输入框里一个回车提交掉这个按钮正在提交的表单
variant'default' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'link''default'变体
size'xs' | 'sm' | 'default' | 'lg' | 'icon-xs' | 'icon-sm' | 'icon' | 'icon-lg''default'尺寸
focusableWhenDisabledbooleanfalse禁用时仍可聚焦(改用 aria-disabled)。pending 自己就是这么实现的
nativeButtonbooleantrue关掉它才能用 render 换成非 <button> 元素(LinkButton 就是这么做的,代价见链接按钮
renderReactElement | (props, state) => ReactElementBase UI 的元素替换。优先函数形态:给元素形态时 Base UI 用字符串拼接合并 className,函数 className 会被 stringify 掉
childrenReactNode内容
classNamestring类名。只收字符串 —— vendored 层把它灌进 cva,函数会被静默丢掉,所以类型上把这条路封了
其余Base UI Button 的 props透传

LinkButton

Prop类型默认值说明
hrefstring目标地址;disabled 时整个属性不渲染
target / rel原生 <a> 属性透传
disabledbooleanfalse禁用:摘掉 href、挂 aria-disabledtabIndex={-1}、吞掉激活、视觉置灰
variant / sizeButton'default'同一套 buttonVariants
classNamestring类名(同上,只收字符串)

buttonVariants 也单独导出 —— 需要把别的元素画成按钮的样子(比如 fumadocs 的卡片链接) 时直接调用:buttonVariants({ variant: 'outline', size: 'sm', className: '...' })ButtonProps / LinkButtonProps 两个类型一并导出。