
Dify 工作流 Block Selector 组件契约解析弹窗会话、Tab 交互与可访问性实现【免费下载链接】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/difyDify 前端工作流画布中添加节点弹窗Block Selector是用户在 Blocks、Tools、Sources、Start、Snippets 五个入口间选择并插入节点的核心交互组件。本文以 block-selector 组件目录下的 README 为核心逐条展开其公共契约、会话状态模型、交互契约与测试策略并结合 index.tsx、tabs.tsx、hooks.ts 等源码说明这些契约在 Dify 中的实际落地方式。读完后你将掌握该组件的完整 Props 契约、挂载式弹窗会话的状态设计以及基于 role、键盘顺序与焦点回退的可访问性验证方法。一、公共契约入口文件、触发器与定位策略README 的 Public contract 一节是整个组件的使用规范总纲核心结论有四条1.index.tsx是唯一正式入口。文档明确指出index.tsx是主BlockSelector组件及其BlockSelectorProps的规范入口canonical entry各专用选择器和共享契约仍由其同目录模块各自维护不应新增转发入口文件或兼容别名。源码印证了这一点index.tsx 直接定义并导出BlockSelectorexport default memo(BlockSelector)同时导出BlockSelectorProps类型调用方直接import BlockSelector from /app/components/workflow/block-selector例如工作流顶栏的 add-block.tsx。2. 组件自己负责的全部状态。README 列举了组件内部拥有的行为边界受控或非受控的 open 状态、disabled 行为、模态 popover、初始焦点、Escape 关闭、焦点回退、以及选中即关闭selection-driven close。对应源码实现受控/非受控双模式const open openFromProps undefined ? localOpen : openFromPropsindex.tsx#L90-L92未传open时组件内部维护localOpen传了open则完全受控disabled 时handleOpenChange直接拦截if (newOpen disabled) return而handleSelect在 disabled 时同样拒绝选中选中即关闭handleSelect先执行handleOpenChange(false)再调用外部onSelect(type, pluginDefaultValue)index.tsx#L110-L117初始焦点通过PopoverPopup initialFocus{searchInputRef}指向当前面板的搜索框Escape 关闭与焦点回退由 Dify UI 的 Popover 原语modaltrap-focus提供tests/index.spec.tsx 中returns focus to the trigger after Escape用例验证了Escape → trigger 获得焦点 → dialog 卸载的完整链路。一个重要的性能设计在文档中也有交代画布订阅canvas subscriptions与可用项解析放在已挂载的弹窗内容中。从源码结构看真正调用useNodes()、useHooksStore()、useStore()的组件是BlockSelectorContentindex.tsx#L229-L320它只在 popover 弹出后才渲染——因此选择器关闭时不会订阅工作流状态画布节点变化不会导致已关闭的触发器重渲染。3. 触发器trigger必须是单个可聚焦的按钮根。文档要求trigger必须返回一个能接收 Dify UIPopoverTrigger传入的 props 和 ref 的可聚焦按钮根节点复合组件或非转发non-forwarding的包装组件都是非法的触发器根。图标-only 的触发器应使用triggerAriaLabeltriggerTooltip仅当可见控件需要补充的 hover/focus 提示时才用。这条约束有真实测试守护tests/index.spec.tsx#L364-L408// 合法的自定义触发器一个转发全部 props 的原生 button 组件 function ForwardingButtonTrigger(props: ButtonHTMLAttributesHTMLButtonElement) { return ( button typebutton {...props} open-selector-root /button ) } renderBlockSelector( BlockSelector onSelect{vi.fn()} blocks{[createBlock(BlockEnum.LLM, LLM)]} availableBlocksTypes{[BlockEnum.LLM]} trigger{ForwardingButtonTrigger /} /, ) const trigger screen.getByRole(button, { name: open-selector-root }) await user.click(trigger) // PopoverTrigger 注入的语义必须出现在该按钮上 expect(trigger).toHaveAttribute(aria-haspopup, dialog)若 ref 无法转发到真实按钮上焦点回退initialFocus、Escape 后返回触发器都会失效——这正是该约束存在的工程原因。4. 定位策略优先placement偏移量是逃生舱。文档规定定位直接使用 Dify UI popover API优先单独使用placementsideOffset和alignOffset只是已验证存在几何约束的调用点的逃生舱escape hatch组件不会替调用方翻译自定义的偏移形态。真实调用点的用法可参考 operator/add-block.tsx#L98-L114BlockSelector open{open} onOpenChange{handleOpenChange} disabled{nodesReadOnly} onSelect{handleSelect} placementright-start sideOffset{sideOffset ?? 4} alignOffset{alignOffset ?? -8} trigger{renderTrigger || renderTriggerElement} triggerAriaLabel{t(($) $[common.addBlock], { ns: workflow })} triggerTooltip{t(($) $[common.addBlock], { ns: workflow })} availableBlocksTypes{availableNextBlocks} showStartTab{showStartTab} isolateKeyboardEvents{isolateKeyboardEvents} /其中sideOffset/alignOffset在AddBlock组件签名里是可选透传参数默认值由该调用点自行决定4 与 -8而组件内部PopoverPositioner固定使用positionMethodfixedindex.tsx#L160-L165。5.standalonePanel与可用性 props 的职责分离。README 要求独立standalone选择器必须显式声明standalonePanelnoBlocks这类可用性 props只决定哪些 Tab 存在不能隐式改变布局模式。这在源码中体现得很干净standalonePanel在 tabs.tsx#L255-L264 走完全独立的渲染分支直接渲染单个面板、无 TabsList而noBlocks/noTools等只参与useTabs的 Tab 过滤两者互不耦合。完整的BlockSelectorProps契约摘自 index.tsx#L33-L60Prop类型说明open/onOpenChangeboolean/ 回调受控模式开关不传open则为非受控onSelectOnSelectBlock选中回调签名(type, pluginDefaultValue)触发后组件自动关闭triggerNonNullablePopoverTriggerProps[render]自定义触发器必须是转发 props 与 ref 的可聚焦按钮根triggerAriaLabel/triggerTooltipstring图标按钮的无障碍名称 / 可选 hover-focus 提示placement/sideOffset/alignOffsetPopoverPositioner 属性定位参数默认placementrightavailableBlocksTypesBlockEnum[]限制 Blocks 面板中可选择的节点类型disabledboolean禁用打开与选中已打开状态下仍可经 Escape 关闭blocks/dataSources节点/数据源数组不提供时从已挂载内容的 store 兜底解析noBlocks/noToolsboolean仅控制对应 Tab 是否存在默认falsestandalonePanelTabType声明独立面板模式无 Tab 栏showStartTab/defaultActiveTabboolean/TabTypeStart Tab 开关与初始激活 TabforceEnableStartTabboolean画布已有触发器/用户输入节点时仍强制启用 Start Tab如更换 Start 节点类型场景allowUserInputSelectionboolean覆盖用户输入节点可用性默认逻辑在存在触发器时禁用它ignoreNodeIdsstring[]计算 Start Tab 可用性时忽略的节点 idsnippetInsertPayloadParametersOnNodeAdd[1]Snippets 面板插入节点时的附加数据isolateKeyboardEventsboolean弹窗键盘事件stopPropagation用于避免影响外层键盘管理的浮层二、所有权模型一次挂载的弹窗会话与逐 Tab 状态隔离README 的 Ownership 一节定义了状态归属规则BlockSelectorPanels拥有一个已挂载的弹窗会话每个 Tab 在该会话内保持独立的搜索词与标签状态关闭弹窗即卸载会话并重置这些值。对应实现在 tabs.tsx// 每个 Tab 一个独立的 { searchText, tags } 状态随组件卸载而销毁 const createTabFilterState (): TabFilterState ({ [TabType.Blocks]: { searchText: , tags: [] }, [TabType.Tools]: { searchText: , tags: [] }, [TabType.Sources]: { searchText: , tags: [] }, [TabType.Start]: { searchText: , tags: [] }, [TabType.Snippets]: { searchText: , tags: [] }, }) function BlockSelectorPanels({ ... }) { const [filters, setFilters] useState(createTabFilterState) // ... }filters是BlockSelectorPanels的局部 state。由于弹窗内容在关闭时整体卸载popover 未挂载时BlockSelectorContent/BlockSelectorPanels均不存在该 state 随之销毁——关闭即重置不需要任何显式清理代码这正是会话式挂载设计的直接收益。测试用例opens with the real blocks tab, filters by search, selects a block, and clears search after closetests/index.spec.tsx#L101-L149验证了完整行为搜索LLM过滤列表 → 选中后onSelect被调用且弹窗卸载 → 重新打开后搜索框reopenedInput.value为且完整列表恢复。文档还规定了四个面板各自的内部所有权源码与之一一对应ToolPanel把已安装工具查询适配为ToolBrowser的输入ToolBrowser拥有工具分类All/Built-in/Custom/Workflow/MCP、视图flat/tree、marketplace 搜索与工具列表呈现。tool-panel.tsx 中ToolPanel负责四个 react-query 查询useAllBuiltInTools/useAllCustomTools/useAllWorkflowTools/useAllMCPTools、basePath 图标路径归一化并把结果通过useEffect同步进 workflow store最终渲染ToolBrowser ...分类 Tab 与视图常量定义在 types.ts#L23-L38 的ToolType与ViewType。DataSources拥有本地源过滤与 datasource marketplace 搜索。见>button typebutton aria-describedby{previewDescriptionId} classNameflex h-8 w-full cursor-pointer items-center rounded-lg px-3 text-left hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden onClick{() onSelect(block.metaData.type)} BlockIcon classNamemr-2 shrink-0 type{block.metaData.type} / span classNamemin-w-0 grow truncate text-sm text-text-secondary {block.metaData.title} /span /buttonFeatured Tools / Featured Triggers 的展开分组使用Collapsible/CollapsibleTrigger/CollapsiblePanelfeatured-tools.tsx#L123-L138trigger 为原生交互元素panel 与之关联。行级通用组件 block-selector-row.tsx 提供asbutton/asdiv两种形态对应单一动作行与复合行容器两类场景div形态刻意不带任何交互语义供调用方在其内部并排放置独立的主/次按钮。焦点指示规范也在此处文档要求列表控件统一使用共享的 2 像素 accent 焦点指示器在裁剪或可滚动表面内使用 inset ring让光环贴着行边界而不被裁剪。上面按钮类名中的focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden以及BlockSelectorRow的buttonClassNameblock-selector-row.tsx#L32-L37即为该规范的具体实现。四、PreviewCard 组合功能级视觉增强而非共享原语扩展README 用最大篇幅界定了预览卡片的契约边界值得逐条对照源码契约内容。每个启用预览的列表拥有一个PreviewCard根和一个 detached handle行提供可聚焦的PreviewCardTriggerpayload参数为delay{150}与closeDelay{150}预览内容统一使用BlockSelectorPreviewCardContent。源码印证。Blocks 面板在列表末尾渲染唯一的PreviewCard根blocks.tsx#L196-L198每行的PreviewCardTrigger携带delay{150}、closeDelay{150}、共享的handle{previewCardHandle}与payload{{ block }}blocks.tsx#L149-L153BlockSelectorPreviewCardContent则封装了 Portal Positioner Popup Viewport 四件套与上下切换动画preview-card.tsx。关键设计决策文档原文强调刻意用原生行按钮而非 link 来组合这些 trigger——这是 feature 拥有的视觉增强不是对共享 Dify UI 原语契约的扩展行的完整选择/插入动作始终保留在按钮自身见上面onClick{() onSelect(...)}预览只是伴随效果。预览可用性不得依赖可选的 description卡片可以展示非必要的只读上下文名称、图标、作者、block 类型等但这些上下文不能影响用户识别或激活行预览内容不含独立交互绝不添加第二动作。若某段仅预览可见的上下文变成选择所必需的信息就必须把它上移到行内或改用可访问的 disclosure 组件替代这种 feature 级组合。五、测试策略只守护可观察行为README 的 Testing 一节给出了一套明确的测试边界与tests/ 下 18 个 spec 文件的风格一致应当守护的可观察行为通过公共接口断言role、accessible name、state 与关系如aria-haspopupdialog、aria-selected、aria-disabled键盘激活与逻辑 Tab 顺序user.tab({ shift: true })、{Escape}、搜索框聚焦初始焦点、Escape 关闭、焦点回退每个 Tab 独立的会话状态与关闭后的重置切换 Start Tab 输入搜索词 → 重新打开后恢复 Blocks 搜索框disabled 行为与选中副作用disabled 时onOpenChange不触发、不产生选中已打开的弹窗变 disabled 后仍可经 Escape 关闭——见tests/index.spec.tsx#L282-L316。明确禁止的断言对象工具类utility classes、子节点索引、组件实现细节、第三方原语内部结构。交互一律通过userEvent与语义化查询getByRole驱动。环境边界几何geometry、被裁剪的焦点指示器、hover/focus 显现、真实浏览器焦点顺序这些在 happy-dom 中无法可靠验证的行为被明确要求放到 Browser Mode 或 E2E 中执行而不是塞进单测。六、小结契约驱动的可组合选择器回到 README 的全文主线这个组件文档本质上是一份契约 所有权 可验证行为的三位一体规范公共契约锁定入口与触发器形态index.tsx唯一入口、可聚焦按钮根、placement优先的定位策略保证 operator/add-block.tsx、节点 handle、迭代/循环容器等十余个调用点如 node-handle.tsx、iteration/add-block.tsx以一致的方式复用同一组件所有权模型挂载式弹窗会话、逐 Tab 状态隔离、section 层防抖与数据获取把重状态限制在短暂存活的弹窗内关闭即释放交互契约与测试策略三选一的行结构、inset focus ring、PreviewCard 的增强边界、role/键盘/焦点断言让可访问性成为可回归测试的一部分而不是样式附赠品。对二次开发者的实际建议在 Dify 中新增或修改选择器调用点时先对照上表的BlockSelectorProps确认自己是否需要standalonePanel或forceEnableStartTab自定义 trigger 时务必保证 props/ref 转发到单个原生可聚焦按钮任何新行结构只能落在按钮 / Collapsible / 非交互容器 独立按钮三种形态之内并按tests的既有模式补充 role 级断言而非 DOM 结构断言。【免费下载链接】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),仅供参考