<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>
用法
import { Form, FormField } from '@hina-ui/vue'
表单把一组字段的值、校验规则与提交动作放在一处。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>
示例
校验规则
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>
校验时机
默认在提交时校验;提交未通过后,每次值变化都会重新校验,错误随修改即时消失。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>
服务端返回的错误
提交之后服务端也可能拒绝某些字段。通过模板引用调用 setErrors,传入字段名到错误文字的映射,错误显示在对应的字段下;该字段的值被修改后,这条错误自动清除。没有对应字段的错误可以从默认插槽的 error 取得,自行展示。
<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>
提交中与禁用
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>
在表单外读取状态
模板引用除了 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>
行为
- 提交时先校验;未通过则显示错误,把焦点移到第一个无效的控件,不调用提交处理函数。
- 校验通过后调用处理函数;返回 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 类型可以从包入口导入。