feat: Form support scrollToField (#17457)

* support scroll & label htmlFor

* update snapshot

* add test case

* doc update

* add decleare

* adjust logic of label id

* clean ts error
This commit is contained in:
zombieJ 2019-07-04 15:00:11 +08:00 committed by GitHub
parent 186f973e50
commit b3a76c14ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 242 additions and 8 deletions

View File

@ -1,16 +1,17 @@
import * as React from 'react';
import omit from 'omit.js';
import classNames from 'classnames';
import FieldForm, { FormInstance, useForm, List } from 'rc-field-form';
import FieldForm, { List } from 'rc-field-form';
import { FormProps as RcFormProps } from 'rc-field-form/lib/Form';
import { ColProps } from '../grid/col';
import { ConfigContext, ConfigConsumerProps } from '../config-provider';
import { FormContext } from './context';
import { FormLabelAlign } from './interface';
import { useForm, FormInstance } from './util';
export type FormLayout = 'horizontal' | 'inline' | 'vertical';
interface FormProps extends RcFormProps {
interface FormProps extends Omit<RcFormProps, 'form'> {
prefixCls?: string;
hideRequiredMark?: boolean;
colon?: boolean;
@ -19,12 +20,14 @@ interface FormProps extends RcFormProps {
labelAlign?: FormLabelAlign;
labelCol?: ColProps;
wrapperCol?: ColProps;
form?: FormInstance;
}
const InternalForm: React.FC<FormProps> = (props, ref) => {
const { getPrefixCls }: ConfigConsumerProps = React.useContext(ConfigContext);
const {
form,
colon,
name,
labelAlign,
@ -57,6 +60,11 @@ const InternalForm: React.FC<FormProps> = (props, ref) => {
'colon',
]);
const [wrapForm] = useForm(form);
wrapForm.__INTERNAL__.name = name;
React.useImperativeHandle(ref, () => wrapForm);
return (
<FormContext.Provider
value={{
@ -68,7 +76,7 @@ const InternalForm: React.FC<FormProps> = (props, ref) => {
colon,
}}
>
<FieldForm id={name} {...formProps} ref={ref} className={formClassName} />
<FieldForm id={name} {...formProps} form={wrapForm} className={formClassName} />
</FormContext.Provider>
);
};

View File

@ -10,7 +10,7 @@ import warning from '../_util/warning';
import FormItemLabel, { FormItemLabelProps } from './FormItemLabel';
import FormItemInput, { FormItemInputProps } from './FormItemInput';
import { FormContext, FormItemContext } from './context';
import { toArray } from './util';
import { toArray, getFieldId } from './util';
const ValidateStatuses = tuple('success', 'warning', 'error', 'validating', '');
export type ValidateStatus = (typeof ValidateStatuses)[number];
@ -154,10 +154,10 @@ const FormItem: React.FC<FormItemProps> = (props: FormItemProps) => {
: !!(rules && rules.some(rule => typeof rule === 'object' && rule.required));
// ======================= Children =======================
const mergedId = mergedName.join('_');
const fieldId = getFieldId(mergedName, formName);
const mergedControl: typeof control = {
...control,
id: formName ? `${formName}_${mergedId}` : mergedId,
id: fieldId,
};
let childNode;
@ -195,7 +195,12 @@ const FormItem: React.FC<FormItemProps> = (props: FormItemProps) => {
return (
<Row type="flex" className={classNames(itemClassName)} style={style} key="row">
{/* Label */}
<FormItemLabel {...props} required={isRequired} prefixCls={prefixCls} />
<FormItemLabel
htmlFor={fieldId}
{...props}
required={isRequired}
prefixCls={prefixCls}
/>
{/* Input Group */}
<FormItemInput
{...props}

View File

@ -6,7 +6,7 @@ import { FormContext, FormContextProps } from './context';
export interface FormItemLabelProps {
colon?: boolean;
htmlFor: string;
htmlFor?: string;
label?: React.ReactNode;
labelAlign?: FormLabelAlign;
labelCol?: ColProps;

View File

@ -22,6 +22,7 @@ exports[`renders ./components/form/demo/advanced-search.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="advanced_search_field-0"
title="Field 0"
>
Field 0
@ -56,6 +57,7 @@ exports[`renders ./components/form/demo/advanced-search.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="advanced_search_field-1"
title="Field 1"
>
Field 1
@ -90,6 +92,7 @@ exports[`renders ./components/form/demo/advanced-search.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="advanced_search_field-2"
title="Field 2"
>
Field 2
@ -124,6 +127,7 @@ exports[`renders ./components/form/demo/advanced-search.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="advanced_search_field-3"
title="Field 3"
>
Field 3
@ -158,6 +162,7 @@ exports[`renders ./components/form/demo/advanced-search.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="advanced_search_field-4"
title="Field 4"
>
Field 4
@ -192,6 +197,7 @@ exports[`renders ./components/form/demo/advanced-search.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="advanced_search_field-5"
title="Field 5"
>
Field 5
@ -287,6 +293,7 @@ exports[`renders ./components/form/demo/basic.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="basic_username"
title="Username"
>
Username
@ -315,6 +322,7 @@ exports[`renders ./components/form/demo/basic.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="basic_password"
title="Password"
>
Password
@ -435,6 +443,7 @@ exports[`renders ./components/form/demo/control-hooks.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="control-hooks_note"
title="Note"
>
Note
@ -463,6 +472,7 @@ exports[`renders ./components/form/demo/control-hooks.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="control-hooks_gender"
title="Gender"
>
Gender
@ -581,6 +591,7 @@ exports[`renders ./components/form/demo/control-ref.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="control-ref_note"
title="Note"
>
Note
@ -609,6 +620,7 @@ exports[`renders ./components/form/demo/control-ref.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="control-ref_gender"
title="Gender"
>
Gender
@ -727,6 +739,7 @@ exports[`renders ./components/form/demo/customized-form-controls.md correctly 1`
>
<label
class=""
for="customized_form_controls_price"
title="Price"
>
Price
@ -910,6 +923,7 @@ exports[`renders ./components/form/demo/dynamic-rule.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="dynamic_rule_username"
title="Name"
>
Name
@ -939,6 +953,7 @@ exports[`renders ./components/form/demo/dynamic-rule.md correctly 1`] = `
>
<label
class=""
for="dynamic_rule_nickname"
title="Nickname"
>
Nickname
@ -1027,6 +1042,7 @@ exports[`renders ./components/form/demo/form-context.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="basicForm_group"
title="Group Name"
>
Group Name
@ -1154,6 +1170,7 @@ exports[`renders ./components/form/demo/global-state.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="global_state_username"
title="Username"
>
Username
@ -1494,6 +1511,7 @@ exports[`renders ./components/form/demo/nest-messages.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="nest-messages_user_name"
title="Name"
>
Name
@ -1522,6 +1540,7 @@ exports[`renders ./components/form/demo/nest-messages.md correctly 1`] = `
>
<label
class=""
for="nest-messages_user_email"
title="Email"
>
Email
@ -1550,6 +1569,7 @@ exports[`renders ./components/form/demo/nest-messages.md correctly 1`] = `
>
<label
class=""
for="nest-messages_user_age"
title="Age"
>
Age
@ -1648,6 +1668,7 @@ exports[`renders ./components/form/demo/nest-messages.md correctly 1`] = `
>
<label
class=""
for="nest-messages_user_website"
title="Website"
>
Website
@ -1676,6 +1697,7 @@ exports[`renders ./components/form/demo/nest-messages.md correctly 1`] = `
>
<label
class=""
for="nest-messages_user_introduction"
title="Introduction"
>
Introduction
@ -1896,6 +1918,7 @@ exports[`renders ./components/form/demo/register.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="register_email"
title="E-mail"
>
E-mail
@ -1924,6 +1947,7 @@ exports[`renders ./components/form/demo/register.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="register_password"
title="Password"
>
Password
@ -1984,6 +2008,7 @@ exports[`renders ./components/form/demo/register.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="register_confirm"
title="Confirm Password"
>
Confirm Password
@ -2044,6 +2069,7 @@ exports[`renders ./components/form/demo/register.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="register_nickname"
title=""
>
<span>
@ -2096,6 +2122,7 @@ exports[`renders ./components/form/demo/register.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="register_residence"
title="Habitual Residence"
>
Habitual Residence
@ -2176,6 +2203,7 @@ exports[`renders ./components/form/demo/register.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="register_phone"
title="Phone Number"
>
Phone Number
@ -2270,6 +2298,7 @@ exports[`renders ./components/form/demo/register.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="register_website"
title="Website"
>
Website
@ -2483,6 +2512,7 @@ exports[`renders ./components/form/demo/time-related-controls.md correctly 1`] =
>
<label
class="ant-form-item-required"
for="time_related_controls_date-picker"
title="DatePicker"
>
DatePicker
@ -2537,6 +2567,7 @@ exports[`renders ./components/form/demo/time-related-controls.md correctly 1`] =
>
<label
class="ant-form-item-required"
for="time_related_controls_date-time-picker"
title="DatePicker[showTime]"
>
DatePicker[showTime]
@ -2592,6 +2623,7 @@ exports[`renders ./components/form/demo/time-related-controls.md correctly 1`] =
>
<label
class="ant-form-item-required"
for="time_related_controls_month-picker"
title="MonthPicker"
>
MonthPicker
@ -2646,6 +2678,7 @@ exports[`renders ./components/form/demo/time-related-controls.md correctly 1`] =
>
<label
class="ant-form-item-required"
for="time_related_controls_range-picker"
title="RangePicker"
>
RangePicker
@ -2716,6 +2749,7 @@ exports[`renders ./components/form/demo/time-related-controls.md correctly 1`] =
>
<label
class="ant-form-item-required"
for="time_related_controls_range-time-picker"
title="RangePicker[showTime]"
>
RangePicker[showTime]
@ -2787,6 +2821,7 @@ exports[`renders ./components/form/demo/time-related-controls.md correctly 1`] =
>
<label
class="ant-form-item-required"
for="time_related_controls_time-picker"
title="TimePicker"
>
TimePicker
@ -2901,6 +2936,7 @@ exports[`renders ./components/form/demo/validate-other.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="validate_other_select"
title="Select"
>
Select
@ -2975,6 +3011,7 @@ exports[`renders ./components/form/demo/validate-other.md correctly 1`] = `
>
<label
class="ant-form-item-required"
for="validate_other_select-multiple"
title="Select[multiple]"
>
Select[multiple]
@ -3150,6 +3187,7 @@ exports[`renders ./components/form/demo/validate-other.md correctly 1`] = `
>
<label
class=""
for="validate_other_switch"
title="Switch"
>
Switch
@ -3183,6 +3221,7 @@ exports[`renders ./components/form/demo/validate-other.md correctly 1`] = `
>
<label
class=""
for="validate_other_slider"
title="Slider"
>
Slider
@ -3294,6 +3333,7 @@ exports[`renders ./components/form/demo/validate-other.md correctly 1`] = `
>
<label
class=""
for="validate_other_radio-group"
title="Radio.Group"
>
Radio.Group
@ -3378,6 +3418,7 @@ exports[`renders ./components/form/demo/validate-other.md correctly 1`] = `
>
<label
class=""
for="validate_other_radio-button"
title="Radio.Button"
>
Radio.Button
@ -3462,6 +3503,7 @@ exports[`renders ./components/form/demo/validate-other.md correctly 1`] = `
>
<label
class=""
for="validate_other_checkbox-group"
title="Checkbox.Group"
>
Checkbox.Group
@ -3612,6 +3654,7 @@ exports[`renders ./components/form/demo/validate-other.md correctly 1`] = `
>
<label
class=""
for="validate_other_rate"
title="Rate"
>
Rate
@ -3930,6 +3973,7 @@ exports[`renders ./components/form/demo/validate-other.md correctly 1`] = `
>
<label
class=""
for="validate_other_upload"
title="Upload"
>
Upload

View File

@ -1,15 +1,19 @@
import React from 'react';
import { mount } from 'enzyme';
import scrollIntoView from 'dom-scroll-into-view';
import Form from '..';
import Input from '../../input';
import Button from '../../button';
jest.mock('dom-scroll-into-view');
const delay = () =>
new Promise(resolve => {
setTimeout(resolve, 0);
});
describe('Form', () => {
scrollIntoView.mockImplementation(() => {});
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
async function change(wrapper, index, value) {
@ -21,12 +25,17 @@ describe('Form', () => {
wrapper.update();
}
beforeEach(() => {
scrollIntoView.mockReset();
});
afterEach(() => {
errorSpy.mockReset();
});
afterAll(() => {
errorSpy.mockRestore();
scrollIntoView.mockRestore();
});
describe('List', () => {
@ -123,4 +132,53 @@ describe('Form', () => {
'Warning: [antd: Form.Item] `children` of render props only work with `shouldUpdate`.',
);
});
describe('scrollToField', () => {
function test(name, genForm) {
it(name, () => {
let callGetForm;
const Demo = () => {
const { props, getForm } = genForm();
callGetForm = getForm;
return (
<Form name="scroll" {...props}>
<Form.Item name="test">
<Input />
</Form.Item>
</Form>
);
};
mount(<Demo />, { attachTo: document.body });
expect(scrollIntoView).not.toHaveBeenCalled();
callGetForm().scrollToField('test');
expect(scrollIntoView).toHaveBeenCalled();
});
}
// hooks
test('useForm', () => {
const [form] = Form.useForm();
return {
props: { form },
getForm: () => form,
};
});
// ref
test('ref', () => {
let form;
return {
props: {
ref: instance => {
form = instance;
},
},
getForm: () => form,
};
});
});
});

View File

@ -90,10 +90,16 @@ const tailFormItemLayout = {
};
const RegistrationForm = () => {
const [form] = Form.useForm();
const onFinish = values => {
console.log('Received values of form: ', values);
};
const onFinishFailed = ({ errorFields }) => {
form.scrollToField(errorFields[0].name);
};
const prefixSelector = (
<Form.Item name="prefix" inline>
<Select style={{ width: 70 }}>
@ -120,8 +126,10 @@ const RegistrationForm = () => {
return (
<Form
{...formItemLayout}
form={form}
name="register"
onFinish={onFinish}
onFinishFailed={onFinishFailed}
initialValues={{
residence: ['zhejiang', 'hangzhou', 'xihu'],
prefix: '86',

View File

@ -181,6 +181,7 @@ Provide linkage between forms. If a sub form with `name` prop update, it will au
| isFieldsTouched | Check if fields have been operated. Check if all fields is touched when `allTouched` is `true` | (nameList?: [NamePath](#NamePath)[], allTouched?: boolean) => boolean |
| isFieldValidating | Check fields if is in validating | (name: [NamePath](#NamePath)) => boolean |
| resetFields | Reset fields to `initialValues` | (fields?: [NamePath](#NamePath)[]) => void |
| scrollToField | Scroll to field position | (name: [NamePath](#NamePath)) => void |
| setFields | Set fields status | (fields: FieldData[]) => void |
| setFieldsValue | Set fields value | (values) => void |
| submit | Submit the form. It's same as click `submit` button | () => void |

View File

@ -182,6 +182,7 @@ Form 通过增量更新方式,只更新被修改的字段相关组件以达到
| isFieldsTouched | 检查一组字段是否被用户操作过,`allTouched` 为 `true` 时检查是否所有字段都被操作过 | (nameList?: [NamePath](#NamePath)[], allTouched?: boolean) => boolean |
| isFieldValidating | 检查一组字段是否正在校验 | (name: [NamePath](#NamePath)) => boolean |
| resetFields | 重置一组字段到 `initialValues` | (fields?: [NamePath](#NamePath)[]) => void |
| scrollToField | 滚动到对应字段位置 | (name: [NamePath](#NamePath)) => void |
| setFields | 设置一组字段状态 | (fields: FieldData[]) => void |
| setFieldsValue | 设置表单的值 | (values) => void |
| submit | 提交表单,与点击 `submit` 按钮效果相同 | () => void |

View File

@ -1,4 +1,8 @@
import * as React from 'react';
import { useForm as useRcForm, FormInstance as RcFormInstance } from 'rc-field-form';
import scrollIntoView from 'dom-scroll-into-view';
type InternalNamePath = (string | number)[];
/**
* We will remove light way shake like:
@ -58,3 +62,59 @@ export function toArray<T>(candidate?: T | T[] | false): T[] {
return Array.isArray(candidate) ? candidate : [candidate];
}
export function getFieldId(namePath: InternalNamePath, formName?: string): string | undefined {
if (!namePath.length) return undefined;
const mergedId = namePath.join('_');
return formName ? `${formName}_${mergedId}` : mergedId;
}
// Source: https://github.com/react-component/form/blob/master/src/createDOMForm.js
function getScrollableContainer(current: HTMLElement) {
let node: HTMLElement | null = current;
let nodeName;
/* eslint no-cond-assign:0 */
while (node && (nodeName = node.nodeName.toLowerCase()) !== 'body') {
const { overflowY } = getComputedStyle(node);
// https://stackoverflow.com/a/36900407/3040605
if (
node !== current &&
(overflowY === 'auto' || overflowY === 'scroll') &&
node.scrollHeight > node.clientHeight
) {
return node;
}
node = node.parentElement;
}
return nodeName === 'body' ? node!.ownerDocument : node;
}
export interface FormInstance extends RcFormInstance {
scrollToField: (name: string | number | InternalNamePath) => void;
__INTERNAL__: {
name?: string;
};
}
export function useForm(form?: FormInstance): [FormInstance] {
const wrapForm: FormInstance = form
? form
: {
...useRcForm()[0],
__INTERNAL__: {},
scrollToField: name => {
const namePath = toArray(name);
const fieldId = getFieldId(namePath, wrapForm.__INTERNAL__.name);
const node: HTMLElement | null = fieldId ? document.getElementById(fieldId) : null;
if (node) {
scrollIntoView(node, getScrollableContainer(node), {
onlyScrollIfNeeded: true,
});
}
},
};
return [wrapForm];
}

View File

@ -191,6 +191,28 @@ const Demo = () => {
};
```
## Replace validateFieldsAndScroll with scrollToField
New version recommend use `onFinish` for submit after validation. Thus `validateFieldsAndScroll` is change to more flexible method `scrollToField`:
```jsx
// antd v3
onSubmit = () => {
form.validateFieldsAndScroll((error, values) => {
// Your logic
});
};
```
To:
```jsx
// antd v4
onFinishFailed = ({ errorFields }) => {
form.scrollToField(errorFields[0].name);
};
```
### Initialization
Besides, we move `initialValue` into Form to avoid field with same name both using `initialValue` to cause conflict:

View File

@ -193,6 +193,28 @@ const Demo = () => {
};
```
## scrollToField 替代 validateFieldsAndScroll
新版推荐使用 `onFinish` 进行校验后提交操作,因而 `validateFieldsAndScroll` 拆成更独立的 `scrollToField` 方法:
```jsx
// antd v3
onSubmit = () => {
form.validateFieldsAndScroll((error, values) => {
// Your logic
});
};
```
改成:
```jsx
// antd v4
onFinishFailed = ({ errorFields }) => {
form.scrollToField(errorFields[0].name);
};
```
## 初始化调整
此外,我们将 `initialValue` 从字段中移到 Form 中。以避免同名字段设置 `initialValue` 的冲突问题:

View File

@ -261,6 +261,7 @@ exports[`renders ./components/mention/demo/controlled.md correctly 1`] = `
>
<label
class=""
for="mention"
title="Top coders"
>
Top coders

View File

@ -36,6 +36,7 @@ exports[`renders ./components/mentions/demo/form.md correctly 1`] = `
>
<label
class=""
for="mention"
title="Top coders"
>
Top coders

View File

@ -52,6 +52,7 @@
"copy-to-clipboard": "^3.2.0",
"css-animation": "^1.5.0",
"dom-closest": "^0.2.0",
"dom-scroll-into-view": "^1.2.1",
"enquire.js": "^2.1.6",
"lodash": "^4.17.11",
"moment": "^2.24.0",

View File

@ -83,6 +83,8 @@ declare module 'react-lazy-load';
declare module 'dom-closest';
declare module 'dom-scroll-into-view';
declare module '*.json' {
const value: any;
export const version: string;