open-slide 中的 shadcn/ui 表单与输入组件规范:FieldGroup、InputGroup、ToggleGroup 与验证状态完全指南

原创2026-09-27 05:34:031,368 阅读

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 需要 items prop: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 的项目后,可以按如下流程落地表单规范:

  1. 确认项目上下文:运行 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。
  2. 安装缺失控件:例如 npx shadcn@latest add toggle-group select textarea(CLI 完整命令与标志见 .agents/skills/shadcn/cli.md);升级已安装控件时先用 --dry-run / --diff 预览,避免直接覆盖本地改动。
  3. 按组合规范书写:骨架用 FieldGroup + Field,复合输入用 InputGroup + InputGroupInput/InputGroupAddon,选项组用 ToggleGroup,相关字段分组用 FieldSet + FieldLegend,校验与禁用按"字段级 data-* + 控件级原生属性"成对声明。
  4. 自查清单:是否仍有裸 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 的源码可以看到,这些规则并非空中楼阁——控件层已经为语义属性内置了完整的样式响应,组合层的规范只需正确声明语义即可。

登录后查看全文
open-slide