Dify Button 组件详解:动作语义、loading 与 disabled 状态契约,以及基于 Base UI 的变体体系
本文基于 Dify 仓库中 Button 组件指南 展开,系统讲解 @langgenius/dify-ui 中 Button 组件的设计契约:它如何作为 Base UI Button 的"有主见"(opinionated)包装器,在保留上游按钮语义、焦点与组合行为的前提下,叠加 Dify 的视觉变体、尺寸间距与 loading 状态。读完后,你可以在 Dify 前端代码中正确选择按钮语义(原生按钮、表单提交、链接样式复用)、正确区分 loading 与 disabled 的适用场景,并理解其 cva 变体体系与无障碍(a11y)设计背后的实现原理。
1. 组件定位:一个"有主见"的包装器
Dify UI 的 Button 位于独立 UI 包 packages/dify-ui 中。按照包级文档的说明,该包的绝大多数交互原语都是对 Base UI headless 组件的薄而"有主见"的包装:Dify 负责视觉变体、设计令牌与状态语义,交互与组合行为仍由上游 Base UI 契约主导。Button 正是这一模式的典型代表:
- 上游行为(按钮语义、键盘交互、disabled 行为、组合渲染)由 Base UI 的
Button提供; - Dify 层通过
class-variance-authority(cva)注入variant/size/tone三组视觉变体; - Dify 层额外新增
loading状态,并接管了 Base UI 推荐的focusableWhenDisabled接线。
组件入口为 packages/dify-ui/src/button/index.tsx,对外导出 Button、buttonVariants 与类型 ButtonProps(见 index.tsx#L143-L145)。包的 package.json 通过子路径导出 ./button 提供稳定边界(见 packages/dify-ui/package.json#L24-L27),消费方按子路径引入,而非根桶文件:
import { Button } from '@langgenius/dify-ui/button'
import '@langgenius/dify-ui/styles.css'
styles.css 提供设计令牌与主题变量,消费方应在根样式表中只引入一次(见 包级 README 的 Usage 章节)。Dify Web 应用中已有大量该子路径的实际消费点,例如 web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx/webapp-signin/components/sso-auth.tsx) 等登录、重置密码页面。
2. 按钮语义:何时用 Button,何时不用
2.1 默认渲染原生按钮,提交表单需显式声明
Button 默认渲染原生 <button type="button">——这一点在源码中体现为组件签名里的 type = 'button' 默认参数(见 index.tsx#L112-L127)。当按钮承担表单提交职责时,必须显式传入 type="submit":
<form onSubmit={handleSubmit}>
<Button type="submit">Save</Button>
</form>
单元测试直接锁定了这两个事实:默认属性为 type="button",且允许覆写为 submit(见 tests/index.spec.tsx#L10-L18)。
2.2 禁止用 Button 渲染链接
指南明确:不要把链接渲染成 Button。Base UI 会对渲染元素施加按钮语义、键盘交互和 disabled 行为,把锚点塞进 Button 会与这套语义冲突。正确做法是保留原生 <a> 或路由链接,只复用视觉变体函数 buttonVariants:
<a className={buttonVariants({ variant: 'secondary' })} href="/settings">
Settings
</a>
buttonVariants 是一个导出的 cva 实例,调用方可以传入 variant、size、tone 与 className,因此链接(或任何其他元素)都能获得与按钮一致的视觉语言,而不承担按钮的交互语义。Storybook 中专门有一个 StyledLink 故事来演示与固化这一反模式边界(见 index.stories.tsx#L160-L180)。
2.3 render + nativeButton={false} 的正确用法
只有当某个非按钮元素确实需要按钮语义时,才使用 render 配合 nativeButton={false}。测试用例验证了渲染结果确实是一个 DIV 但保留了 button 的 accessibility role(见 index.spec.tsx#L20-L28)。指南特别强调:这不是"链接模式",链接场景请回到上一节的原生锚点方案。
3. loading 与 disabled:两个描述不同事实的 prop
这是该文档最核心的语义契约。disabled 与 loading 描述的是两个不同的事实,不能混用:
| Prop | 含义 | 默认焦点行为 |
|---|---|---|
disabled |
该动作不可用(availability) | 原生 disabled,移出 Tab 顺序 |
loading |
动作已被触发,正在等待中(pending) | 激活被阻断,但按钮保留焦点 |
3.1 源码中的状态映射
内部实现把这两个状态映射到 Base UI 的交互契约上(见 index.tsx#L125-L130):
<BaseButton
type={type}
className={cn(buttonVariants({ variant, size, tone, className }))}
disabled={disabled || loading}
focusableWhenDisabled={focusableWhenDisabled ?? loading}
{...props}
>
两个关键点:
disabled || loading:loading 期间一律阻断激活,无论disabled是什么;focusableWhenDisabled ?? loading:Dify 的loadingprop "拥有" 这行接线——加载中默认让按钮保持可聚焦。这样用户在触发一个异步动作后不会丢失焦点(比如回车键焦点还停在按钮上),交互连续性更好。
实现细节上,loading 按钮使用的是 aria-disabled 而非原生 disabled 属性来表达"不可用"。aria-disabled 保留可聚焦性,但要求组件自身负责抑制激活行为——这正是 Base UI 对 focusableWhenDisabled 的处理方式。Dify UI 在测试中验证了默认 disabled 走原生语义(无 aria-disabled 属性,见 index.spec.tsx#L32-L38),而 Storybook 的 Loading 故事则通过 play 函数断言:loading 按钮带 aria-disabled="true"、不带 aria-busy、可被聚焦、点击不触发 onClick(见 index.stories.tsx#L88-L115)。
还有一个防"意外提交"的行为级测试:表单内一个 type="submit" loading 的按钮,在输入框中按回车不会触发表单 onSubmit,因为 loading 态会阻断隐式提交(见 index.spec.tsx#L50-L67)。
3.2 调用方守则:pending 状态只写进 loading
- 独立的可可用性条件放在
disabled:
<Button loading={isSaving} disabled={!canSave}>
Save
</Button>
- 不要把同一个 pending 状态重复写进
disabled:
// 错误:loading 已经阻断了激活
<Button loading={isSaving} disabled={isSaving}>Save</Button>
// 错误:disabled 中只保留独立可用性条件
<Button loading={isSaving} disabled={isSaving || !canSave}>Save</Button>
// 正确
<Button loading={isSaving} disabled={!canSave}>Save</Button>
loading与独立的disabled条件同时为 true 是合法的:pending 期间 loading 的焦点策略生效;loading 结束后,剩余可用性条件仍决定按钮是否禁用。- 只有当 loading 按钮应显式退回到原生 disabled 行为、允许离开 Tab 顺序时,才传
focusableWhenDisabled={false}。测试用例覆盖了这个退出开关(见 index.spec.tsx#L40-L47)。
3.3 无障碍:为什么不加 aria-busy
加载中的旋转图标是装饰性的(源码中带 aria-hidden="true",见 index.tsx#L133-L138),不会替代按钮的可见文本。Button 故意不添加 aria-busy:按照 WAI-ARIA 规范,aria-busy 定义的是"内容正在被修改、辅助技术可以推迟获取其内容变化"的元素,它不是"待处理动作状态"的通用替代品。当长任务需要状态播报或进度更新时,由功能层(feature)自行拥有对应的 status、live region 或 progress 组件,而不是依赖按钮。
旋转图标的实现细节同样体现了克制:i-ri-loader-2-line size-3 animate-spin,并带 motion-reduce:animate-none 以尊重用户的"减少动态效果"系统偏好。
4. 内容与间距:Button 拥有子元素间距
Button 负责其直接子元素之间的间距,调用方不应再给图标加 margin 或在调用点补一个常规的 gap-*:
<Button>
<span aria-hidden="true" className="i-ri-rocket-line size-4" />
Launch
</Button>
从源码的 cva 定义可以精确还原指南给出的间距数值(gap-* 为 Tailwind 间距刻度,1 = 4px):
| size | 类名 | 实际 gap |
|---|---|---|
small |
gap-1(index.tsx#L46) |
4px |
small + primary |
compoundVariants 覆写为 gap-0.75(index.tsx#L55-L60) |
3px |
medium |
gap-1(index.tsx#L47) |
4px |
large |
gap-1.5(index.tsx#L48) |
6px |
即:常规(medium)与 large 使用 4px / 6px 间距;small 对 primary 变体用 3px、其余变体用 4px——与指南描述完全一致。只有在有文档记录的布局例外时,才用 className 覆写间距。
5. 变体体系:variant / size / tone 三轴
源码中 buttonVariants 的完整定义见 index.tsx#L9-L104。三轴结构如下:
- variant(6 种):
primary、secondary、secondary-accent、tertiary、ghost、ghost-accent; - size(3 种):
small(h-6 / 12px 高、text-xs)、medium(h-8、13px 字号)、large(h-9、text-sm、font-semibold); - tone(2 种):
default与destructive。
默认组合为 variant: 'secondary'、size: 'medium'、tone: 'default'(index.tsx#L98-L102)。
值得注意的实现手法:所有视觉状态(normal / hover / disabled)都挂在同一组令牌前缀上(components-button-*),并且 disabled 态使用 Tailwind 的 data-disabled: 变体而不是硬编码 disabled:——这与第 3 节中 aria-disabled 而非原生 disabled 的 loading 方案是一致的:样式层只依赖 data-disabled 这个统一事实来源,与底层究竟是原生 disabled 还是 aria-disabled 解耦。tone: 'destructive' 通过 compoundVariants 与 primary / secondary / tertiary / ghost 四个变体交叉生成红色系令牌(index.tsx#L55-L97)。primary + small 还有一个复合覆写:更小的 gap-0.75 px-2,用于紧凑场景。
公共基类统一处理了布局与焦点环:inline-flex items-center justify-center overflow-hidden whitespace-nowrap focus-visible:ring-2 ... data-disabled:cursor-not-allowed(index.tsx#L10),即焦点可见性由组件保证,调用方无需重复。
6. 组合与相邻组件的分工
指南给出了三条组件选择边界:
- 有可见文本标签的动作 →
Button; - 纯图标命令(无可见文本) →
IconButton。IconButton额外要求恰好一个可访问名称来源(aria-label或aria-labelledby),且装饰性字形必须由aria-hidden的子元素携带;tooltip 只是视觉增强,不是可访问名称。图标 + 文字的场景仍归Button("including buttons with leading or trailing icons"); - 持久按压状态 →
Toggle;激活即跳转 URL → 原生链接。
当 Toggle、Menu、Popover 等原语拥有交互状态时,应保持这些原语在外层、通过 render prop 组合 IconButton(见 icon-button/README.md 的 Composition 章节),而不是把状态镜像到图标按钮上。Button 的 ref 转发能力(测试见 index.spec.tsx#L78-L92)也支持这种组合场景。命名与描述的系统性契约详见 docs/accessible-names-and-descriptions.md。
7. 开发验证:如何运行本组件的测试与 Story
dify-ui 包提供以下脚本(见 package.json#L165-L172):
pnpm --filter @langgenius/dify-ui test:以浏览器模式(vitest + playwright)运行单元测试,例如 button 的 spec 文件;pnpm --filter @langgenius/dify-ui storybook:在 6006 端口启动 Storybook,浏览 index.stories.tsx 中的全部 11 个故事(Default / Secondary / SecondaryAccent / Ghost / GhostAccent / Tertiary / Disabled / Loading / Destructive / WithIcon / SmallSize / LargeSize / StyledLink),其中Loading与StyledLink故事自带play交互断言,可自动验证焦点与 aria 行为。
8. 要点回顾
Button默认type="button";表单提交必须显式type="submit";链接保持原生<a>+buttonVariants,不要塞进Button。disabled表达"不可用",loading表达"已触发、待完成";pending 状态只写loading,可用性条件只写disabled,两者正交。- loading 按钮默认走
aria-disabled+ 保留焦点(focusableWhenDisabled ?? loading),可聚焦性可通过focusableWhenDisabled={false}显式退出;组件不加aria-busy,播报职责归功能层。 - 子元素间距由组件按 size/variant 统一管理(3–6px),调用方不补
gap-*。 - 视觉三轴
variant × size × tone全部经由导出的cva实例buttonVariants生成,disabled 样式统一挂在data-disabled:变体上。 - 行为正确性由浏览器模式单元测试(tests/index.spec.tsx)与 Storybook play 断言双重固化。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00