Browse Source

feat: add scrollToFirstError to the form component

sqchen 2 months ago
parent
commit
243f3a201d

+ 1 - 0
docs/src/components/common-ui/vben-form.md

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

+ 1 - 0
docs/src/demos/vben-form/rules/index.vue

@@ -15,6 +15,7 @@ const [Form] = useVbenForm({
   handleSubmit: onSubmit,
   // 垂直布局,label和input在不同行,值为vertical
   // 水平布局,label和input在同一行
+  scrollToFirstError: true,
   layout: 'horizontal',
   schema: [
     {

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

@@ -48,13 +48,18 @@ const queryFormStyle = computed(() => {
 async function handleSubmit(e: Event) {
   e?.preventDefault();
   e?.stopPropagation();
-  const { valid } = await form.validate();
+  const props = unref(rootProps);
+  if (!props.formApi) {
+    return;
+  }
+
+  const { valid } = await props.formApi.validate();
   if (!valid) {
     return;
   }
 
-  const values = toRaw(await unref(rootProps).formApi?.getValues());
-  await unref(rootProps).handleSubmit?.(values);
+  const values = toRaw(await props.formApi.getValues());
+  await props.handleSubmit?.(values);
 }
 
 async function handleReset(e: Event) {

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

@@ -39,6 +39,7 @@ function getDefaultState(): VbenFormProps {
     layout: 'horizontal',
     resetButtonOptions: {},
     schema: [],
+    scrollToFirstError: false,
     showCollapseButton: false,
     showDefaultActions: true,
     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) {
     const form = await this.getForm();
     form.setFieldValue(field, value, shouldValidate);
@@ -377,14 +413,21 @@ export class FormApi {
 
     if (Object.keys(validateResult?.errors ?? {}).length > 0) {
       console.error('validate error', validateResult?.errors);
+
+      if (this.state?.scrollToFirstError) {
+        this.scrollToFirstError(validateResult.errors);
+      }
     }
     return validateResult;
   }
 
   async validateAndSubmitForm() {
     const form = await this.getForm();
-    const { valid } = await form.validate();
+    const { valid, errors } = await form.validate();
     if (!valid) {
+      if (this.state?.scrollToFirstError) {
+        this.scrollToFirstError(errors);
+      }
       return;
     }
     return await this.submitForm();
@@ -396,6 +439,10 @@ export class FormApi {
 
     if (Object.keys(validateResult?.errors ?? {}).length > 0) {
       console.error('validate error', validateResult?.errors);
+
+      if (this.state?.scrollToFirstError) {
+        this.scrollToFirstError(fieldName);
+      }
     }
     return validateResult;
   }

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

@@ -387,6 +387,12 @@ export interface VbenFormProps<
    */
   resetButtonOptions?: ActionButtonOptions;
 
+  /**
+   * 验证失败时是否自动滚动到第一个错误字段
+   * @default true
+   */
+  scrollToFirstError?: boolean;
+
   /**
    * 是否显示默认操作按钮
    * @default true

+ 1 - 0
playground/src/locales/langs/en-US/examples.json

@@ -19,6 +19,7 @@
     "custom": "Custom Component",
     "api": "Api",
     "merge": "Merge Form",
+    "scrollToError": "Scroll to Error Field",
     "upload-error": "Partial file upload failed",
     "upload-urls": "Urls after file upload",
     "file": "file",

+ 1 - 0
playground/src/locales/langs/zh-CN/examples.json

@@ -22,6 +22,7 @@
     "custom": "自定义组件",
     "api": "Api",
     "merge": "合并表单",
+    "scrollToError": "滚动到错误字段",
     "upload-error": "部分文件上传失败",
     "upload-urls": "文件上传后的网址",
     "file": "文件",

+ 9 - 0
playground/src/router/routes/modules/examples.ts

@@ -85,6 +85,15 @@ const routes: RouteRecordRaw[] = [
               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'),
+            },
+          },
         ],
       },
       {

+ 183 - 0
playground/src/views/examples/form/scroll-to-error-test.vue

@@ -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>