coss UI 表单与输入组件规则实战指南:Field 组合、InputGroup 顺序与验证语义
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 UIForm解析后的表单值对象,适合需要结构化值处理的场景。
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,以下是表单开发中最常见的错误:
- 用了
Form却没有字段级结构:裸 Input 堆在 Form 里,没有Field、label、error,字段语义完全丢失; - 控件缺少
name:字段不会出现在表单提交载荷(FormData)中; - 缺失输入框/按钮的
type:依赖浏览器默认值,type="search"/type="file"样式不生效,按钮可能意外触发表单提交; - 只显示校验文案、不设置无效语义:
aria-invalid与错误提示脱节,容器错误配色(border-destructive)不会触发; - 复选框/单选组缺少分组结构与图例:多控件分组应使用
Fieldset+FieldItem之类的 fieldset 式分组,而不是临时 div 包裹; - 第三方库集成不转发 ref:焦点跳转错误、focus-on-error 失效;
- 对 InputGroup 顺序不敏感:addon 放错位置破坏焦点与内边距行为;
- 过度依赖 placeholder 当标签:输入内容后 placeholder 消失,标签语义即丢失;
- 图标按钮/纯图标控件无标签上下文:只有 icon 的控件必须配
aria-label或可见标签,装饰图标则用aria-hidden="true"。
九、进一步阅读
- 规则来源文档:apps/ui/skills/coss/references/rules/forms.md
- 表单原始组件指南:apps/ui/skills/coss/references/primitives/form.md(安装、导入、最小模式、陷阱)
- 输入组件指南:apps/ui/skills/coss/references/primitives/input.md(尺寸
sm/default/lg、addon、粒子索引) - 核心源码:
- 可运行粒子示例:p-form-1.tsx、p-form-2.tsx、p-input-group-1.tsx、p-otp-field-1.tsx
安装方式:在项目中使用 npx shadcn@latest add @coss/form(及对应的 @coss/input、@coss/input-group、@coss/otp-field、@coss/textarea 等)即可按需引入组件,手动安装时需确保 @base-ui/react 依赖存在(zod 校验场景还需安装 zod)。