From fe6479a95f741cc73ff055a76d671d00fd9b85ef Mon Sep 17 00:00:00 2001 From: zhouyun1 Date: Fri, 20 Sep 2024 19:42:51 +0800 Subject: [PATCH 1/3] feat(form): Add scrollToFirstError api --- packages/ui/form/package.json | 3 ++- packages/ui/form/src/FormItem.tsx | 7 +++-- packages/ui/form/src/context.ts | 4 ++- packages/ui/form/src/use-form.ts | 43 ++++++++++++++++++++++++++++++- yarn.lock | 12 +++++++++ 5 files changed, 64 insertions(+), 5 deletions(-) diff --git a/packages/ui/form/package.json b/packages/ui/form/package.json index 09a70f45e..f277b6b13 100644 --- a/packages/ui/form/package.json +++ b/packages/ui/form/package.json @@ -52,7 +52,8 @@ "@hi-ui/object-utils": "^4.0.4", "@hi-ui/type-assertion": "^4.0.4", "@hi-ui/use-latest": "^4.0.4", - "async-validator": "^4.0.7" + "async-validator": "^4.0.7", + "scroll-into-view-if-needed": "^3.1.0" }, "peerDependencies": { "@hi-ui/core": ">=4.0.8", diff --git a/packages/ui/form/src/FormItem.tsx b/packages/ui/form/src/FormItem.tsx index 54cd8ad45..1e19b5dc5 100644 --- a/packages/ui/form/src/FormItem.tsx +++ b/packages/ui/form/src/FormItem.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react' +import React, { useMemo, useRef } from 'react' import { __DEV__ } from '@hi-ui/env' import { useFiledRules, UseFormFieldProps } from './use-form-field' import { FormLabel, FormLabelProps } from './FormLabel' @@ -21,7 +21,7 @@ export const FormItem: React.FC = ({ render, ...rest }) => { - const { prefixCls, showRequiredOnValidateRequired } = useFormContext() + const { prefixCls, showRequiredOnValidateRequired, formItemsRef } = useFormContext() const fieldRules = useFiledRules({ field, rules, valueType }) const { required } = rest @@ -36,6 +36,9 @@ export const FormItem: React.FC = ({ return ( { + field && formItemsRef.current.set(field.toString(), ref) + }} required={showRequired} // @ts-ignore formMessage={} diff --git a/packages/ui/form/src/context.ts b/packages/ui/form/src/context.ts index e1aff7fa7..05dbf3cf6 100644 --- a/packages/ui/form/src/context.ts +++ b/packages/ui/form/src/context.ts @@ -1,6 +1,7 @@ -import React, { createContext, useContext } from 'react' +import React, { createContext, MutableRefObject, useContext } from 'react' import { UseFormReturn } from './use-form' +import { FormFieldPath } from './types' export interface FormContextProps extends UseFormReturn { labelWidth: React.ReactText @@ -10,6 +11,7 @@ export interface FormContextProps extends UseFormReturn { showRequiredOnValidateRequired: boolean showValidateMessage: boolean prefixCls: string + formItemsRef: MutableRefObject> } const formContext = createContext | null>(null) diff --git a/packages/ui/form/src/use-form.ts b/packages/ui/form/src/use-form.ts index 1fcc1b6ab..4b6fffb6f 100644 --- a/packages/ui/form/src/use-form.ts +++ b/packages/ui/form/src/use-form.ts @@ -1,5 +1,8 @@ import { stringify, parse, isValidField, mergeValues } from './utils' import React, { useCallback, useMemo, useReducer, useRef } from 'react' +import scrollIntoView, { + StandardBehaviorOptions as ScrollOptions, +} from 'scroll-into-view-if-needed' import { FormAction, FormState, @@ -35,6 +38,7 @@ export const useForm = >({ rules = EMPTY_RULES, validateAfterTouched = false, validateTrigger: validateTriggerProp = DEFAULT_VALIDATE_TRIGGER, + scrollToFirstError, ...rest }: UseFormProps) => { /** @@ -44,6 +48,9 @@ export const useForm = >({ // eslint-disable-next-line react-hooks/exhaustive-deps const validateTriggersMemo = useMemo(() => validateTrigger, validateTrigger) + const formItemsMp = useMemo(() => new Map(), []) + const formItemsRef = useRef(formItemsMp) + /** * 收集 Field 的校验器注册表 */ @@ -69,6 +76,21 @@ export const useForm = >({ // formStateRef, // ]) + const getFormItemNode = useCallback((fieldName: FormFieldPath) => { + return formItemsRef.current.get(fieldName.toString()) + }, []) + + const scrollToNode = useCallback( + (fieldName: FormFieldPath, options: ScrollOptions = {}) => { + scrollIntoView(getFormItemNode(fieldName), { + scrollMode: 'if-needed', + block: 'nearest', + ...options, + }) + }, + [getFormItemNode] + ) + const getFieldValue = useCallback( (fieldName: FormFieldPath) => getNested(formStateRef.current.values, fieldName), [formStateRef] @@ -151,6 +173,8 @@ export const useForm = >({ const fieldNames = getRegisteredKeys() formDispatch({ type: 'SET_VALIDATING', payload: true }) + let firstError = false + return Promise.all( fieldNames.map((fieldName) => { const value = getFieldValue(fieldName) @@ -162,6 +186,11 @@ export const useForm = >({ // catch 错误,保证检验所有表单项 return fieldValidation.validate(value).catch((error) => { + if (scrollToFirstError && !firstError) { + firstError = true + scrollToNode(fieldName, scrollToFirstError) + } + // 第一个出错,即退出校验 if (lazyValidate) { throw error @@ -229,7 +258,14 @@ export const useForm = >({ return combinedError }) - }, [getRegisteredKeys, getFieldValue, getValidation, lazyValidate]) + }, [ + getFieldValue, + getRegisteredKeys, + getValidation, + lazyValidate, + scrollToFirstError, + scrollToNode, + ]) /** * 控件值更新策略 @@ -568,6 +604,7 @@ export const useForm = >({ getFieldsValue, setFieldsValue, getFieldsError, + formItemsRef, } } @@ -615,6 +652,10 @@ export interface UseFormProps> { * 重置时回调 */ onReset?: (values: T) => void | Promise + /** + * 提交失败自动滚动到第一个错误字段 + */ + scrollToFirstError?: boolean | Options } export type UseFormReturn = ReturnType diff --git a/yarn.lock b/yarn.lock index 3af2ccbea..3d23142ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7349,6 +7349,11 @@ compute-scroll-into-view@^1.0.17: version "1.0.17" resolved "https://registry.npm.taobao.org/compute-scroll-into-view/download/compute-scroll-into-view-1.0.17.tgz?cache=0&sync_timestamp=1614042424875&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcompute-scroll-into-view%2Fdownload%2Fcompute-scroll-into-view-1.0.17.tgz#6a88f18acd9d42e9cf4baa6bec7e0522607ab7ab" +compute-scroll-into-view@^3.0.2: + version "3.1.0" + resolved "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz#753f11d972596558d8fe7c6bcbc8497690ab4c87" + integrity sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.npm.taobao.org/concat-map/download/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -16680,6 +16685,13 @@ schema-utils@^3.0.0: ajv "^6.12.5" ajv-keywords "^3.5.2" +scroll-into-view-if-needed@^3.1.0: + version "3.1.0" + resolved "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz#fa9524518c799b45a2ef6bbffb92bcad0296d01f" + integrity sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ== + dependencies: + compute-scroll-into-view "^3.0.2" + semver-compare@^1.0.0: version "1.0.0" resolved "https://registry.npm.taobao.org/semver-compare/download/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" From 82678d9d34e2c4cae2ef987aa75e2509c34fd1e1 Mon Sep 17 00:00:00 2001 From: zhouyun1 Date: Tue, 24 Sep 2024 16:55:29 +0800 Subject: [PATCH 2/3] =?UTF-8?q?chore(form):=20=E5=A4=84=E7=90=86=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ui/form/src/use-form.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/ui/form/src/use-form.ts b/packages/ui/form/src/use-form.ts index 4b6fffb6f..4844dc13a 100644 --- a/packages/ui/form/src/use-form.ts +++ b/packages/ui/form/src/use-form.ts @@ -188,7 +188,10 @@ export const useForm = >({ return fieldValidation.validate(value).catch((error) => { if (scrollToFirstError && !firstError) { firstError = true - scrollToNode(fieldName, scrollToFirstError) + scrollToNode( + fieldName, + typeof scrollToFirstError === 'object' ? scrollToFirstError : {} + ) } // 第一个出错,即退出校验 @@ -655,7 +658,7 @@ export interface UseFormProps> { /** * 提交失败自动滚动到第一个错误字段 */ - scrollToFirstError?: boolean | Options + scrollToFirstError?: boolean | ScrollOptions } export type UseFormReturn = ReturnType From 798da288776315b88c77ac08db8cb8814a27cfe9 Mon Sep 17 00:00:00 2001 From: zhouyun1 Date: Tue, 24 Sep 2024 19:19:46 +0800 Subject: [PATCH 3/3] feat(form): Add scrollToFirstError api (#3003) --- .changeset/nasty-geckos-laugh.md | 5 + .changeset/six-carrots-protect.md | 5 + packages/ui/form/src/use-form.ts | 2 +- packages/ui/form/stories/index.stories.tsx | 1 + .../form/stories/scroll-to-error.stories.tsx | 228 ++++++++++++++++++ 5 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 .changeset/nasty-geckos-laugh.md create mode 100644 .changeset/six-carrots-protect.md create mode 100644 packages/ui/form/stories/scroll-to-error.stories.tsx diff --git a/.changeset/nasty-geckos-laugh.md b/.changeset/nasty-geckos-laugh.md new file mode 100644 index 000000000..3b71e7e2b --- /dev/null +++ b/.changeset/nasty-geckos-laugh.md @@ -0,0 +1,5 @@ +--- +"@hi-ui/hiui": patch +--- + +feat(form): Add scrollToFirstError api diff --git a/.changeset/six-carrots-protect.md b/.changeset/six-carrots-protect.md new file mode 100644 index 000000000..2afb5e589 --- /dev/null +++ b/.changeset/six-carrots-protect.md @@ -0,0 +1,5 @@ +--- +"@hi-ui/form": minor +--- + +feat: Add scrollToFirstError api diff --git a/packages/ui/form/src/use-form.ts b/packages/ui/form/src/use-form.ts index 4844dc13a..f204b43ca 100644 --- a/packages/ui/form/src/use-form.ts +++ b/packages/ui/form/src/use-form.ts @@ -656,7 +656,7 @@ export interface UseFormProps> { */ onReset?: (values: T) => void | Promise /** - * 提交失败自动滚动到第一个错误字段 + * 提交失败自动滚动到第一个错误字段,配置参考:https://github.com/scroll-into-view/scroll-into-view-if-needed?tab=readme-ov-file#options */ scrollToFirstError?: boolean | ScrollOptions } diff --git a/packages/ui/form/stories/index.stories.tsx b/packages/ui/form/stories/index.stories.tsx index 7f07a03bb..7893073a9 100644 --- a/packages/ui/form/stories/index.stories.tsx +++ b/packages/ui/form/stories/index.stories.tsx @@ -7,6 +7,7 @@ export * from './placement.stories' export * from './validate.stories' export * from './validate-field.stories' export * from './validate-message.stories' +export * from './scroll-to-error.stories' export * from './set-values.stories' export * from './get-values.stories' export * from './render.stories' diff --git a/packages/ui/form/stories/scroll-to-error.stories.tsx b/packages/ui/form/stories/scroll-to-error.stories.tsx new file mode 100644 index 000000000..be37f3c46 --- /dev/null +++ b/packages/ui/form/stories/scroll-to-error.stories.tsx @@ -0,0 +1,228 @@ +import React from 'react' +import Form, { FormHelpers, FormRules } from '../src' +import Input from '@hi-ui/input' +import { Select } from '@hi-ui/select' +import { Cascader } from '@hi-ui/cascader' +import Radio from '@hi-ui/radio' +import Button from '@hi-ui/button' + +/** + * @title 滑动到错误字段 + * @desc 校验失败时,表单会自动滚动至第一个未通过的表单项 + */ +export const ScrollToError = () => { + const FormItem = Form.Item + const RadioGroup = Radio.Group + + const formRef = React.useRef(null) + + const [cascaderOptions] = React.useState([ + { + id: '手机', + title: '手机', + children: [ + { + id: '小米', + title: '小米', + children: [ + { + id: '小米3', + title: '小米3', + }, + { + id: '小米4', + title: '小米4', + }, + ], + }, + { + id: '红米', + title: '红米', + children: [ + { + id: '红米3', + title: '红米3', + }, + { + id: '红米4', + title: '红米4', + }, + ], + }, + ], + }, + { + id: '电视', + title: '电视', + children: [ + { + id: '小米电视4A', + title: '小米电视4A', + }, + { + id: '小米电视4C', + title: '小米电视4C', + }, + ], + }, + ]) + + const [rules] = React.useState({ + region: [ + { + required: true, + message: '请选择区域', + }, + ], + store: [ + { + required: true, + message: '请选择门店', + }, + ], + count: [ + { + required: true, + validator: (rule, value, cb) => { + const count = +value + if (isNaN(count)) { + cb(new Error('请输入数字')) + } else if (count <= 0) { + cb(new Error('必须是正数')) + } else { + cb() + } + }, + }, + ], + }) + + return ( + <> +

ScrollToError

+
+
+
+ + + + + + + +