open-slide 中的 shadcn/ui 表单与输入组件规范:FieldGroup、InputGroup、ToggleGroup 与验证状态完全指南
open-slide 中的 shadcn/ui 表单与输入组件规范:FieldGroup、InputGroup、ToggleGroup 与验证状态完全指南
本篇指南围绕 open-slide 仓库 .agents/skills/shadcn/rules/forms.md 中沉淀的 shadcn/ui 表单与输入组件组合规范展开,系统讲解如何用 FieldGroup + Field 构建表单骨架、用 InputGroup + InputGroupAddon 组装带按钮的输入框、用 ToggleGroup 实现选项组,以及如何正确落地 data-invalid / aria-invalid 验证与禁用状态。读完你可以直接将这些规范应用到基于 shadcn/ui 的 React 项目中,并理解 open-slide 仓库 packages/core 中已安装控件的底层实现(base 原语库 API)与这些规范之间的对应关系。
规范在仓库中的位置与适用范围
open-slide 仓库以 pnpm workspace 组织,其中 packages/core 是核心实现包。它通过 packages/core/components.json 接入 shadcn/ui 体系:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/styles.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
从该配置可以看到:组件统一从 @/components/ui 导入(对应物理目录 packages/core/src/app/components/ui/),图标库为 lucide,样式走 CSS 变量体系(cssVariables: true)。在该目录下,仓库已落地了 input.tsx、textarea.tsx、select.tsx、toggle-group.tsx、button.tsx、card.tsx、dialog.tsx 等 20 个基础控件,这些正是 forms.md 各条组合规则的"原子积木"。
而 .agents/skills/shadcn/rules/forms.md 本身属于仓库内嵌的 Agent 技能(skill)体系,位于 .agents/skills/shadcn/ 下,与 base-vs-radix.md、composition.md、styling.md、icons.md 等规则文件共同构成 shadcn 组件的强制编码规范。它的核心主张是:表单 UI 必须通过专用组合组件表达,而不是用裸 div + Tailwind 工具类手工拼装,从而保证可访问性(标签、描述、校验状态的语义绑定)与视觉一致性。
表单骨架:永远使用 FieldGroup + Field
规范的第一条硬性要求:表单布局一律使用 FieldGroup + Field,禁止用裸 div 搭配 space-y-* 或 grid gap-* 手工排列字段。
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" type="email" />
</Field>
<Field>
<FieldLabel htmlFor="password">Password</FieldLabel>
<Input id="password" type="password" />
</Field>
</FieldGroup>
两点常用变体:
- 设置页使用水平布局:
<Field orientation="horizontal">,让标签与控件并排,适合 Settings 场景。 - 视觉隐藏标签:
<FieldLabel className="sr-only">,标签仍然存在于 DOM、可被屏幕阅读器与自动化测试感知,只是不显示。
Field 承担了"字段级"的语义容器职责:它统一承载标签(FieldLabel / FieldTitle)、描述(FieldDescription)与校验状态(data-invalid / data-disabled),这些信息会通过 CSS 联动到标签和描述的样式,而不只是作用在控件本身——这是裸 div 无法提供的结构语义。
表单控件选择速查表
| 需求 | 使用组件 |
|---|---|
| 简单文本输入 | Input |
| 预定义选项下拉 | Select |
| 可搜索下拉 | Combobox |
| 原生 HTML select(无 JS) | native-select |
| 布尔开关(设置页) | Switch;表单场景用 Checkbox |
| 少量选项单选 | RadioGroup |
| 2–5 个选项间切换 | ToggleGroup + ToggleGroupItem |
| OTP / 验证码 | InputOTP |
| 多行文本 | Textarea |
注意 Switch 与 Checkbox 的语义差别:设置面板中的布尔偏好用 Switch(即时生效),数据录入表单中的勾选项用 Checkbox(需要提交)。
InputGroup:复合输入框必须用专用子组件
当需要把前缀、后缀或按钮与输入框组合成一体时,禁止在 InputGroup 内直接放裸 Input 或 Textarea:
// 错误:裸 Input 直接放在 InputGroup 里
<InputGroup>
<Input placeholder="Search..." />
</InputGroup>
// 正确
import { InputGroup, InputGroupInput } from "@/components/ui/input-group"
<InputGroup>
<InputGroupInput placeholder="Search..." />
</InputGroup>
原因是 InputGroupInput / InputGroupTextarea 继承了 InputGroup 提供的尺寸、边框、圆角合并与内部间距上下文,裸 Input 无法感知这些结构信息,会出现边框重叠或圆角错位。
输入框内嵌按钮:InputGroup + InputGroupAddon
搜索框、密码可见性切换等"输入框 + 按钮"场景,规范明确禁止用绝对定位手工摆放:
// 错误:relative 容器 + absolute 定位的按钮
<div className="relative">
<Input placeholder="Search..." className="pr-10" />
<Button className="absolute right-0 top-0" size="icon">
<SearchIcon />
</Button>
</div>
// 正确
import { InputGroup, InputGroupInput, InputGroupAddon } from "@/components/ui/input-group"
<InputGroup>
<InputGroupInput placeholder="Search..." />
<InputGroupAddon>
<Button size="icon">
<SearchIcon data-icon="inline-start" />
</Button>
</InputGroupAddon>
</InputGroup>
这里的细节是图标上的 data-icon="inline-start":按照 .agents/skills/shadcn/rules/icons.md 的约定,Button 内的图标通过 data-icon 声明自己是行内前缀(inline-start)还是后缀(inline-end),由按钮的 CSS 统一处理间距与尺寸,不要在图标上写 size-4、mr-2 之类的手工类。这正是"组合优先于手写定位"原则的体现——InputGroupAddon 保证按钮与输入框同高、无缝贴合,且天然支持焦点与禁用状态的视觉联动。
选项组(2–7 项):用 ToggleGroup,不手写按钮循环
当需要在 2~7 个互斥/多选选项中切换时,禁止手写 Button 数组并手动维护 selected 状态:
// 错误:手工循环 Button + useState 维护激活态
const [selected, setSelected] = useState("daily")
<div className="flex gap-2">
{["daily", "weekly", "monthly"].map((option) => (
<Button
key={option}
variant={selected === option ? "default" : "outline"}
onClick={() => setSelected(option)}
>
{option}
</Button>
))}
</div>
// 正确
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
<ToggleGroup spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
<ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
</ToggleGroup>
ToggleGroup 自带激活态管理、键盘方向键导航与 aria-pressed 语义,spacing 控制项间距。与 Field 组合可以得到带标签的选项组:
<Field orientation="horizontal">
<FieldTitle id="theme-label">Theme</FieldTitle>
<ToggleGroup aria-labelledby="theme-label" spacing={2}>
<ToggleGroupItem value="light">Light</ToggleGroupItem>
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
<ToggleGroupItem value="system">System</ToggleGroupItem>
</ToggleGroup>
</Field>
base 与 radix 的 API 差异(详见 .agents/skills/shadcn/rules/base-vs-radix.md#togglegroup):
- radix 原语:
<ToggleGroup type="single" defaultValue="daily">,单选时defaultValue是字符串。 - base 原语:单选时不传
type,defaultValue永远是数组<ToggleGroup defaultValue={["daily"]}>;多选时传multiple布尔属性。
从 open-slide 仓库源码看,packages/core/src/app/components/ui/toggle-group.tsx 基于 @base-ui/react/toggle-group 与 @base-ui/react/toggle 实现,即当前使用的是 base 原语库。因此在本仓库内写 ToggleGroup 时应遵循 base 语义:单选 defaultValue={["daily"]}、多选加 multiple,受控时 value 与 onValueChange 需要围绕数组包装/解包:
// base 原语:受控单选需要包一层数组
const [value, setValue] = React.useState("normal")
<ToggleGroup value={[value]} onValueChange={(v) => setValue(v[0])}>
该组件还通过 ToggleGroupContext 向 ToggleGroupItem 下发 variant / size / spacing 上下文(packages/core/src/app/components/ui/toggle-group.tsx 中的 ToggleGroupContext.Provider),并在 spacing={0} 时自动合并相邻项的圆角与边框,形成无缝隙的分段控件样式——这解释了为什么手写 flex gap-2 + Button 永远复现不出这种内聚的分段外观。
相关字段分组:FieldSet + FieldLegend
一组相关的复选框、单选框或开关,应当用 FieldSet + FieldLegend 表达,而不是"div + 标题文字":
<FieldSet>
<FieldLegend variant="label">Preferences</FieldLegend>
<FieldDescription>Select all that apply.</FieldDescription>
<FieldGroup className="gap-3">
<Field orientation="horizontal">
<Checkbox id="dark" />
<FieldLabel htmlFor="dark" className="font-normal">Dark mode</FieldLabel>
</Field>
</FieldGroup>
</FieldSet>
FieldSet 对应 HTML 原生 <fieldset> 的分组语义,FieldLegend 则是 <legend>:屏幕阅读器会把整组控件与分组标题关联起来;FieldDescription 提供了组级说明文本。注意这里复用了 FieldGroup 作为组内字段的纵向布局容器,className="gap-3" 只用于调整布局间距——这符合 .agents/skills/shadcn/rules/styling.md 中"className 只做布局、不覆盖组件颜色与排版"的原则。
字段验证与禁用状态:两组属性缺一不可
验证与禁用需要成对设置——data-* 属性负责"字段级"的样式联动(标签、描述文字),原生语义属性负责控件本身的视觉:
// 验证失败态
<Field data-invalid>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" aria-invalid />
<FieldDescription>Invalid email address.</FieldDescription>
</Field>
// 禁用态
<Field data-disabled>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" disabled />
</Field>
data-invalid打在Field上,驱动标签/描述的失效配色;aria-invalid打在控件上,驱动控件红框,并同步向辅助技术宣告该字段值无效。data-disabled打在Field上,disabled打在控件上,两者共同保证标签与控件的一致性视觉。
该约定对所有表单控件一律适用:Input、Textarea、Select、Checkbox、RadioGroupItem、Switch、Slider、NativeSelect、InputOTP。
源码佐证:aria-invalid 的真实实现
open-slide 仓库中已安装的控件已经内置了对 aria-invalid 的样式响应。以 packages/core/src/app/components/ui/input.tsx 为例:
className={cn(
'h-8 w-full min-w-0 rounded-[5px] border border-border bg-background px-2.5 text-[13px] outline-none',
'transition-colors selection:bg-brand-soft selection:text-foreground',
'placeholder:text-muted-foreground/70',
'focus-visible:border-foreground/40 focus-visible:ring-2 focus-visible:ring-ring/30',
'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
'aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25',
...
)}
一旦传入 aria-invalid,Input 会自动切换为 border-destructive 并叠加红色 ring——开发者只需声明状态,视觉样式由组件统一接管。packages/core/src/app/components/ui/textarea.tsx 同样内置了 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25,select.tsx 的 SelectTrigger 也包含 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25。这正是 forms.md 要求"data-invalid 管字段、aria-invalid 管控件"的落地证据:原生语义属性同时承担了无障碍与视觉的双重职责。
base 原语对表单控件的其他影响
由于本仓库使用 base 原语库,.agents/skills/shadcn/rules/base-vs-radix.md 中还有几条与表单直接相关的 API 差异需要留意:
- Select 需要
itemsprop:base 的Select根组件要求传入items数组(含{ label, value }),占位符通过{ label: "Select a fruit", value: null }这一条"空项"表达;radix 则用<SelectValue placeholder="...">。packages/core/src/app/components/ui/select.tsx中const Select = SelectPrimitive.Root,并且SelectTrigger的图标通过render={<ChevronDownIcon ... />}注入——base 的render对应 radix 的asChild。 - Select 内容定位:base 用
alignItemWithTrigger(select.tsx中默认alignItemWithTrigger = true),radix 用position="popper"。 - 多选与对象值:base 的
Select支持multiple、SelectValue的 render-function children(例如显示"3 selected")以及itemToStringValue对象值;radix 仅支持字符串值的单选。 - Slider:base 单选滑块
defaultValue直接传数字<Slider defaultValue={50} />,radix 恒为数组<Slider defaultValue={[50]} />;范围滑块两者都用数组,base 受控时onValueChange可能需要v as number[]断言。
需要澄清的是,FieldGroup、Field、InputGroup、FieldSet 等组合组件属于 shadcn 技能规则所描述的组件族,当前 packages/core/src/app/components/ui/ 中已安装的是 Input、Textarea、Select、ToggleGroup、Button、Card 等基础控件;在引入 Field 系列组件之前,可以先按 forms.md 的语义约定用现有控件组织字段结构。
在实际项目中落地这套规范
拿到一个接入了 shadcn/ui 的项目后,可以按如下流程落地表单规范:
- 确认项目上下文:运行
npx shadcn@latest info --json(或npx shadcn@latest info),读取base(radix 或 base)、aliases.ui、iconLibrary、tailwindCssFile等字段——forms.md中的ToggleGroup、Select写法会随base字段不同而变化。open-slide 的packages/core/components.json中iconLibrary: "lucide",对应图标包为lucide-react。 - 安装缺失控件:例如
npx shadcn@latest add toggle-group select textarea(CLI 完整命令与标志见.agents/skills/shadcn/cli.md);升级已安装控件时先用--dry-run/--diff预览,避免直接覆盖本地改动。 - 按组合规范书写:骨架用
FieldGroup+Field,复合输入用InputGroup+InputGroupInput/InputGroupAddon,选项组用ToggleGroup,相关字段分组用FieldSet+FieldLegend,校验与禁用按"字段级data-*+ 控件级原生属性"成对声明。 - 自查清单:是否仍有裸
div排布表单字段?InputGroup内是否误放了裸Input?按钮是否用了绝对定位而非InputGroupAddon?是否手写了Button循环而非ToggleGroup?data-invalid/aria-invalid、data-disabled/disabled是否成对出现?
小结
forms.md 的每一条规则都指向同一个设计目标:用具有语义和状态的组合组件取代手写的布局与状态管理。Field 系列接管标签、描述与校验状态,InputGroup 系列接管复合输入框的结构完整性,ToggleGroup 接管选项组的激活与键盘交互,FieldSet 接管字段分组语义;而 data-invalid / aria-invalid 的双属性约定,则在无障碍与视觉之间建立了明确的分工。结合 open-slide 仓库中 input.tsx、textarea.tsx、select.tsx、toggle-group.tsx 的源码可以看到,这些规则并非空中楼阁——控件层已经为语义属性内置了完整的样式响应,组合层的规范只需正确声明语义即可。