Dify UI 选择型组件契约指南:RadioGroup、Combobox 与 Select 如何选、如何用、如何保持类型安全
【免费下载链接】difyBuild Agentic workflows, RAG pipelines, with rich AI model and tool support on one collaborative workspace. Deploy on cloud, VPC, or self-hosted, so teams move from prototype to production without rebuilding the stack.项目地址: https://gitcode.com/GitHub_Trending/di/dify
本文基于 Dify 前端组件库@langgenius/dify-ui的官方契约文档 Selection 展开,系统讲解该组件库中六类"选择型"原语(RadioGroup、SegmentedControl、Tabs、Autocomplete、Combobox、Select)的选择规则、值类型泛型契约、弹出层宽度约束,以及Radio家族(Radio/RadioItem/RadioControl)的解剖结构。读完后,你将能够在 Dify 前端代码中按值语义正确选型,写出类型不丢失、行为可预测的选择控件。
从"值与交互契约"出发选型
Selection 文档 开篇给出了一条总原则:先从"值"和"交互"两个维度选择选择原语,再通过其公开 API 完整保留调用方的领域值类型。这不是样式层面的选择,而是数据流层面的决策——选错原语往往意味着值语义(单选/多选、持久字段/视图模式、开放文本/封闭集合)与业务模型错位。
文档给出的六类原语及其适用场景如下:
RadioGroup:从一组可见选项中选择一个持久字段值。每个Radio或RadioItem必须隶属于一个组;禁止渲染游离的、不在组内的 radio。SegmentedControl:选择一个模式、过滤器或视图。它遵循 radio-group 语义:已激活项不能被再次点击取消(不可 toggle off)、Tab键聚焦到当前选中项、方向键在项之间移动并选中。Tabs:选择一个面板,提供tablist/tabpanelARIA 语义。Autocomplete:接受自由文本,可附带建议项。Combobox:从可搜索集合中选择一个或多个值,且记住所选值。Select:从一个封闭、可快速扫视的列表中选取,无需文本输入。
源码印证:SegmentedControl 复用 RadioGroup 内核
"SegmentedControl 遵循 radio-group 语义"在源码中不是文档修辞,而是实现事实。SegmentedControl 实现 中,SegmentedControl本身就是 Base UIRadioGroup的薄包装:
function SegmentedControl<Value = string>({ className, ...props }: SegmentedControlProps<Value>) { return ( <BaseRadioGroup<Value> className={cn( 'inline-flex items-center gap-px rounded-[10px] bg-components-segmented-control-bg-normal p-0.5', className, )} {...props} /> ) }而SegmentedControlItem内部渲染的是BaseRadio.Root(源码 L47-L64)。这解释了为什么它能天然获得"选中项不可取消、Tab 键进入选中项、方向键移动并选中"的行为——这些行为由底层 radio-group 内核统一保证。此外,SegmentedControl还通过SegmentedControlSelectionProps强制value与defaultValue二选一(源码 L10-L18),在类型层面杜绝受控/非受控状态混用。
Tabs 实现 则更薄:Tabs = BaseTabs.Root直接透传,TabsList/TabsTab/TabsPanel/TabsIndicator仅叠加 Dify 设计令牌的样式类,语义完全继承 Base UI 的tablist/tabpanel。
多选 Combobox 的 chips 组合方式
文档特别指出,多选 Combobox 遵循 Base UI 的 chips 组合模式:chips 与输入框共享同一个 input group,chips 可换行(wrap),整个组随内容垂直生长。在 Combobox 源码 中可以看到对应实现:ComboboxChips使用flex flex-wrap items-center,ComboboxChip使用inline-flex ... rounded-md渲染单个选中项,并与ComboboxChipRemove配合实现逐项移除。
Radio 家族解剖与单入口导入
文档对Radio家族的用法给出了明确的分工规则:
- 使用
Radio表示默认外观的 radio; - 当自定义内容本身就构成一个 radio item时使用
RadioItem,并在其内部放置RadioControl作为 Dify UI 的视觉指示器; RadioControl是视觉部件,不是独立的 radio,不能脱离上下文单独使用。
从 RadioGroup 源码 看,这一分工有清晰的类型边界:
RadioGroup<Value = string>包装 Base UIRadioGroup,附加flex items-center gap-2基础布局(L10-L16);RadioItem<Value>直接映射BaseRadio.Root,是"容器"角色(L18-L24);RadioControl映射BaseRadio.Indicator,通过data-checked:border-[5px]、data-disabled:*等状态类实现选中/禁用态的视觉(L33-L52);Radio则省略了children(Omit<RadioItemProps<Value>, 'children'>),保证它只能作为无内容的纯指示点使用(L54-L74)。
该模块还额外导出RadioSkeleton(L78-L85),用于加载占位。
文档要求:从唯一的公开子路径导入完整家族:
import { Radio, RadioControl, RadioGroup, RadioItem } from '@langgenius/dify-ui/radio-group'这与 package.json 中的exports声明一致:./radio-group子路径同时指向类型与运行时入口。组件库 README 强调包内"刻意没有根 barrel 导出",所有原语都必须通过各自的公开子路径导入(./select、./combobox、./autocomplete等),这保证了各组件模块边界的稳定性与按需加载。
类型化值:绝不把领域值拓宽为string
这是 Selection 文档最核心的契约:不要把领域值拓宽为string。对于枚举、联合类型、布尔、数字、对象、可空占位值,应使用Select<Value, Multiple>、RadioGroup<Value>、Radio<Value>、RadioItem<Value>。
根泛型的作用范围与"JSX 子边界"问题
根组件的泛型负责约束value、defaultValue以及依赖值的回调。但文档点出一个容易被忽略的 TypeScript 机制:JSX children 不会继承父组件的泛型,因此对于"独立消费"的解剖部件,当其值无法在局部被推断时,需要独立标注类型:
<RadioGroup<PromptMode> value={promptMode} onValueChange={setPromptMode}> <Radio<PromptMode> value={PROMPT_MODE.default} /> <RadioItem<PromptMode> value={PROMPT_MODE.custom}> <RadioControl /> Custom prompt </RadioItem> </RadioGroup>注意Radio和RadioItem上都重复标注了<PromptMode>——这正是"独立消费"的体现:RadioControl位于RadioItem内部,是视觉部件,不参与值类型传播;而Radio/RadioItem各自直接接收valueprop,类型需要就地声明。
Select与Combobox:字面multiple类型必须匹配运行时模式
文档规则:<Combobox<Subject, true> multiple>——泛型第二参数的字面量必须与实际运行时multiple属性一致。同时,值显示部件在尚未选择时仍可能收到null,渲染函数必须处理空值:
<Combobox<Subject, true> multiple value={subjects} onValueChange={setSubjects}> <ComboboxValue<Subject, true>> {(selected) => selected?.map((subject) => subject.name).join(', ') ?? 'Anyone'} </ComboboxValue> <ComboboxList<Subject>> {(subject) => <ComboboxItem value={subject}>{subject.name}</ComboboxItem>} </ComboboxList> </Combobox>源码印证了这一契约的设计意图。Combobox 的 props 类型 通过条件类型强制"声明多选就必须传multiple":
type ComboboxProps<Value, Multiple extends boolean | undefined = false> = BaseCombobox.Root.Props< Value, Multiple > & ([Multiple] extends [true] ? { multiple: true } : unknown)而ComboboxSelectedValue类型(L35-L37)直接编码了"多选得到数组、单选得到单值、且都可为null"的完整联合:
type ComboboxSelectedValue<Value, Multiple extends boolean | undefined = false> = | (Multiple extends true ? Value[] : Value) | nullSelect有完全同构的约束(Select 源码 L19-L35),SelectValue的 children 回调签名同样以SelectSelectedValue<Value, Multiple>为参数。
三条补充类型规则
AutocompleteList遵循同样规则。Autocomplete 实现 为分组(AutocompleteGroupedProps)与扁平(AutocompleteFlatProps)两种items形态定义了重载:分组时根、组、项共享同一 item 类型,组内可从items本地推断类型;但嵌套的Collection是独立的 JSX 边界,需要自行标注泛型(对应 ComboboxCollection 中children: (item: Value, index: number) => React.ReactNode的签名形态)。- 动态
multiple={condition}会产生单选/多选联合类型——类型系统按字面boolean而非true收窄,回调内需要用分支处理两种形态。 - 优先使用 Base UI 的
items集合模式,让根组件、值显示、项列表共享同一个运行时数据源;只在真正的序列化边界处才把值转成字符串(例如提交 API 前)。这保证了领域对象(如{ id, name })在整条 UI 数据流中不被降级。
CheckboxGroup的例外:string[]
文档同时说明:CheckboxGroup遵循 Base UI 上游契约,值类型为string[]。如需更强的业务 ID 区分(如数字主键),应在领域边界建模转换,而非假设原语支持更强类型。这与 CheckboxGroup 源码 的极简包装(直接透传BaseCheckboxGroup,无泛型参数)完全吻合。
弹出层宽度契约:--anchor-width与--available-width
文档对Autocomplete、Combobox、Select三类弹出组件立下硬性约束:弹出层使用 Base UI 的--anchor-width与--available-widthCSS 变量跟随触发器,同时向视口收敛(clamp)。不要用固定宽度或未经收敛的最小宽度替换该尺寸策略。
三个组件的源码均落实了同一条 CSS 尺寸规则——"宽度等于锚点宽度,但最大不超过可用视口宽度":
Combobox 弹出层(源码 L72-L74):
w-(--anchor-width) max-w-[min(28rem,var(--available-width))]Autocomplete 弹出层(源码 L65-L67):
w-(--anchor-width) max-w-[min(28rem,var(--available-width))]Select 弹出层(源码 L159-L170)则对下拉菜单形态略作调整,取"锚点宽度与可用宽度中较小者"作为最小宽度,同时以
--available-width为最大宽度:max-w-(--available-width) min-w-[min(var(--anchor-width),var(--available-width))]
列表高度同样受--available-height约束(如 Combobox 列表的max-h-[min(20rem,var(--available-height))],L76-L79)。这套契约的意义在于:弹出层在窄屏幕、侧边栏面板等受限容器内不会溢出视口,同时不丢失与触发器等宽的扫视体验。自定义包装或覆盖className时,应避免删除这些宽度令牌。
测试用例如何锁定这些契约
组件库用测试用例将上述类型与交互契约固化为可执行断言。RadioGroup 测试 中有一个专门的类型示例块,用@ts-expect-error证明"boolean 泛组的 radio 项不接受字符串值":
<RadioGroup<boolean> value={true} onValueChange={() => {}}> <Radio<boolean> value={true} /> <RadioItem<boolean> value={false} /> {/* @ts-expect-error boolean radio items should not accept string values */} <Radio<boolean> value="true" /> </RadioGroup>同类测试还验证了受控单选行为(点击后aria-checked状态在项之间正确迁移,L61-L89),以及RadioGroup与 Dify UI 的Field/Fieldset组合时标签语义不丢失(radiogrouprole 可被辅助技术按名称寻址)。
实践速查表
| 场景 | 首选原语 | 关键契约 |
|---|---|---|
| 表单中的持久单选字段 | RadioGroup<Value>+Radio | 每个项必须属于组,不得游离渲染 |
| 自定义行内选项卡(整行可点) | RadioItem<Value>内嵌RadioControl | RadioControl仅是视觉部件 |
| 模式 / 过滤 / 视图切换 | SegmentedControl<Value> | 不可 toggle off;Tab 进入选中项;方向键移动并选中 |
| 面板切换 | Tabs | 保留tablist/tabpanel语义 |
| 自由文本 + 建议 | Autocomplete | AutocompleteList需独立标注泛型 |
| 可搜索单/多选集合 | Combobox<Value, Multiple> | 字面multiple类型匹配运行时;值显示处理null |
| 封闭列表快速选取 | Select<Value, Multiple> | 弹出层宽度遵循--anchor-width/--available-width |
配套文档可继续参考组件库的 README(公开子路径与跨组件契约索引)、表单契约、样式契约、可访问性命名契约 与 测试与开发指南。
适用前提:本文基于当前仓库packages/dify-ui的实际实现,该包为 pnpm workspace 私有包(@langgenius/dify-ui),依赖 Base UI 无头组件与 Tailwind 设计令牌,行为与版本以本仓库代码为准;上游 Base UI 的通用行为细节不在本文仓库证据范围内。
【免费下载链接】difyBuild Agentic workflows, RAG pipelines, with rich AI model and tool support on one collaborative workspace. Deploy on cloud, VPC, or self-hosted, so teams move from prototype to production without rebuilding the stack.项目地址: https://gitcode.com/GitHub_Trending/di/dify
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考