coss UI 表单与输入组件规则实战指南:Field 组合、InputGroup 顺序与验证语义

原创2026-09-23 19:25:581,215 阅读
文章标签:前端UI组件设计系统

coss UI 表单与输入组件规则实战指南:Field 组合、InputGroup 顺序与验证语义

导读: 本文聚焦 coss UI(Cal.com 官方设计系统)中表单与输入类组件的实现规则,覆盖 Field 组合式字段结构、InputGroup 的 DOM 顺序不变量、OTP 槽位同步、Textarea 的 Base UI 语义复用,以及显式 type 与无障碍标签等核心要求。读完本文,你将掌握如何在 coss/Base UI 体系下写出结构正确、可访问、验证信号一致的表单代码,并理解每条规则背后的源码依据。

一、为什么需要一套表单规则

coss 是建立在 Base UI(@base-ui/react)之上的组件库,表单是其中最典型、也最容易出错的场景:一个字段同时涉及标签关联、校验状态、错误提示、输入组嵌套等多重语义。若放任开发者各自实现,很容易出现「独立 Input + placeholder 当标签」「校验提示与字段状态脱节」「InputGroup 顺序导致焦点丢失」等一致性问题。

coss 通过 rules/forms.md 把这些约定固化为规则文档,适用于以下实现场景:

  • 字段(field)与输入控件(input-like control)的实现;
  • 输入组(input group)的组装;
  • 校验状态(validation state)的呈现;
  • 各类表单示例的编写。

规则的核心思路可以概括为一句话:凡是受表单约束的控件,优先用 Field 组合而非裸控件;凡是控件,必须显式声明 type;凡是校验,必须让容器与控制件的语义保持对齐。

二、核心规则(Core Rules)

2.1 优先使用 Field 组合,而不是独立 Input

对绑定表单的控件,默认使用 Field 组合结构(Field、FieldLabel、FieldDescription、FieldError),而不是直接使用独立输入框。这一条是 coss 表单代码的第一准则。

// 推荐:Field 组合
<Field>
  <FieldLabel>Email</FieldLabel>
  <Input type="email" />
  <FieldDescription>用于接收账户通知</FieldDescription>
  <FieldError>请输入有效的邮箱地址</FieldError>
</Field>

在 packages/ui/src/components/field.tsx 中可以看到,Field、FieldLabel、FieldDescription、FieldError 分别是对 Base UI FieldPrimitive.Root / Label / Description / Error 的样式化封装:

  • Field 根容器默认 flex flex-col items-start gap-2(field.tsx#L7-L18),负责统一字段内部间距与纵向布局;
  • FieldLabel 提供 font-medium 的可见标签样式(field.tsx#L20-L34);
  • FieldDescription 以 text-muted-foreground text-xs 渲染辅助说明(field.tsx#L49-L60);
  • FieldError 以 text-destructive-foreground text-xs 渲染错误信息(field.tsx#L62-L73)。

此外该文件还导出了 FieldItem、FieldControl、FieldValidity 与 FieldPrimitive,用于更精细的字段分组和受控接入(见下文第七节)。

2.2 输入类控件必须显式声明 type

所有输入类控件,凡涉及具体输入语义,都必须显式写 type:type="text"、type="email"、type="password"、type="search"、type="file" 等,不能依赖浏览器默认值。

这一要求不仅是语义问题,在 coss 中还直接联动样式。查看 packages/ui/src/components/input.tsx#L29-L32:

props.type === "search" &&
  "[&::-webkit-search-cancel-button]:appearance-none ...",
props.type === "file" &&
  "text-muted-foreground file:me-3 file:bg-transparent file:font-medium ...",

也就是说,type="search" 会清除 WebKit 的搜索取消按钮样式,type="file" 会启用文件选择按钮的专属排版。显式 type 是触发这些内置样式的先决条件。

2.3 按钮同样必须显式声明 type

按钮必须显式指定 type(button、submit 或 reset)。原因在于 HTML 规范中 button 的默认 type 是 submit:在 Form 内部如果漏写 type="button",一个普通按钮点击后就会触发表单提交,这是表单开发中最常见的隐蔽 Bug。所有涉及按钮的表单粒子(如 p-form-1.tsx 中的提交按钮)都统一写作 type="submit",工具栏型按钮则写 type="button"。

2.4 保留可访问标签

  • 有可见标签时,保持标签与控件的关联(Label / htmlFor / id),或直接使用 coss 的 FieldLabel(由 Base UI 自动建立关联);
  • 没有可见标签时,必须提供 aria-label。

例如 p-input-1.tsx 中独立 Input 没有可见标签,因此补上了 aria-label="Enter text":

import { Input } from "@/registry/default/ui/input";

export default function Particle() {
  return <Input aria-label="Enter text" placeholder="Enter text" type="text" />;
}

2.5 验证信号在容器与控制件之间对齐

校验状态的呈现必须同时作用于容器和控制件:控件上的 aria-invalid 要与相关字段语义保持一致。以 InputGroup 为例,其外层容器的 class 中大量使用 has-[input[aria-invalid],textarea[aria-invalid]] 选择器——只有输入控件真实携带 aria-invalid,整个输入组容器才会切换为 border-destructive 的错误配色。因此「错误提示文案」与「aria-invalid 状态」必须成对出现,避免只显示错误文字而控件本身无无效语义。

三、InputGroup:Addon 必须位于 Input 之后(DOM 顺序不变量)

InputGroup 用于把输入框与前后缀装饰(图标、按钮、提示文字)组合为一个整体。规则非常明确:

在 InputGroup 中,InputGroupAddon 必须按 DOM 顺序放在 InputGroupInput 或 InputGroupTextarea 之后,以保证焦点行为正确。

// 正确:Input 在前,Addon 在后
<InputGroup>
  <InputGroupInput type="text" />
  <InputGroupAddon>{/* ... */}</InputGroupAddon>
</InputGroup>

// 错误:Addon 在前会破坏焦点行为
<InputGroup>
  <InputGroupAddon>{/* ... */}</InputGroupAddon>
  <InputGroupInput type="text" />
</InputGroup>

为什么顺序如此关键?查看 packages/ui/src/components/input-group.tsx#L47-L78 中 InputGroupAddon 的实现:

export function InputGroupAddon({ className, align = "inline-start", ...props }) {
  return (
    <div
      className={cn(inputGroupAddonVariants({ align }), className)}
      data-align={align}
      data-slot="input-group-addon"
      onMouseDown={(e) => {
        const target = e.target as Element;
        if (!e.currentTarget.contains(target)) return;
        const isInteractive = target.closest(
          "button, a, input, select, textarea, [role='button'], ..."
        );
        if (isInteractive) return;
        e.preventDefault();
        const parent = e.currentTarget.parentElement;
        const input = parent?.querySelector("input, textarea");
        if (input && !parent?.querySelector("input:focus, textarea:focus")) {
          input.focus();
        }
      }}
      {...props}
    />
  );
}

这段代码的含义是:当用户点击 addon 的非交互区域(非按钮、链接、输入框等)时,onMouseDown 会调用 preventDefault() 阻止默认行为,并把焦点移交到父级 InputGroup 内的第一个 input/textarea。此时 addon 自身并不存在于输入框之前,因而点击 addon 相当于「聚焦输入框」,这是搜索框、金额前缀等交互体验的关键。

如果把 InputGroupAddon 放到 InputGroupInput 前面,addon 会位于输入框左侧(align 的 inline-start 变体),此时点击 addon 时焦点移交仍可能工作,但会破坏该组件在样式层针对「input 先于 addon」的排列假设(例如 has-data-<a href="https://link.gitcode.com/i/37875139b41a7f73fcc323a35935f289" target="_blank">align=inline-start] 类对 input 内边距的补偿调整),导致视觉与交互不一致。DOM 顺序不变量是 coss 对使用方的硬性约束,粒子示例 [p-input-group-1.tsx 正是按「Input 前、Addon 后」的标准顺序书写的:

<InputGroup>
  <InputGroupInput aria-label="Search" placeholder="Search" type="search" />
  <InputGroupAddon>
    <SearchIcon aria-hidden="true" />
  </InputGroupAddon>
</InputGroup>

注意:图标本身用 aria-hidden="true" 装饰性处理,输入框的语义交给 aria-label="Search",这同样呼应了核心规则中的无障碍要求。

另外,InputGroup 还支持 InputGroupText(展示性文本)以及 InputGroupInput / InputGroupTextarea 两个子控件封装(input-group.tsx#L95-L107),后者分别以 unstyled 模式渲染 Input 与 Textarea,从而让外框样式统一由 InputGroup 容器负责。

四、OTP 输入:length 与槽位同步

一次性密码(OTP)输入遵循一条专门规则:

根节点 OTPField 的 length 必须与渲染出的 OTPFieldInput 槽位数量保持同步。

在 packages/ui/src/components/otp-field.tsx 中,OTPField 是对 Base UI OTPFieldPrimitive.Root 的封装(size 支持 default | lg),OTPFieldInput 是对 OTPFieldPrimitive.Input 的封装(含 aria-invalid、focus-visible 等状态样式)。length 是根节点声明槽位总数、并驱动自动前进/后退/粘贴分发的核心参数。

官方粒子 p-otp-field-1.tsx 展示了标准做法:先定义常量 OTP_LENGTH = 6,再用 Array.from 生成 6 个槽位 key,根节点 length={OTP_LENGTH} 与槽位数严格一致:

import { OTPField, OTPFieldInput } from "@/registry/default/ui/otp-field";

const OTP_LENGTH = 6;

const OTP_SLOT_KEYS = Array.from(
  { length: OTP_LENGTH },
  (_, i) => `otp-slot-${i}`,
);

export default function Particle() {
  return (
    <OTPField aria-label="One-time password" length={OTP_LENGTH}>
      {OTP_SLOT_KEYS.map((slotKey, index) => (
        <OTPFieldInput
          key={slotKey}
          aria-label={
            index === 0 ? undefined : `Character ${index + 1} of ${OTP_LENGTH}`
          }
        />
      ))}
    </OTPField>
  );
}

这里同样能看到无障碍细节:根节点提供 aria-label="One-time password"(无可见标签场景),非首个槽位额外补充 Character N of 6 的标签,方便读屏用户感知当前字符位置。若 length 与槽位数不一致,Base UI 的自动聚焦与校验分发都会出现异常,因此同步是硬性要求。

五、Textarea:直接复用内置的 Field 语义

Textarea 有一条容易被忽略的规则:

coss Textarea 内部已经使用 Base UI 的 field control 语义;常规表单流程中应直接把 Textarea 放进 Field,除非确实需要自定义控件实现,否则不要手动做 FieldControl 的 render 接线。

在 packages/ui/src/components/textarea.tsx#L33-L53 中可以确认这一实现:Textarea 内部通过 FieldPrimitive.Control 的 render 渲染原生 textarea,并把 value、defaultValue、disabled、id、name 等属性交给 mergeProps 合并,同时挂载 data-slot="textarea-control" 标记。这意味着 Textarea 已经与 Base UI 的字段上下文(标签关联、校验状态、错误语义)打通,无需再手动包裹 FieldControl。

// 推荐:直接放入 Field
<Field>
  <FieldLabel>留言</FieldLabel>
  <Textarea name="message" placeholder="输入内容" />
</Field>

// 不必要:标准场景下无需手动接线
<Field>
  <FieldLabel>留言</FieldLabel>
  <FieldControl render={<textarea />} />
</Field>

只有在实现非标准控件(如自定义富文本编辑器、自定义输入法面板)时,才需要显式使用 FieldControl(即 field.tsx#L75-L78 中导出的 FieldControl = FieldPrimitive.Control)。Textarea 还支持 size(sm、default、lg)与 unstyled 两个 props,供不同密度与无样式接入场景使用。

六、完整对照:Do / Don't

把上述规则汇总为一份可直接对照的代码清单(继承自 rules/forms.md):

// ✅ Do
<Field>
  <FieldLabel>Email</FieldLabel>
  <Input type="email" />
</Field>

<InputGroup>
  <InputGroupInput type="text" />
  <InputGroupAddon>{/* ... */}</InputGroupAddon>
</InputGroup>

// ❌ Don't
<Input placeholder="Email" />

<InputGroup>
  <InputGroupAddon>{/* ... */}</InputGroupAddon>
  <InputGroupInput type="text" />
</InputGroup>

逐条拆解:

片段 违反的规则 后果
<Input placeholder="Email" /> 未用 Field 组合、无可见标签、依赖 placeholder 充当标签 无程序化标签关联,读屏用户与自动化测试无法识别字段,且 placeholder 在部分场景会被误当作值
Addon 在 Input 之前 InputGroup DOM 顺序不变量 破坏 addon 点击聚焦逻辑与内边距补偿样式,焦点行为异常

七、完整表单实战:从原生 FormData 到 zod 校验

规则文档指出,表单的「提交模式」有两种:

  • onSubmit:处理原生 FormData,适合把数据直接交给浏览器表单协议(含 action/method 场景);
  • onFormSubmit:接收 Base UI Form 解析后的表单值对象,适合需要结构化值处理的场景。

coss 的 Form 组件即是对 Base UI FormPrimitive 的薄封装(data-slot="form")。仓库粒子提供了两个可直接参考的完整实现:

7.1 基础集成(p-form-1)

p-form-1.tsx 展示最小可用模式:Field name="email" 声明字段名,Input 显式 required type="email",FieldError 提供错误文案,按钮 type="submit",提交时通过 new FormData(e.currentTarget) 读取值:

const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  const formData = new FormData(e.currentTarget);
  setLoading(true);
  await new Promise((r) => setTimeout(r, 800));
  setLoading(false);
  alert(`Email: ${formData.get("email") || ""}`);
};

return (
  <Form className="flex w-full max-w-64 flex-col gap-4" onSubmit={onSubmit}>
    <Field name="email">
      <FieldLabel>Email</FieldLabel>
      <Input placeholder="you@example.com" required type="email" />
      <FieldError>Please enter a valid email.</FieldError>
    </Field>
    <Button loading={loading} type="submit">
      Submit
    </Button>
  </Form>
);

注意这里 Field 上的 name="email" 与 Input 配合,确保字段值进入提交载荷——这正是 primitives/form.md 中「Field naming:在每个字段/控件上设置 name,使值包含在提交结果中」的落地。

7.2 zod 校验(p-form-2)

p-form-2.tsx 演示了 zod schema 校验流程:schema.safeParse 解析 FormData,失败时用 z.flattenError 提取 fieldErrors 并写入 errors 对象,最后把 errors 传给 <Form errors={errors}>。FieldError 无需再写死文案,直接与 errors 按字段名关联渲染:

const schema = z.object({
  age: z.coerce.number({ message: "Please enter a number." })
    .positive({ message: "Number must be positive." }),
  name: z.string().min(1, { message: "Please enter a name." }),
});

<Form className="flex w-full max-w-64 flex-col gap-4" errors={errors} onSubmit={onSubmit}>
  <Field name="name">
    <FieldLabel>Name</FieldLabel>
    <Input placeholder="Enter name" />
    <FieldError />
  </Field>
  <Field name="age">
    <FieldLabel>Age</FieldLabel>
    <Input placeholder="Enter age" />
    <FieldError />
  </Field>
  <Button loading={loading} type="submit">
    Submit
  </Button>
</Form>

这一模式的关键在于:errors 与 Field name 的键对应关系把校验结果映射到具体字段,FieldError 只负责呈现,符合「错误输出与同一字段语义绑定」的验证渲染规则。

7.3 第三方表单库集成

规则文档还明确了与 React Hook Form / TanStack Form 的集成要点:必须把 ref 转发给底层控件(forwardRef/input ref),并把 invalid/touched/dirty 状态映射进 Field。不转发 ref 会导致「提交后聚焦首个错误字段」的行为失效。这是 primitives/form.md 中明确列出的常见陷阱之一。

八、常见陷阱清单

综合 rules/forms.md、primitives/form.md 与 primitives/input.md,以下是表单开发中最常见的错误:

  1. 用了 Form 却没有字段级结构:裸 Input 堆在 Form 里,没有 Field、label、error,字段语义完全丢失;
  2. 控件缺少 name:字段不会出现在表单提交载荷(FormData)中;
  3. 缺失输入框/按钮的 type:依赖浏览器默认值,type="search"/type="file" 样式不生效,按钮可能意外触发表单提交;
  4. 只显示校验文案、不设置无效语义:aria-invalid 与错误提示脱节,容器错误配色(border-destructive)不会触发;
  5. 复选框/单选组缺少分组结构与图例:多控件分组应使用 Fieldset + FieldItem 之类的 fieldset 式分组,而不是临时 div 包裹;
  6. 第三方库集成不转发 ref:焦点跳转错误、focus-on-error 失效;
  7. 对 InputGroup 顺序不敏感:addon 放错位置破坏焦点与内边距行为;
  8. 过度依赖 placeholder 当标签:输入内容后 placeholder 消失,标签语义即丢失;
  9. 图标按钮/纯图标控件无标签上下文:只有 icon 的控件必须配 aria-label 或可见标签,装饰图标则用 aria-hidden="true"。

九、进一步阅读

安装方式:在项目中使用 npx shadcn@latest add @coss/form(及对应的 @coss/input、@coss/input-group、@coss/otp-field、@coss/textarea 等)即可按需引入组件,手动安装时需确保 @base-ui/react 依赖存在(zod 校验场景还需安装 zod)。

登录后查看全文
coss