Dify UI 选择型组件契约指南:RadioGroup、Combobox 与 Select 如何选、如何用、如何保持类型安全
2026/9/7 15:06:39 网站建设 项目流程

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:从一组可见选项中选择一个持久字段值。每个RadioRadioItem必须隶属于一个组;禁止渲染游离的、不在组内的 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强制valuedefaultValue二选一(源码 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-centerComboboxChip使用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则省略了childrenOmit<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 子边界"问题

根组件的泛型负责约束valuedefaultValue以及依赖值的回调。但文档点出一个容易被忽略的 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>

注意RadioRadioItem上都重复标注了<PromptMode>——这正是"独立消费"的体现:RadioControl位于RadioItem内部,是视觉部件,不参与值类型传播;而Radio/RadioItem各自直接接收valueprop,类型需要就地声明。

SelectCombobox:字面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) | null

Select有完全同构的约束(Select 源码 L19-L35),SelectValue的 children 回调签名同样以SelectSelectedValue<Value, Multiple>为参数。

三条补充类型规则

  1. AutocompleteList遵循同样规则。Autocomplete 实现 为分组(AutocompleteGroupedProps)与扁平(AutocompleteFlatProps)两种items形态定义了重载:分组时根、组、项共享同一 item 类型,组内可从items本地推断类型;但嵌套的Collection独立的 JSX 边界,需要自行标注泛型(对应 ComboboxCollection 中children: (item: Value, index: number) => React.ReactNode的签名形态)。
  2. 动态multiple={condition}会产生单选/多选联合类型——类型系统按字面boolean而非true收窄,回调内需要用分支处理两种形态。
  3. 优先使用 Base UI 的items集合模式,让根组件、值显示、项列表共享同一个运行时数据源;只在真正的序列化边界处才把值转成字符串(例如提交 API 前)。这保证了领域对象(如{ id, name })在整条 UI 数据流中不被降级。

CheckboxGroup的例外:string[]

文档同时说明:CheckboxGroup遵循 Base UI 上游契约,值类型为string[]。如需更强的业务 ID 区分(如数字主键),应在领域边界建模转换,而非假设原语支持更强类型。这与 CheckboxGroup 源码 的极简包装(直接透传BaseCheckboxGroup,无泛型参数)完全吻合。

弹出层宽度契约:--anchor-width--available-width

文档对AutocompleteComboboxSelect三类弹出组件立下硬性约束:弹出层使用 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>内嵌RadioControlRadioControl仅是视觉部件
模式 / 过滤 / 视图切换SegmentedControl<Value>不可 toggle off;Tab 进入选中项;方向键移动并选中
面板切换Tabs保留tablist/tabpanel语义
自由文本 + 建议AutocompleteAutocompleteList需独立标注泛型
可搜索单/多选集合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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询