Form 表单

汇集字段的值、按规则校验并提交。

不超过 80 个字

'use client'

import { useState } from 'react'
import * as v from 'valibot'
import { Button, Form, FormField, Input, Select, Switch, Text, Textarea } from '@hina-ui/react'

const schema = v.object({
  title: v.pipe(
    v.string('请输入标题'),
    v.nonEmpty('请输入标题'),
    v.maxLength(20, '标题不超过 20 个字'),
  ),
  category: v.pipe(v.string('请选择分类'), v.nonEmpty('请选择分类')),
  summary: v.pipe(v.string('请输入简介'), v.maxLength(80, '简介不超过 80 个字')),
  published: v.boolean('请选择是否公开'),
})

const categories = [
  { label: '游戏', value: 'game' },
  { label: '小说', value: 'novel' },
  { label: '漫画', value: 'manga' },
]

export default function Demo() {
  const [values, setValues] = useState({
    title: '',
    category: null as string | number | null | undefined,
    summary: '',
    published: true,
  })
  const [saved, setSaved] = useState('')

  async function save(data: unknown) {
    await new Promise(resolve => setTimeout(resolve, 800))
    setSaved(JSON.stringify(data))
  }

  return (
    <Form values={values} rules={schema} className="w-80" onSubmit={save}>
      {({ submitting }) => (
        <>
          <FormField name="title" label="标题" required>
            <Input
              value={values.title}
              onValueChange={title => setValues({ ...values, title })}
              placeholder="作品名称"
            />
          </FormField>
          <FormField name="category" label="分类" required>
            <Select
              value={values.category}
              onValueChange={category => setValues({ ...values, category })}
              options={categories}
            />
          </FormField>
          <FormField name="summary" label="简介" description="不超过 80 个字">
            <Textarea
              value={values.summary}
              onValueChange={summary => setValues({ ...values, summary })}
            />
          </FormField>
          <FormField name="published">
            <Switch
              checked={values.published}
              onCheckedChange={published => setValues({ ...values, published })}
            >
              公开显示
            </Switch>
          </FormField>
          <Button type="submit" loading={submitting} className="self-start">
            提交
          </Button>
          {saved && (
            <Text tone="muted" size="sm">
              已提交:{saved}
            </Text>
          )}
        </>
      )}
    </Form>
  )
}
tsx

用法

import { Form, FormField } from '@hina-ui/react'
ts

表单把一组字段的值、校验规则与提交动作放在一处。values 传入当前的值对象(通常来自 useState),各控件用 value 与 onValueChange 读写其中的字段,修改时传入新的对象;rules 传入校验规则,提交时先校验,全部通过才调用 onSubmit。每个字段用 FormField 包住,标签、说明与错误信息由它渲染,控件会自动与字段关联。

'use client'

import { useState } from 'react'
import * as v from 'valibot'
import { Button, Form, FormField, Input, Text } from '@hina-ui/react'

const schema = v.object({
  name: v.pipe(v.string('请输入昵称'), v.nonEmpty('请输入昵称')),
  email: v.pipe(v.string('请输入邮箱'), v.nonEmpty('请输入邮箱'), v.email('邮箱格式不正确')),
})

export default function Demo() {
  const [values, setValues] = useState({ name: '', email: '' })
  const [saved, setSaved] = useState('')

  function save(data: unknown) {
    setSaved(JSON.stringify(data))
  }

  return (
    <Form values={values} rules={schema} className="w-80" onSubmit={save}>
      <FormField name="name" label="昵称" required>
        <Input value={values.name} onValueChange={name => setValues({ ...values, name })} />
      </FormField>
      <FormField name="email" label="邮箱" required>
        <Input
          value={values.email}
          onValueChange={email => setValues({ ...values, email })}
          type="email"
        />
      </FormField>
      <Button type="submit" className="self-start">
        提交
      </Button>
      {saved && (
        <Text tone="muted" size="sm">
          已提交:{saved}
        </Text>
      )}
    </Form>
  )
}
tsx

示例

校验规则

rules 接受两种写法。一种是实现了 Standard Schema 规范的对象,Valibot、Zod、ArkType 等库生成的 schema 都可以直接传入,问题的路径按 . 拼接成字段名;另一种是一个函数,接收当前的值,返回以字段名为键、错误文字为值的对象,没有错误时返回空对象,也可以返回 Promise。

'use client'

import { useState } from 'react'
import {
  Button,
  Form,
  FormField,
  Input,
  PasswordInput,
  Text,
  type FormValidator,
} from '@hina-ui/react'

const rules: FormValidator = data => {
  const errors: Record<string, string> = {}
  const username = String(data.username ?? '')
  if (!username) errors.username = '请输入用户名'
  else if (!/^[a-z0-9_]{3,16}$/.test(username)) {
    errors.username = '3 到 16 位小写字母、数字或者下划线'
  }
  if (!data.password) errors.password = '请输入密码'
  if (data.confirm !== data.password) errors.confirm = '两次输入的密码不一致'
  return errors
}

export default function Demo() {
  const [values, setValues] = useState({ username: '', password: '', confirm: '' })
  const [saved, setSaved] = useState('')

  function save() {
    setSaved(values.username)
  }

  return (
    <Form values={values} rules={rules} className="w-80" onSubmit={save}>
      <FormField name="username" label="用户名" required>
        <Input
          value={values.username}
          onValueChange={username => setValues({ ...values, username })}
        />
      </FormField>
      <FormField name="password" label="密码" required>
        <PasswordInput
          value={values.password}
          onValueChange={password => setValues({ ...values, password })}
        />
      </FormField>
      <FormField name="confirm" label="确认密码" required>
        <PasswordInput
          value={values.confirm}
          onValueChange={confirm => setValues({ ...values, confirm })}
        />
      </FormField>
      <Button type="submit" className="self-start">
        注册
      </Button>
      {saved && (
        <Text tone="muted" size="sm">
          已注册:{saved}
        </Text>
      )}
    </Form>
  )
}
tsx

校验时机

默认在提交时校验;提交未通过后,每次值变化都会重新校验,错误随修改即时消失。validateOn 设为 blur 时,字段在失去焦点后开始校验;设为 change 时,值一变化就校验。

'use client'

import { useState } from 'react'
import * as v from 'valibot'
import {
  Button,
  Form,
  FormField,
  Input,
  SegmentedControl,
  Stack,
  type FormValidateOn,
} from '@hina-ui/react'

const modes = [
  { label: '提交时', value: 'submit' },
  { label: '失去焦点', value: 'blur' },
  { label: '值变化', value: 'change' },
]

const schema = v.object({
  name: v.pipe(v.string('请输入昵称'), v.nonEmpty('请输入昵称')),
  email: v.pipe(v.string('请输入邮箱'), v.nonEmpty('请输入邮箱'), v.email('邮箱格式不正确')),
})

export default function Demo() {
  const [mode, setMode] = useState<string | number>('submit')
  const [values, setValues] = useState({ name: '', email: '' })

  return (
    <Stack gap="md" align="stretch" className="w-80">
      <SegmentedControl
        value={mode}
        onValueChange={setMode}
        options={modes}
        aria-label="校验时机"
      />
      <Form key={mode} values={values} rules={schema} validateOn={mode as FormValidateOn}>
        <FormField name="name" label="昵称" required>
          <Input value={values.name} onValueChange={name => setValues({ ...values, name })} />
        </FormField>
        <FormField name="email" label="邮箱" required>
          <Input
            value={values.email}
            onValueChange={email => setValues({ ...values, email })}
            type="email"
          />
        </FormField>
        <Button type="submit" className="self-start">
          提交
        </Button>
      </Form>
    </Stack>
  )
}
tsx

服务端返回的错误

提交之后服务端也可能拒绝某些字段。通过 ref 调用 setErrors,传入字段名到错误文字的映射,错误显示在对应的字段下;该字段的值被修改后,这条错误自动清除。没有对应字段的错误可以把 children 写成函数,从参数的 error 取得,自行展示。

taken@example.com 会被服务端拒绝

'use client'

import { useRef, useState } from 'react'
import * as v from 'valibot'
import { Button, Form, FormField, Input, Text, type FormHandle } from '@hina-ui/react'

const schema = v.object({
  email: v.pipe(v.string('请输入邮箱'), v.nonEmpty('请输入邮箱'), v.email('邮箱格式不正确')),
})

export default function Demo() {
  const [values, setValues] = useState({ email: 'taken@example.com' })
  const form = useRef<FormHandle>(null)
  const [saved, setSaved] = useState('')

  async function save(data: Record<string, unknown>) {
    await new Promise(resolve => setTimeout(resolve, 600))
    if (data.email === 'taken@example.com') {
      form.current?.setErrors({ email: '该邮箱已被注册' })
      return
    }
    setSaved(String(data.email))
  }

  return (
    <Form ref={form} values={values} rules={schema} className="w-80" onSubmit={save}>
      {({ submitting }) => (
        <>
          <FormField name="email" label="邮箱" description="taken@example.com 会被服务端拒绝">
            <Input
              value={values.email}
              onValueChange={email => setValues({ ...values, email })}
              type="email"
            />
          </FormField>
          <Button type="submit" loading={submitting} className="self-start">
            注册
          </Button>
          {saved && (
            <Text tone="muted" size="sm">
              已注册:{saved}
            </Text>
          )}
        </>
      )}
    </Form>
  )
}
tsx

提交中与禁用

onSubmit 返回 Promise 时,表单在其结束前处于提交中状态,所有字段禁用,children 函数参数中的 submitting 可用于按钮的加载指示。disabled 禁用整个表单。

'use client'

import { useState } from 'react'
import * as v from 'valibot'
import { Button, Form, FormField, Input, Stack, Switch } from '@hina-ui/react'

const schema = v.object({
  name: v.pipe(v.string('请输入名称'), v.nonEmpty('请输入名称')),
})

function save() {
  return new Promise(resolve => setTimeout(resolve, 1500))
}

export default function Demo() {
  const [values, setValues] = useState({ name: '星见书音' })
  const [disabled, setDisabled] = useState(false)

  return (
    <Stack gap="md" align="stretch" className="w-80">
      <Switch checked={disabled} onCheckedChange={setDisabled}>
        禁用表单
      </Switch>
      <Form values={values} rules={schema} disabled={disabled} onSubmit={save}>
        {({ submitting }) => (
          <>
            <FormField name="name" label="名称">
              <Input value={values.name} onValueChange={name => setValues({ ...values, name })} />
            </FormField>
            <Button type="submit" loading={submitting} disabled={disabled} className="self-start">
              保存
            </Button>
          </>
        )}
      </Form>
    </Stack>
  )
}
tsx

行为

  • 提交时先校验;未通过则显示错误,把焦点移到第一个无效的控件,不调用提交处理函数。
  • 校验通过后调用处理函数;返回 Promise 时等待其完成,期间表单处于提交中。
  • 提交未通过之后,或者校验时机为 change 时,值的变化触发重新校验;校验时机为 blur 时,只有失去过焦点的字段显示错误。
  • setErrors 设置的错误优先显示,字段的值改变后清除。
  • 路径为空的问题不属于任何字段,作为表单整体的错误通过渲染函数参数 error 暴露。

无障碍

  • 根元素是原生表单,关闭了浏览器自带的校验提示;回车提交与提交按钮的行为保持默认。
  • 提交中时根元素带有 aria-busy。
  • 错误信息由 FormField 渲染,并通过 aria-describedby 与 aria-invalid 关联到控件。

API

Props

属性
类型
默认值
说明
values
Record<string, unknown>
—
字段值所在的对象
rules
FormRules
—
校验规则,Standard Schema 对象或者校验函数
validateOn
'submit' | 'blur' | 'change'
'submit'
校验时机
disabled
boolean
false
是否禁用整个表单
className
string
—
追加至根元素的类名

内容属性

属性
参数
说明
children
errors、error、invalid、submitting、submitted
表单内容

回调

回调
参数
说明
onSubmit
values: Record<string, unknown>
校验通过后触发;处理函数返回 Promise 时表单进入提交中

方法

方法
说明
submit()
触发一次提交
validate()
校验并返回是否通过
setErrors(errors)
设置外部错误,键为字段名
reset()
清空错误、失焦记录与提交状态

FormRules、FormErrors、FormValidator 与 StandardSchema 类型可以从包入口导入。