merge feature into master

This commit is contained in:
afc163 2021-06-05 13:30:36 +08:00
commit 8fe1cc9da5
29 changed files with 809 additions and 417 deletions

View File

@ -3,7 +3,10 @@ import { MotionEvent } from 'rc-motion/lib/interface';
// ================== Collapse Motion ================== // ================== Collapse Motion ==================
const getCollapsedHeight: MotionEventHandler = () => ({ height: 0, opacity: 0 }); const getCollapsedHeight: MotionEventHandler = () => ({ height: 0, opacity: 0 });
const getRealHeight: MotionEventHandler = node => ({ height: node.scrollHeight, opacity: 1 }); const getRealHeight: MotionEventHandler = node => {
const { scrollHeight } = node;
return { height: scrollHeight, opacity: 1 };
};
const getCurrentHeight: MotionEventHandler = node => ({ height: node.offsetHeight }); const getCurrentHeight: MotionEventHandler = node => ({ height: node.offsetHeight });
const skipOpacityTransition: MotionEndEventHandler = (_, event: MotionEvent) => const skipOpacityTransition: MotionEndEventHandler = (_, event: MotionEvent) =>
event?.deadline === true || (event as TransitionEvent).propertyName === 'height'; event?.deadline === true || (event as TransitionEvent).propertyName === 'height';

View File

@ -13272,9 +13272,10 @@ exports[`ConfigProvider components Form configProvider 1`] = `
</div> </div>
</div> </div>
<div <div
class="config-form-item-explain config-form-item-explain-error" class="config-form-item-explain"
> >
<div <div
class="config-form-item-explain-error"
role="alert" role="alert"
> >
Bamboo is Light Bamboo is Light
@ -13309,9 +13310,10 @@ exports[`ConfigProvider components Form configProvider componentSize large 1`] =
</div> </div>
</div> </div>
<div <div
class="config-form-item-explain config-form-item-explain-error" class="config-form-item-explain"
> >
<div <div
class="config-form-item-explain-error"
role="alert" role="alert"
> >
Bamboo is Light Bamboo is Light
@ -13346,9 +13348,10 @@ exports[`ConfigProvider components Form configProvider componentSize middle 1`]
</div> </div>
</div> </div>
<div <div
class="config-form-item-explain config-form-item-explain-error" class="config-form-item-explain"
> >
<div <div
class="config-form-item-explain-error"
role="alert" role="alert"
> >
Bamboo is Light Bamboo is Light
@ -13383,9 +13386,10 @@ exports[`ConfigProvider components Form configProvider virtual and dropdownMatch
</div> </div>
</div> </div>
<div <div
class="ant-form-item-explain ant-form-item-explain-error" class="ant-form-item-explain"
> >
<div <div
class="ant-form-item-explain-error"
role="alert" role="alert"
> >
Bamboo is Light Bamboo is Light
@ -13420,9 +13424,10 @@ exports[`ConfigProvider components Form normal 1`] = `
</div> </div>
</div> </div>
<div <div
class="ant-form-item-explain ant-form-item-explain-error" class="ant-form-item-explain"
> >
<div <div
class="ant-form-item-explain-error"
role="alert" role="alert"
> >
Bamboo is Light Bamboo is Light
@ -13457,9 +13462,10 @@ exports[`ConfigProvider components Form prefixCls 1`] = `
</div> </div>
</div> </div>
<div <div
class="prefix-Form-item-explain prefix-Form-item-explain-error" class="prefix-Form-item-explain"
> >
<div <div
class="prefix-Form-item-explain-error"
role="alert" role="alert"
> >
Bamboo is Light Bamboo is Light

View File

@ -1,97 +1,114 @@
import * as React from 'react'; import * as React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import CSSMotion from 'rc-motion'; import CSSMotion, { CSSMotionList } from 'rc-motion';
import useMemo from 'rc-util/lib/hooks/useMemo';
import useCacheErrors from './hooks/useCacheErrors';
import useForceUpdate from '../_util/hooks/useForceUpdate';
import { FormItemPrefixContext } from './context'; import { FormItemPrefixContext } from './context';
import { ConfigContext } from '../config-provider'; import { ConfigContext } from '../config-provider';
import { ValidateStatus } from './FormItem';
import collapseMotion from '../_util/motion';
const EMPTY_LIST: React.ReactNode[] = []; const EMPTY_LIST: React.ReactNode[] = [];
interface ErrorEntity {
error: React.ReactNode;
errorStatus?: ValidateStatus;
key: string;
}
function toErrorEntity(
error: React.ReactNode,
errorStatus: ValidateStatus | undefined,
prefix: string,
index: number = 0,
): ErrorEntity {
return {
key: typeof error === 'string' ? error : `${prefix}-${index}`,
error,
errorStatus,
};
}
export interface ErrorListProps { export interface ErrorListProps {
errors?: React.ReactNode[];
/** @private Internal Usage. Do not use in your production */
help?: React.ReactNode; help?: React.ReactNode;
/** @private Internal Usage. Do not use in your production */ helpStatus?: ValidateStatus;
onDomErrorVisibleChange?: (visible: boolean) => void; errors?: React.ReactNode[];
warnings?: React.ReactNode[];
} }
export default function ErrorList({ export default function ErrorList({
errors = EMPTY_LIST,
help, help,
onDomErrorVisibleChange, helpStatus,
errors = EMPTY_LIST,
warnings = EMPTY_LIST,
}: ErrorListProps) { }: ErrorListProps) {
const forceUpdate = useForceUpdate(); const { prefixCls } = React.useContext(FormItemPrefixContext);
const { prefixCls, status } = React.useContext(FormItemPrefixContext);
const { getPrefixCls } = React.useContext(ConfigContext); const { getPrefixCls } = React.useContext(ConfigContext);
const [visible, cacheErrors] = useCacheErrors(
errors,
changedVisible => {
if (changedVisible) {
/**
* We trigger in sync to avoid dom shaking but this get warning in react 16.13.
*
* So use Promise to keep in micro async to handle this.
* https://github.com/ant-design/ant-design/issues/21698#issuecomment-593743485
*/
Promise.resolve().then(() => {
onDomErrorVisibleChange?.(true);
});
}
forceUpdate();
},
!!help,
);
const memoErrors = useMemo(
() => cacheErrors,
visible,
(_, nextVisible) => nextVisible,
);
// Memo status in same visible
const [innerStatus, setInnerStatus] = React.useState(status);
React.useEffect(() => {
if (visible && status) {
setInnerStatus(status);
}
}, [visible, status]);
const baseClassName = `${prefixCls}-item-explain`; const baseClassName = `${prefixCls}-item-explain`;
const rootPrefixCls = getPrefixCls(); const rootPrefixCls = getPrefixCls();
const fullKeyList = React.useMemo(() => {
if (help !== undefined && help !== null) {
return [toErrorEntity(help, helpStatus, 'help')];
}
return [
...errors.map((error, index) => toErrorEntity(error, 'error', 'error', index)),
...warnings.map((warning, index) => toErrorEntity(warning, 'warning', 'warning', index)),
];
}, [help, helpStatus, errors, warnings]);
return ( return (
<CSSMotion <CSSMotion
motionDeadline={500} {...collapseMotion}
visible={visible}
motionName={`${rootPrefixCls}-show-help`} motionName={`${rootPrefixCls}-show-help`}
onLeaveEnd={() => { motionAppear={false}
onDomErrorVisibleChange?.(false); motionEnter={false}
}} motionLeave
motionAppear visible={!!fullKeyList.length}
removeOnLeave removeOnLeave
onLeaveStart={node => {
// Force disable css override style in index.less configured
node.style.height = 'auto';
return { height: node.offsetHeight };
}}
> >
{({ className: motionClassName }: { className?: string }) => ( {holderProps => {
const { className: holderClassName, style: holderStyle } = holderProps;
return (
<div className={classNames(baseClassName, holderClassName)} style={holderStyle}>
<CSSMotionList
keys={fullKeyList}
{...collapseMotion}
motionName={`${rootPrefixCls}-show-help-item`}
component={false}
>
{itemProps => {
const {
key,
error,
errorStatus,
className: itemClassName,
style: itemStyle,
} = itemProps;
return (
<div <div
className={classNames( key={key}
baseClassName, role="alert"
{ className={classNames(itemClassName, {
[`${baseClassName}-${innerStatus}`]: innerStatus, [`${baseClassName}-${errorStatus}`]: errorStatus,
}, })}
motionClassName, style={itemStyle}
)}
key="help"
> >
{memoErrors.map((error, index) => (
// eslint-disable-next-line react/no-array-index-key
<div key={index} role="alert">
{error} {error}
</div> </div>
))} );
}}
</CSSMotionList>
</div> </div>
)} );
}}
</CSSMotion> </CSSMotion>
); );
} }

View File

@ -1,6 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { useContext, useRef } from 'react'; import { useContext } from 'react';
import isEqual from 'lodash/isEqual';
import classNames from 'classnames'; import classNames from 'classnames';
import { Field, FormInstance } from 'rc-field-form'; import { Field, FormInstance } from 'rc-field-form';
import { FieldProps } from 'rc-field-form/lib/Field'; import { FieldProps } from 'rc-field-form/lib/Field';
@ -14,14 +13,20 @@ import { tuple } from '../_util/type';
import devWarning from '../_util/devWarning'; import devWarning from '../_util/devWarning';
import FormItemLabel, { FormItemLabelProps, LabelTooltipType } from './FormItemLabel'; import FormItemLabel, { FormItemLabelProps, LabelTooltipType } from './FormItemLabel';
import FormItemInput, { FormItemInputProps } from './FormItemInput'; import FormItemInput, { FormItemInputProps } from './FormItemInput';
import { FormContext, FormItemContext } from './context'; import { FormContext, NoStyleItemContext } from './context';
import { toArray, getFieldId } from './util'; import { toArray, getFieldId } from './util';
import { cloneElement, isValidElement } from '../_util/reactNode'; import { cloneElement, isValidElement } from '../_util/reactNode';
import useFrameState from './hooks/useFrameState'; import useFrameState from './hooks/useFrameState';
import useDebounce from './hooks/useDebounce';
import useItemRef from './hooks/useItemRef'; import useItemRef from './hooks/useItemRef';
const NAME_SPLIT = '__SPLIT__'; const NAME_SPLIT = '__SPLIT__';
interface FieldError {
errors: string[];
warnings: string[];
}
const ValidateStatuses = tuple('success', 'warning', 'error', 'validating', ''); const ValidateStatuses = tuple('success', 'warning', 'error', 'validating', '');
export type ValidateStatus = typeof ValidateStatuses[number]; export type ValidateStatus = typeof ValidateStatuses[number];
@ -31,7 +36,7 @@ type ChildrenType<Values = any> = RenderChildren<Values> | React.ReactNode;
interface MemoInputProps { interface MemoInputProps {
value: any; value: any;
update: number; update: any;
children: React.ReactNode; children: React.ReactNode;
} }
@ -68,6 +73,16 @@ function hasValidName(name?: NamePath): Boolean {
return !(name === undefined || name === null); return !(name === undefined || name === null);
} }
function genEmptyMeta(): Meta {
return {
errors: [],
warnings: [],
touched: false,
validating: false,
name: [],
};
}
function FormItem<Values = any>(props: FormItemProps<Values>): React.ReactElement { function FormItem<Values = any>(props: FormItemProps<Values>): React.ReactElement {
const { const {
name, name,
@ -91,104 +106,109 @@ function FormItem<Values = any>(props: FormItemProps<Values>): React.ReactElemen
hidden, hidden,
...restProps ...restProps
} = props; } = props;
const destroyRef = useRef(false);
const { getPrefixCls } = useContext(ConfigContext); const { getPrefixCls } = useContext(ConfigContext);
const { name: formName, requiredMark } = useContext(FormContext); const { name: formName, requiredMark } = useContext(FormContext);
const { updateItemErrors } = useContext(FormItemContext); const isRenderProps = typeof children === 'function';
const [domErrorVisible, innerSetDomErrorVisible] = React.useState(!!help); const notifyParentMetaChange = useContext(NoStyleItemContext);
const [inlineErrors, setInlineErrors] = useFrameState<Record<string, string[]>>({});
const { validateTrigger: contextValidateTrigger } = useContext(FieldContext); const { validateTrigger: contextValidateTrigger } = useContext(FieldContext);
const mergedValidateTrigger = const mergedValidateTrigger =
validateTrigger !== undefined ? validateTrigger : contextValidateTrigger; validateTrigger !== undefined ? validateTrigger : contextValidateTrigger;
function setDomErrorVisible(visible: boolean) {
if (!destroyRef.current) {
innerSetDomErrorVisible(visible);
}
}
const hasName = hasValidName(name); const hasName = hasValidName(name);
// Cache Field NamePath
const nameRef = useRef<(string | number)[]>([]);
// Should clean up if Field removed
React.useEffect(
() => () => {
destroyRef.current = true;
updateItemErrors(nameRef.current.join(NAME_SPLIT), []);
},
[],
);
const prefixCls = getPrefixCls('form', customizePrefixCls); const prefixCls = getPrefixCls('form', customizePrefixCls);
// ======================== Errors ======================== // ======================== Errors ========================
// Collect noStyle Field error to the top FormItem // >>>>> Collect sub field errors
const updateChildItemErrors = noStyle const [subFieldErrors, setSubFieldErrors] = useFrameState<Record<string, FieldError>>({});
? updateItemErrors
: (subName: string, subErrors: string[], originSubName?: string) => { // >>>>> Current field errors
setInlineErrors((prevInlineErrors = {}) => { const [meta, setMeta] = React.useState<Meta>(() => genEmptyMeta());
// Clean up origin error when name changed
if (originSubName && originSubName !== subName) { const onMetaChange = (nextMeta: Meta & { destroy?: boolean }) => {
delete prevInlineErrors[originSubName]; // Destroy will reset all the meta
setMeta(nextMeta.destroy ? genEmptyMeta() : nextMeta);
// Bump to parent since noStyle
if (noStyle && notifyParentMetaChange) {
let namePath = nextMeta.name;
if (fieldKey !== undefined) {
namePath = Array.isArray(fieldKey) ? fieldKey : [fieldKey!];
}
notifyParentMetaChange(nextMeta, namePath);
}
};
// >>>>> Collect noStyle Field error to the top FormItem
const onSubItemMetaChange = (subMeta: Meta & { destroy: boolean }, uniqueKeys: React.Key[]) => {
// Only `noStyle` sub item will trigger
setSubFieldErrors(prevSubFieldErrors => {
const clone = {
...prevSubFieldErrors,
};
// name: ['user', 1] + key: [4] = ['user', 4]
const mergedNamePath = [...subMeta.name.slice(0, -1), ...uniqueKeys];
const mergedNameKey = mergedNamePath.join(NAME_SPLIT);
if (subMeta.destroy) {
// Remove
delete clone[mergedNameKey];
} else {
// Update
clone[mergedNameKey] = subMeta;
} }
if (!isEqual(prevInlineErrors[subName], subErrors)) { return clone;
return {
...prevInlineErrors,
[subName]: subErrors,
};
}
return prevInlineErrors;
}); });
}; };
// >>>>> Get merged errors
const [mergedErrors, mergedWarnings] = React.useMemo(() => {
const errorList: string[] = [...meta.errors];
const warningList: string[] = [...meta.warnings];
Object.values(subFieldErrors).forEach(subFieldError => {
errorList.push(...(subFieldError.errors || []));
warningList.push(...(subFieldError.warnings || []));
});
return [errorList, warningList];
}, [subFieldErrors, meta.errors, meta.warnings]);
const debounceErrors = useDebounce(mergedErrors);
const debounceWarnings = useDebounce(mergedWarnings);
// ===================== Children Ref ===================== // ===================== Children Ref =====================
const getItemRef = useItemRef(); const getItemRef = useItemRef();
// ======================== Render ========================
function renderLayout( function renderLayout(
baseChildren: React.ReactNode, baseChildren: React.ReactNode,
fieldId?: string, fieldId?: string,
meta?: Meta,
isRequired?: boolean, isRequired?: boolean,
): React.ReactNode { ): React.ReactNode {
if (noStyle && !hidden) { if (noStyle && !hidden) {
return baseChildren; return baseChildren;
} }
// ======================== Errors ========================
// >>> collect sub errors
let subErrorList: string[] = [];
Object.keys(inlineErrors).forEach(subName => {
subErrorList = [...subErrorList, ...(inlineErrors[subName] || [])];
});
// >>> merged errors
let mergedErrors: React.ReactNode[];
if (help !== undefined && help !== null) {
mergedErrors = toArray(help);
} else {
mergedErrors = meta ? meta.errors : [];
mergedErrors = [...mergedErrors, ...subErrorList];
}
// ======================== Status ======================== // ======================== Status ========================
let mergedValidateStatus: ValidateStatus = ''; let mergedValidateStatus: ValidateStatus = '';
if (validateStatus !== undefined) { if (validateStatus !== undefined) {
mergedValidateStatus = validateStatus; mergedValidateStatus = validateStatus;
} else if (meta?.validating) { } else if (meta?.validating) {
mergedValidateStatus = 'validating'; mergedValidateStatus = 'validating';
} else if (meta?.errors?.length || subErrorList.length) { } else if (debounceErrors.length) {
mergedValidateStatus = 'error'; mergedValidateStatus = 'error';
} else if (debounceWarnings.length) {
mergedValidateStatus = 'warning';
} else if (meta?.touched) { } else if (meta?.touched) {
mergedValidateStatus = 'success'; mergedValidateStatus = 'success';
} }
const itemClassName = { const itemClassName = {
[`${prefixCls}-item`]: true, [`${prefixCls}-item`]: true,
[`${prefixCls}-item-with-help`]: domErrorVisible || !!help, [`${prefixCls}-item-with-help`]: help || debounceErrors.length || debounceWarnings.length,
[`${className}`]: !!className, [`${className}`]: !!className,
// Status // Status
@ -238,26 +258,21 @@ function FormItem<Values = any>(props: FormItemProps<Values>): React.ReactElemen
<FormItemInput <FormItemInput
{...props} {...props}
{...meta} {...meta}
errors={mergedErrors} errors={debounceErrors}
warnings={debounceWarnings}
prefixCls={prefixCls} prefixCls={prefixCls}
status={mergedValidateStatus} status={mergedValidateStatus}
onDomErrorVisibleChange={setDomErrorVisible}
validateStatus={mergedValidateStatus} validateStatus={mergedValidateStatus}
help={help}
> >
<FormItemContext.Provider value={{ updateItemErrors: updateChildItemErrors }}> <NoStyleItemContext.Provider value={onSubItemMetaChange}>
{baseChildren} {baseChildren}
</FormItemContext.Provider> </NoStyleItemContext.Provider>
</FormItemInput> </FormItemInput>
</Row> </Row>
); );
} }
const isRenderProps = typeof children === 'function';
// Record for real component render
const updateRef = useRef(0);
updateRef.current += 1;
if (!hasName && !isRenderProps && !dependencies) { if (!hasName && !isRenderProps && !dependencies) {
return renderLayout(children) as JSX.Element; return renderLayout(children) as JSX.Element;
} }
@ -270,46 +285,31 @@ function FormItem<Values = any>(props: FormItemProps<Values>): React.ReactElemen
variables = { ...variables, ...messageVariables }; variables = { ...variables, ...messageVariables };
} }
// >>>>> With Field
return ( return (
<Field <Field
{...props} {...props}
messageVariables={variables} messageVariables={variables}
trigger={trigger} trigger={trigger}
validateTrigger={mergedValidateTrigger} validateTrigger={mergedValidateTrigger}
onReset={() => { onMetaChange={onMetaChange}
setDomErrorVisible(false);
}}
> >
{(control, meta, context) => { {(control, renderMeta, context) => {
const { errors } = meta; const mergedName = toArray(name).length && renderMeta ? renderMeta.name : [];
const mergedName = toArray(name).length && meta ? meta.name : [];
const fieldId = getFieldId(mergedName, formName); const fieldId = getFieldId(mergedName, formName);
if (noStyle) {
// Clean up origin one
const originErrorName = nameRef.current.join(NAME_SPLIT);
nameRef.current = [...mergedName];
if (fieldKey) {
const fieldKeys = Array.isArray(fieldKey) ? fieldKey : [fieldKey];
nameRef.current = [...mergedName.slice(0, -1), ...fieldKeys];
}
updateItemErrors(nameRef.current.join(NAME_SPLIT), errors, originErrorName);
}
const isRequired = const isRequired =
required !== undefined required !== undefined
? required ? required
: !!( : !!(
rules && rules &&
rules.some(rule => { rules.some(rule => {
if (rule && typeof rule === 'object' && rule.required) { if (rule && typeof rule === 'object' && rule.required && !rule.warningOnly) {
return true; return true;
} }
if (typeof rule === 'function') { if (typeof rule === 'function') {
const ruleEntity = rule(context); const ruleEntity = rule(context);
return ruleEntity && ruleEntity.required; return ruleEntity && ruleEntity.required && !ruleEntity.warningOnly;
} }
return false; return false;
}) })
@ -377,10 +377,7 @@ function FormItem<Values = any>(props: FormItemProps<Values>): React.ReactElemen
}); });
childNode = ( childNode = (
<MemoInput <MemoInput value={mergedControl[props.valuePropName || 'value']} update={children}>
value={mergedControl[props.valuePropName || 'value']}
update={updateRef.current}
>
{cloneElement(children, childProps)} {cloneElement(children, childProps)}
</MemoInput> </MemoInput>
); );
@ -395,7 +392,7 @@ function FormItem<Values = any>(props: FormItemProps<Values>): React.ReactElemen
childNode = children; childNode = children;
} }
return renderLayout(childNode, fieldId, meta, isRequired); return renderLayout(childNode, fieldId, isRequired);
}} }}
</Field> </Field>
); );

View File

@ -14,9 +14,9 @@ interface FormItemInputMiscProps {
prefixCls: string; prefixCls: string;
children: React.ReactNode; children: React.ReactNode;
errors: React.ReactNode[]; errors: React.ReactNode[];
warnings: React.ReactNode[];
hasFeedback?: boolean; hasFeedback?: boolean;
validateStatus?: ValidateStatus; validateStatus?: ValidateStatus;
onDomErrorVisibleChange: (visible: boolean) => void;
/** @private Internal Usage, do not use in any of your production. */ /** @private Internal Usage, do not use in any of your production. */
_internalItemRender?: { _internalItemRender?: {
mark: string; mark: string;
@ -33,9 +33,9 @@ interface FormItemInputMiscProps {
export interface FormItemInputProps { export interface FormItemInputProps {
wrapperCol?: ColProps; wrapperCol?: ColProps;
help?: React.ReactNode;
extra?: React.ReactNode; extra?: React.ReactNode;
status?: ValidateStatus; status?: ValidateStatus;
help?: React.ReactNode;
} }
const iconMap: { [key: string]: any } = { const iconMap: { [key: string]: any } = {
@ -51,13 +51,13 @@ const FormItemInput: React.FC<FormItemInputProps & FormItemInputMiscProps> = pro
status, status,
wrapperCol, wrapperCol,
children, children,
help,
errors, errors,
onDomErrorVisibleChange, warnings,
hasFeedback, hasFeedback,
_internalItemRender: formItemRender, _internalItemRender: formItemRender,
validateStatus, validateStatus,
extra, extra,
help,
} = props; } = props;
const baseClassName = `${prefixCls}-item`; const baseClassName = `${prefixCls}-item`;
@ -67,13 +67,6 @@ const FormItemInput: React.FC<FormItemInputProps & FormItemInputMiscProps> = pro
const className = classNames(`${baseClassName}-control`, mergedWrapperCol.className); const className = classNames(`${baseClassName}-control`, mergedWrapperCol.className);
React.useEffect(
() => () => {
onDomErrorVisibleChange(false);
},
[],
);
// Should provides additional icon if `hasFeedback` // Should provides additional icon if `hasFeedback`
const IconNode = validateStatus && iconMap[validateStatus]; const IconNode = validateStatus && iconMap[validateStatus];
const icon = const icon =
@ -96,7 +89,7 @@ const FormItemInput: React.FC<FormItemInputProps & FormItemInputMiscProps> = pro
); );
const errorListDom = ( const errorListDom = (
<FormItemPrefixContext.Provider value={{ prefixCls, status }}> <FormItemPrefixContext.Provider value={{ prefixCls, status }}>
<ErrorList errors={errors} help={help} onDomErrorVisibleChange={onDomErrorVisibleChange} /> <ErrorList errors={errors} warnings={warnings} help={help} helpStatus={status} />
</FormItemPrefixContext.Provider> </FormItemPrefixContext.Provider>
); );

View File

@ -25,7 +25,7 @@ export interface FormListProps {
children: ( children: (
fields: FormListFieldData[], fields: FormListFieldData[],
operation: FormListOperation, operation: FormListOperation,
meta: { errors: React.ReactNode[] }, meta: { errors: React.ReactNode[]; warnings: React.ReactNode[] },
) => React.ReactNode; ) => React.ReactNode;
} }
@ -48,6 +48,7 @@ const FormList: React.FC<FormListProps> = ({
operation, operation,
{ {
errors: meta.errors, errors: meta.errors,
warnings: meta.warnings,
}, },
)} )}
</FormItemPrefixContext.Provider> </FormItemPrefixContext.Provider>

View File

@ -306,6 +306,7 @@ exports[`renders ./components/form/demo/advanced-search.md correctly 1`] = `
exports[`renders ./components/form/demo/basic.md correctly 1`] = ` exports[`renders ./components/form/demo/basic.md correctly 1`] = `
<form <form
autocomplete="off"
class="ant-form ant-form-horizontal" class="ant-form ant-form-horizontal"
id="basic" id="basic"
> >
@ -1073,9 +1074,10 @@ exports[`renders ./components/form/demo/disabled-input-debug.md correctly 1`] =
</div> </div>
</div> </div>
<div <div
class="ant-form-item-explain ant-form-item-explain-error" class="ant-form-item-explain"
> >
<div <div
class="ant-form-item-explain-error"
role="alert" role="alert"
> >
Buggy! Buggy!
@ -1115,9 +1117,10 @@ exports[`renders ./components/form/demo/disabled-input-debug.md correctly 1`] =
</div> </div>
</div> </div>
<div <div
class="ant-form-item-explain ant-form-item-explain-error" class="ant-form-item-explain"
> >
<div <div
class="ant-form-item-explain-error"
role="alert" role="alert"
> >
Buggy! Buggy!
@ -1188,9 +1191,10 @@ exports[`renders ./components/form/demo/disabled-input-debug.md correctly 1`] =
</div> </div>
</div> </div>
<div <div
class="ant-form-item-explain ant-form-item-explain-error" class="ant-form-item-explain"
> >
<div <div
class="ant-form-item-explain-error"
role="alert" role="alert"
> >
Buggy! Buggy!
@ -1230,9 +1234,10 @@ exports[`renders ./components/form/demo/disabled-input-debug.md correctly 1`] =
</div> </div>
</div> </div>
<div <div
class="ant-form-item-explain ant-form-item-explain-error" class="ant-form-item-explain"
> >
<div <div
class="ant-form-item-explain-error"
role="alert" role="alert"
> >
Buggy! Buggy!
@ -1329,9 +1334,10 @@ exports[`renders ./components/form/demo/disabled-input-debug.md correctly 1`] =
</div> </div>
</div> </div>
<div <div
class="ant-form-item-explain ant-form-item-explain-error" class="ant-form-item-explain"
> >
<div <div
class="ant-form-item-explain-error"
role="alert" role="alert"
> >
Buggy! Buggy!
@ -1384,9 +1390,10 @@ exports[`renders ./components/form/demo/disabled-input-debug.md correctly 1`] =
</div> </div>
</div> </div>
<div <div
class="ant-form-item-explain ant-form-item-explain-error" class="ant-form-item-explain"
> >
<div <div
class="ant-form-item-explain-error"
role="alert" role="alert"
> >
Buggy! Buggy!
@ -1475,9 +1482,10 @@ exports[`renders ./components/form/demo/disabled-input-debug.md correctly 1`] =
</div> </div>
</div> </div>
<div <div
class="ant-form-item-explain ant-form-item-explain-error" class="ant-form-item-explain"
> >
<div <div
class="ant-form-item-explain-error"
role="alert" role="alert"
> >
Buggy! Buggy!
@ -1526,9 +1534,10 @@ exports[`renders ./components/form/demo/disabled-input-debug.md correctly 1`] =
</div> </div>
</div> </div>
<div <div
class="ant-form-item-explain ant-form-item-explain-error" class="ant-form-item-explain"
> >
<div <div
class="ant-form-item-explain-error"
role="alert" role="alert"
> >
Buggy! Buggy!
@ -6472,9 +6481,10 @@ exports[`renders ./components/form/demo/validate-static.md correctly 1`] = `
</div> </div>
</div> </div>
<div <div
class="ant-form-item-explain ant-form-item-explain-error" class="ant-form-item-explain"
> >
<div <div
class="ant-form-item-explain-error"
role="alert" role="alert"
> >
Should be combination of numbers & alphabets Should be combination of numbers & alphabets
@ -6597,9 +6607,10 @@ exports[`renders ./components/form/demo/validate-static.md correctly 1`] = `
</span> </span>
</div> </div>
<div <div
class="ant-form-item-explain ant-form-item-explain-validating" class="ant-form-item-explain"
> >
<div <div
class="ant-form-item-explain-validating"
role="alert" role="alert"
> >
The information is being validated... The information is being validated...
@ -6774,9 +6785,10 @@ exports[`renders ./components/form/demo/validate-static.md correctly 1`] = `
</span> </span>
</div> </div>
<div <div
class="ant-form-item-explain ant-form-item-explain-error" class="ant-form-item-explain"
> >
<div <div
class="ant-form-item-explain-error"
role="alert" role="alert"
> >
Should be combination of numbers & alphabets Should be combination of numbers & alphabets
@ -7154,9 +7166,10 @@ exports[`renders ./components/form/demo/validate-static.md correctly 1`] = `
</span> </span>
</div> </div>
<div <div
class="ant-form-item-explain ant-form-item-explain-validating" class="ant-form-item-explain"
> >
<div <div
class="ant-form-item-explain-validating"
role="alert" role="alert"
> >
The information is being validated... The information is being validated...
@ -7242,9 +7255,10 @@ exports[`renders ./components/form/demo/validate-static.md correctly 1`] = `
</div> </div>
</div> </div>
<div <div
class="ant-form-item-explain ant-form-item-explain-error" class="ant-form-item-explain"
> >
<div <div
class="ant-form-item-explain-error"
role="alert" role="alert"
> >
Please select the correct date Please select the correct date
@ -7720,6 +7734,97 @@ exports[`renders ./components/form/demo/validate-static.md correctly 1`] = `
</form> </form>
`; `;
exports[`renders ./components/form/demo/warning-only.md correctly 1`] = `
<form
autocomplete="off"
class="ant-form ant-form-vertical"
>
<div
style="overflow:hidden"
>
<div
class="ant-row ant-form-item"
>
<div
class="ant-col ant-form-item-label"
>
<label
class="ant-form-item-required"
for="url"
title="URL"
>
URL
</label>
</div>
<div
class="ant-col ant-form-item-control"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<input
class="ant-input"
id="url"
placeholder="input placeholder"
type="text"
value=""
/>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-row ant-form-item"
>
<div
class="ant-col ant-form-item-control"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-space ant-space-horizontal ant-space-align-center"
>
<div
class="ant-space-item"
style="margin-right:8px"
>
<button
class="ant-btn ant-btn-primary"
type="submit"
>
<span>
Submit
</span>
</button>
</div>
<div
class="ant-space-item"
>
<button
class="ant-btn"
type="button"
>
<span>
Fill
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
`;
exports[`renders ./components/form/demo/without-form-create.md correctly 1`] = ` exports[`renders ./components/form/demo/without-form-create.md correctly 1`] = `
<form <form
class="ant-form ant-form-horizontal" class="ant-form ant-form-horizontal"
@ -7828,6 +7933,7 @@ exports[`renders ./components/form/demo/without-form-create.md correctly 1`] = `
class="ant-form-item-explain" class="ant-form-item-explain"
> >
<div <div
class=""
role="alert" role="alert"
> >
A prime is a natural number greater than 1 that has no positive divisors other than 1 and itself. A prime is a natural number greater than 1 that has no positive divisors other than 1 and itself.

View File

@ -1,5 +1,6 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import scrollIntoView from 'scroll-into-view-if-needed'; import scrollIntoView from 'scroll-into-view-if-needed';
import Form from '..'; import Form from '..';
import Input from '../../input'; import Input from '../../input';
@ -20,10 +21,17 @@ describe('Form', () => {
scrollIntoView.mockImplementation(() => {}); scrollIntoView.mockImplementation(() => {});
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
async function change(wrapper, index, value) { async function change(wrapper, index, value, executeMockTimer) {
wrapper.find(Input).at(index).simulate('change', { target: { value } }); wrapper.find(Input).at(index).simulate('change', { target: { value } });
await sleep(200); await sleep(200);
if (executeMockTimer) {
act(() => {
jest.runAllTimers();
wrapper.update(); wrapper.update();
});
await sleep(1);
}
} }
beforeEach(() => { beforeEach(() => {
@ -42,6 +50,8 @@ describe('Form', () => {
describe('noStyle Form.Item', () => { describe('noStyle Form.Item', () => {
it('work', async () => { it('work', async () => {
jest.useFakeTimers();
const onChange = jest.fn(); const onChange = jest.fn();
const wrapper = mount( const wrapper = mount(
@ -54,14 +64,18 @@ describe('Form', () => {
</Form>, </Form>,
); );
await change(wrapper, 0, ''); await change(wrapper, 0, '', true);
expect(wrapper.find('.ant-form-item-with-help').length).toBeTruthy(); expect(wrapper.find('.ant-form-item-with-help').length).toBeTruthy();
expect(wrapper.find('.ant-form-item-has-error').length).toBeTruthy(); expect(wrapper.find('.ant-form-item-has-error').length).toBeTruthy();
expect(onChange).toHaveBeenCalled(); expect(onChange).toHaveBeenCalled();
jest.useRealTimers();
}); });
it('should clean up', async () => { it('should clean up', async () => {
jest.useFakeTimers();
const Demo = () => { const Demo = () => {
const [form] = Form.useForm(); const [form] = Form.useForm();
@ -105,12 +119,14 @@ describe('Form', () => {
}; };
const wrapper = mount(<Demo />); const wrapper = mount(<Demo />);
await change(wrapper, 0, '1'); await change(wrapper, 0, '1', true);
expect(wrapper.find('.ant-form-item-explain').text()).toEqual('aaa'); expect(wrapper.find('.ant-form-item-explain').text()).toEqual('aaa');
await change(wrapper, 0, '2'); await change(wrapper, 0, '2', true);
expect(wrapper.find('.ant-form-item-explain').text()).toEqual('ccc'); expect(wrapper.find('.ant-form-item-explain').text()).toEqual('ccc');
await change(wrapper, 0, '1'); await change(wrapper, 0, '1', true);
expect(wrapper.find('.ant-form-item-explain').text()).toEqual('aaa'); expect(wrapper.find('.ant-form-item-explain').text()).toEqual('aaa');
jest.useRealTimers();
}); });
}); });
@ -315,6 +331,8 @@ describe('Form', () => {
// https://github.com/ant-design/ant-design/issues/20706 // https://github.com/ant-design/ant-design/issues/20706
it('Error change should work', async () => { it('Error change should work', async () => {
jest.useFakeTimers();
const wrapper = mount( const wrapper = mount(
<Form> <Form>
<Form.Item <Form.Item
@ -338,15 +356,17 @@ describe('Form', () => {
/* eslint-disable no-await-in-loop */ /* eslint-disable no-await-in-loop */
for (let i = 0; i < 3; i += 1) { for (let i = 0; i < 3; i += 1) {
await change(wrapper, 0, ''); await change(wrapper, 0, '', true);
expect(wrapper.find('.ant-form-item-explain').first().text()).toEqual("'name' is required"); expect(wrapper.find('.ant-form-item-explain').first().text()).toEqual("'name' is required");
await change(wrapper, 0, 'p'); await change(wrapper, 0, 'p', true);
await sleep(100); await sleep(100);
wrapper.update(); wrapper.update();
expect(wrapper.find('.ant-form-item-explain').first().text()).toEqual('not a p'); expect(wrapper.find('.ant-form-item-explain').first().text()).toEqual('not a p');
} }
/* eslint-enable */ /* eslint-enable */
jest.useRealTimers();
}); });
// https://github.com/ant-design/ant-design/issues/20813 // https://github.com/ant-design/ant-design/issues/20813
@ -428,6 +448,8 @@ describe('Form', () => {
}); });
it('Form.Item with `help` should display error style when validate failed', async () => { it('Form.Item with `help` should display error style when validate failed', async () => {
jest.useFakeTimers();
const wrapper = mount( const wrapper = mount(
<Form> <Form>
<Form.Item name="test" help="help" rules={[{ required: true, message: 'message' }]}> <Form.Item name="test" help="help" rules={[{ required: true, message: 'message' }]}>
@ -436,12 +458,16 @@ describe('Form', () => {
</Form>, </Form>,
); );
await change(wrapper, 0, ''); await change(wrapper, 0, '', true);
expect(wrapper.find('.ant-form-item').first().hasClass('ant-form-item-has-error')).toBeTruthy(); expect(wrapper.find('.ant-form-item').first().hasClass('ant-form-item-has-error')).toBeTruthy();
expect(wrapper.find('.ant-form-item-explain').text()).toEqual('help'); expect(wrapper.find('.ant-form-item-explain').text()).toEqual('help');
jest.useRealTimers();
}); });
it('clear validation message when ', async () => { it('clear validation message when ', async () => {
jest.useFakeTimers();
const wrapper = mount( const wrapper = mount(
<Form> <Form>
<Form.Item name="username" rules={[{ required: true, message: 'message' }]}> <Form.Item name="username" rules={[{ required: true, message: 'message' }]}>
@ -449,14 +475,18 @@ describe('Form', () => {
</Form.Item> </Form.Item>
</Form>, </Form>,
); );
await change(wrapper, 0, '1'); await change(wrapper, 0, '1', true);
expect(wrapper.find('.ant-form-item-explain').length).toBeFalsy(); expect(wrapper.find('.ant-form-item-explain').length).toBeFalsy();
await change(wrapper, 0, '');
await change(wrapper, 0, '', true);
expect(wrapper.find('.ant-form-item-explain').length).toBeTruthy(); expect(wrapper.find('.ant-form-item-explain').length).toBeTruthy();
await change(wrapper, 0, '123');
await change(wrapper, 0, '123', true);
await sleep(800); await sleep(800);
wrapper.update(); wrapper.update();
expect(wrapper.find('.ant-form-item-explain').length).toBeFalsy(); expect(wrapper.find('.ant-form-item-explain').length).toBeFalsy();
jest.useRealTimers();
}); });
// https://github.com/ant-design/ant-design/issues/21167 // https://github.com/ant-design/ant-design/issues/21167

View File

@ -200,34 +200,6 @@ describe('Form.List', () => {
jest.useRealTimers(); jest.useRealTimers();
}); });
describe('ErrorList component', () => {
it('should trigger onDomErrorVisibleChange by motion end', async () => {
jest.useFakeTimers();
const onDomErrorVisibleChange = jest.fn();
const wrapper = mount(
<Form.ErrorList
errors={['bamboo is light']}
onDomErrorVisibleChange={onDomErrorVisibleChange}
/>,
);
await act(async () => {
await sleep();
jest.runAllTimers();
wrapper.update();
});
act(() => {
wrapper.find('CSSMotion').props().onLeaveEnd();
});
expect(onDomErrorVisibleChange).toHaveBeenCalledWith(false);
jest.useRealTimers();
});
});
it('should render empty without errors', () => { it('should render empty without errors', () => {
const wrapper = mount(<Form.ErrorList />); const wrapper = mount(<Form.ErrorList />);
expect(wrapper.render()).toMatchSnapshot(); expect(wrapper.render()).toMatchSnapshot();

View File

@ -1,5 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import omit from 'rc-util/lib/omit'; import omit from 'rc-util/lib/omit';
import { Meta } from 'rc-field-form/lib/interface';
import { FormProvider as RcFormProvider } from 'rc-field-form'; import { FormProvider as RcFormProvider } from 'rc-field-form';
import { FormProviderProps as RcFormProviderProps } from 'rc-field-form/lib/FormContext'; import { FormProviderProps as RcFormProviderProps } from 'rc-field-form/lib/FormContext';
import { ColProps } from '../grid/col'; import { ColProps } from '../grid/col';
@ -25,14 +26,9 @@ export const FormContext = React.createContext<FormContextProps>({
itemRef: (() => {}) as any, itemRef: (() => {}) as any,
}); });
/** Form Item Context. Used for Form noStyle Item error collection */ /** `noStyle` Form Item Context. Used for error collection */
export interface FormItemContextProps { export type ReportMetaChange = (meta: Meta, uniqueKeys: React.Key[]) => void;
updateItemErrors: (name: string, errors: string[], originName?: string) => void; export const NoStyleItemContext = React.createContext<ReportMetaChange | null>(null);
}
export const FormItemContext = React.createContext<FormItemContextProps>({
updateItemErrors: () => {},
});
/** Form Provider */ /** Form Provider */
export interface FormProviderProps extends Omit<RcFormProviderProps, 'validateMessages'> { export interface FormProviderProps extends Omit<RcFormProviderProps, 'validateMessages'> {

View File

@ -40,6 +40,7 @@ const Demo = () => {
initialValues={{ remember: true }} initialValues={{ remember: true }}
onFinish={onFinish} onFinish={onFinish}
onFinishFailed={onFinishFailed} onFinishFailed={onFinishFailed}
autoComplete="off"
> >
<Form.Item <Form.Item
label="Username" label="Username"

View File

@ -0,0 +1,73 @@
---
order: 3.2
title:
zh-CN: 非阻塞校验
en-US: No block rule
---
## zh-CN
`rule` 添加 `warningOnly` 后校验不再阻塞表单提交。
## en-US
`rule` with `warningOnly` will not block form submit.
```tsx
import React from 'react';
import { Form, Input, message, Button, Space } from 'antd';
const Demo = () => {
const [form] = Form.useForm();
const onFinish = () => {
message.success('Submit success!');
};
const onFinishFailed = () => {
message.error('Submit failed!');
};
const onFill = () => {
form.setFieldsValue({
url: 'https://taobao.com/',
});
};
return (
<Form
form={form}
layout="vertical"
onFinish={onFinish}
onFinishFailed={onFinishFailed}
autoComplete="off"
>
<div style={{ overflow: 'hidden' }}>
<Form.Item
name="url"
label="URL"
rules={[
{ required: true },
{ type: 'url', warningOnly: true },
{ type: 'string', min: 6 },
]}
>
<Input placeholder="input placeholder" />
</Form.Item>
</div>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
Submit
</Button>
<Button htmlType="button" onClick={onFill}>
Fill
</Button>
</Space>
</Form.Item>
</Form>
);
};
ReactDOM.render(<Demo />, mountNode);
```

View File

@ -1,47 +0,0 @@
import * as React from 'react';
import useForceUpdate from '../../_util/hooks/useForceUpdate';
/** Always debounce error to avoid [error -> null -> error] blink */
export default function useCacheErrors(
errors: React.ReactNode[],
changeTrigger: (visible: boolean) => void,
directly: boolean,
): [boolean, React.ReactNode[]] {
const cacheRef = React.useRef({
errors,
visible: !!errors.length,
});
const forceUpdate = useForceUpdate();
const update = () => {
const prevVisible = cacheRef.current.visible;
const newVisible = !!errors.length;
const prevErrors = cacheRef.current.errors;
cacheRef.current.errors = errors;
cacheRef.current.visible = newVisible;
if (prevVisible !== newVisible) {
changeTrigger(newVisible);
} else if (
prevErrors.length !== errors.length ||
prevErrors.some((prevErr, index) => prevErr !== errors[index])
) {
forceUpdate();
}
};
React.useEffect(() => {
if (!directly) {
const timeout = setTimeout(update, 10);
return () => clearTimeout(timeout);
}
}, [errors]);
if (directly) {
update();
}
return [cacheRef.current.visible, cacheRef.current.errors];
}

View File

@ -0,0 +1,19 @@
import * as React from 'react';
export default function useDebounce<T>(value: T[]): T[] {
const [cacheValue, setCacheValue] = React.useState(value);
React.useEffect(() => {
const timeout = setTimeout(
() => {
setCacheValue(value);
},
value.length ? 0 : 10,
);
return () => {
clearTimeout(timeout);
};
}, [value]);
return cacheValue;
}

View File

@ -302,22 +302,23 @@ Rule supports a config object, or a function returning config object:
type Rule = RuleConfig | ((form: FormInstance) => RuleConfig); type Rule = RuleConfig | ((form: FormInstance) => RuleConfig);
``` ```
| Name | Description | Type | | Name | Description | Type | Version |
| --- | --- | --- | | --- | --- | --- | --- |
| defaultField | Validate rule for all array elements, valid when `type` is `array` | [rule](#Rule) | | defaultField | Validate rule for all array elements, valid when `type` is `array` | [rule](#Rule) | |
| enum | Match enum value. You need to set `type` to `enum` to enable this | any\[] | | enum | Match enum value. You need to set `type` to `enum` to enable this | any\[] | |
| fields | Validate rule for child elements, valid when `type` is `array` or `object` | Record&lt;string, [rule](#Rule)> | | fields | Validate rule for child elements, valid when `type` is `array` or `object` | Record&lt;string, [rule](#Rule)> | |
| len | Length of string, number, array | number | | len | Length of string, number, array | number | |
| max | `type` required: max length of `string`, `number`, `array` | number | | max | `type` required: max length of `string`, `number`, `array` | number | |
| message | Error message. Will auto generate by [template](#validateMessages) if not provided | string | | message | Error message. Will auto generate by [template](#validateMessages) if not provided | string | |
| min | `type` required: min length of `string`, `number`, `array` | number | | min | `type` required: min length of `string`, `number`, `array` | number | |
| pattern | Regex pattern | RegExp | | pattern | Regex pattern | RegExp | |
| required | Required field | boolean | | required | Required field | boolean | |
| transform | Transform value to the rule before validation | (value) => any | | transform | Transform value to the rule before validation | (value) => any | |
| type | Normally `string` \|`number` \|`boolean` \|`url` \| `email`. More type to ref [here](https://github.com/yiminghe/async-validator#type) | string | | type | Normally `string` \|`number` \|`boolean` \|`url` \| `email`. More type to ref [here](https://github.com/yiminghe/async-validator#type) | string | |
| validateTrigger | Set validate trigger event. Must be the sub set of `validateTrigger` in Form.Item | string \| string\[] | | validateTrigger | Set validate trigger event. Must be the sub set of `validateTrigger` in Form.Item | string \| string\[] | |
| validator | Customize validation rule. Accept Promise as return. See [example](#components-form-demo-register) | ([rule](#Rule), value) => Promise | | validator | Customize validation rule. Accept Promise as return. See [example](#components-form-demo-register) | ([rule](#Rule), value) => Promise | |
| whitespace | Failed if only has whitespace | boolean | | warningOnly | Warning only. Not block form submit | boolean | 4.17.0 |
| whitespace | Failed if only has whitespace | boolean | |
## Migrate to v4 ## Migrate to v4

View File

@ -301,22 +301,23 @@ Rule 支持接收 object 进行配置,也支持 function 来动态获取 form
type Rule = RuleConfig | ((form: FormInstance) => RuleConfig); type Rule = RuleConfig | ((form: FormInstance) => RuleConfig);
``` ```
| 名称 | 说明 | 类型 | | 名称 | 说明 | 类型 | 版本 |
| --- | --- | --- | | --- | --- | --- | --- |
| defaultField | 仅在 `type``array` 类型时有效,用于指定数组元素的校验规则 | [rule](#Rule) | | defaultField | 仅在 `type``array` 类型时有效,用于指定数组元素的校验规则 | [rule](#Rule) | |
| enum | 是否匹配枚举中的值(需要将 `type` 设置为 `enum` | any\[] | | enum | 是否匹配枚举中的值(需要将 `type` 设置为 `enum` | any\[] | |
| fields | 仅在 `type``array``object` 类型时有效,用于指定子元素的校验规则 | Record&lt;string, [rule](#Rule)> | | fields | 仅在 `type``array``object` 类型时有效,用于指定子元素的校验规则 | Record&lt;string, [rule](#Rule)> | |
| len | string 类型时为字符串长度number 类型时为确定数字; array 类型时为数组长度 | number | | len | string 类型时为字符串长度number 类型时为确定数字; array 类型时为数组长度 | number | |
| max | 必须设置 `type`string 类型为字符串最大长度number 类型时为最大值array 类型时为数组最大长度 | number | | max | 必须设置 `type`string 类型为字符串最大长度number 类型时为最大值array 类型时为数组最大长度 | number | |
| message | 错误信息,不设置时会通过[模板](#validateMessages)自动生成 | string | | message | 错误信息,不设置时会通过[模板](#validateMessages)自动生成 | string | |
| min | 必须设置 `type`string 类型为字符串最小长度number 类型时为最小值array 类型时为数组最小长度 | number | | min | 必须设置 `type`string 类型为字符串最小长度number 类型时为最小值array 类型时为数组最小长度 | number | |
| pattern | 正则表达式匹配 | RegExp | | pattern | 正则表达式匹配 | RegExp | |
| required | 是否为必选字段 | boolean | | required | 是否为必选字段 | boolean | |
| transform | 将字段值转换成目标值后进行校验 | (value) => any | | transform | 将字段值转换成目标值后进行校验 | (value) => any | |
| type | 类型,常见有 `string` \|`number` \|`boolean` \|`url` \| `email`。更多请参考[此处](https://github.com/yiminghe/async-validator#type) | string | | type | 类型,常见有 `string` \|`number` \|`boolean` \|`url` \| `email`。更多请参考[此处](https://github.com/yiminghe/async-validator#type) | string | |
| validateTrigger | 设置触发验证时机,必须是 Form.Item 的 `validateTrigger` 的子集 | string \| string\[] | | validateTrigger | 设置触发验证时机,必须是 Form.Item 的 `validateTrigger` 的子集 | string \| string\[] | |
| validator | 自定义校验,接收 Promise 作为返回值。[示例](#components-form-demo-register)参考 | ([rule](#Rule), value) => Promise | | validator | 自定义校验,接收 Promise 作为返回值。[示例](#components-form-demo-register)参考 | ([rule](#Rule), value) => Promise | |
| whitespace | 如果字段仅包含空格则校验不通过 | boolean | | warningOnly | 仅警告,不阻塞表单提交 | boolean | 4.17.0 |
| whitespace | 如果字段仅包含空格则校验不通过 | boolean | |
## 从 v3 升级到 v4 ## 从 v3 升级到 v4

View File

@ -61,9 +61,12 @@
margin-bottom: @form-item-margin-bottom; margin-bottom: @form-item-margin-bottom;
vertical-align: top; vertical-align: top;
// We delay one frame (0.017s) here to let CSSMotion goes
transition: margin-bottom @animation-duration-slow 0.017s linear;
&-with-help { &-with-help {
margin-bottom: 0; margin-bottom: 0;
transition: none;
} }
&-hidden, &-hidden,
@ -89,7 +92,6 @@
> label { > label {
position: relative; position: relative;
// display: inline;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
height: @form-item-label-height; height: @form-item-label-height;
@ -179,10 +181,12 @@
} }
} }
// ==============================================================
// = Explain =
// ==============================================================
&-explain, &-explain,
&-extra { &-extra {
clear: both; clear: both;
min-height: @form-item-margin-bottom;
color: @text-color-secondary; color: @text-color-secondary;
font-size: @font-size-base; font-size: @font-size-base;
line-height: @line-height-base; line-height: @line-height-base;
@ -190,43 +194,64 @@
.explainAndExtraDistance((@form-item-margin-bottom - @form-font-height) / 2); .explainAndExtraDistance((@form-item-margin-bottom - @form-font-height) / 2);
} }
&-explain {
height: 0;
min-height: 0;
opacity: 0;
}
&-extra {
min-height: @form-item-margin-bottom;
}
.@{ant-prefix}-input-textarea-show-count { .@{ant-prefix}-input-textarea-show-count {
&::after { &::after {
margin-bottom: -22px; margin-bottom: -22px;
} }
} }
}
.show-help-motion(@className, @keyframeName, @duration: @animation-duration-slow) { &-with-help &-explain {
@name: ~'@{ant-prefix}-@{className}'; height: auto;
.make-motion(@name, @keyframeName, @duration); min-height: @form-item-margin-bottom;
.@{name}-enter, opacity: 1;
.@{name}-appear {
opacity: 0;
animation-timing-function: @ease-in-out;
}
.@{name}-leave {
animation-timing-function: @ease-in-out;
} }
} }
.show-help-motion(show-help, antShowHelp, 0.3s); // >>>>>>>>>> Motion <<<<<<<<<<
// Explain holder
.@{ant-prefix}-show-help {
transition: height @animation-duration-slow linear, min-height @animation-duration-slow linear,
margin-bottom @animation-duration-slow @ease-in-out,
opacity @animation-duration-slow @ease-in-out;
@keyframes antShowHelpIn { &-leave {
0% { min-height: @form-item-margin-bottom;
&-active {
min-height: 0;
}
}
}
// Explain
.@{ant-prefix}-show-help-item {
overflow: hidden;
transition: height @animation-duration-slow @ease-in-out,
opacity @animation-duration-slow @ease-in-out, transform @animation-duration-slow @ease-in-out !important;
&-appear,
&-enter {
transform: translateY(-5px); transform: translateY(-5px);
opacity: 0; opacity: 0;
}
100% { &-active {
transform: translateY(0); transform: translateY(0);
opacity: 1; opacity: 1;
} }
} }
@keyframes antShowHelpOut { &-leave-active {
to {
transform: translateY(-5px); transform: translateY(-5px);
opacity: 0;
} }
} }

View File

@ -9,11 +9,11 @@
// ========================= Explain ========================= // ========================= Explain =========================
/* To support leave along ErrorList. We add additional className to handle explain style */ /* To support leave along ErrorList. We add additional className to handle explain style */
&-explain { &-explain {
&&-error { &-error {
color: @error-color; color: @error-color;
} }
&&-warning { &-warning {
color: @warning-color; color: @warning-color;
} }
} }

View File

@ -16705,7 +16705,21 @@ exports[`renders ./components/table/demo/sticky.md correctly 1`] = `
colspan="2" colspan="2"
style="position:sticky;left:0" style="position:sticky;left:0"
> >
Fix Left <button
aria-checked="false"
class="ant-switch"
role="switch"
type="button"
>
<div
class="ant-switch-handle"
/>
<span
class="ant-switch-inner"
>
Fixed Top
</span>
</button>
</td> </td>
<td <td
class="ant-table-cell" class="ant-table-cell"

View File

@ -14,7 +14,7 @@ title:
For long tableneed to scroll to view the header and scroll barthen you can now set the fixed header and scroll bar to follow the page. For long tableneed to scroll to view the header and scroll barthen you can now set the fixed header and scroll bar to follow the page.
```jsx ```jsx
import { Table } from 'antd'; import { Table, Switch } from 'antd';
const columns = [ const columns = [
{ {
@ -93,16 +93,26 @@ for (let i = 0; i < 100; i++) {
}); });
} }
ReactDOM.render( const Demo = () => {
const [fixedTop, setFixedTop] = React.useState(false);
return (
<Table <Table
columns={columns} columns={columns}
dataSource={data} dataSource={data}
scroll={{ x: 1500 }} scroll={{ x: 1500 }}
summary={pageData => ( summary={pageData => (
<Table.Summary fixed> <Table.Summary fixed={fixedTop ? 'top' : 'bottom'}>
<Table.Summary.Row> <Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={2}> <Table.Summary.Cell index={0} colSpan={2}>
Fix Left <Switch
checkedChildren="Fixed Top"
unCheckedChildren="Fixed Top"
checked={fixedTop}
onChange={() => {
setFixedTop(!fixedTop);
}}
/>
</Table.Summary.Cell> </Table.Summary.Cell>
<Table.Summary.Cell index={2} colSpan={8}> <Table.Summary.Cell index={2} colSpan={8}>
Scroll Context Scroll Context
@ -112,7 +122,9 @@ ReactDOM.render(
</Table.Summary> </Table.Summary>
)} )}
sticky sticky
/>, />
mountNode,
); );
};
ReactDOM.render(<Demo />, mountNode);
``` ```

View File

@ -381,3 +381,111 @@ exports[`renders ./components/tree-select/demo/treeData.md correctly 1`] = `
</span> </span>
</div> </div>
`; `;
exports[`renders ./components/tree-select/demo/treeLine.md correctly 1`] = `
<div
class="ant-space ant-space-vertical"
>
<div
class="ant-space-item"
style="margin-bottom:8px"
>
<button
aria-checked="true"
class="ant-switch ant-switch-checked"
role="switch"
type="button"
>
<div
class="ant-switch-handle"
/>
<span
class="ant-switch-inner"
>
treeLine
</span>
</button>
</div>
<div
class="ant-space-item"
style="margin-bottom:8px"
>
<button
aria-checked="false"
class="ant-switch"
role="switch"
type="button"
>
<div
class="ant-switch-handle"
/>
<span
class="ant-switch-inner"
>
showLeafIcon
</span>
</button>
</div>
<div
class="ant-space-item"
>
<div
class="ant-select ant-tree-select ant-select-single ant-select-show-arrow"
style="width:300px"
>
<div
class="ant-select-selector"
>
<span
class="ant-select-selection-search"
>
<input
aria-activedescendant="undefined_list_0"
aria-autocomplete="list"
aria-controls="undefined_list"
aria-haspopup="listbox"
aria-owns="undefined_list"
autocomplete="off"
class="ant-select-selection-search-input"
readonly=""
role="combobox"
style="opacity:0"
type="search"
unselectable="on"
value=""
/>
</span>
<span
class="ant-select-selection-placeholder"
/>
</div>
<span
aria-hidden="true"
class="ant-select-arrow"
style="user-select:none;-webkit-user-select:none"
unselectable="on"
>
<span
aria-label="down"
class="anticon anticon-down ant-select-suffix"
role="img"
>
<svg
aria-hidden="true"
data-icon="down"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M884 256h-75c-5.1 0-9.9 2.5-12.9 6.6L512 654.2 227.9 262.6c-3-4.1-7.8-6.6-12.9-6.6h-75c-6.5 0-10.3 7.4-6.5 12.7l352.6 486.1c12.8 17.6 39 17.6 51.7 0l352.6-486.1c3.9-5.3.1-12.7-6.4-12.7z"
/>
</svg>
</span>
</span>
</div>
</div>
</div>
`;

View File

@ -45,6 +45,7 @@ class Demo extends React.Component {
treeData: this.state.treeData.concat([ treeData: this.state.treeData.concat([
this.genTreeNode(id, false), this.genTreeNode(id, false),
this.genTreeNode(id, true), this.genTreeNode(id, true),
this.genTreeNode(id, true),
]), ]),
}); });
resolve(); resolve();

View File

@ -0,0 +1,56 @@
---
order: 6
title:
zh-CN: 线性样式
en-US: Show Tree Line
---
## zh-CN
通过 `treeLine` 配置线性样式。
## en-US
Use `treeLine` to show the line style.
```tsx
import { TreeSelect, Switch, Space } from 'antd';
const { TreeNode } = TreeSelect;
const Demo = () => {
const [treeLine, setTreeLine] = React.useState(true);
const [showLeafIcon, setShowLeafIcon] = React.useState(false);
return (
<Space direction="vertical">
<Switch
checkedChildren="treeLine"
unCheckedChildren="treeLine"
checked={treeLine}
onChange={() => setTreeLine(!treeLine)}
/>
<Switch
disabled={!treeLine}
checkedChildren="showLeafIcon"
unCheckedChildren="showLeafIcon"
checked={showLeafIcon}
onChange={() => setShowLeafIcon(!showLeafIcon)}
/>
<TreeSelect treeLine={treeLine && { showLeafIcon }} style={{ width: 300 }}>
<TreeNode value="parent 1" title="parent 1">
<TreeNode value="parent 1-0" title="parent 1-0">
<TreeNode value="leaf1" title="my leaf" />
<TreeNode value="leaf2" title="your leaf" />
</TreeNode>
<TreeNode value="parent 1-1" title="parent 1-1">
<TreeNode value="sss" title="sss" />
</TreeNode>
</TreeNode>
</TreeSelect>
</Space>
);
};
ReactDOM.render(<Demo />, mountNode);
```

View File

@ -50,6 +50,7 @@ Tree selection control.
| treeDefaultExpandedKeys | Default expanded treeNodes | string\[] | - | | | treeDefaultExpandedKeys | Default expanded treeNodes | string\[] | - | |
| treeExpandedKeys | Set expanded keys | string\[] | - | | | treeExpandedKeys | Set expanded keys | string\[] | - | |
| treeIcon | Shows the icon before a TreeNode's title. There is no default style; you must set a custom style for it if set to `true` | boolean | false | | | treeIcon | Shows the icon before a TreeNode's title. There is no default style; you must set a custom style for it if set to `true` | boolean | false | |
| treeLine | Show the line. Ref [Tree - showLine](/components/tree/#components-tree-demo-line) | boolean \| object | false | 4.17.0 |
| treeNodeFilterProp | Will be used for filtering if `filterTreeNode` returns true | string | `value` | | | treeNodeFilterProp | Will be used for filtering if `filterTreeNode` returns true | string | `value` | |
| treeNodeLabelProp | Will render as content of select | string | `title` | | | treeNodeLabelProp | Will render as content of select | string | `title` | |
| value | To set the current selected treeNode(s) | string \| string\[] | - | | | value | To set the current selected treeNode(s) | string \| string\[] | - | |
@ -63,7 +64,7 @@ Tree selection control.
### Tree Methods ### Tree Methods
| Name | Description | Version | | Name | Description | Version |
| --- | --- | --- | | ------- | ------------ | ------- |
| blur() | Remove focus | | | blur() | Remove focus | |
| focus() | Get focus | | | focus() | Get focus | |

View File

@ -11,7 +11,7 @@ import omit from 'rc-util/lib/omit';
import { DefaultValueType } from 'rc-tree-select/lib/interface'; import { DefaultValueType } from 'rc-tree-select/lib/interface';
import { ConfigContext } from '../config-provider'; import { ConfigContext } from '../config-provider';
import devWarning from '../_util/devWarning'; import devWarning from '../_util/devWarning';
import { AntTreeNodeProps } from '../tree'; import { AntTreeNodeProps, TreeProps } from '../tree';
import getIcons from '../select/utils/iconUtil'; import getIcons from '../select/utils/iconUtil';
import renderSwitcherIcon from '../tree/utils/iconUtil'; import renderSwitcherIcon from '../tree/utils/iconUtil';
import SizeContext, { SizeType } from '../config-provider/SizeContext'; import SizeContext, { SizeType } from '../config-provider/SizeContext';
@ -30,11 +30,18 @@ export type SelectValue = RawValue | RawValue[] | LabeledValue | LabeledValue[];
export interface TreeSelectProps<T> export interface TreeSelectProps<T>
extends Omit< extends Omit<
RcTreeSelectProps<T>, RcTreeSelectProps<T>,
'showTreeIcon' | 'treeMotion' | 'inputIcon' | 'mode' | 'getInputElement' | 'backfill' | 'showTreeIcon'
| 'treeMotion'
| 'inputIcon'
| 'mode'
| 'getInputElement'
| 'backfill'
| 'treeLine'
> { > {
suffixIcon?: React.ReactNode; suffixIcon?: React.ReactNode;
size?: SizeType; size?: SizeType;
bordered?: boolean; bordered?: boolean;
treeLine?: TreeProps['showLine'];
} }
export interface RefTreeSelectProps { export interface RefTreeSelectProps {
@ -140,6 +147,7 @@ const InternalTreeSelect = <T extends DefaultValueType>(
treeCheckable={ treeCheckable={
treeCheckable ? <span className={`${prefixCls}-tree-checkbox-inner`} /> : treeCheckable treeCheckable ? <span className={`${prefixCls}-tree-checkbox-inner`} /> : treeCheckable
} }
treeLine={!!treeLine}
inputIcon={suffixIcon} inputIcon={suffixIcon}
multiple={multiple} multiple={multiple}
removeIcon={removeIcon} removeIcon={removeIcon}

View File

@ -51,6 +51,7 @@ cover: https://gw.alipayobjects.com/zos/alicdn/Ax4DA0njr/TreeSelect.svg
| treeDefaultExpandedKeys | 默认展开的树节点 | string\[] | - | | | treeDefaultExpandedKeys | 默认展开的树节点 | string\[] | - | |
| treeExpandedKeys | 设置展开的树节点 | string\[] | - | | | treeExpandedKeys | 设置展开的树节点 | string\[] | - | |
| treeIcon | 是否展示 TreeNode title 前的图标,没有默认样式,如设置为 true需要自行定义图标相关样式 | boolean | false | | | treeIcon | 是否展示 TreeNode title 前的图标,没有默认样式,如设置为 true需要自行定义图标相关样式 | boolean | false | |
| treeLine | 是否展示线条样式,请参考 [Tree - showLine](/components/tree/#components-tree-demo-line) | boolean \| object | false | 4.17.0 |
| treeNodeFilterProp | 输入项过滤对应的 treeNode 属性 | string | `value` | | | treeNodeFilterProp | 输入项过滤对应的 treeNode 属性 | string | `value` | |
| treeNodeLabelProp | 作为显示的 prop 设置 | string | `title` | | | treeNodeLabelProp | 作为显示的 prop 设置 | string | `title` | |
| value | 指定当前选中的条目 | string \| string\[] | - | | | value | 指定当前选中的条目 | string \| string\[] | - | |
@ -64,7 +65,7 @@ cover: https://gw.alipayobjects.com/zos/alicdn/Ax4DA0njr/TreeSelect.svg
### Tree 方法 ### Tree 方法
| 名称 | 描述 | 版本 | | 名称 | 描述 | 版本 |
| --- | --- | --- | | ------- | -------- | ---- |
| blur() | 移除焦点 | | | blur() | 移除焦点 | |
| focus() | 获取焦点 | | | focus() | 获取焦点 | |
@ -73,7 +74,7 @@ cover: https://gw.alipayobjects.com/zos/alicdn/Ax4DA0njr/TreeSelect.svg
> 建议使用 treeData 来代替 TreeNode免去手工构造麻烦 > 建议使用 treeData 来代替 TreeNode免去手工构造麻烦
| 参数 | 说明 | 类型 | 默认值 | 版本 | | 参数 | 说明 | 类型 | 默认值 | 版本 |
| --- | --- | --- | --- | --- | | --------------- | -------------------------------------------------- | --------- | ------ | ---- |
| checkable | 当树为 Checkbox 时,设置独立节点是否展示 Checkbox | boolean | - | | | checkable | 当树为 Checkbox 时,设置独立节点是否展示 Checkbox | boolean | - | |
| disableCheckbox | 禁掉 Checkbox | boolean | false | | | disableCheckbox | 禁掉 Checkbox | boolean | false | |
| disabled | 是否禁用 | boolean | false | | | disabled | 是否禁用 | boolean | false | |

View File

@ -11,7 +11,7 @@
.@{tree-select-prefix-cls} { .@{tree-select-prefix-cls} {
// ======================= Dropdown ======================= // ======================= Dropdown =======================
&-dropdown { &-dropdown {
padding: @padding-xs (@padding-xs / 2) 0; padding: @padding-xs (@padding-xs / 2);
&-rtl { &-rtl {
direction: rtl; direction: rtl;
@ -24,8 +24,6 @@
align-items: stretch; align-items: stretch;
.@{select-tree-prefix-cls}-treenode { .@{select-tree-prefix-cls}-treenode {
padding-bottom: @padding-xs;
.@{select-tree-prefix-cls}-node-content-wrapper { .@{select-tree-prefix-cls}-node-content-wrapper {
flex: auto; flex: auto;
} }

View File

@ -1,7 +1,6 @@
@import '../../style/mixins/index'; @import '../../style/mixins/index';
@tree-prefix-cls: ~'@{ant-prefix}-tree'; @tree-prefix-cls: ~'@{ant-prefix}-tree';
@tree-node-prefix-cls: ~'@{tree-prefix-cls}-treenode';
@select-tree-prefix-cls: ~'@{ant-prefix}-select-tree'; @select-tree-prefix-cls: ~'@{ant-prefix}-select-tree';
@tree-motion: ~'@{ant-prefix}-motion-collapse'; @tree-motion: ~'@{ant-prefix}-motion-collapse';
@tree-node-padding: (@padding-xs / 2); @tree-node-padding: (@padding-xs / 2);
@ -259,10 +258,9 @@
} }
} }
} }
}
.@{tree-node-prefix-cls}-leaf-last { .@{custom-tree-node-prefix-cls}-leaf-last {
.@{tree-prefix-cls}-switcher { .@{custom-tree-prefix-cls}-switcher {
&-leaf-line { &-leaf-line {
&::before { &::before {
top: auto !important; top: auto !important;
@ -272,3 +270,4 @@
} }
} }
} }
}

View File

@ -122,12 +122,12 @@
"rc-dialog": "~8.5.1", "rc-dialog": "~8.5.1",
"rc-drawer": "~4.3.0", "rc-drawer": "~4.3.0",
"rc-dropdown": "~3.2.0", "rc-dropdown": "~3.2.0",
"rc-field-form": "~1.20.0", "rc-field-form": "~1.21.0-2",
"rc-image": "~5.2.4", "rc-image": "~5.2.4",
"rc-input-number": "~7.1.0", "rc-input-number": "~7.1.0",
"rc-mentions": "~1.6.1", "rc-mentions": "~1.6.1",
"rc-menu": "~9.0.9", "rc-menu": "~9.0.9",
"rc-motion": "^2.4.0", "rc-motion": "^2.4.4",
"rc-notification": "~4.5.7", "rc-notification": "~4.5.7",
"rc-pagination": "~3.1.6", "rc-pagination": "~3.1.6",
"rc-picker": "~2.5.10", "rc-picker": "~2.5.10",
@ -138,7 +138,7 @@
"rc-slider": "~9.7.1", "rc-slider": "~9.7.1",
"rc-steps": "~4.1.0", "rc-steps": "~4.1.0",
"rc-switch": "~3.2.0", "rc-switch": "~3.2.0",
"rc-table": "~7.15.1", "rc-table": "~7.16.0",
"rc-tabs": "~11.9.1", "rc-tabs": "~11.9.1",
"rc-textarea": "~0.3.0", "rc-textarea": "~0.3.0",
"rc-tooltip": "~5.1.1", "rc-tooltip": "~5.1.1",