Form 表单

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

不超过 80 个字

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, Form, FormField, Input, Select, Switch, Text, Textarea } from '@hina-ui/vue'

  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' },
  ]

  const values = reactive({
    title: '',
    category: null as string | number | null,
    summary: '',
    published: true,
  })
  const saved = ref('')

  async function save(data: unknown) {
    await new Promise(resolve => setTimeout(resolve, 800))
    saved.value = JSON.stringify(data)
  }
</script>

<template>
  <Form v-slot="{ submitting }" :values="values" :rules="schema" class="w-80" @submit="save">
    <FormField name="title" label="标题" required>
      <Input v-model="values.title" placeholder="作品名称" />
    </FormField>
    <FormField name="category" label="分类" required>
      <Select v-model="values.category" :options="categories" />
    </FormField>
    <FormField name="summary" label="简介" description="不超过 80 个字">
      <Textarea v-model="values.summary" />
    </FormField>
    <FormField name="published">
      <Switch v-model="values.published">公开显示</Switch>
    </FormField>
    <Button type="submit" :loading="submitting" class="self-start">提交</Button>
    <Text v-if="saved" tone="muted" size="sm">已提交:{{ saved }}</Text>
  </Form>
</template>
vue

用法

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

表单把一组字段的值、校验规则与提交动作放在一处。values 传入一个响应式对象,各控件照常用 v-model 绑定到它的属性;rules 传入校验规则,提交时先校验,全部通过才调用 submit 事件的处理函数。每个字段用 FormField 包住,标签、说明与错误信息由它渲染,控件会自动与字段关联。

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, Form, FormField, Input, Text } from '@hina-ui/vue'

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

  const values = reactive({ name: '', email: '' })
  const saved = ref('')

  function save(data: unknown) {
    saved.value = JSON.stringify(data)
  }
</script>

<template>
  <Form :values="values" :rules="schema" class="w-80" @submit="save">
    <FormField name="name" label="昵称" required>
      <Input v-model="values.name" />
    </FormField>
    <FormField name="email" label="邮箱" required>
      <Input v-model="values.email" type="email" />
    </FormField>
    <Button type="submit" class="self-start">提交</Button>
    <Text v-if="saved" tone="muted" size="sm">已提交:{{ saved }}</Text>
  </Form>
</template>
vue

示例

校验规则

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

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import {
    Button,
    Form,
    FormField,
    Input,
    PasswordInput,
    Text,
    type FormValidator,
  } from '@hina-ui/vue'

  const values = reactive({ username: '', password: '', confirm: '' })
  const saved = ref('')

  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
  }

  function save() {
    saved.value = values.username
  }
</script>

<template>
  <Form :values="values" :rules="rules" class="w-80" @submit="save">
    <FormField name="username" label="用户名" required>
      <Input v-model="values.username" />
    </FormField>
    <FormField name="password" label="密码" required>
      <PasswordInput v-model="values.password" />
    </FormField>
    <FormField name="confirm" label="确认密码" required>
      <PasswordInput v-model="values.confirm" />
    </FormField>
    <Button type="submit" class="self-start">注册</Button>
    <Text v-if="saved" tone="muted" size="sm">已注册:{{ saved }}</Text>
  </Form>
</template>
vue

校验时机

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

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import {
    Button,
    Form,
    FormField,
    Input,
    SegmentedControl,
    Stack,
    type FormValidateOn,
  } from '@hina-ui/vue'

  const modes = [
    { label: '提交时', value: 'submit' },
    { label: '失去焦点', value: 'blur' },
    { label: '值变化', value: 'change' },
  ]
  const mode = ref<string | number>('submit')

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

  const values = reactive({ name: '', email: '' })
</script>

<template>
  <Stack gap="md" align="stretch" class="w-80">
    <SegmentedControl v-model="mode" :options="modes" aria-label="校验时机" />
    <Form :key="mode" :values="values" :rules="schema" :validate-on="mode as FormValidateOn">
      <FormField name="name" label="昵称" required>
        <Input v-model="values.name" />
      </FormField>
      <FormField name="email" label="邮箱" required>
        <Input v-model="values.email" type="email" />
      </FormField>
      <Button type="submit" class="self-start">提交</Button>
    </Form>
  </Stack>
</template>
vue

服务端返回的错误

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

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

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, Form, FormField, Input, Text } from '@hina-ui/vue'

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

  const values = reactive({ email: 'taken@example.com' })
  const form = ref<InstanceType<typeof Form> | null>(null)
  const saved = ref('')

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

<template>
  <Form
    ref="form"
    v-slot="{ submitting }"
    :values="values"
    :rules="schema"
    class="w-80"
    @submit="save"
  >
    <FormField name="email" label="邮箱" description="taken@example.com 会被服务端拒绝">
      <Input v-model="values.email" type="email" />
    </FormField>
    <Button type="submit" :loading="submitting" class="self-start">注册</Button>
    <Text v-if="saved" tone="muted" size="sm">已注册:{{ saved }}</Text>
  </Form>
</template>
vue

提交中与禁用

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

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, Form, FormField, Input, Stack, Switch } from '@hina-ui/vue'

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

  const values = reactive({ name: '星见书音' })
  const disabled = ref(false)

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

<template>
  <Stack gap="md" align="stretch" class="w-80">
    <Switch v-model="disabled">禁用表单</Switch>
    <Form
      v-slot="{ submitting }"
      :values="values"
      :rules="schema"
      :disabled="disabled"
      @submit="save"
    >
      <FormField name="name" label="名称">
        <Input v-model="values.name" />
      </FormField>
      <Button type="submit" :loading="submitting" :disabled="disabled" class="self-start">
        保存
      </Button>
    </Form>
  </Stack>
</template>
vue

在表单外读取状态

模板引用除了 submit()、validate()、reset() 与 setErrors(),还暴露响应式的 submitting、submitted、invalid、errors 与 error,含义与默认插槽的参数相同,可在表单之外的模板中读取。

示例的保存按钮位于表单之外,loading 绑定 submitting,点击时调用 submit()。

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, Form, FormField, Input, Stack } from '@hina-ui/vue'

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

  const values = reactive({ name: '星见书音' })
  const form = ref<InstanceType<typeof Form> | null>(null)

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

<template>
  <Stack gap="md" align="stretch" class="w-80">
    <Form ref="form" :values="values" :rules="schema" @submit="save">
      <FormField name="name" label="名称">
        <Input v-model="values.name" />
      </FormField>
    </Form>
    <Button :loading="form?.submitting" class="self-start" @click="form?.submit()">保存</Button>
  </Stack>
</template>
vue

行为

  • 提交时先校验;未通过则显示错误,把焦点移到第一个无效的控件,不调用提交处理函数。
  • 校验通过后调用处理函数;返回 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
是否禁用整个表单
class
string
—
追加至根元素的类名

插槽

插槽
参数
说明
default
errors、error、invalid、submitting、submitted
表单内容

事件

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

方法

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

状态

模板引用同时暴露下列响应式状态。

属性
类型
说明
submitting
boolean
是否正在提交
submitted
boolean
是否提交过,reset() 之后为 false
invalid
boolean
是否有字段错误或表单整体错误
errors
FormErrors
正在显示的字段错误,键为字段名
error
string | undefined
不属于任何字段的表单整体错误

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