wui
组件

表单 Form

具备完整语义化结构、自动无障碍 ARIA 关联、字段校验动画与微动效反馈的表单容器体系。

第三方依赖 · motion

基础用法

通过组合 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
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> 的所有属性:

属性类型默认值说明
animatedbooleantrue挂载初次渲染时是否播放轻量平滑的向上渐入过渡动效。
noValidatebooleantrue是否跳过浏览器原生的校验气泡,统一由 `FormField` 与 `FormMessage` 呈现错误。字段上的 `required` 仍会写入原生属性与 `aria-required`。
onSubmit(event: React.FormEvent<HTMLFormElement>) => void—表单提交时的原生回调事件。
classNamestring—应用于表单根容器的额外 CSS 类名。

FormField

<FormField> 为每个独立字段提供上下文,并自动生成唯一的 DOM ID 与无障碍映射:

属性类型默认值说明
invalidbooleanfalse标记当前字段是否处于校验失败状态。为 true 时触发平滑左右微抖动提示动画。
requiredbooleanfalse是否为必填字段。为 true 时自动向 `FormLabel` 追加红色星号并向控件挂载 `aria-required`。
classNamestring—应用于单字段包装容器的额外 CSS 类名。

FormLabel

<FormLabel> 字段标题标签,自动关联对应控件的 id:

属性类型默认值说明
classNamestring—应用于标签文本的额外 CSS 类名。

FormControl

<FormControl> 将表单上下文中的无障碍属性(id、aria-describedby、aria-invalid、aria-required)自动注入到唯一的子元素控件中:

属性类型默认值说明
childrenReact.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 时自动关闭,确保敏感用户不受困扰。