Field
AniBT 的表单版式:标签在上、标签旁的小签和右端的元信息、控件下的说明或错误、短字段两两成行、一组字段一张卡。导出名与 shadcn 的 field 相同。
基本信息
短字段两两成行四位数年份。
"use client"
import { LockIcon } from "lucide-react"
import { StatusBadge } from "@/components/ui/badge"
import { Field, FieldControl, FieldGroup, FieldRow, FieldTag } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export default function FieldDemo() {
return (
<FieldGroup title="基本信息" note="短字段两两成行" className="w-full max-w-2xl">
<Field
label="中文标题"
required
tags={<StatusBadge tone="on">手动</StatusBadge>}
meta="Demo Admin 接管 · 2 天前"
>
<FieldControl>
<Input defaultValue="药屋少女的呢喃 第二季" />
</FieldControl>
</Field>
<FieldRow>
<Field label="集数" tags={<FieldTag icon={LockIcon}>已锁定</FieldTag>}>
<FieldControl>
<Input type="number" className="anibt-num" defaultValue={24} />
</FieldControl>
</Field>
<Field label="年份" hint="四位数年份。">
<FieldControl>
<Input inputMode="numeric" className="anibt-num" defaultValue="2026" />
</FieldControl>
</Field>
</FieldRow>
<FieldRow>
<Field label="Slug" readOnly>
<FieldControl>
<Input readOnly className="anibt-num" defaultValue="sakura-subs" />
</FieldControl>
</Field>
<Field label="官网" error="请输入以 https:// 开头的网址">
<FieldControl>
<Input type="url" defaultValue="kusuriya-anime" />
</FieldControl>
</Field>
</FieldRow>
</FieldGroup>
)
}安装
npx shadcn@latest add @anibt/field第一次用之前:在 components.json 里登记 @anibt,再单独运行一次 npx shadcn@latest add @anibt/theme(见快速开始)。只有单独安装主题时,CLI 才会把 shadcn 的标准颜色换成白樱 / 夜樱;随组件带进来的主题只补上 AniBT 自己的变量和配方。 CLI 会一并装好 @anibt/label。
手动安装
- 1. 先装主题组件只读
@anibt/theme的 token 和配方,没有主题就没有样子。npx shadcn@latest add @anibt/theme - 2. 安装依赖
npm install class-variance-authority lucide-react radix-ui - 3. 它还用到
npx shadcn@latest add @anibt/label - 4. 把下面的文件放进项目components/ui/field.tsx
"use client" import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { AlertCircleIcon, LockIcon } from "lucide-react" import { Slot } from "radix-ui" import { cn } from "@/lib/utils" import { Label } from "@/components/ui/label" /** * Field — AniBT's form grammar (A 「贴纸手账」), on shadcn's field names. * * The rules, the same in every form: * - The label sits above its control: 13/600 `--anibt-ink2`, 6px over it. * Required is a pink `*` (read as 「必填」). Beside the label only small * facts (`FieldTag`: a source, 已锁定, 只读); the label row's far end holds * meta (a count, a time) and wraps under the label on a narrow field. * - Under the control: a 12px hint in `--muted-foreground`, or a 12px error * in `--destructive` behind its glyph (`aria-live`) — never both. * - 16px between fields. A group of fields is one card (`FieldGroup` with a * `title`: paper, a stitched 14/700 head, 16px padding); 20px between cards. * - Two or three fields side by side only for short values, and only when * the row itself is wide enough (`FieldRow`, a container query). * - Option text beside a checkbox or a switch is body copy, 13/400 ink * (`orientation="horizontal"`). * * Two ways to write it: * - the one-call form: `<Field label="标题" hint="…" error={…}>` with the * control inside `FieldControl`, which gives it the field's id, its * `aria-describedby` and `aria-invalid`; * - shadcn's composition (`FieldSet`, `FieldLegend`, `FieldLabel`, * `FieldContent`, `FieldTitle`, `FieldDescription`, `FieldError`, * `FieldSeparator`), with the same exports and props as shadcn's field so * code written for it keeps compiling. */ type FieldContextValue = { controlId: string labelId: string hintId: string errorId: string hasHint: boolean hasError: boolean labelFor: "control" | "group" } const FieldContext = React.createContext<FieldContextValue | null>(null) /** Groups of fields: a plain stack, or — with a `title` — one A card. */ function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { return ( <fieldset data-slot="field-set" className={cn( "flex min-w-0 flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", className )} {...props} /> ) } /** A fieldset's caption: `legend` is the 14/700 head, `label` the 13/600 label tier. */ function FieldLegend({ className, variant = "legend", ...props }: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { return ( <legend data-slot="field-legend" data-variant={variant} className={cn( "mb-1.5 data-[variant=label]:text-[13px] data-[variant=label]:leading-5 data-[variant=label]:font-semibold data-[variant=label]:text-anibt-ink2 data-[variant=legend]:text-[14px] data-[variant=legend]:leading-[1.4] data-[variant=legend]:font-bold data-[variant=legend]:text-foreground", className )} {...props} /> ) } type FieldGroupProps = Omit<React.ComponentProps<"div">, "title"> & { /** Turns the group into an A card with this stitched 14/700 head. */ title?: React.ReactNode /** A 12px note beside the title (the card form only). */ note?: React.ReactNode /** Tools at the head's far end (the card form only). */ actions?: React.ReactNode /** Classes for the card's body, where the fields stack. */ bodyClassName?: string } function FieldGroup({ title, note, actions, bodyClassName, className, children, ...props }: FieldGroupProps) { const headingId = React.useId() const stack = "group/field-group @container/field-group flex w-full min-w-0 flex-col gap-4 has-[>[data-slot=field-group]]:gap-5 data-[slot=checkbox-group]:gap-3" if (title === undefined || title === null) { return ( <div data-slot="field-group" className={cn(stack, className)} {...props}> {children} </div> ) } return ( <section data-slot="field-group" data-variant="card" aria-labelledby={headingId} className={cn("anibt-sheet min-w-0 scroll-mt-3 overflow-clip", className)} {...(props as React.ComponentProps<"section">)} > <div className="anibt-stitch-under flex min-h-11 min-w-0 items-center gap-2 px-4 py-2"> <h3 id={headingId} className="anibt-ell text-[14px] leading-[1.4] font-bold text-foreground"> {title} </h3> {note ? <span className="anibt-ell text-[12px] text-muted-foreground">{note}</span> : null} {actions ? <div className="ml-auto flex shrink-0 items-center gap-2">{actions}</div> : null} </div> <div className={cn(stack, "p-4", bodyClassName)}>{children}</div> </section> ) } const fieldVariants = cva("group/field w-full min-w-0", { variants: { orientation: { // Label row, control, hint: 6px apart. A select trigger takes the width. vertical: "grid content-start gap-1.5 *:data-[slot=select-trigger]:w-full", // A choice row: the control, then its option text (13/400 ink). horizontal: "flex flex-row items-center gap-2 has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto *:data-[slot=field-label]:font-normal *:data-[slot=field-label]:text-foreground has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px has-[>:disabled]:*:data-[slot=field-label]:text-muted-foreground data-[disabled=true]:*:data-[slot=field-label]:text-muted-foreground", // Stacked in a narrow group, label beside the control from 28rem. responsive: "flex flex-col gap-1.5 *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:gap-3 @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto", }, }, defaultVariants: { orientation: "vertical", }, }) type FieldProps = Omit<React.ComponentProps<"div">, "children"> & VariantProps<typeof fieldVariants> & { /** The one-call form: the field's label, drawn above the control. */ label?: React.ReactNode /** The control's id; generated when absent. The label points at it. */ id?: string /** A pink `*` after the label, read as `requiredLabel`. */ required?: boolean /** Shown but not editable here: a 只读 tag beside the label. */ readOnly?: boolean /** Small tags beside the label: a source, a lock (`FieldTag`). */ tags?: React.ReactNode /** The label row's far end: a count, a time, a disclosure. */ meta?: React.ReactNode /** 12px help under the control. Hidden while there is an error. */ hint?: React.ReactNode /** The error under the control; also marks the control invalid. */ error?: React.ReactNode /** * `group` for a set of controls (a segmented control, a chip row) that * is named with `aria-labelledby` instead of `for`. */ labelFor?: "control" | "group" /** What a screen reader says for the `*`. */ requiredLabel?: string /** The read-only tag's word. */ readOnlyLabel?: string children?: React.ReactNode } function Field({ className, orientation = "vertical", label, id, required, readOnly, tags, meta, hint, error, labelFor = "control", requiredLabel = "必填", readOnlyLabel = "只读", children, ...props }: FieldProps) { const reactId = React.useId() const controlId = id ?? `${reactId}-control` const hasHint = Boolean(hint) const hasError = Boolean(error) const context = React.useMemo<FieldContextValue>( () => ({ controlId, labelId: `${controlId}-label`, hintId: `${controlId}-hint`, errorId: `${controlId}-error`, hasHint, hasError, labelFor, }), [controlId, hasHint, hasError, labelFor] ) // The one-call form puts the label above the control (or, horizontal, // beside a choice); "responsive" is for shadcn's composition only. const layout = label !== undefined && label !== null && orientation === "responsive" ? "vertical" : orientation const shell = (body: React.ReactNode) => ( <FieldContext.Provider value={context}> <div role="group" data-slot="field" data-orientation={layout} data-invalid={hasError || undefined} className={cn(fieldVariants({ orientation: layout }), className)} {...props} > {body} </div> </FieldContext.Provider> ) if (label === undefined || label === null) return shell(children) const labelText = ( <> {label} {required ? ( <> <span className="text-accent-foreground" aria-hidden="true"> * </span> <span className="sr-only">{requiredLabel}</span> </> ) : null} </> ) const under = hasError ? ( <FieldError id={context.errorId}>{error}</FieldError> ) : hasHint ? ( <FieldDescription id={context.hintId}>{hint}</FieldDescription> ) : null if (orientation === "horizontal") { return shell( <> {children} <FieldContent> <FieldLabel id={context.labelId} htmlFor={labelFor === "control" ? controlId : undefined} className="font-normal text-foreground" > {labelText} </FieldLabel> {under} </FieldContent> </> ) } return shell( <> {/* The label row wraps: on a narrow field the meta drops under the label instead of squeezing it. */} <div className="flex min-h-6 min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1"> <FieldLabel id={context.labelId} htmlFor={labelFor === "control" ? controlId : undefined} className="min-w-0 shrink"> {labelText} </FieldLabel> {readOnly ? <FieldTag icon={LockIcon}>{readOnlyLabel}</FieldTag> : null} {tags} {meta ? ( <div className="ml-auto flex max-w-full min-w-0 items-center gap-1.5 text-[12px] text-muted-foreground">{meta}</div> ) : null} </div> {children} {under} </> ) } /** * Gives the single child the field's id (or, for `labelFor="group"`, its * `aria-labelledby`), the hint or error as its description and its invalid * state. Outside a `Field` it passes the child through. */ function FieldControl(props: React.ComponentProps<typeof Slot.Root>) { const field = React.useContext(FieldContext) if (!field) return <Slot.Root {...props} /> const describedBy = field.hasError ? field.errorId : field.hasHint ? field.hintId : undefined return ( <Slot.Root id={field.controlId} aria-labelledby={field.labelFor === "group" ? field.labelId : undefined} aria-describedby={describedBy} aria-invalid={field.hasError || undefined} {...props} /> ) } /** Title and description stacked beside a control in a horizontal field. */ function FieldContent({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="field-content" className={cn("group/field-content flex min-w-0 flex-1 flex-col gap-0.5 leading-snug", className)} {...props} /> ) } /** * The field's label (a `Label`). Wrapping a whole `Field` in it makes a * choice card: an A well that turns pink when its control is checked. */ function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) { return ( <Label data-slot="field-label" className={cn( "group/field-label peer/field-label flex w-fit gap-1.5 leading-5 group-data-[disabled=true]/field:text-muted-foreground", "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:items-stretch has-[>[data-slot=field]]:rounded-[10px] has-[>[data-slot=field]]:bg-card has-[>[data-slot=field]]:font-normal has-[>[data-slot=field]]:text-foreground has-[>[data-slot=field]]:inset-ring-1 has-[>[data-slot=field]]:inset-ring-border has-[>[data-slot=field]]:transition-[background-color,box-shadow] has-[>[data-slot=field]]:duration-150 *:data-[slot=field]:px-3 *:data-[slot=field]:py-2.5", "has-[>[data-slot=field]]:not-has-[:disabled]:hover:inset-ring-anibt-wash-line", // Picked (a radio or a checkbox inside): the pink wash. A switch is a // setting, not a pick, so an "on" settings card stays paper. "has-[>[data-slot=field]]:has-[[role=radio][data-state=checked]]:bg-accent has-[>[data-slot=field]]:has-[[role=radio][data-state=checked]]:inset-ring-anibt-wash-line has-[>[data-slot=field]]:has-[[role=checkbox][data-state=checked]]:bg-accent has-[>[data-slot=field]]:has-[[role=checkbox][data-state=checked]]:inset-ring-anibt-wash-line", className )} {...props} /> ) } /** A label-tier title that labels nothing (a choice card's head). */ function FieldTitle({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="field-label" className={cn( "flex w-fit items-center gap-1.5 text-[13px] leading-5 font-semibold text-foreground group-data-[disabled=true]/field:text-muted-foreground", className )} {...props} /> ) } /** The hint under a control (or under a title): 12px, muted. */ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) { return ( <p data-slot="field-description" className={cn( "text-[12px] leading-normal font-normal text-muted-foreground [[data-variant=legend]+&]:-mt-1", "[&>a]:text-accent-foreground [&>a]:underline-offset-4 [&>a:hover]:underline", className )} {...props} /> ) } /** A stitched rule between fields, with an optional word in the middle. */ function FieldSeparator({ children, className, ...props }: React.ComponentProps<"div"> & { children?: React.ReactNode }) { return ( <div data-slot="field-separator" data-content={!!children} className={cn("relative -my-1 h-5 text-[12px]", className)} {...props} > <div aria-hidden="true" className="anibt-stitch absolute inset-x-0 top-1/2" /> {children ? ( <span data-slot="field-separator-content" className="relative mx-auto block w-fit bg-card px-2 leading-5 text-muted-foreground" > {children} </span> ) : null} </div> ) } /** * The error under a control: 12px `--destructive` behind its glyph, * announced politely. Takes `children`, or shadcn's `errors` list (a * validator's `{ message }` objects; duplicates are dropped). */ function FieldError({ className, children, errors, ...props }: React.ComponentProps<"div"> & { errors?: Array<{ message?: string } | undefined> }) { const content = React.useMemo(() => { if (children) return children if (!errors?.length) return null const unique = [...new Map(errors.map((error) => [error?.message, error])).values()] if (unique.length === 1) return unique[0]?.message return ( <ul className="ml-4 flex list-disc flex-col gap-1"> {unique.map((error, index) => (error?.message ? <li key={index}>{error.message}</li> : null))} </ul> ) }, [children, errors]) if (!content) return null return ( <div data-slot="field-error" aria-live="polite" className={cn("flex items-start gap-1 text-[12px] leading-normal font-normal text-destructive", className)} {...props} > <AlertCircleIcon className="mt-px size-3.5 shrink-0" aria-hidden="true" /> <div className="min-w-0">{content}</div> </div> ) } /** AniBT's name for the hint under a control. */ const FieldHint = FieldDescription type FieldTagTone = "neutral" | "accent" | "ok" | "warn" | "danger" const TAG_TONE: Record<FieldTagTone, string> = { neutral: "", accent: "anibt-tag-lang", ok: "anibt-tag-ok", warn: "anibt-tag-warn", danger: "anibt-tag-danger", } /** * A small tag beside a label: a fact about the field (a source, 已锁定, * 只读), not a control. Never tilted — form rows stay straight. */ function FieldTag({ icon: Icon, tone = "neutral", className, children, ...props }: React.ComponentProps<"span"> & { icon?: React.ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" }> tone?: FieldTagTone }) { return ( <span data-slot="field-tag" data-tone={tone} className={cn("anibt-tag shrink-0", TAG_TONE[tone], className)} {...props}> {Icon ? <Icon className="size-3 shrink-0" aria-hidden="true" /> : null} {children} </span> ) } /** * Short fields side by side — only once the row itself is wide enough (30rem * for two, 34rem for three), stacked otherwise. Titles, URLs and prose take * a row of their own. */ function FieldRow({ columns = 2, className, ...props }: React.ComponentProps<"div"> & { columns?: 2 | 3 }) { return ( <div className="@container/field-row min-w-0"> <div data-slot="field-row" className={cn( "grid min-w-0 gap-4", columns === 3 ? "@[34rem]/field-row:grid-cols-3" : "@[30rem]/field-row:grid-cols-2", className )} {...props} /> </div> ) } export { Field, FieldLabel, FieldDescription, FieldError, FieldGroup, FieldLegend, FieldSeparator, FieldSet, FieldContent, FieldTitle, FieldControl, FieldHint, FieldRow, FieldTag, fieldVariants, } export type { FieldProps, FieldGroupProps }
版式
一个表单只有一套版式:
- 标签在控件上方:13/600
ink2,离控件 6px。必填是粉色*,读屏念「必填」。 - 标签旁只放小签(
FieldTag,20 高、不倾斜):数据来源、已锁定、只读。标签行右端放元信息(字数、接管人、变更时间);窄的时候元信息折到下一行,不挤标签。 - 控件下方:12px 的说明(
muted-foreground),或 12px 的红色错误(带图标、aria-live),二者只出一个。 - 节奏:字段之间 16px;一组字段是一张卡(
FieldGroup带title:纸面、缝线头、14/700 标题、16px 内边距),卡与卡之间 20px。 - 两列只给短字段(集数、年份、类型、状态……),而且由这一行自己的宽度决定(
FieldRow是容器查询:够 30rem 才两列)。标题、网址、正文永远独占一行。
用法
import { LockIcon } from "lucide-react"
import { Field, FieldControl, FieldGroup, FieldRow, FieldTag } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export function AnimeForm() {
return (
<FieldGroup title="基本信息" note="短字段两两成行">
<Field label="中文标题" required meta="2 天前修改">
<FieldControl>
<Input defaultValue="药屋少女的呢喃 第二季" />
</FieldControl>
</Field>
<FieldRow>
<Field label="集数" tags={<FieldTag icon={LockIcon}>已锁定</FieldTag>}>
<FieldControl>
<Input type="number" className="anibt-num" />
</FieldControl>
</Field>
<Field label="官网" error="请输入以 https:// 开头的网址">
<FieldControl>
<Input type="url" />
</FieldControl>
</Field>
</FieldRow>
</FieldGroup>
)
}FieldControl 把字段的 id、aria-describedby(指向说明或错误)和 aria-invalid 交给唯一的子控件,标签的 for 也指向它。给一组控件(分段控件、筛选签)命名时用 labelFor="group":标签不再用 for,控件改用 aria-labelledby。
选项行与选择卡
设置
import { Checkbox } from "@/components/ui/checkbox"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
FieldTitle,
} from "@/components/ui/field"
import { Switch } from "@/components/ui/switch"
const LOCKS = [
{ id: "title", label: "标题", locked: true },
{ id: "format", label: "格式" },
{ id: "status", label: "状态" },
{ id: "season", label: "季度" },
{ id: "year", label: "年份" },
{ id: "premiere", label: "首播" },
{ id: "cover", label: "封面" },
{ id: "site", label: "官方网站", locked: true },
]
export default function FieldChoices() {
return (
<FieldGroup title="设置" className="w-full max-w-2xl">
<FieldSet>
<FieldLegend>锁字段(同步不会覆盖)</FieldLegend>
<FieldDescription>保存时会自动锁定所有被修改的字段;被锁定的字段不会被同步覆盖。</FieldDescription>
<div className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
{LOCKS.map((lock) => (
<Field key={lock.id} orientation="horizontal">
<Checkbox id={`field-choices-${lock.id}`} defaultChecked={lock.locked} />
<FieldLabel htmlFor={`field-choices-${lock.id}`}>{lock.label}</FieldLabel>
</Field>
))}
</div>
</FieldSet>
<FieldLabel htmlFor="field-choices-hide">
<Field orientation="horizontal">
<FieldContent>
<FieldTitle>对公开页面隐藏</FieldTitle>
<FieldDescription>隐藏后不会出现在公开列表、搜索和周表中。</FieldDescription>
</FieldContent>
<Switch id="field-choices-hide" />
</Field>
</FieldLabel>
</FieldGroup>
)
}orientation="horizontal"是选项行:控件在前,选项文字是 13/400 墨色正文,不是字段标签。- 用
FieldLabel包住整个Field就是一张选择卡:1px 边的井,悬停边变粉;里面的单选或勾选被选中时整张卡变成粉色淡底。开关是设置不是选择,开着的设置卡仍是纸面。 FieldSet+FieldLegend给一组勾选框起标题;FieldLegend默认是 14/700 的小标题,variant="label"是 13/600 的标签档。
组合写法
"use client"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
export default function FieldCompose() {
return (
<form className="w-full max-w-md" onSubmit={(event) => event.preventDefault()}>
<FieldGroup>
<FieldSet>
<FieldLegend>订阅 RSS</FieldLegend>
<FieldDescription>新资源发布后,你的阅读器会自动收到。</FieldDescription>
<FieldGroup>
<Field>
<FieldLabel htmlFor="field-compose-anime">番剧</FieldLabel>
<Input id="field-compose-anime" defaultValue="药屋少女的呢喃 第二季" />
</Field>
<Field>
<FieldLabel htmlFor="field-compose-group">字幕组</FieldLabel>
<Select defaultValue="nekomoe">
<SelectTrigger id="field-compose-group">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="nekomoe">喵萌奶茶屋</SelectItem>
<SelectItem value="sakurato">桜都字幕组</SelectItem>
<SelectItem value="lolihouse">LoliHouse</SelectItem>
</SelectContent>
</Select>
<FieldDescription>只收这个字幕组的发布。</FieldDescription>
</Field>
<Field data-invalid>
<FieldLabel htmlFor="field-compose-keyword">关键词</FieldLabel>
<Input id="field-compose-keyword" aria-invalid defaultValue="简" />
<FieldError errors={[{ message: "关键词至少两个字,例如「简中」或「1080」。" }]} />
</Field>
</FieldGroup>
</FieldSet>
<FieldSeparator>推送</FieldSeparator>
<Field orientation="horizontal">
<Checkbox id="field-compose-push" defaultChecked />
<FieldLabel htmlFor="field-compose-push">同时推送到 qBittorrent</FieldLabel>
</Field>
<Field orientation="horizontal">
<Button type="submit">生成订阅链接</Button>
<Button type="button" variant="ghost">
取消
</Button>
</Field>
</FieldGroup>
</form>
)
}shadcn 的 field 写法原样可用:FieldSet、FieldLegend、FieldGroup、Field、FieldLabel、FieldContent、FieldTitle、FieldDescription、FieldError、FieldSeparator 的导出名和属性都相同,替换 components/ui/field.tsx 后原来的代码继续能编译。差别只在样子:A 的字号、间距和缝线。
orientation="responsive" 在 FieldGroup 宽于 28rem 时把标签放到控件左边。A 的表单标签一律在上方,这一档只为兼容 shadcn 的写法。
配合 react-hook-form
不需要额外的 form 组件:用 react-hook-form 的 Controller,把 fieldState 交给 Field。
<Controller
name="title"
control={form.control}
render={({ field, fieldState }) => (
<Field label="中文标题" required error={fieldState.error?.message}>
<FieldControl>
<Input {...field} />
</FieldControl>
</Field>
)}
/>属性
其余导出:FieldControl(把 id 和描述交给子控件的 Slot)、FieldLabel、FieldTitle、FieldContent、FieldDescription(别名 FieldHint)、FieldSet、FieldLegend(variant 为 legend / label)、FieldSeparator(缝线分隔,可在中间放一个词)、fieldVariants。
