组件
表单 Form
具备完整语义化结构、自动无障碍 ARIA 关联、字段校验动画与微动效反馈的表单容器体系。
基础用法
通过组合 Form、FormField、FormLabel、FormControl、FormDescription、FormMessage 与 FormActions 构建标准业务表单:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/form安装基础依赖与动效库
pnpm add motion clsx tailwind-merge复制组件源码到
components/ui/form.tsx"use client"
import * as React from "react"
import {
AnimatePresence,
motion,
useReducedMotion,
type HTMLMotionProps,
} from "motion/react"
import { cn } from "@/lib/utils"
interface FormFieldContextValue {
controlId: string
descriptionId: string
messageId: string
invalid: boolean
required: boolean
}
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null)
function useFormField() {
const context = React.useContext(FormFieldContext)
if (!context)
throw new Error("表单字段子组件必须在 <FormField> 内使用。")
return context
}
export interface FormProps extends HTMLMotionProps<"form"> {
/** 挂载时是否播放表单入场动效。@default true */
animated?: boolean
/**
* 是否跳过浏览器原生校验气泡,由 FormField / FormMessage 呈现校验反馈。
* 字段的 `required` 仍会写入原生属性与 `aria-required`。@default true
*/
noValidate?: boolean
}
/** 协调字段动效与校验状态的语义化表单容器。 */
function Form({
className,
animated = true,
noValidate = true,
children,
...props
}: FormProps) {
const reduceMotion = useReducedMotion()
return (
<motion.form
data-slot="form"
noValidate={noValidate}
initial={animated && !reduceMotion ? { opacity: 0, y: 8 } : false}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reduceMotion ? 0 : 0.38,
ease: [0.22, 1, 0.36, 1],
}}
className={cn("grid gap-4", className)}
{...props}
>
{children}
</motion.form>
)
}
export interface FormFieldProps extends HTMLMotionProps<"div"> {
/** 标记字段为无效并显示错误反馈。 */
invalid?: boolean
/** 为标签和辅助技术标记字段为必填。 */
required?: boolean
}
function FormField({
className,
invalid = false,
required = false,
children,
...props
}: FormFieldProps) {
const id = React.useId()
const reduceMotion = useReducedMotion()
return (
<FormFieldContext.Provider
value={{
controlId: `${id}-control`,
descriptionId: `${id}-description`,
messageId: `${id}-message`,
invalid,
required,
}}
>
<motion.div
data-slot="form-field"
data-invalid={invalid || undefined}
animate={
invalid && !reduceMotion ? { x: [0, -4, 4, -2, 2, 0] } : { x: 0 }
}
transition={{ duration: reduceMotion ? 0 : 0.32, ease: "easeOut" }}
className={cn("grid min-w-0 gap-1.5", className)}
{...props}
>
{children}
</motion.div>
</FormFieldContext.Provider>
)
}
function FormLabel({
className,
children,
...props
}: React.ComponentProps<"label">) {
const { controlId, invalid, required } = useFormField()
return (
<label
data-slot="form-label"
htmlFor={controlId}
data-invalid={invalid || undefined}
className={cn(
"data-[invalid=true]:text-destructive flex items-baseline gap-1 text-sm font-medium leading-none transition-colors",
className
)}
{...props}
>
{children}
{required ? (
<span aria-hidden="true" className="text-destructive">
*
</span>
) : null}
</label>
)
}
export interface FormControlProps extends React.ComponentProps<"div"> {
/** 接收表单无障碍属性的单个输入类元素。 */
children: React.ReactElement<Record<string, unknown>>
}
function FormControl({ children, ...props }: FormControlProps) {
const { controlId, descriptionId, messageId, invalid, required } =
useFormField()
return (
<div data-slot="form-control" {...props}>
{React.cloneElement(children, {
id: children.props.id ?? controlId,
"aria-describedby":
children.props["aria-describedby"] ??
`${descriptionId}${invalid ? ` ${messageId}` : ""}`,
"aria-invalid": children.props["aria-invalid"] ?? invalid,
"aria-required": children.props["aria-required"] ?? required,
required: children.props.required ?? required,
})}
</div>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { descriptionId } = useFormField()
return (
<p
id={descriptionId}
data-slot="form-description"
className={cn("text-muted-foreground text-xs leading-relaxed", className)}
{...props}
/>
)
}
/**
* 校验失败时展开的错误提示。保持挂载即可获得完整的展开与收起动效:
* 高度与透明度同步过渡,并抵消字段栅格间距,避免出现与消失时布局跳动。
*/
function FormMessage({ className, children, ...props }: HTMLMotionProps<"p">) {
const { messageId, invalid } = useFormField()
const reduceMotion = useReducedMotion()
const transition = {
duration: reduceMotion ? 0 : 0.24,
ease: [0.22, 1, 0.36, 1] as const,
}
return (
<AnimatePresence initial={false}>
{invalid && children ? (
<motion.div
key="message"
data-slot="form-message-container"
className="-mt-1.5 overflow-hidden"
initial={reduceMotion ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduceMotion ? { opacity: 0 } : { height: 0, opacity: 0 }}
transition={transition}
>
<motion.p
id={messageId}
data-slot="form-message"
role="alert"
initial={reduceMotion ? false : { y: -4 }}
animate={{ y: 0 }}
exit={reduceMotion ? undefined : { y: -4 }}
transition={transition}
className={cn("text-destructive pt-1 text-xs font-medium", className)}
{...props}
>
{children}
</motion.p>
</motion.div>
) : null}
</AnimatePresence>
)
}
function FormSection({
className,
...props
}: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="form-section"
className={cn("grid min-w-0 gap-3.5 border-0 p-0", className)}
{...props}
/>
)
}
function FormLegend({ className, ...props }: React.ComponentProps<"legend">) {
return (
<legend
data-slot="form-legend"
className={cn("mb-1 text-base font-semibold tracking-tight", className)}
{...props}
/>
)
}
function FormActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="form-actions"
className={cn("flex items-center justify-end gap-2 pt-1", className)}
{...props}
/>
)
}
export {
Form,
FormActions,
FormControl,
FormDescription,
FormField,
FormLabel,
FormLegend,
FormMessage,
FormSection,
}
属性 Props
Form
<Form> 承载表单顶层容器,并继承 Framer Motion <motion.form> 的所有属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| animated | boolean | true | 挂载初次渲染时是否播放轻量平滑的向上渐入过渡动效。 |
| noValidate | boolean | true | 是否跳过浏览器原生的校验气泡,统一由 `FormField` 与 `FormMessage` 呈现错误。字段上的 `required` 仍会写入原生属性与 `aria-required`。 |
| onSubmit | (event: React.FormEvent<HTMLFormElement>) => void | — | 表单提交时的原生回调事件。 |
| className | string | — | 应用于表单根容器的额外 CSS 类名。 |
FormField
<FormField> 为每个独立字段提供上下文,并自动生成唯一的 DOM ID 与无障碍映射:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| invalid | boolean | false | 标记当前字段是否处于校验失败状态。为 true 时触发平滑左右微抖动提示动画。 |
| required | boolean | false | 是否为必填字段。为 true 时自动向 `FormLabel` 追加红色星号并向控件挂载 `aria-required`。 |
| className | string | — | 应用于单字段包装容器的额外 CSS 类名。 |
FormLabel
<FormLabel> 字段标题标签,自动关联对应控件的 id:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| className | string | — | 应用于标签文本的额外 CSS 类名。 |
FormControl
<FormControl> 将表单上下文中的无障碍属性(id、aria-describedby、aria-invalid、aria-required)自动注入到唯一的子元素控件中:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | React.ReactElement | — | 单个表单输入控件(如 Input、Select、Cascader、Switch、Checkbox 等)。 |
FormDescription 与 FormMessage
<FormDescription>:显示字段的帮助说明文本,自动与控件的aria-describedby关联。<FormMessage>:校验错误提示文本,在invalid={true}且有内容时展开,挂载role="alert"。展开与收起同步过渡高度与透明度,并抵消字段内的栅格间距,出现和消失都不会让下方内容跳动。
让错误提示完整地收起
请始终渲染 <FormMessage>{error}</FormMessage>,由 invalid 控制显隐;不要写成 invalid ? <FormMessage /> : null。条件卸载会让组件来不及播放收起动效。
FormSection, FormLegend, FormActions
<FormSection>:语义化<fieldset>,用于组织一组关联紧密的字段集合。<FormLegend>:语义化<legend>,区块标题。<FormActions>:表单底部操作按钮区域容器。
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onSubmit | (event: React.FormEvent<HTMLFormElement>) => void | — | 用户点击提交按钮或在输入框内按下 Enter 触发提交时调用。 |
| onReset | (event: React.FormEvent<HTMLFormElement>) => void | — | 表单重置时触发。 |
使用场景与设计规范
Form 组件是现代复杂企业级应用和 C 端产品中所有录入交互的基础中枢。
- 自动建立无障碍关联:
FormField在内部自动通过React.useId()协调controlId、descriptionId与messageId,开发者无需手动拼写繁琐的htmlFor和aria-describedby字符串。 - 动态错误抖动反馈:当字段触发
invalid时,FormField自动播放符合物理弹性的 X 轴微抖动反馈,同时FormMessage展开,使表单错误一目了然。 - 校验文案规范:错误提示应具体指明如何修复(例如“密码需至少包含 1 个大写字母与 8 位字符”),而非抽象模糊的“格式错误”。
- 表单状态与库集成:可无缝配合 React Hook Form、Formik、Zod 或原生 React
useState。
场景示例
实时表单校验
在失焦或提交时校验字段,错误提示平滑展开,修正后平滑收起;提交成功后表单与结果视图之间淡入切换:
Loading…
多列排版与业务配置表单
在配置面板中,使用 FormSection、多列网格、InputNumber 与开关组合高密度表单,并根据是否有修改控制操作按钮:
Loading…
异步请求与加载反馈
在校验 API 密钥或提交远程接口时,控制按钮加载状态与成功横幅:
Loading…
无障碍与交互 Accessibility
- WAI-ARIA 规范:
FormControl会自动将子控件的aria-invalid、aria-required与aria-describedby(同时关联描述与错误提示)绑定到位。 - 屏幕阅读器支持:错误提示
FormMessage具有role="alert",出现时会立即被屏幕阅读器感知播报。 - 动效降级:内置的入场动效、错误抖动和折叠动画在检测到
prefers-reduced-motion时自动关闭,确保敏感用户不受困扰。