Skip to content

Commit

Permalink
feat: focus on the first field with error in form validation (ant-des…
Browse files Browse the repository at this point in the history
…ign#51231)

Co-authored-by: afc163 <[email protected]>
  • Loading branch information
nathanlao and afc163 authored Oct 15, 2024
1 parent d52de71 commit 1d76fe9
Show file tree
Hide file tree
Showing 6 changed files with 54 additions and 7 deletions.
15 changes: 11 additions & 4 deletions components/form/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Values = any> extends Omit<RcFormProps<Values>, 'form'> {
prefixCls?: string;
colon?: boolean;
Expand All @@ -44,7 +48,7 @@ export interface FormProps<Values = any> extends Omit<RcFormProps<Values>, '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;
Expand Down Expand Up @@ -166,13 +170,16 @@ const InternalForm: React.ForwardRefRenderFunction<FormRef, FormProps> = (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);
}
}
};

Expand Down
32 changes: 32 additions & 0 deletions components/form/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Form scrollToFirstError={{ block: 'center', focus: true }} onFinishFailed={onFinishFailed}>
<Form.Item name="test" rules={[{ required: true }]}>
<input />
</Form.Item>
<Form.Item>
<Button htmlType="submit">Submit</Button>
</Form.Item>
</Form>,
);

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<any>();
Expand Down
2 changes: 1 addition & 1 deletion components/form/demo/validate-scroll-to-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const App = () => {
return (
<Form
form={form}
scrollToFirstError
scrollToFirstError={{ behavior: 'instant', block: 'end', focus: true }}
style={{ paddingBlock: 32 }}
labelCol={{ span: 6 }}
wrapperCol={{ span: 14 }}
Expand Down
8 changes: 8 additions & 0 deletions components/form/hooks/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getFieldId, toArray } from '../util';

export interface FormInstance<Values = any> extends RcFormInstance<Values> {
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! */
Expand Down Expand Up @@ -67,6 +68,13 @@ export default function useForm<Values = any>(form?: FormInstance<Values>): [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];
Expand Down
2 changes: 1 addition & 1 deletion components/form/index.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion components/form/index.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down

0 comments on commit 1d76fe9

Please sign in to comment.