首页
/ Dify UI InputGroup 组件指南:复合输入框的结构、共享表面与无障碍交互契约

Dify UI InputGroup 组件指南:复合输入框的结构、共享表面与无障碍交互契约

2026-09-06 15:16:36作者:咎岭娴Homer

本篇指南基于 Dify 前端组件库 @langgenius/dify-uiinput-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)实现了"按下组内非交互区域时聚焦输入框"的行为,其判定链值得逐行阅读:

  1. 先调用消费者的 onMouseDown,若已 preventDefault 或不是主按钮(event.button !== 0),直接返回——这就是官方暴露的"取消开关",消费者在 InputGrouponMouseDown 里调用 event.preventDefault() 即可关闭共享表面行为;
  2. event.nativeEvent.composedPath() 取事件路径,要求路径必须穿过组容器本身,并用 :scope > input 精确定位直接子级输入框;
  3. 若命中的目标元素匹配"可交互元素选择器"(interactiveElementSelectorindex.tsx#L10-L11),则不拦截——该选择器覆盖了 buttona[href][role="button"]select、可聚焦的 [tabindex]、未禁用的 input/textarea、可编辑的 [contenteditable] 等,保证 addon 内的真实控件保持自己的焦点与事件;
  4. 找到直接子级 input 且未禁用时,preventDefault()input.focus()

这里有两个容易被忽视的边界,源码和测试都给出了明确答案:

  • Portal 化的 addon 内容不在组的事件路径内。由于判定依赖 composedPath() 包含组容器,而 Portal 弹出的内容(Popover、Dropdown 等)渲染在 body 下的独立树中,点击它不会触发输入框聚焦。测试用例 should not handle pointer events from portalled add-on contentPopover 验证了:点击弹层内文本后,弹层保持焦点,输入框不失焦也不抢焦。
  • 消费者可以完全接管指针行为。测试用例 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 指南 可以落地为具体做法:

  1. 每个 InputGroupInput 必须有可访问名称,来源可以是可见的 FieldLabelaria-labelaria-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)。
  2. 需要共享名称、标签、校验状态、描述或错误时,用 Field 包裹整组Field 的状态会投射到 InputGroupInput 上——测试用例 should project Field state onto the grouped surface 渲染了 <Field name="repositoryUrl" invalid>,断言 input 获得 aria-invalid="true",同时 computed style 验证了"边框归组、背景透明"的分工:input 的 borderTopWidth0px、背景为透明,而组容器承担破坏性边框。
  3. addon 内的内容分两类
    • 纯文本与装饰图标是辅助内容,装饰性图标必须加 aria-hidden(如 Stories 中的搜索图标);
    • 交互内容应放进语义化的 ButtonIconButton 或链接,而不是在 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 的 typepassword 切换为 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 变体目前只暴露 aligncva 定义),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 同时暴露 typesimport 指向 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 UIInputGroupInput 是 Base UI Input 的封装,原生 input 的上游契约(可访问名称来源、render 定制等)仍由 Base UI 官方文档负责;InputGroup 本身不引入任何新的原生语义,它只是在组层面管理视觉与指针。
  • 测试归属:组件级行为测试位于 tests/index.spec.tsx,基于 vitest browser 环境(rendervitest-browser-react),覆盖状态投射、指针表面扩展与取消、只读键盘聚焦、交互 addon 焦点归属、Portal 豁免、焦点顺序共七类断言;交互流程另由 index.stories.tsx 中四个 story 的 play 函数(Storybook test-runner)补充验证。修改组件行为时,这两处是必须保持通过的回归基线。

速查清单

  1. 共享表面 → InputGroup;独立输入 → Input;不要绝对定位伪造共享表面。
  2. 恰好一个直接 InputGroupInput + 一到多个直接 InputGroupAddon;input 必须位于所有 addon 的 DOM 之前。
  3. 视觉位置用 align="inline-start" | "inline-end" 控制,焦点顺序不受影响。
  4. InputGroupInput 必须有可访问名称(可见 label、aria-labelaria-labelledby);需要名称/标签/校验/描述/错误时用 Field 包裹整组。
  5. 状态(invalid、disabled、focus)只写在直接 input 上,由组容器派生共享视觉;装饰图标 aria-hidden,交互内容用 Button/IconButton/链接。
  6. 点击组内非交互区域聚焦输入框;addon 内交互控件不被抢占焦点;Portal 内容不参与该行为;消费者可在 InputGrouponMouseDownpreventDefault() 取消。
  7. 新增尺寸走 InputGroup 级变体,不单独缩放 input 与 addon。
登录后查看全文
热门项目推荐
相关项目推荐