Skip to content

Commit 243f3a2

Browse files
committed
feat: add scrollToFirstError to the form component
1 parent e6bfbce commit 243f3a2

File tree

9 files changed

+258
-4
lines changed

9 files changed

+258
-4
lines changed

docs/src/components/common-ui/vben-form.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ useVbenForm 返回的第二个参数,是一个对象,包含了一些表单
324324
| submitOnEnter | 按下回车健时提交表单 | `boolean` | false |
325325
| submitOnChange | 字段值改变时提交表单(内部防抖,这个属性一般用于表格的搜索表单) | `boolean` | false |
326326
| compact | 是否紧凑模式(忽略为校验信息所预留的空间) | `boolean` | false |
327+
| scrollToFirstError | 表单验证失败时是否自动滚动到第一个错误字段 | `boolean` | false |
327328

328329
::: tip handleValuesChange
329330

docs/src/demos/vben-form/rules/index.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const [Form] = useVbenForm({
1515
handleSubmit: onSubmit,
1616
// 垂直布局,label和input在不同行,值为vertical
1717
// 水平布局,label和input在同一行
18+
scrollToFirstError: true,
1819
layout: 'horizontal',
1920
schema: [
2021
{

packages/@core/ui-kit/form-ui/src/components/form-actions.vue

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,18 @@ const queryFormStyle = computed(() => {
4848
async function handleSubmit(e: Event) {
4949
e?.preventDefault();
5050
e?.stopPropagation();
51-
const { valid } = await form.validate();
51+
const props = unref(rootProps);
52+
if (!props.formApi) {
53+
return;
54+
}
55+
56+
const { valid } = await props.formApi.validate();
5257
if (!valid) {
5358
return;
5459
}
5560
56-
const values = toRaw(await unref(rootProps).formApi?.getValues());
57-
await unref(rootProps).handleSubmit?.(values);
61+
const values = toRaw(await props.formApi.getValues());
62+
await props.handleSubmit?.(values);
5863
}
5964
6065
async function handleReset(e: Event) {

packages/@core/ui-kit/form-ui/src/form-api.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ function getDefaultState(): VbenFormProps {
3939
layout: 'horizontal',
4040
resetButtonOptions: {},
4141
schema: [],
42+
scrollToFirstError: false,
4243
showCollapseButton: false,
4344
showDefaultActions: true,
4445
submitButtonOptions: {},
@@ -253,6 +254,41 @@ export class FormApi {
253254
});
254255
}
255256

257+
/**
258+
* 滚动到第一个错误字段
259+
* @param errors 验证错误对象
260+
*/
261+
scrollToFirstError(errors: Record<string, any> | string) {
262+
// https://github.com/logaretm/vee-validate/discussions/3835
263+
const firstErrorFieldName =
264+
typeof errors === 'string' ? errors : Object.keys(errors)[0];
265+
266+
if (!firstErrorFieldName) {
267+
return;
268+
}
269+
270+
let el = document.querySelector(
271+
`[name="${firstErrorFieldName}"]`,
272+
) as HTMLElement;
273+
274+
// 如果通过 name 属性找不到,尝试通过组件引用查找, 正常情况下不会走到这,怕哪天 vee-validate 改了 name 属性有个兜底的
275+
if (!el) {
276+
const componentRef = this.getFieldComponentRef(firstErrorFieldName);
277+
if (componentRef && componentRef.$el instanceof HTMLElement) {
278+
el = componentRef.$el;
279+
}
280+
}
281+
282+
if (el) {
283+
// 滚动到错误字段,添加一些偏移量以确保字段完全可见
284+
el.scrollIntoView({
285+
behavior: 'smooth',
286+
block: 'center',
287+
inline: 'nearest',
288+
});
289+
}
290+
}
291+
256292
async setFieldValue(field: string, value: any, shouldValidate?: boolean) {
257293
const form = await this.getForm();
258294
form.setFieldValue(field, value, shouldValidate);
@@ -377,14 +413,21 @@ export class FormApi {
377413

378414
if (Object.keys(validateResult?.errors ?? {}).length > 0) {
379415
console.error('validate error', validateResult?.errors);
416+
417+
if (this.state?.scrollToFirstError) {
418+
this.scrollToFirstError(validateResult.errors);
419+
}
380420
}
381421
return validateResult;
382422
}
383423

384424
async validateAndSubmitForm() {
385425
const form = await this.getForm();
386-
const { valid } = await form.validate();
426+
const { valid, errors } = await form.validate();
387427
if (!valid) {
428+
if (this.state?.scrollToFirstError) {
429+
this.scrollToFirstError(errors);
430+
}
388431
return;
389432
}
390433
return await this.submitForm();
@@ -396,6 +439,10 @@ export class FormApi {
396439

397440
if (Object.keys(validateResult?.errors ?? {}).length > 0) {
398441
console.error('validate error', validateResult?.errors);
442+
443+
if (this.state?.scrollToFirstError) {
444+
this.scrollToFirstError(fieldName);
445+
}
399446
}
400447
return validateResult;
401448
}

packages/@core/ui-kit/form-ui/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,12 @@ export interface VbenFormProps<
387387
*/
388388
resetButtonOptions?: ActionButtonOptions;
389389

390+
/**
391+
* 验证失败时是否自动滚动到第一个错误字段
392+
* @default true
393+
*/
394+
scrollToFirstError?: boolean;
395+
390396
/**
391397
* 是否显示默认操作按钮
392398
* @default true

playground/src/locales/langs/en-US/examples.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"custom": "Custom Component",
2020
"api": "Api",
2121
"merge": "Merge Form",
22+
"scrollToError": "Scroll to Error Field",
2223
"upload-error": "Partial file upload failed",
2324
"upload-urls": "Urls after file upload",
2425
"file": "file",

playground/src/locales/langs/zh-CN/examples.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"custom": "自定义组件",
2323
"api": "Api",
2424
"merge": "合并表单",
25+
"scrollToError": "滚动到错误字段",
2526
"upload-error": "部分文件上传失败",
2627
"upload-urls": "文件上传后的网址",
2728
"file": "文件",

playground/src/router/routes/modules/examples.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,15 @@ const routes: RouteRecordRaw[] = [
8585
title: $t('examples.form.merge'),
8686
},
8787
},
88+
{
89+
name: 'FormScrollToErrorExample',
90+
path: '/examples/form/scroll-to-error-test',
91+
component: () =>
92+
import('#/views/examples/form/scroll-to-error-test.vue'),
93+
meta: {
94+
title: $t('examples.form.scrollToError'),
95+
},
96+
},
8897
],
8998
},
9099
{
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
<script lang="ts" setup>
2+
import { ref } from 'vue';
3+
4+
import { Page } from '@vben/common-ui';
5+
6+
import { Button, Card, Switch } from 'ant-design-vue';
7+
8+
import { useVbenForm } from '#/adapter/form';
9+
10+
defineOptions({
11+
name: 'ScrollToErrorTest',
12+
});
13+
14+
const scrollEnabled = ref(true);
15+
16+
const [Form, formApi] = useVbenForm({
17+
scrollToFirstError: scrollEnabled.value,
18+
schema: [
19+
{
20+
component: 'Input',
21+
componentProps: {
22+
placeholder: '请输入用户名',
23+
},
24+
fieldName: 'username',
25+
label: '用户名',
26+
rules: 'required',
27+
},
28+
{
29+
component: 'Input',
30+
componentProps: {
31+
placeholder: '请输入邮箱',
32+
},
33+
fieldName: 'email',
34+
label: '邮箱',
35+
rules: 'required',
36+
},
37+
{
38+
component: 'Input',
39+
componentProps: {
40+
placeholder: '请输入手机号',
41+
},
42+
fieldName: 'phone',
43+
label: '手机号',
44+
rules: 'required',
45+
},
46+
{
47+
component: 'Input',
48+
componentProps: {
49+
placeholder: '请输入地址',
50+
},
51+
fieldName: 'address',
52+
label: '地址',
53+
rules: 'required',
54+
},
55+
{
56+
component: 'Input',
57+
componentProps: {
58+
placeholder: '请输入备注',
59+
},
60+
fieldName: 'remark',
61+
label: '备注',
62+
rules: 'required',
63+
},
64+
{
65+
component: 'Input',
66+
componentProps: {
67+
placeholder: '请输入公司名称',
68+
},
69+
fieldName: 'company',
70+
label: '公司名称',
71+
rules: 'required',
72+
},
73+
{
74+
component: 'Input',
75+
componentProps: {
76+
placeholder: '请输入职位',
77+
},
78+
fieldName: 'position',
79+
label: '职位',
80+
rules: 'required',
81+
},
82+
{
83+
component: 'Select',
84+
componentProps: {
85+
options: [
86+
{ label: '', value: 'male' },
87+
{ label: '', value: 'female' },
88+
],
89+
placeholder: '请选择性别',
90+
},
91+
fieldName: 'gender',
92+
label: '性别',
93+
rules: 'selectRequired',
94+
},
95+
],
96+
showDefaultActions: false,
97+
});
98+
99+
// 测试 validateAndSubmitForm(验证并提交)
100+
async function testValidateAndSubmit() {
101+
await formApi.validateAndSubmitForm();
102+
}
103+
104+
// 测试 validate(手动验证整个表单)
105+
async function testValidate() {
106+
await formApi.validate();
107+
}
108+
109+
// 测试 validateField(验证单个字段)
110+
async function testValidateField() {
111+
await formApi.validateField('username');
112+
}
113+
114+
// 切换滚动功能
115+
function toggleScrollToError() {
116+
formApi.setState({ scrollToFirstError: scrollEnabled.value });
117+
}
118+
119+
// 填充部分数据测试
120+
async function fillPartialData() {
121+
await formApi.resetForm();
122+
await formApi.setFieldValue('username', '测试用户');
123+
await formApi.setFieldValue('email', 'test@example.com');
124+
}
125+
</script>
126+
127+
<template>
128+
<Page
129+
description="测试表单验证失败时自动滚动到错误字段的功能"
130+
title="滚动到错误字段测试"
131+
>
132+
<Card title="功能测试">
133+
<template #extra>
134+
<div class="flex items-center gap-2">
135+
<Switch
136+
v-model:checked="scrollEnabled"
137+
@change="toggleScrollToError"
138+
/>
139+
<span>启用滚动到错误字段</span>
140+
</div>
141+
</template>
142+
143+
<div class="space-y-4">
144+
<div class="rounded bg-blue-50 p-4">
145+
<h3 class="mb-2 font-medium">测试说明:</h3>
146+
<ul class="list-inside list-disc space-y-1 text-sm">
147+
<li>所有验证方法在验证失败时都会自动滚动到第一个错误字段</li>
148+
<li>可以通过右上角的开关控制是否启用自动滚动功能</li>
149+
</ul>
150+
</div>
151+
152+
<div class="rounded border p-4">
153+
<h4 class="mb-3 font-medium">验证方法测试:</h4>
154+
<div class="flex flex-wrap gap-2">
155+
<Button type="primary" @click="testValidateAndSubmit">
156+
测试 validateAndSubmitForm()
157+
</Button>
158+
<Button @click="testValidate"> 测试 validate() </Button>
159+
<Button @click="testValidateField"> 测试 validateField() </Button>
160+
</div>
161+
<div class="mt-2 text-xs text-gray-500">
162+
<p>• validateAndSubmitForm(): 验证表单并提交</p>
163+
<p>• validate(): 手动验证整个表单</p>
164+
<p>• validateField(): 验证单个字段(这里测试用户名字段)</p>
165+
</div>
166+
</div>
167+
168+
<div class="rounded border p-4">
169+
<h4 class="mb-3 font-medium">数据填充测试:</h4>
170+
<div class="flex flex-wrap gap-2">
171+
<Button @click="fillPartialData"> 填充部分数据 </Button>
172+
<Button @click="() => formApi.resetForm()"> 清空表单 </Button>
173+
</div>
174+
<div class="mt-2 text-xs text-gray-500">
175+
<p>• 填充部分数据后验证,会滚动到第一个错误字段</p>
176+
</div>
177+
</div>
178+
179+
<Form />
180+
</div>
181+
</Card>
182+
</Page>
183+
</template>

0 commit comments

Comments
 (0)