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/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..f204b43ca 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,14 @@ export const useForm = >({ // catch 错误,保证检验所有表单项 return fieldValidation.validate(value).catch((error) => { + if (scrollToFirstError && !firstError) { + firstError = true + scrollToNode( + fieldName, + typeof scrollToFirstError === 'object' ? scrollToFirstError : {} + ) + } + // 第一个出错,即退出校验 if (lazyValidate) { throw error @@ -229,7 +261,14 @@ export const useForm = >({ return combinedError }) - }, [getRegisteredKeys, getFieldValue, getValidation, lazyValidate]) + }, [ + getFieldValue, + getRegisteredKeys, + getValidation, + lazyValidate, + scrollToFirstError, + scrollToNode, + ]) /** * 控件值更新策略 @@ -568,6 +607,7 @@ export const useForm = >({ getFieldsValue, setFieldsValue, getFieldsError, + formItemsRef, } } @@ -615,6 +655,10 @@ 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 } export type UseFormReturn = ReturnType 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

+
+
+
+ + + + + + + +