feat: add scrollToFirstError to the form component
parent
e6bfbce6cb
commit
243f3a201d
|
@ -324,6 +324,7 @@ useVbenForm 返回的第二个参数,是一个对象,包含了一些表单
|
||||||
| submitOnEnter | 按下回车健时提交表单 | `boolean` | false |
|
| submitOnEnter | 按下回车健时提交表单 | `boolean` | false |
|
||||||
| submitOnChange | 字段值改变时提交表单(内部防抖,这个属性一般用于表格的搜索表单) | `boolean` | false |
|
| submitOnChange | 字段值改变时提交表单(内部防抖,这个属性一般用于表格的搜索表单) | `boolean` | false |
|
||||||
| compact | 是否紧凑模式(忽略为校验信息所预留的空间) | `boolean` | false |
|
| compact | 是否紧凑模式(忽略为校验信息所预留的空间) | `boolean` | false |
|
||||||
|
| scrollToFirstError | 表单验证失败时是否自动滚动到第一个错误字段 | `boolean` | false |
|
||||||
|
|
||||||
::: tip handleValuesChange
|
::: tip handleValuesChange
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,7 @@ const [Form] = useVbenForm({
|
||||||
handleSubmit: onSubmit,
|
handleSubmit: onSubmit,
|
||||||
// 垂直布局,label和input在不同行,值为vertical
|
// 垂直布局,label和input在不同行,值为vertical
|
||||||
// 水平布局,label和input在同一行
|
// 水平布局,label和input在同一行
|
||||||
|
scrollToFirstError: true,
|
||||||
layout: 'horizontal',
|
layout: 'horizontal',
|
||||||
schema: [
|
schema: [
|
||||||
{
|
{
|
||||||
|
|
|
@ -48,13 +48,18 @@ const queryFormStyle = computed(() => {
|
||||||
async function handleSubmit(e: Event) {
|
async function handleSubmit(e: Event) {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
e?.stopPropagation();
|
e?.stopPropagation();
|
||||||
const { valid } = await form.validate();
|
const props = unref(rootProps);
|
||||||
|
if (!props.formApi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { valid } = await props.formApi.validate();
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const values = toRaw(await unref(rootProps).formApi?.getValues());
|
const values = toRaw(await props.formApi.getValues());
|
||||||
await unref(rootProps).handleSubmit?.(values);
|
await props.handleSubmit?.(values);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleReset(e: Event) {
|
async function handleReset(e: Event) {
|
||||||
|
|
|
@ -39,6 +39,7 @@ function getDefaultState(): VbenFormProps {
|
||||||
layout: 'horizontal',
|
layout: 'horizontal',
|
||||||
resetButtonOptions: {},
|
resetButtonOptions: {},
|
||||||
schema: [],
|
schema: [],
|
||||||
|
scrollToFirstError: false,
|
||||||
showCollapseButton: false,
|
showCollapseButton: false,
|
||||||
showDefaultActions: true,
|
showDefaultActions: true,
|
||||||
submitButtonOptions: {},
|
submitButtonOptions: {},
|
||||||
|
@ -253,6 +254,41 @@ export class FormApi {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滚动到第一个错误字段
|
||||||
|
* @param errors 验证错误对象
|
||||||
|
*/
|
||||||
|
scrollToFirstError(errors: Record<string, any> | string) {
|
||||||
|
// https://github.com/logaretm/vee-validate/discussions/3835
|
||||||
|
const firstErrorFieldName =
|
||||||
|
typeof errors === 'string' ? errors : Object.keys(errors)[0];
|
||||||
|
|
||||||
|
if (!firstErrorFieldName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let el = document.querySelector(
|
||||||
|
`[name="${firstErrorFieldName}"]`,
|
||||||
|
) as HTMLElement;
|
||||||
|
|
||||||
|
// 如果通过 name 属性找不到,尝试通过组件引用查找, 正常情况下不会走到这,怕哪天 vee-validate 改了 name 属性有个兜底的
|
||||||
|
if (!el) {
|
||||||
|
const componentRef = this.getFieldComponentRef(firstErrorFieldName);
|
||||||
|
if (componentRef && componentRef.$el instanceof HTMLElement) {
|
||||||
|
el = componentRef.$el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el) {
|
||||||
|
// 滚动到错误字段,添加一些偏移量以确保字段完全可见
|
||||||
|
el.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
inline: 'nearest',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async setFieldValue(field: string, value: any, shouldValidate?: boolean) {
|
async setFieldValue(field: string, value: any, shouldValidate?: boolean) {
|
||||||
const form = await this.getForm();
|
const form = await this.getForm();
|
||||||
form.setFieldValue(field, value, shouldValidate);
|
form.setFieldValue(field, value, shouldValidate);
|
||||||
|
@ -377,14 +413,21 @@ export class FormApi {
|
||||||
|
|
||||||
if (Object.keys(validateResult?.errors ?? {}).length > 0) {
|
if (Object.keys(validateResult?.errors ?? {}).length > 0) {
|
||||||
console.error('validate error', validateResult?.errors);
|
console.error('validate error', validateResult?.errors);
|
||||||
|
|
||||||
|
if (this.state?.scrollToFirstError) {
|
||||||
|
this.scrollToFirstError(validateResult.errors);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return validateResult;
|
return validateResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateAndSubmitForm() {
|
async validateAndSubmitForm() {
|
||||||
const form = await this.getForm();
|
const form = await this.getForm();
|
||||||
const { valid } = await form.validate();
|
const { valid, errors } = await form.validate();
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
|
if (this.state?.scrollToFirstError) {
|
||||||
|
this.scrollToFirstError(errors);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return await this.submitForm();
|
return await this.submitForm();
|
||||||
|
@ -396,6 +439,10 @@ export class FormApi {
|
||||||
|
|
||||||
if (Object.keys(validateResult?.errors ?? {}).length > 0) {
|
if (Object.keys(validateResult?.errors ?? {}).length > 0) {
|
||||||
console.error('validate error', validateResult?.errors);
|
console.error('validate error', validateResult?.errors);
|
||||||
|
|
||||||
|
if (this.state?.scrollToFirstError) {
|
||||||
|
this.scrollToFirstError(fieldName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return validateResult;
|
return validateResult;
|
||||||
}
|
}
|
||||||
|
|
|
@ -387,6 +387,12 @@ export interface VbenFormProps<
|
||||||
*/
|
*/
|
||||||
resetButtonOptions?: ActionButtonOptions;
|
resetButtonOptions?: ActionButtonOptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证失败时是否自动滚动到第一个错误字段
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
scrollToFirstError?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否显示默认操作按钮
|
* 是否显示默认操作按钮
|
||||||
* @default true
|
* @default true
|
||||||
|
|
|
@ -19,6 +19,7 @@
|
||||||
"custom": "Custom Component",
|
"custom": "Custom Component",
|
||||||
"api": "Api",
|
"api": "Api",
|
||||||
"merge": "Merge Form",
|
"merge": "Merge Form",
|
||||||
|
"scrollToError": "Scroll to Error Field",
|
||||||
"upload-error": "Partial file upload failed",
|
"upload-error": "Partial file upload failed",
|
||||||
"upload-urls": "Urls after file upload",
|
"upload-urls": "Urls after file upload",
|
||||||
"file": "file",
|
"file": "file",
|
||||||
|
|
|
@ -22,6 +22,7 @@
|
||||||
"custom": "自定义组件",
|
"custom": "自定义组件",
|
||||||
"api": "Api",
|
"api": "Api",
|
||||||
"merge": "合并表单",
|
"merge": "合并表单",
|
||||||
|
"scrollToError": "滚动到错误字段",
|
||||||
"upload-error": "部分文件上传失败",
|
"upload-error": "部分文件上传失败",
|
||||||
"upload-urls": "文件上传后的网址",
|
"upload-urls": "文件上传后的网址",
|
||||||
"file": "文件",
|
"file": "文件",
|
||||||
|
|
|
@ -85,6 +85,15 @@ const routes: RouteRecordRaw[] = [
|
||||||
title: $t('examples.form.merge'),
|
title: $t('examples.form.merge'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'FormScrollToErrorExample',
|
||||||
|
path: '/examples/form/scroll-to-error-test',
|
||||||
|
component: () =>
|
||||||
|
import('#/views/examples/form/scroll-to-error-test.vue'),
|
||||||
|
meta: {
|
||||||
|
title: $t('examples.form.scrollToError'),
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -0,0 +1,183 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { Page } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Button, Card, Switch } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'ScrollToErrorTest',
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrollEnabled = ref(true);
|
||||||
|
|
||||||
|
const [Form, formApi] = useVbenForm({
|
||||||
|
scrollToFirstError: scrollEnabled.value,
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入用户名',
|
||||||
|
},
|
||||||
|
fieldName: 'username',
|
||||||
|
label: '用户名',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入邮箱',
|
||||||
|
},
|
||||||
|
fieldName: 'email',
|
||||||
|
label: '邮箱',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入手机号',
|
||||||
|
},
|
||||||
|
fieldName: 'phone',
|
||||||
|
label: '手机号',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入地址',
|
||||||
|
},
|
||||||
|
fieldName: 'address',
|
||||||
|
label: '地址',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入备注',
|
||||||
|
},
|
||||||
|
fieldName: 'remark',
|
||||||
|
label: '备注',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入公司名称',
|
||||||
|
},
|
||||||
|
fieldName: 'company',
|
||||||
|
label: '公司名称',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入职位',
|
||||||
|
},
|
||||||
|
fieldName: 'position',
|
||||||
|
label: '职位',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Select',
|
||||||
|
componentProps: {
|
||||||
|
options: [
|
||||||
|
{ label: '男', value: 'male' },
|
||||||
|
{ label: '女', value: 'female' },
|
||||||
|
],
|
||||||
|
placeholder: '请选择性别',
|
||||||
|
},
|
||||||
|
fieldName: 'gender',
|
||||||
|
label: '性别',
|
||||||
|
rules: 'selectRequired',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showDefaultActions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 测试 validateAndSubmitForm(验证并提交)
|
||||||
|
async function testValidateAndSubmit() {
|
||||||
|
await formApi.validateAndSubmitForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 validate(手动验证整个表单)
|
||||||
|
async function testValidate() {
|
||||||
|
await formApi.validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 validateField(验证单个字段)
|
||||||
|
async function testValidateField() {
|
||||||
|
await formApi.validateField('username');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换滚动功能
|
||||||
|
function toggleScrollToError() {
|
||||||
|
formApi.setState({ scrollToFirstError: scrollEnabled.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充部分数据测试
|
||||||
|
async function fillPartialData() {
|
||||||
|
await formApi.resetForm();
|
||||||
|
await formApi.setFieldValue('username', '测试用户');
|
||||||
|
await formApi.setFieldValue('email', 'test@example.com');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page
|
||||||
|
description="测试表单验证失败时自动滚动到错误字段的功能"
|
||||||
|
title="滚动到错误字段测试"
|
||||||
|
>
|
||||||
|
<Card title="功能测试">
|
||||||
|
<template #extra>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
v-model:checked="scrollEnabled"
|
||||||
|
@change="toggleScrollToError"
|
||||||
|
/>
|
||||||
|
<span>启用滚动到错误字段</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="rounded bg-blue-50 p-4">
|
||||||
|
<h3 class="mb-2 font-medium">测试说明:</h3>
|
||||||
|
<ul class="list-inside list-disc space-y-1 text-sm">
|
||||||
|
<li>所有验证方法在验证失败时都会自动滚动到第一个错误字段</li>
|
||||||
|
<li>可以通过右上角的开关控制是否启用自动滚动功能</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded border p-4">
|
||||||
|
<h4 class="mb-3 font-medium">验证方法测试:</h4>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button type="primary" @click="testValidateAndSubmit">
|
||||||
|
测试 validateAndSubmitForm()
|
||||||
|
</Button>
|
||||||
|
<Button @click="testValidate"> 测试 validate() </Button>
|
||||||
|
<Button @click="testValidateField"> 测试 validateField() </Button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-xs text-gray-500">
|
||||||
|
<p>• validateAndSubmitForm(): 验证表单并提交</p>
|
||||||
|
<p>• validate(): 手动验证整个表单</p>
|
||||||
|
<p>• validateField(): 验证单个字段(这里测试用户名字段)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded border p-4">
|
||||||
|
<h4 class="mb-3 font-medium">数据填充测试:</h4>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button @click="fillPartialData"> 填充部分数据 </Button>
|
||||||
|
<Button @click="() => formApi.resetForm()"> 清空表单 </Button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-xs text-gray-500">
|
||||||
|
<p>• 填充部分数据后验证,会滚动到第一个错误字段</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Page>
|
||||||
|
</template>
|
Loading…
Reference in New Issue