Dify UI InputGroup 组件指南:复合输入框的结构、共享表面与无障碍交互契约
本篇指南基于 Dify 前端组件库 @langgenius/dify-ui 中 input-group 组件的官方说明(README)与配套源码,完整讲解 InputGroup 的复合结构(InputGroupInput + InputGroupAddon)、共享视觉表面的状态归属规则、键盘与指针焦点行为,以及 Field 状态投射机制。读完后你能够正确组合前缀/后缀/操作按钮型输入框,理解其焦点管理源码实现(指针事件扩展、可交互元素豁免、Portal 隔离),并能在业务表单中按契约写出可访问、可测试的输入组合。
何时使用 InputGroup,何时使用独立 Input
input-group 组件的定位是复合输入表面(compound wrapper):当一个文本输入框需要与某个前缀、后缀或操作控件共享同一块视觉表面(同一条边框、同一个背景、同一套聚焦态)时,应使用 InputGroup;反之,如果没有任何内容需要与输入框共享视觉表面,应直接使用独立的 Input 组件(对应 input 子路径)。
从 组件包 README 可以看到,./input-group 属于 @langgenius/dify-ui 的 Form 类别公开子路径(与 ./form、./field、./input、./textarea、./number-field 等并列),它是 Dify UI 对 Base UI Input 的复合包装:拥有组合后的视觉表面与指针交互区域,但不改变原生 <input> 的语义。
组件结构:一个输入 + 一到多个 Addon
InputGroup 采用固定的复合结构:恰好一个直接子级 InputGroupInput,加上一到多个直接子级 InputGroupAddon。最小示例如下:
<InputGroup>
<InputGroupInput aria-label="Repository URL" />
<InputGroupAddon>https://</InputGroupAddon>
</InputGroup>
三个子组件的职责划分(这是该组件的核心契约):
| 组件 | 职责 |
|---|---|
InputGroup |
拥有边框、背景、聚焦状态,以及非交互区域的指针表面(点击空白处可聚焦输入框) |
InputGroupInput |
拥有原生 input 及其值,负责实际的输入能力 |
InputGroupAddon |
拥有布局与辅助内容(前缀文本、后缀文本、装饰图标、操作按钮等) |
官方明确警告:不要通过在独立 Input 上绝对定位内容来"伪造"这种共享表面——那会破坏焦点、边框与无障碍语义。
DOM 顺序约定:Input 永远在 Addon 之前
InputGroupInput 必须出现在每个 addon 之前的 DOM 中:输入框是主控件,addons 在读取顺序和焦点顺序上都位于其后。视觉位置由 align 属性控制,与语义顺序解耦:
align="inline-start"(默认值):addon 视觉前置,源码中对应 index.tsx 里的order-first ps-2 pe-1;align="inline-end":addon 视觉后置,对应order-last ps-1 pe-2。
也就是说,焦点顺序永远不随视觉位置改变:即使 addon 视觉上显示在左侧,Tab 键仍然先聚焦输入框、再到达 addon 内的控件。这一行为有专门的测试用例验证——测试文件 中 should keep a visually leading interactive add-on after the input in focus order 用例渲染了默认的 inline-start addon(内含 Currency 按钮),按下 Tab 后焦点从输入框落到 addon 按钮上,而非反向。
视觉对齐还反过来微调输入框内边距:当存在 data-align="inline-start" 的 addon 时,输入框左内边距归零(ps-0);存在 inline-end addon 时右内边距归零(pe-0),由 index.tsx 的组级 className 中的 has-[>[data-align=...]] 选择器实现,避免"addon 内边距 + 输入框内边距"双重留白。
实现剖析:共享表面的状态与指针逻辑
InputGroup 的实现 是一个普通 <div>,其全部行为来自两部分:Tailwind 的 has-[] 后代选择器和一段 onMouseDown 处理函数。
状态如何"上抛"到组容器
InputGroup 自身不持有任何 React state,它用 CSS 后代选择器直接从直接子级 input 派生整组的外观,源码中对应这几组类名(index.tsx#L22-L29):
- 悬停态:
has-[>input:enabled]:not-has-[>input[readonly]]:hover:...—— 直接子级 input 是启用且非只读时,悬停整组才显示悬停边框与背景; - 聚焦态:
has-[>input:focus]:not-has-[>input[readonly]]:...—— input 聚焦时整组切换激活边框、激活背景与阴影(shadow-xs); - 失效态:
has-[>input[data-invalid]]:...—— 直接子级 input 携带data-invalid属性时,整组切换为破坏性(destructive)边框与背景; - 禁用态:
has-[>input[data-disabled]]:...—— 整组禁用光标(cursor-not-allowed)、背景置灰,并通过*:data-align选择器把所有 addon 的文字也置为禁用色; - 只读态:
has-[>input[readonly]]:focus-visible:ring-2 ...—— 只读输入聚焦时显示 ring 而非激活边框,光标为cursor-default。
这种"输入框属性驱动整组样式"的方式,正是 README 所说"InputGroup 从直接 input 派生共享的 invalid、disabled 与 focus 视觉"的实现机制。同时它给出了一条使用纪律:不要在 addon 或组容器上重复表达这些状态、不要为它们重写样式——状态的所有者只有一个,就是那个直接子级 InputGroupInput。
InputGroupInput 本身(index.tsx#L62-L77)是 Base UI Input 的薄包装,类名刻意"掏空"了自身的表面样式:rounded-none border-0 bg-transparent,让边框与背景完全交给组容器;同时用 w-0 flex-1 让它弹性占满剩余宽度。它剔除了上游的 size 属性(见 类型定义 中的 Omit<BaseInputNS.Props, 'className' | 'render' | 'size'>),这就是下文"尺寸契约"在类型层面的落点。
点击共享表面 = 聚焦输入框,但有三个豁免
onMouseDown 处理函数(index.tsx#L36-L53)实现了"按下组内非交互区域时聚焦输入框"的行为,其判定链值得逐行阅读:
- 先调用消费者的
onMouseDown,若已preventDefault或不是主按钮(event.button !== 0),直接返回——这就是官方暴露的"取消开关",消费者在InputGroup的onMouseDown里调用event.preventDefault()即可关闭共享表面行为; - 用
event.nativeEvent.composedPath()取事件路径,要求路径必须穿过组容器本身,并用:scope > input精确定位直接子级输入框; - 若命中的目标元素匹配"可交互元素选择器"(
interactiveElementSelector,index.tsx#L10-L11),则不拦截——该选择器覆盖了button、a[href]、[role="button"]、select、可聚焦的[tabindex]、未禁用的input/textarea、可编辑的[contenteditable]等,保证 addon 内的真实控件保持自己的焦点与事件; - 找到直接子级 input 且未禁用时,
preventDefault()并input.focus()。
这里有两个容易被忽视的边界,源码和测试都给出了明确答案:
- Portal 化的 addon 内容不在组的事件路径内。由于判定依赖
composedPath()包含组容器,而 Portal 弹出的内容(Popover、Dropdown 等)渲染在body下的独立树中,点击它不会触发输入框聚焦。测试用例 should not handle pointer events from portalled add-on content 用Popover验证了:点击弹层内文本后,弹层保持焦点,输入框不失焦也不抢焦。 - 消费者可以完全接管指针行为。测试用例 should let consumers cancel input pointer-surface behavior 展示了在
InputGroup上传onMouseDown={(event) => event.preventDefault()}后,点击 addon 文本不再聚焦输入框。
onMouseDown 上有一行 oxlint-disable 注释(index.tsx#L17)说明了对静态元素加交互处理的取舍:该处理器只是扩展原生 input 的指针目标,键盘用户本来就直达 input,因此不违反无障碍规则。
聚焦态的"谁拥有焦点"问题
当焦点落在 addon 内的交互控件(如按钮)上时,组容器的"聚焦视觉"应归谁?测试用例 should preserve an interactive add-on as the focus owner 给出了可验证的预期:点击输入框后组容器出现聚焦阴影;点击 addon 内的 Copy 按钮后,按钮获得焦点、onClick 触发一次,且组容器的阴影回到静止态——即 addon 内的交互控件是焦点的合法所有者,组容器不"假装有焦点"。
无障碍:可访问名称与交互内容放置
README 对无障碍的三条契约,结合源码与 Forms 指南 可以落地为具体做法:
- 每个
InputGroupInput必须有可访问名称,来源可以是可见的FieldLabel、aria-label或aria-labelledby。Stories 中的真实用法分别演示了两种方式:可见标签(<FieldLabel>Repository URL</FieldLabel>,见 index.stories.tsx#L39-L52)与aria-label="API key"(index.stories.tsx#L102-L112)。测试也依赖这一点:screen.getByRole('textbox', { name: 'Repository URL' })正是按可访问名称查询输入框(index.spec.tsx#L27)。 - 需要共享名称、标签、校验状态、描述或错误时,用
Field包裹整组。Field的状态会投射到InputGroupInput上——测试用例 should project Field state onto the grouped surface 渲染了<Field name="repositoryUrl" invalid>,断言 input 获得aria-invalid="true",同时 computed style 验证了"边框归组、背景透明"的分工:input 的borderTopWidth为0px、背景为透明,而组容器承担破坏性边框。 - addon 内的内容分两类:
- 纯文本与装饰图标是辅助内容,装饰性图标必须加
aria-hidden(如 Stories 中的搜索图标); - 交互内容应放进语义化的
Button、IconButton或链接,而不是在 addon 本身加 click/keyboard 行为——这些控件自带焦点与事件,并被前面提到的interactiveElementSelector豁免。
- 纯文本与装饰图标是辅助内容,装饰性图标必须加
四种典型场景的完整示例
以下四个示例全部取自 index.stories.tsx,每个都附带了可执行的 Storybook play 函数断言,可直接作为回归基线。
1. 固定前缀(Repository URL)
https:// 作为固定前缀展示,不属于输入值,配合 FieldDescription 向用户说明这一点:
<Field name="repositoryUrl">
<FieldLabel>Repository URL</FieldLabel>
<InputGroup>
<InputGroupInput
placeholder="github.com/langgenius/dify"
autoComplete="url"
spellCheck={false}
/>
<InputGroupAddon className="pe-0">https://</InputGroupAddon>
</InputGroup>
<FieldDescription>
https:// is displayed as a fixed prefix and is not part of the input value.
</FieldDescription>
</Field>
注意 className="pe-0" 的覆盖:默认 inline-start addon 带 pe-1 间距,当 addon 紧贴输入框(如 URL 前缀)时可按需清零。
2. 后缀操作按钮(密码可见性切换)
<InputGroup>
<InputGroupInput
type={passwordVisible ? 'text' : 'password'}
placeholder="Enter your password"
autoComplete="current-password"
/>
<InputGroupAddon align="inline-end">
<IconButton
size="md"
aria-label={passwordVisible ? 'Hide password' : 'Show password'}
onClick={() => setPasswordVisible((visible) => !visible)}
>
<span
className={passwordVisible ? 'i-ri-eye-off-line size-4' : 'i-ri-eye-line size-4'}
aria-hidden="true"
/>
</IconButton>
</InputGroupAddon>
</InputGroup>
配套测试(Basic story 的 play 函数)断言点击按钮后 input 的 type 从 password 切换为 text。
3. 只读值 + 复制操作(API key)
<Field name="apiKey" className="w-80">
<FieldLabel>API key</FieldLabel>
<InputGroup>
<InputGroupInput value={apiKey} readOnly />
<InputGroupAddon align="inline-end">
<Button type="button" size="small" variant="tertiary" onClick={handleCopy}>
<span aria-live="polite">{copied ? 'Copied' : 'Copy'}</span>
</Button>
</InputGroupAddon>
</InputGroup>
</Field>
两个细节值得注意:状态文案包在 aria-live="polite" 的 span 里,使"Copy → Copied"的变化对屏幕阅读器可感知;只读输入框的键盘聚焦表现由 should show keyboard focus on a read-only input 用例保证——Tab 聚焦后组容器出现 ring(computed boxShadow 变化),而非聚焦阴影。
4. 下拉菜单操作(Dropdown / Popover)
addon 内放置 DropdownMenu,其弹出层经 Portal 渲染:
<InputGroupAddon align="inline-end">
<DropdownMenu>
<DropdownMenuTrigger
render={
<IconButton size="md" aria-label="File actions">
<span aria-hidden="true" className="i-ri-more-fill size-4" />
</IconButton>
}
/>
<DropdownMenuContent placement="bottom-end" sideOffset={8}>
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>Copy path</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</InputGroupAddon>
WithDropdownAction 的 play 函数 验证了完整链路:打开菜单 → 点击 menuitem → 菜单关闭且焦点回到触发按钮(Base UI 弹层语义)。
尺寸契约
README 的尺寸规则一句话可概括:当前契约只支持默认输入尺寸;要新增尺寸,请给 InputGroup 加新的尺寸变体,而不是分别调整 input 和 addon 的大小——共享表面与 addon 布局的所有者是组容器,各自缩放会破坏对齐。这一点在类型上已有体现:InputGroupInputProps 通过 Omit<..., 'size'>(index.tsx#L58-L60)显式去掉了上游的 size 属性,逼迫消费者走组级尺寸扩展路径。Addon 变体目前只暴露 align(cva 定义),defaultVariants 固定为 inline-start。
公共 API 与导入方式
包公开三个组件与三个类型(导出语句):
export { InputGroup, InputGroupAddon, InputGroupInput }
export type { InputGroupAddonProps, InputGroupInputProps, InputGroupProps }
按 包 README 的约定,消费者应从子路径导入(包内无根 barrel),并在入口引入一次 styles.css:
import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group'
子路径的包映射见 package.json:./input-group 同时暴露 types 与 import 指向 src/input-group/index.tsx。组件带 'use client' 指令(index.tsx#L1),在 Next.js 等 RSC 框架中应放在客户端组件内使用。
与其他组件的协作边界
- 与
Field/Form:表单级契约(提交边界、值所有权、标签选择规则)统一收敛在 Forms 指南,其中明确"当 prefix、suffix 或 action 需要共享输入框视觉表面时,使用InputGroup";本文的状态投射规则(invalid/disabled/focus 由直接 input 驱动)正是该指南的组级补充。 - 与上游 Base UI:
InputGroupInput是 Base UIInput的封装,原生 input 的上游契约(可访问名称来源、render定制等)仍由 Base UI 官方文档负责;InputGroup本身不引入任何新的原生语义,它只是在组层面管理视觉与指针。 - 测试归属:组件级行为测试位于 tests/index.spec.tsx,基于 vitest browser 环境(
render自vitest-browser-react),覆盖状态投射、指针表面扩展与取消、只读键盘聚焦、交互 addon 焦点归属、Portal 豁免、焦点顺序共七类断言;交互流程另由 index.stories.tsx 中四个 story 的play函数(Storybook test-runner)补充验证。修改组件行为时,这两处是必须保持通过的回归基线。
速查清单
- 共享表面 →
InputGroup;独立输入 →Input;不要绝对定位伪造共享表面。 - 恰好一个直接
InputGroupInput+ 一到多个直接InputGroupAddon;input 必须位于所有 addon 的 DOM 之前。 - 视觉位置用
align="inline-start" | "inline-end"控制,焦点顺序不受影响。 InputGroupInput必须有可访问名称(可见 label、aria-label或aria-labelledby);需要名称/标签/校验/描述/错误时用Field包裹整组。- 状态(invalid、disabled、focus)只写在直接 input 上,由组容器派生共享视觉;装饰图标
aria-hidden,交互内容用Button/IconButton/链接。 - 点击组内非交互区域聚焦输入框;addon 内交互控件不被抢占焦点;Portal 内容不参与该行为;消费者可在
InputGroup的onMouseDown中preventDefault()取消。 - 新增尺寸走
InputGroup级变体,不单独缩放 input 与 addon。
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 StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00