From 1d76fe94fa1e14a75085fbd325a135a8bae7de02 Mon Sep 17 00:00:00 2001 From: NathanLao <75557717+nathanlao@users.noreply.github.com> Date: Tue, 15 Oct 2024 01:24:27 -0700 Subject: [PATCH] feat: focus on the first field with error in form validation (#51231) Co-authored-by: afc163 --- components/form/Form.tsx | 15 ++++++--- components/form/__tests__/index.test.tsx | 32 +++++++++++++++++++ .../form/demo/validate-scroll-to-field.tsx | 2 +- components/form/hooks/useForm.ts | 8 +++++ components/form/index.en-US.md | 2 +- components/form/index.zh-CN.md | 2 +- 6 files changed, 54 insertions(+), 7 deletions(-) diff --git a/components/form/Form.tsx b/components/form/Form.tsx index 5b59bbfc1eb3..b2941245b683 100644 --- a/components/form/Form.tsx +++ b/components/form/Form.tsx @@ -31,6 +31,10 @@ export type RequiredMark = export type FormLayout = 'horizontal' | 'inline' | 'vertical'; export type FormItemLayout = 'horizontal' | 'vertical'; +export type ScrollFocusOptions = Options & { + focus?: boolean; +}; + export interface FormProps extends Omit, 'form'> { prefixCls?: string; colon?: boolean; @@ -44,7 +48,7 @@ export interface FormProps extends Omit, 'form feedbackIcons?: FeedbackIcons; size?: SizeType; disabled?: boolean; - scrollToFirstError?: Options | boolean; + scrollToFirstError?: ScrollFocusOptions | boolean; requiredMark?: RequiredMark; /** @deprecated Will warning in future branch. Pls use `requiredMark` instead. */ hideRequiredMark?: boolean; @@ -166,13 +170,16 @@ const InternalForm: React.ForwardRefRenderFunction = (props, nativeElement: nativeElementRef.current?.nativeElement, })); - const scrollToField = (options: boolean | Options, fieldName: InternalNamePath) => { + const scrollToField = (options: ScrollFocusOptions | boolean, fieldName: InternalNamePath) => { if (options) { - let defaultScrollToFirstError: Options = { block: 'nearest' }; + let defaultScrollToFirstError: ScrollFocusOptions = { block: 'nearest' }; if (typeof options === 'object') { - defaultScrollToFirstError = options; + defaultScrollToFirstError = { ...defaultScrollToFirstError, ...options }; } wrapForm.scrollToField(fieldName, defaultScrollToFirstError); + if (defaultScrollToFirstError.focus) { + wrapForm.focusField(fieldName); + } } }; diff --git a/components/form/__tests__/index.test.tsx b/components/form/__tests__/index.test.tsx index 5c070458a429..08d76ab1f011 100644 --- a/components/form/__tests__/index.test.tsx +++ b/components/form/__tests__/index.test.tsx @@ -534,6 +534,38 @@ describe('Form', () => { expect(scrollIntoView).toHaveBeenCalledTimes(3); }); + it('should scrollToFirstError work with focus', async () => { + const onFinishFailed = jest.fn(); + const focusSpy = jest.spyOn(HTMLElement.prototype, 'focus'); + + const { container } = render( +
+ + + + + + +
, + ); + + expect(scrollIntoView).not.toHaveBeenCalled(); + expect(focusSpy).not.toHaveBeenCalled(); + + fireEvent.submit(container.querySelector('form')!); + await waitFakeTimer(); + + const inputNode = document.getElementById('test'); + expect(focusSpy).toHaveBeenCalledWith(); + expect(scrollIntoView).toHaveBeenCalledWith(inputNode, { + block: 'center', + focus: true, + scrollMode: 'if-needed', + }); + + focusSpy.mockRestore(); + }); + // https://github.com/ant-design/ant-design/issues/28869 it('should work with Upload', async () => { const uploadRef = React.createRef(); diff --git a/components/form/demo/validate-scroll-to-field.tsx b/components/form/demo/validate-scroll-to-field.tsx index 53209679dff3..b483c7a5621f 100644 --- a/components/form/demo/validate-scroll-to-field.tsx +++ b/components/form/demo/validate-scroll-to-field.tsx @@ -7,7 +7,7 @@ const App = () => { return (
extends RcFormInstance { scrollToField: (name: NamePath, options?: ScrollOptions) => void; + focusField: (name: NamePath) => void; /** @internal: This is an internal usage. Do not use in your prod */ __INTERNAL__: { /** No! Do not use this in your code! */ @@ -67,6 +68,13 @@ export default function useForm(form?: FormInstance): [For } as any); } }, + focusField: (name: NamePath) => { + const node = getFieldDOMNode(name, wrapForm); + + if (node) { + node.focus?.(); + } + }, getFieldInstance: (name: NamePath) => { const namePathStr = toNamePathStr(name); return itemsRef.current[namePathStr]; diff --git a/components/form/index.en-US.md b/components/form/index.en-US.md index b06ed00bafab..6899392d5c7a 100644 --- a/components/form/index.en-US.md +++ b/components/form/index.en-US.md @@ -80,7 +80,7 @@ Common props ref:[Common props](/docs/react/common-props) | name | Form name. Will be the prefix of Field `id` | string | - | | | preserve | Keep field value even when field removed. You can get the preserve field value by `getFieldsValue(true)` | boolean | true | 4.4.0 | | requiredMark | Required mark style. Can use required mark or optional mark. You can not config to single Form.Item since this is a Form level config | boolean \| `optional` \| ((label: ReactNode, info: { required: boolean }) => ReactNode) | true | `renderProps`: 5.9.0 | -| scrollToFirstError | Auto scroll to first failed field when submit | boolean \| [Options](https://github.com/stipsan/scroll-into-view-if-needed/tree/ece40bd9143f48caf4b99503425ecb16b0ad8249#options) | false | | +| scrollToFirstError | Auto scroll to first failed field when submit | boolean \| [Options](https://github.com/stipsan/scroll-into-view-if-needed/tree/ece40bd9143f48caf4b99503425ecb16b0ad8249#options) \| { focus: boolean } | false | | | size | Set field component size (antd components only) | `small` \| `middle` \| `large` | - | | | validateMessages | Validation prompt template, description [see below](#validatemessages) | [ValidateMessages](https://github.com/ant-design/ant-design/blob/6234509d18bac1ac60fbb3f92a5b2c6a6361295a/components/locale/en_US.ts#L88-L134) | - | | | validateTrigger | Config field validate trigger | string \| string\[] | `onChange` | 4.3.0 | diff --git a/components/form/index.zh-CN.md b/components/form/index.zh-CN.md index c2fd3ef2d9c5..81d5ea50d9c7 100644 --- a/components/form/index.zh-CN.md +++ b/components/form/index.zh-CN.md @@ -81,7 +81,7 @@ coverDark: https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*ylFATY6w-ygAAA | name | 表单名称,会作为表单字段 `id` 前缀使用 | string | - | | | preserve | 当字段被删除时保留字段值。你可以通过 `getFieldsValue(true)` 来获取保留字段值 | boolean | true | 4.4.0 | | requiredMark | 必选样式,可以切换为必选或者可选展示样式。此为 Form 配置,Form.Item 无法单独配置 | boolean \| `optional` \| ((label: ReactNode, info: { required: boolean }) => ReactNode) | true | `renderProps`: 5.9.0 | -| scrollToFirstError | 提交失败自动滚动到第一个错误字段 | boolean \| [Options](https://github.com/stipsan/scroll-into-view-if-needed/tree/ece40bd9143f48caf4b99503425ecb16b0ad8249#options) | false | | +| scrollToFirstError | 提交失败自动滚动到第一个错误字段 | boolean \| [Options](https://github.com/stipsan/scroll-into-view-if-needed/tree/ece40bd9143f48caf4b99503425ecb16b0ad8249#options) \| { focus: boolean } | false | | | size | 设置字段组件的尺寸(仅限 antd 组件) | `small` \| `middle` \| `large` | - | | | validateMessages | 验证提示模板,说明[见下](#validatemessages) | [ValidateMessages](https://github.com/ant-design/ant-design/blob/6234509d18bac1ac60fbb3f92a5b2c6a6361295a/components/locale/en_US.ts#L88-L134) | - | | | validateTrigger | 统一设置字段触发验证的时机 | string \| string\[] | `onChange` | 4.3.0 |