表单 form
数据录入组件。三层同源:无头内核给出解剖与状态机,Vue 组件与自定义元素只是它的两层外壳,行为完全一致。
示例
基础用法
默认只在提交时整表校验:过了发 submit,没过发 invalid、摘要显形并把焦点送到第一个出错的字段
<script setup lang="ts">
import { ref } from "vue";
import {
XhFieldControl,
XhFieldErrorText,
XhFieldLabel,
XhFieldRoot,
XhFormErrorSummary,
XhFormErrorSummaryItem,
XhFormFieldGroup,
XhFormResetTrigger,
XhFormRoot,
XhFormSubmitTrigger,
} from "@xihan-ui/vue";
const submitted = ref("");
// 校验整表跑一遍,返回「字段名 → 错误文案」;空串表示这条没错
function validate(values: Record<string, unknown>) {
return {
email: String(values.email ?? "").includes("@") ? "" : "邮箱要带一个 @",
nickname: String(values.nickname ?? "").trim() ? "" : "昵称不能为空",
};
}
function onSubmit(details: { values: Record<string, unknown> }) {
submitted.value = JSON.stringify(details.values);
}
</script>
<template>
<XhFormRoot
:default-values="{ email: '', nickname: '' }"
:validate="validate"
style="inline-size: 320px;"
@submit="onSubmit"
>
<!-- 摘要只在提交失败后显形;条目一次全写上,谁露面由当下的错误表决定 -->
<XhFormErrorSummary v-slot="{ errorCount }">
<span>共 {{ errorCount }} 处需要修改</span>
<XhFormErrorSummaryItem v-slot="{ error }" value="email">{{ error }}</XhFormErrorSummaryItem>
<XhFormErrorSummaryItem v-slot="{ error }" value="nickname">{{ error }}</XhFormErrorSummaryItem>
</XhFormErrorSummary>
<XhFormFieldGroup v-slot="{ value, error, invalid, setValue }" value="email">
<XhFieldRoot :invalid="invalid" required>
<XhFieldLabel>邮箱</XhFieldLabel>
<XhFieldControl>
<input
type="email"
placeholder="you@example.com"
:value="value"
@input="setValue(($event.target as HTMLInputElement).value)"
/>
</XhFieldControl>
<XhFieldErrorText>{{ error }}</XhFieldErrorText>
</XhFieldRoot>
</XhFormFieldGroup>
<XhFormFieldGroup v-slot="{ value, error, invalid, setValue }" value="nickname">
<XhFieldRoot :invalid="invalid" required>
<XhFieldLabel>昵称</XhFieldLabel>
<XhFieldControl>
<input
:value="value"
@input="setValue(($event.target as HTMLInputElement).value)"
/>
</XhFieldControl>
<XhFieldErrorText>{{ error }}</XhFieldErrorText>
</XhFieldRoot>
</XhFormFieldGroup>
<div style="display: flex; gap: 8px;">
<XhFormSubmitTrigger>提交</XhFormSubmitTrigger>
<XhFormResetTrigger>重置</XhFormResetTrigger>
</div>
<p v-if="submitted" style="margin: 0; font-size: 13px;">已提交:{{ submitted }}</p>
</XhFormRoot>
</template>校验时机
blur 与 change 两种模式下 validate 仍整表跑(校验可能带跨字段规则),但只把当事字段那一条写回错误表
<script setup lang="ts">
import {
XhFieldControl,
XhFieldErrorText,
XhFieldLabel,
XhFieldRoot,
XhFormFieldGroup,
XhFormRoot,
XhFormSubmitTrigger,
} from "@xihan-ui/vue";
function validate(values: Record<string, unknown>) {
const port = String(values.port ?? "").trim();
return { port: /^\d+$/.test(port) ? "" : "端口只能是数字" };
}
</script>
<template>
<!-- 失焦时校验这一个字段:填的过程中不打断 -->
<XhFormRoot
:default-values="{ port: 'abc' }"
:validate="validate"
validate-on="blur"
style="inline-size: 240px;"
>
<XhFormFieldGroup v-slot="{ value, error, invalid, setValue }" value="port">
<XhFieldRoot :invalid="invalid">
<XhFieldLabel>端口(失焦校验)</XhFieldLabel>
<XhFieldControl>
<input :value="value" @input="setValue(($event.target as HTMLInputElement).value)" />
</XhFieldControl>
<XhFieldErrorText>{{ error }}</XhFieldErrorText>
</XhFieldRoot>
</XhFormFieldGroup>
<XhFormSubmitTrigger>提交</XhFormSubmitTrigger>
</XhFormRoot>
<!-- 改一个字就校验一次:错误随输入实时消长 -->
<XhFormRoot
:default-values="{ port: 'abc' }"
:validate="validate"
validate-on="change"
style="inline-size: 240px;"
>
<XhFormFieldGroup v-slot="{ value, error, invalid, setValue }" value="port">
<XhFieldRoot :invalid="invalid">
<XhFieldLabel>端口(改动即校验)</XhFieldLabel>
<XhFieldControl>
<input :value="value" @input="setValue(($event.target as HTMLInputElement).value)" />
</XhFieldControl>
<XhFieldErrorText>{{ error }}</XhFieldErrorText>
</XhFieldRoot>
</XhFormFieldGroup>
<XhFormSubmitTrigger>提交</XhFormSubmitTrigger>
</XhFormRoot>
</template>受控值表
传了 values 就由宿主说了算:组件内部不再落值,只发 update:values;页面别处也能直接改这张表
<script setup lang="ts">
import { ref } from "vue";
import {
XhFieldControl,
XhFieldLabel,
XhFieldRoot,
XhFormFieldGroup,
XhFormResetTrigger,
XhFormRoot,
} from "@xihan-ui/vue";
const defaults = { host: "127.0.0.1", port: "5173" };
const values = ref<Record<string, unknown>>({ ...defaults });
</script>
<template>
<XhFormRoot
v-model:values="values"
:default-values="defaults"
style="inline-size: 260px;"
>
<XhFormFieldGroup v-slot="{ value, setValue }" value="host">
<XhFieldRoot>
<XhFieldLabel>主机</XhFieldLabel>
<XhFieldControl>
<input :value="value" @input="setValue(($event.target as HTMLInputElement).value)" />
</XhFieldControl>
</XhFieldRoot>
</XhFormFieldGroup>
<XhFormFieldGroup v-slot="{ value, setValue }" value="port">
<XhFieldRoot>
<XhFieldLabel>端口</XhFieldLabel>
<XhFieldControl>
<input :value="value" @input="setValue(($event.target as HTMLInputElement).value)" />
</XhFieldControl>
</XhFieldRoot>
</XhFormFieldGroup>
<!-- 重置把值送回 default-values,同样经由 update:values 落到宿主这张表上 -->
<XhFormResetTrigger>重置</XhFormResetTrigger>
</XhFormRoot>
<span style="font-size: 13px;">宿主持有的值:{{ JSON.stringify(values) }}</span>
</template>禁用与只读
disabled 把提交、重置、写值三条路一起封死;read-only 只封写值与重置,提交照发
已提交:(还没提交过)
<script setup lang="ts">
import { ref } from "vue";
import {
XhFieldControl,
XhFieldDescription,
XhFieldLabel,
XhFieldRoot,
XhFormFieldGroup,
XhFormResetTrigger,
XhFormRoot,
XhFormSubmitTrigger,
} from "@xihan-ui/vue";
const submitted = ref("(还没提交过)");
function onSubmit(details: { values: Record<string, unknown> }) {
submitted.value = JSON.stringify(details.values);
}
</script>
<template>
<!-- 整表禁用:两颗按钮自带原生 disabled,控件那一侧的 disabled 由自己落 -->
<XhFormRoot disabled :default-values="{ token: 'xh-0f2a' }" style="inline-size: 260px;">
<XhFormFieldGroup v-slot="{ value, setValue }" value="token">
<XhFieldRoot disabled>
<XhFieldLabel>接入令牌</XhFieldLabel>
<XhFieldControl>
<input
disabled
:value="value"
@input="setValue(($event.target as HTMLInputElement).value)"
/>
</XhFieldControl>
<XhFieldDescription>整表禁用</XhFieldDescription>
</XhFieldRoot>
</XhFormFieldGroup>
<div style="display: flex; gap: 8px;">
<XhFormSubmitTrigger>提交</XhFormSubmitTrigger>
<XhFormResetTrigger>重置</XhFormResetTrigger>
</div>
</XhFormRoot>
<!-- 只读:重置键置灰、写值不发生,提交仍旧把当下这份值交出去 -->
<XhFormRoot
read-only
:default-values="{ token: 'xh-0f2a' }"
style="inline-size: 260px;"
@submit="onSubmit"
>
<XhFormFieldGroup v-slot="{ value, setValue }" value="token">
<XhFieldRoot>
<XhFieldLabel>接入令牌</XhFieldLabel>
<XhFieldControl>
<input
readonly
:value="value"
@input="setValue(($event.target as HTMLInputElement).value)"
/>
</XhFieldControl>
<XhFieldDescription>只读:能提交,改不动</XhFieldDescription>
</XhFieldRoot>
</XhFormFieldGroup>
<div style="display: flex; gap: 8px;">
<XhFormSubmitTrigger>提交</XhFormSubmitTrigger>
<XhFormResetTrigger>重置</XhFormResetTrigger>
</div>
</XhFormRoot>
<p style="margin: 0; font-size: 13px;">已提交:{{ submitted }}</p>
</template>动态字段
字段容器随数组增删,值表的键跟着字段名走;校验只遍历当下这几行,删掉的行不再参与
<script setup lang="ts">
import { ref } from "vue";
import {
XhButton,
XhFieldControl,
XhFieldErrorText,
XhFieldLabel,
XhFieldRoot,
XhFormFieldGroup,
XhFormRoot,
XhFormSubmitTrigger,
} from "@xihan-ui/vue";
let nextId = 1;
const rows = ref([{ id: nextId }]);
const submitted = ref("(还没提交过)");
// 字段名的派生规则只此一处:模板、校验、提交回调都读它
function fieldName(id: number) {
return `tag-${id}`;
}
function add() {
nextId += 1;
rows.value.push({ id: nextId });
}
function remove(id: number) {
rows.value = rows.value.filter(row => row.id !== id);
}
function validate(values: Record<string, unknown>) {
const errors: Record<string, string> = {};
for (const row of rows.value) {
const name = fieldName(row.id);
errors[name] = String(values[name] ?? "").trim() ? "" : "标签不能为空";
}
return errors;
}
function onSubmit(details: { values: Record<string, unknown> }) {
submitted.value = rows.value
.map(row => String(details.values[fieldName(row.id)] ?? ""))
.join(" / ");
}
</script>
<template>
<XhFormRoot :validate="validate" style="inline-size: 320px;" @submit="onSubmit">
<template v-for="(row, index) in rows" :key="row.id">
<XhFormFieldGroup v-slot="{ value, error, invalid, setValue }" :value="fieldName(row.id)">
<XhFieldRoot :invalid="invalid">
<XhFieldLabel>标签 {{ index + 1 }}</XhFieldLabel>
<XhFieldControl>
<input :value="value" @input="setValue(($event.target as HTMLInputElement).value)" />
</XhFieldControl>
<XhFieldErrorText>{{ error }}</XhFieldErrorText>
</XhFieldRoot>
<XhButton variant="ghost" size="sm" @click="remove(row.id)">删掉这一行</XhButton>
</XhFormFieldGroup>
</template>
<div style="display: flex; gap: 8px;">
<XhButton variant="outline" @click="add">添加一行</XhButton>
<XhFormSubmitTrigger>提交</XhFormSubmitTrigger>
</div>
<p style="margin: 0; font-size: 13px;">已提交:{{ submitted }}</p>
</XhFormRoot>
</template>异步校验
核验结果落在宿主自己的表里,validate 同步读它;核验回来直接写受控错误表,提交这一路照样拦得住
<script setup lang="ts">
import { ref } from "vue";
import {
XhFieldControl,
XhFieldDescription,
XhFieldErrorText,
XhFieldLabel,
XhFieldRoot,
XhFormFieldGroup,
XhFormRoot,
XhFormSubmitTrigger,
} from "@xihan-ui/vue";
const taken = ["admin", "root", "xihan"];
// 已经核验过的名字:名字 → 错误文案,空串表示这个名字可用
const checked = ref<Record<string, string>>({});
const checking = ref(false);
const errors = ref<Record<string, string>>({});
const submitted = ref("(还没提交过)");
let timer = 0;
// 值一改就发起核验,回来后把结论写进受控错误表
function onValuesChange(details: { values: Record<string, unknown> }) {
const name = String(details.values.username ?? "").trim();
window.clearTimeout(timer);
if (!name || Object.hasOwn(checked.value, name)) {
checking.value = false;
return;
}
checking.value = true;
timer = window.setTimeout(() => {
const message = taken.includes(name) ? "这个用户名已经有人用了" : "";
checked.value = { ...checked.value, [name]: message };
errors.value = { ...errors.value, username: message };
checking.value = false;
}, 700);
}
// 校验函数是同步的:这里只读核验结果,还没回来就先把提交拦下
function validate(values: Record<string, unknown>) {
const name = String(values.username ?? "").trim();
if (!name)
return { username: "用户名不能为空" };
if (!Object.hasOwn(checked.value, name))
return { username: "还在核验,稍等一下再提交" };
return { username: checked.value[name] };
}
function onSubmit(details: { values: Record<string, unknown> }) {
submitted.value = String(details.values.username ?? "");
}
</script>
<template>
<XhFormRoot
v-model:errors="errors"
:default-values="{ username: '' }"
:validate="validate"
style="inline-size: 320px;"
@values-change="onValuesChange"
@submit="onSubmit"
>
<XhFormFieldGroup v-slot="{ value, error, invalid, setValue }" value="username">
<XhFieldRoot :invalid="invalid" required>
<XhFieldLabel>用户名</XhFieldLabel>
<XhFieldControl>
<input
placeholder="试试 admin"
:value="value"
@input="setValue(($event.target as HTMLInputElement).value)"
/>
</XhFieldControl>
<XhFieldDescription>{{ checking ? "正在核验…" : "改动后自动核验,占用的名字会被挡下" }}</XhFieldDescription>
<XhFieldErrorText>{{ error }}</XhFieldErrorText>
</XhFieldRoot>
</XhFormFieldGroup>
<XhFormSubmitTrigger>提交</XhFormSubmitTrigger>
<p style="margin: 0; font-size: 13px;">已提交:{{ submitted }}</p>
</XhFormRoot>
</template>跨字段规则与手动入口
validate 拿到的是整张值表,可以写两个字段互相约束的规则;插槽里的 setFieldError 与 clearErrors 随时能单独动一条
<script setup lang="ts">
import {
XhButton,
XhFieldControl,
XhFieldErrorText,
XhFieldLabel,
XhFieldRoot,
XhFormFieldGroup,
XhFormRoot,
XhFormSubmitTrigger,
} from "@xihan-ui/vue";
// 确认密码这一条要跟密码比,单看自己判不出来
function confirmError(values: Record<string, unknown>) {
const password = String(values.password ?? "");
const confirm = String(values.confirm ?? "");
if (confirm === "")
return "请再输入一遍密码";
return confirm === password ? "" : "两次输入不一致";
}
function validate(values: Record<string, unknown>) {
return {
password: String(values.password ?? "").length >= 8 ? "" : "密码至少 8 位",
confirm: confirmError(values),
};
}
</script>
<template>
<XhFormRoot
v-slot="{ values, setFieldError, clearErrors }"
:default-values="{ password: '', confirm: '' }"
:validate="validate"
style="inline-size: 320px;"
>
<XhFormFieldGroup v-slot="{ value, error, invalid, setValue }" value="password">
<XhFieldRoot :invalid="invalid" required>
<XhFieldLabel>密码</XhFieldLabel>
<XhFieldControl>
<input
type="password"
:value="value"
@input="setValue(($event.target as HTMLInputElement).value)"
/>
</XhFieldControl>
<XhFieldErrorText>{{ error }}</XhFieldErrorText>
</XhFieldRoot>
</XhFormFieldGroup>
<XhFormFieldGroup v-slot="{ value, error, invalid, setValue }" value="confirm">
<XhFieldRoot :invalid="invalid" required>
<XhFieldLabel>确认密码</XhFieldLabel>
<XhFieldControl>
<input
type="password"
:value="value"
@input="setValue(($event.target as HTMLInputElement).value)"
/>
</XhFieldControl>
<XhFieldErrorText>{{ error }}</XhFieldErrorText>
</XhFieldRoot>
</XhFormFieldGroup>
<div style="display: flex; gap: 8px;">
<XhFormSubmitTrigger>提交</XhFormSubmitTrigger>
<!-- 只动确认密码这一条:给文案就写上,给空串就撤掉 -->
<XhButton variant="outline" @click="setFieldError('confirm', confirmError(values))">
只查确认密码
</XhButton>
<XhButton variant="ghost" @click="clearErrors()">清空错误</XhButton>
</div>
</XhFormRoot>
</template>提醒但不拦下
可疑的值只在描述里提醒一句,不写进错误表:控件的 aria-invalid 仍是 false,提交照样放行
<script setup lang="ts">
import { computed, ref } from "vue";
import {
XhFieldControl,
XhFieldDescription,
XhFieldErrorText,
XhFieldLabel,
XhFieldRoot,
XhFormFieldGroup,
XhFormRoot,
XhFormSubmitTrigger,
} from "@xihan-ui/vue";
const personal = ["qq.com", "163.com", "gmail.com"];
const values = ref<Record<string, unknown>>({ email: "zhaifanhua@qq.com" });
const submitted = ref("(还没提交过)");
// 拦得住的只有格式这一条,它才进错误表
function validate(source: Record<string, unknown>) {
return {
email: String(source.email ?? "").includes("@") ? "" : "邮箱要带一个 @",
};
}
// 提醒由值现算,与错误表无关
const warning = computed(() => {
const text = String(values.value.email ?? "");
const domain = text.slice(text.indexOf("@") + 1).toLowerCase();
return text.includes("@") && personal.includes(domain)
? "这是个人邮箱,同事之间通常填公司邮箱"
: "";
});
// 警告档只换配色:边框取语气层的强调色,描述取语气层的文字色
const warningStyle = {
"--xh-field-control-border": "var(--xh-_tone-soft)",
"--xh-field-description-fg": "var(--xh-_tone-fg)",
};
function onSubmit(details: { values: Record<string, unknown> }) {
submitted.value = String(details.values.email ?? "");
}
</script>
<template>
<XhFormRoot
v-model:values="values"
:default-values="{ email: 'zhaifanhua@qq.com' }"
:validate="validate"
style="inline-size: 320px;"
@submit="onSubmit"
>
<XhFormFieldGroup v-slot="{ value, error, invalid, setValue }" value="email">
<XhFieldRoot
:invalid="invalid"
:data-tone="!invalid && warning ? 'warning' : undefined"
:style="!invalid && warning ? warningStyle : undefined"
>
<XhFieldLabel>邮箱</XhFieldLabel>
<XhFieldControl>
<input
type="email"
:value="value"
@input="setValue(($event.target as HTMLInputElement).value)"
/>
</XhFieldControl>
<!-- 描述恒在描述链里:提醒会被念出来,又不会把控件标成无效 -->
<XhFieldDescription>{{ warning || "用于接收账单与安全提醒" }}</XhFieldDescription>
<XhFieldErrorText>{{ error }}</XhFieldErrorText>
</XhFieldRoot>
</XhFormFieldGroup>
<XhFormSubmitTrigger>提交</XhFormSubmitTrigger>
<p style="margin: 0; font-size: 13px;">已提交:{{ submitted }}</p>
</XhFormRoot>
</template>分步校验
校验函数每次提交现读一次:闭住当前这一步,提交就只校验这一步的字段;存草稿走的是普通按钮,一条规则都不跑
<script setup lang="ts">
import { computed, ref } from "vue";
import {
XhButton,
XhFieldControl,
XhFieldErrorText,
XhFieldLabel,
XhFieldRoot,
XhFormFieldGroup,
XhFormRoot,
XhFormSubmitTrigger,
} from "@xihan-ui/vue";
const steps = [
{
title: "第 1 步 · 联系人",
fields: [
{ name: "name", label: "姓名" },
{ name: "phone", label: "手机" },
],
},
{
title: "第 2 步 · 任职",
fields: [
{ name: "company", label: "公司" },
{ name: "title", label: "职位" },
],
},
];
const step = ref(0);
const current = computed(() => steps[step.value]);
const isLast = computed(() => step.value === steps.length - 1);
const draft = ref("(还没存过)");
const done = ref("");
function ruleOf(name: string, text: string) {
if (!text.trim())
return "这一项不能为空";
if (name === "phone" && !/^\d{11}$/.test(text.trim()))
return "手机号要 11 位数字";
return "";
}
// 只返回当前这一步的字段,别的步骤这一次不参与
function validate(values: Record<string, unknown>) {
const errors: Record<string, string> = {};
for (const field of current.value.fields)
errors[field.name] = ruleOf(field.name, String(values[field.name] ?? ""));
return errors;
}
// 这一步过了才走到这里:不是最后一步就往下推一步
function onSubmit(details: { values: Record<string, unknown> }) {
if (!isLast.value) {
step.value += 1;
return;
}
done.value = JSON.stringify(details.values);
}
function saveDraft(values: Record<string, unknown>) {
draft.value = JSON.stringify(values);
}
</script>
<template>
<XhFormRoot
v-slot="{ values }"
:default-values="{ name: '', phone: '', company: '', title: '' }"
:validate="validate"
style="inline-size: 320px;"
@submit="onSubmit"
>
<strong style="font-size: 13px;">{{ current.title }}</strong>
<!-- 上一步的字段容器这会儿并没渲染,值仍留在值表里 -->
<template v-for="field in current.fields" :key="field.name">
<XhFormFieldGroup v-slot="{ value, error, invalid, setValue }" :value="field.name">
<XhFieldRoot :invalid="invalid" required>
<XhFieldLabel>{{ field.label }}</XhFieldLabel>
<XhFieldControl>
<input :value="value" @input="setValue(($event.target as HTMLInputElement).value)" />
</XhFieldControl>
<XhFieldErrorText>{{ error }}</XhFieldErrorText>
</XhFieldRoot>
</XhFormFieldGroup>
</template>
<div style="display: flex; gap: 8px;">
<XhFormSubmitTrigger>{{ isLast ? "提交" : "下一步" }}</XhFormSubmitTrigger>
<!-- 普通按钮不是提交键,点了不发提交,也就不跑校验 -->
<XhButton variant="outline" @click="saveDraft(values)">存草稿</XhButton>
<XhButton v-if="step > 0" variant="ghost" @click="step -= 1">上一步</XhButton>
</div>
<p style="margin: 0; font-size: 13px;">草稿:{{ draft }}</p>
<p v-if="done" style="margin: 0; font-size: 13px;">已提交:{{ done }}</p>
</XhFormRoot>
</template>嵌套模型与路径字段名
字段名直接写成路径,值仍住在宿主自己的嵌套对象里:表单只管错误、id 与摘要跳转,提交时不用把扁平表折回去
<script setup lang="ts">
import { ref } from "vue";
import {
XhFieldControl,
XhFieldErrorText,
XhFieldLabel,
XhFieldRoot,
XhFormErrorSummary,
XhFormErrorSummaryItem,
XhFormFieldGroup,
XhFormRoot,
XhFormSubmitTrigger,
} from "@xihan-ui/vue";
const model = ref({
user: { name: "", email: "" },
hobbies: [{ hobby: "" }, { hobby: "" }],
});
const submitted = ref("(还没提交过)");
// 路径名的派生规则只此一处:模板、校验、摘要都读它
function hobbyName(index: number) {
return `hobbies[${index}].hobby`;
}
// 校验不看入参,直接读宿主的嵌套模型;返回的键就是那几条路径
function validate() {
const errors: Record<string, string> = {
"user.name": model.value.user.name.trim() ? "" : "姓名不能为空",
"user.email": model.value.user.email.includes("@") ? "" : "邮箱要带一个 @",
};
model.value.hobbies.forEach((row, index) => {
errors[hobbyName(index)] = row.hobby.trim() ? "" : "爱好不能为空";
});
return errors;
}
function onSubmit() {
submitted.value = JSON.stringify(model.value);
}
</script>
<template>
<XhFormRoot :validate="validate" style="inline-size: 320px;" @submit="onSubmit">
<!-- 摘要条目按路径名指过去,点一下焦点落进对应的字段容器 -->
<XhFormErrorSummary v-slot="{ errorCount }">
<span>共 {{ errorCount }} 处需要修改</span>
<XhFormErrorSummaryItem v-slot="{ error }" value="user.name">姓名:{{ error }}</XhFormErrorSummaryItem>
<XhFormErrorSummaryItem v-slot="{ error }" value="user.email">邮箱:{{ error }}</XhFormErrorSummaryItem>
<template v-for="(row, index) in model.hobbies" :key="index">
<XhFormErrorSummaryItem v-slot="{ error }" :value="hobbyName(index)">
爱好 {{ index + 1 }}:{{ error }}
</XhFormErrorSummaryItem>
</template>
</XhFormErrorSummary>
<XhFormFieldGroup v-slot="{ error, invalid }" value="user.name">
<XhFieldRoot :invalid="invalid" required>
<XhFieldLabel>姓名</XhFieldLabel>
<XhFieldControl>
<!-- 控件直接绑在嵌套模型上,值不经过表单的值表 -->
<input v-model="model.user.name" />
</XhFieldControl>
<XhFieldErrorText>{{ error }}</XhFieldErrorText>
</XhFieldRoot>
</XhFormFieldGroup>
<XhFormFieldGroup v-slot="{ error, invalid }" value="user.email">
<XhFieldRoot :invalid="invalid" required>
<XhFieldLabel>邮箱</XhFieldLabel>
<XhFieldControl>
<input v-model="model.user.email" type="email" />
</XhFieldControl>
<XhFieldErrorText>{{ error }}</XhFieldErrorText>
</XhFieldRoot>
</XhFormFieldGroup>
<template v-for="(row, index) in model.hobbies" :key="index">
<XhFormFieldGroup v-slot="{ error, invalid }" :value="hobbyName(index)">
<XhFieldRoot :invalid="invalid" required>
<XhFieldLabel>爱好 {{ index + 1 }}</XhFieldLabel>
<XhFieldControl>
<input v-model="row.hobby" />
</XhFieldControl>
<XhFieldErrorText>{{ error }}</XhFieldErrorText>
</XhFieldRoot>
</XhFormFieldGroup>
</template>
<XhFormSubmitTrigger>提交</XhFormSubmitTrigger>
<p style="margin: 0; font-size: 13px;">已提交:{{ submitted }}</p>
</XhFormRoot>
</template>重置回默认值
复合控件的值攥在组件里,原生重置只还原原生控件——它们各自认这条事件,一起回到 defaultValue
<script setup lang="ts">
import {
XhButton,
XhCheckbox,
XhRadioGroupItem,
XhRadioGroupRoot,
XhRatingControl,
XhRatingItem,
XhRatingRoot,
XhSwitch,
} from "@xihan-ui/vue";
import { ref } from "vue";
const submitted = ref("");
function onSubmit(event: Event) {
const data = new FormData(event.target as HTMLFormElement);
submitted.value = [...data.entries()]
.map(([k, v]) => `${k}=${v}`)
.join(" ") || "(空)";
}
</script>
<template>
<form style="display: grid; gap: 12px" @submit.prevent="onSubmit">
<label>
套餐
<XhRadioGroupRoot name="plan" default-value="standard">
<XhRadioGroupItem value="standard">标准</XhRadioGroupItem>
<XhRadioGroupItem value="pro">专业</XhRadioGroupItem>
</XhRadioGroupRoot>
</label>
<label>
评分
<XhRatingRoot name="score" :default-value="3" :count="5">
<XhRatingControl>
<XhRatingItem v-for="i in 5" :key="i" :value="i" />
</XhRatingControl>
</XhRatingRoot>
</label>
<!-- 原生输入框做对照:它靠 value 这个内容属性还原,组件靠自己的 defaultValue -->
<label>备注 <input name="note" value="默认备注" /></label>
<label><XhCheckbox name="agree" default-checked /> 已阅读条款</label>
<label><XhSwitch name="notify" /> 接收通知</label>
<div style="display: flex; gap: 8px">
<XhButton type="submit" size="sm">提交</XhButton>
<!-- 原生 reset:组件与旁边那个原生输入框会一起回到各自的默认值 -->
<XhButton type="reset" size="sm" variant="outline">重置</XhButton>
</div>
<span v-if="submitted">表单收到:{{ submitted }}</span>
</form>
</template>产物
| 层 | 值 |
|---|---|
| 自定义元素 | <xh-form> |
| Vue 组件 | XhFormErrorSummary XhFormErrorSummaryItem XhFormFieldGroup XhFormResetTrigger XhFormRoot XhFormSubmitTrigger |
| 组合式函数 | useForm |
| 状态机 | formMachine |
| 皮肤 | @xihan-ui/styles/form.css |
解剖
部件名即 data-part 属性值,也是皮肤的选择器。加粗的是必备部件,不渲染它组件不工作(Web Components 适配器会在诊断通道上报 wc.missing-part)。
data-scope="form":root · field-group · error-summary · error-summary-item · submit-trigger · reset-trigger
Props
| 属性 | 类型 | 必填 | 说明 |
|---|---|---|---|
values | FormValues | 受控值表;给定即受控:cell 直读 prop,写只发 onValuesChange 不落内部值。 | |
defaultValues | FormValues | 非受控初值,同时也是 reset 的落点。 | |
errors | FormErrorPatch | 受控错误表;给定即受控。空串会被清理掉(空串不是一条错误)。 | |
defaultErrors | FormErrorPatch | ||
validate | (values: FormValues) => FormErrorPatch | 校验函数。同步返回「字段名 → 错误文案」,没错的字段给空串或干脆不写。 不给这个函数即没有校验,提交时沿用当下的错误表。 | |
validateOn | FormValidateOn | 校验时机,默认 submit。 | |
disabled | boolean | 整个表单禁用:提交、重置、写值一概不发生,两颗按钮带原生 disabled。 | |
readOnly | boolean | 只读:写值与重置不发生,但仍可提交。 | |
onValuesChange | (details: FormValuesChangeDetails) => void | 值表变化意图回调;受控时是唯一出口,非受控随内部写入一并通知。 | |
onErrorsChange | (details: FormErrorsChangeDetails) => void | 错误表变化意图回调;受控时是唯一出口。 | |
onSubmit | (details: FormSubmitDetails) => void | 校验通过才调。 | |
onInvalid | (details: FormInvalidDetails) => void | 校验不通过时调,带上拦下来的整张错误表。 |
状态机
状态:idle · invalid
事件:SUBMIT · RESET · VALIDATION.PASS · VALIDATION.FAIL · FIELD.SET · FIELD.BLUR · ERROR.SET · ERRORS.CLEAR · ERROR.FOCUS
判据:isEnabled · isEditable
connect API
useForm 产出的对象。getXxxProps() 铺到对应部件的宿主元素上,其余是可读状态与操作入口。
| 成员 | 类型 | 说明 |
|---|---|---|
values | FormValues | 当下的值表。 |
errors | FormErrors | 当下的错误表(已清理)。 |
errorNames | string[] | 出错的字段名,插入顺序。 |
errorCount | number | |
invalid | boolean | 错误表非空。与"提交失败过"无关,挂载时作者塞进来的错误也算。 |
submitFailed | boolean | 上一次提交被拦下了:错误摘要据此显形。 |
disabled | boolean | |
readOnly | boolean | |
validateOn | FormValidateOn | |
getFieldId | (name: string) => string | 字段容器的 DOM id;错误摘要的链接指向它。 |
getFieldValue | (name: string) => unknown | |
getFieldError | (name: string) => string | undefined | 该字段此刻的错误文案;没错时为 undefined。 |
isFieldInvalid | (name: string) => boolean | |
setFieldValue | (name: string, value: unknown) => void | 写一个字段的值;禁用或只读时不动。 |
setFieldError | (name: string, message?: string) => void | 写一个字段的错误;不给文案(或给空串)即清掉这一条。 |
clearErrors | () => void | |
submit | () => void | 走完整的校验与提交流程,与用户按提交键同一条路。 |
reset | () => void | 值与错误都回到初始;禁用或只读时不动。 |
getRootProps | () => T['element'] | |
getFieldGroupProps | (props: FormFieldGroupProps) => T['element'] | |
getErrorSummaryProps | () => T['element'] | |
getErrorSummaryItemProps | (props: FormErrorSummaryItemProps) => T['element'] | |
getSubmitTriggerProps | () => T['button'] | |
getResetTriggerProps | () => T['button'] |
键盘
规格出处:W3C APG
无键盘交互(不接收焦点,或焦点行为完全由原生元素提供)。
