@douyinfe/semi-ui
Version:
A modern, comprehensive, flexible design system and UI library. Connect DesignOps & DevOps. Quickly build beautiful React apps. Maintained by Douyin-fe team.
523 lines • 21.1 kB
JavaScript
/* eslint-disable react-hooks/rules-of-hooks */
import React, { useState, useLayoutEffect, useEffect, useMemo, useRef, forwardRef } from 'react';
import classNames from 'classnames';
import { cssClasses } from '@douyinfe/semi-foundation/lib/es/form/constants';
import { isValid, generateValidatesFromRules, mergeOptions, mergeProps, getDisplayName, transformTrigger, transformDefaultBooleanAPI } from '@douyinfe/semi-foundation/lib/es/form/utils';
import * as ObjectUtil from '@douyinfe/semi-foundation/lib/es/utils/object';
import isPromise from '@douyinfe/semi-foundation/lib/es/utils/isPromise';
import warning from '@douyinfe/semi-foundation/lib/es/utils/warning';
import { useFormState, useStateWithGetter, useFormUpdater, useArrayFieldState } from '../hooks/index';
import ErrorMessage from '../errorMessage';
import { isElement } from '../../_base/reactUtils';
import Label from '../label';
import { Col } from '../../grid';
const prefix = cssClasses.PREFIX;
// To avoid useLayoutEffect warning when ssr, refer: https://gist.github.com/gaearon/e7d97cdf38a2907924ea12e4ebdf3c85
// Fix issue 1140
const useIsomorphicEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
/**
* withFiled is used to inject components
* 1. Takes over the value and onChange of the component and synchronizes them to Form Foundation
* 2. Insert <Label>
* 3. Insert <ErrorMessage>
*/
function withField(Component, opts) {
let SemiField = (props, ref) => {
let {
// condition,
field,
label,
labelPosition,
labelWidth,
labelAlign,
labelCol,
wrapperCol,
noLabel,
noErrorMessage,
isInInputGroup,
initValue,
validate,
validateStatus,
trigger,
allowEmptyString,
allowEmpty,
emptyValue,
rules,
required,
keepState,
transform,
name,
fieldClassName,
fieldStyle,
convert,
stopValidateWithError,
helpText,
extraText,
extraTextPosition,
pure,
id,
rest
} = mergeProps(props);
let {
options,
shouldInject
} = mergeOptions(opts, props);
warning(typeof field === 'undefined' && options.shouldInject, "[Semi Form]: 'field' is required, please check your props of Field Component");
// 无需注入的直接返回,eg:Group内的checkbox、radio
// Return without injection, eg: <Checkbox> / <Radio> inside CheckboxGroup/RadioGroup
if (!shouldInject) {
return /*#__PURE__*/React.createElement(Component, Object.assign({}, rest, {
ref: ref
}));
}
// grab formState from context
const formState = useFormState();
// grab formUpdater (the api for field to read/modify FormState) from context
const updater = useFormUpdater();
if (!updater.getFormProps) {
warning(true, '[Semi Form]: Field Component must be use inside the Form, please check your dom declaration');
return null;
}
let formProps = updater.getFormProps(['labelPosition', 'labelWidth', 'labelAlign', 'labelCol', 'wrapperCol', 'disabled', 'showValidateIcon', 'extraTextPosition', 'stopValidateWithError', 'trigger']);
let mergeLabelPos = labelPosition || formProps.labelPosition;
let mergeLabelWidth = labelWidth || formProps.labelWidth;
let mergeLabelAlign = labelAlign || formProps.labelAlign;
let mergeLabelCol = labelCol || formProps.labelCol;
let mergeWrapperCol = wrapperCol || formProps.wrapperCol;
let mergeExtraPos = extraTextPosition || formProps.extraTextPosition || 'bottom';
let mergeStopValidateWithError = transformDefaultBooleanAPI(stopValidateWithError, formProps.stopValidateWithError, false);
let mergeTrigger = transformTrigger(trigger, formProps.trigger);
// To prevent user forgetting to pass the field, use undefined as the key, and updater.getValue will get the wrong value.
let initValueInFormOpts = typeof field !== 'undefined' ? updater.getValue(field) : undefined; // Get the init value of form from formP rops.init Values Get the initial value set in the initValues of Form
let initVal = typeof initValue !== 'undefined' ? initValue : initValueInFormOpts;
// use arrayFieldState to fix issue 615
let arrayFieldState;
try {
arrayFieldState = useArrayFieldState();
if (arrayFieldState) {
initVal = arrayFieldState.shouldUseInitValue && typeof initValue !== 'undefined' ? initValue : initValueInFormOpts;
}
} catch (err) {}
// FIXME typeof initVal
const [value, setValue, getVal] = useStateWithGetter(typeof initVal !== undefined ? initVal : null);
const validateOnMount = mergeTrigger.includes('mount');
allowEmpty = allowEmpty || updater.getFormProps().allowEmpty;
// Error information: Array, String, undefined
const [error, setError, getError] = useStateWithGetter();
const [touched, setTouched] = useState();
const [cursor, setCursor, getCursor] = useStateWithGetter(0);
const [status, setStatus] = useState(validateStatus); // use props.validateStatus to init
const isUnmounted = useRef(false);
const rulesRef = useRef(rules);
const validateRef = useRef(validate);
const validatePromise = useRef(null);
// notNotify is true means that the onChange of the Form does not need to be triggered
// notUpdate is true means that this operation does not need to trigger the forceUpdate
const updateTouched = (isTouched, callOpts) => {
setTouched(isTouched);
updater.updateStateTouched(field, isTouched, callOpts);
};
const updateError = (errors, callOpts) => {
if (isUnmounted.current) {
return;
}
if (errors === getError()) {
// When the inspection result is unchanged, no need to update, saving a forceUpdate overhead
// When errors is an array, deepEqual is not used, and it is always treated as a need to update
// 检验结果不变时,无需更新,节省一次forceUpdate开销
// errors为数组时,不做deepEqual,始终当做需要更新处理
return;
}
setError(errors);
updater.updateStateError(field, errors, callOpts);
if (!isValid(errors)) {
setStatus('error');
} else {
setStatus('success');
}
};
const updateValue = (val, callOpts) => {
setValue(val);
let newOpts = Object.assign(Object.assign({}, callOpts), {
allowEmpty
});
updater.updateStateValue(field, val, newOpts);
};
const reset = () => {
let callOpts = {
notNotify: true,
notUpdate: true
};
// reset is called by the FormFoundaion uniformly. The field level does not need to trigger notify and update.
updateValue(initVal !== null ? initVal : undefined, callOpts);
updateError(undefined, callOpts);
updateTouched(undefined, callOpts);
setStatus('default');
};
// Execute the validation rules specified by rules
const _validateInternal = (val, callOpts) => {
let latestRules = rulesRef.current || [];
const validator = generateValidatesFromRules(field, latestRules);
const model = {
[field]: val
};
const rootPromise = new Promise((resolve, reject) => {
validator.validate(model, {
first: mergeStopValidateWithError
}, (errors, fields) => {}).then(res => {
if (isUnmounted.current || validatePromise.current !== rootPromise) {
console.warn(`[Semi Form]: When FieldComponent (${field}) has an unfinished validation process, you repeatedly trigger a new validation, the old validation will be abandoned, and will neither resolve nor reject. Usually this is an unreasonable practice. Please check your code.`);
return;
}
// validation passed
setStatus('success');
updateError(undefined, callOpts);
resolve({});
}).catch(err => {
if (isUnmounted.current || validatePromise.current !== rootPromise) {
console.warn(`[Semi Form]: When FieldComponent (${field}) has an unfinished validation process, you repeatedly trigger a new validation, the old validation will be abandoned, and will neither resolve nor reject. Usually this is an unreasonable practice. Please check your code.`);
return;
}
let {
errors,
fields
} = err;
if (errors && fields) {
let messages = errors.map(e => e.message);
if (messages.length === 1) {
messages = messages[0];
}
updateError(messages, callOpts);
if (!isValid(messages)) {
setStatus('error');
resolve(errors);
}
} else {
// Some grammatical errors in rules
setStatus('error');
updateError(err.message, callOpts);
resolve(err.message);
throw err;
}
});
});
validatePromise.current = rootPromise;
return rootPromise;
};
// execute custom validate function
const _validate = (val, values, callOpts) => {
const rootPromise = new Promise(resolve => {
let maybePromisedErrors;
// let errorThrowSync;
try {
maybePromisedErrors = validateRef.current(val, values);
} catch (err) {
// error throw by syncValidate
maybePromisedErrors = err;
}
if (maybePromisedErrors === undefined) {
resolve({});
updateError(undefined, callOpts);
} else if (isPromise(maybePromisedErrors)) {
maybePromisedErrors.then(result => {
// If the async validate is outdated (a newer validate occurs), the result should be discarded
if (isUnmounted.current || validatePromise.current !== rootPromise) {
console.warn(`[Semi Form]: When Field: (${field}) has an unfinished validation process, you repeatedly trigger a new validation, the old validation will be abandoned, and will neither resolve nor reject. Usually this is an unreasonable practice. Please check your code.`);
return;
}
if (isValid(result)) {
// validate success,no need to do anything with result
updateError(undefined, callOpts);
resolve(null);
} else {
// validate failed
updateError(result, callOpts);
resolve(result);
}
});
} else {
if (isValid(maybePromisedErrors)) {
updateError(undefined, callOpts);
resolve(null);
} else {
updateError(maybePromisedErrors, callOpts);
resolve(maybePromisedErrors);
}
}
});
validatePromise.current = rootPromise;
return rootPromise;
};
const fieldValidate = (val, callOpts) => {
let finalVal = val;
let latestRules = rulesRef.current;
if (transform) {
finalVal = transform(val);
}
if (validateRef.current) {
return _validate(finalVal, updater.getValue(), callOpts);
} else if (latestRules) {
return _validateInternal(finalVal, callOpts);
}
return null;
};
/**
* parse / format
* validate when trigger
*
*/
const handleChange = function (newValue, e) {
let fnKey = options.onKeyChangeFnName;
if (fnKey in props && typeof props[options.onKeyChangeFnName] === 'function') {
for (var _len = arguments.length, other = new Array(_len > 2 ? _len - 2 : 0), _key = 2; _key < _len; _key++) {
other[_key - 2] = arguments[_key];
}
props[options.onKeyChangeFnName](newValue, e, ...other);
}
// support various type component
let val;
if (!options.valuePath) {
val = newValue;
} else {
val = ObjectUtil.get(newValue, options.valuePath);
}
// User can use convert function to updateValue before Component UI render
if (typeof convert === 'function') {
val = convert(val);
}
// TODO: allowEmptyString split into allowEmpty, emptyValue
// Added abandonment warning
// if (process.env.NODE_ENV !== 'production') {
// warning(allowEmptyString, `'allowEmptyString' will be de deprecated in next version, please replace with 'allowEmpty' & 'emptyValue'
// `)
// }
// set value to undefined if it's an empty string
// allowEmptyString={true} is equivalent to allowEmpty = {true} emptyValue = "
if (allowEmptyString || allowEmpty) {
if (val === '') {
// do nothing
}
} else {
if (val === emptyValue) {
val = undefined;
}
}
// maintain compoent cursor if needed
try {
if (e && e.target && e.target.selectionStart) {
setCursor(e.target.selectionStart);
}
} catch (err) {}
updateTouched(true, {
notNotify: true,
notUpdate: true
});
updateValue(val);
// only validate when trigger includes change
if (mergeTrigger.includes('change')) {
fieldValidate(val);
}
};
const handleBlur = function () {
if (props.onBlur) {
props.onBlur(...arguments);
}
if (!touched) {
updateTouched(true);
}
if (mergeTrigger.includes('blur')) {
let val = getVal();
fieldValidate(val);
}
};
/** Field level maintains a separate layer of data, which is convenient for Form to control Field to update the UI */
// The field level maintains a separate layer of data, which is convenient for the Form to control the Field for UI updates.
const fieldApi = {
setValue: updateValue,
setTouched: updateTouched,
setError: updateError,
reset,
validate: fieldValidate
};
const fieldState = {
value,
error,
touched,
status
};
// avoid hooks capture value, fixed issue 346
useIsomorphicEffect(() => {
rulesRef.current = rules;
validateRef.current = validate;
}, [rules, validate]);
useIsomorphicEffect(() => {
isUnmounted.current = false;
// exec validate once when trigger include 'mount'
if (validateOnMount) {
fieldValidate(value);
}
return () => {
isUnmounted.current = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// register when mounted,unregister when unmounted
// register again when field change
useIsomorphicEffect(() => {
// register
if (typeof field === 'undefined') {
return () => {};
}
// log('register: ' + field);
// field value may change after field component mounted, we use ref value here to get changed value
const refValue = getVal();
updater.register(field, {
value: refValue,
error,
touched,
status
}, {
field,
fieldApi,
keepState,
allowEmpty: allowEmpty || allowEmptyString
});
// return unRegister cb
return () => {
updater.unRegister(field);
// log('unRegister: ' + field);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [field]);
// id attribute to improve a11y
const a11yId = id ? id : field;
const labelId = `${a11yId}-label`;
const helpTextId = `${a11yId}-helpText`;
const extraTextId = `${a11yId}-extraText`;
const errorMessageId = `${a11yId}-errormessage`;
const FieldComponent = () => {
// prefer to use validateStatus which pass by user throught props
let blockStatus = validateStatus ? validateStatus : status;
const extraCls = classNames(`${prefix}-field-extra`, {
[`${prefix}-field-extra-string`]: typeof extraText === 'string',
[`${prefix}-field-extra-middle`]: mergeExtraPos === 'middle',
[`${prefix}-field-extra-bottom`]: mergeExtraPos === 'bottom'
});
const extraContent = extraText ? /*#__PURE__*/React.createElement("div", {
className: extraCls,
id: extraTextId,
"x-semi-prop": "extraText"
}, extraText) : null;
let newProps = Object.assign(Object.assign({
id: a11yId,
disabled: formProps.disabled
}, rest), {
ref,
onBlur: handleBlur,
[options.onKeyChangeFnName]: handleChange,
[options.valueKey]: value,
validateStatus: blockStatus,
'aria-required': required,
'aria-labelledby': labelId
});
if (name) {
newProps['name'] = name;
}
if (helpText) {
newProps['aria-describedby'] = extraText ? `${helpTextId} ${extraTextId}` : helpTextId;
}
if (extraText) {
newProps['aria-describedby'] = helpText ? `${helpTextId} ${extraTextId}` : extraTextId;
}
if (status === 'error') {
newProps['aria-errormessage'] = errorMessageId;
newProps['aria-invalid'] = true;
}
const fieldCls = classNames({
[`${prefix}-field`]: true,
[`${prefix}-field-${name}`]: Boolean(name),
[fieldClassName]: Boolean(fieldClassName)
});
const fieldMaincls = classNames({
[`${prefix}-field-main`]: true
});
if (mergeLabelPos === 'inset' && !noLabel) {
newProps.insetLabel = label || field;
newProps.insetLabelId = labelId;
if (typeof label === 'object' && !isElement(label)) {
newProps.insetLabel = label.text;
newProps.insetLabelId = labelId;
}
}
const com = /*#__PURE__*/React.createElement(Component, Object.assign({}, newProps));
// when use in InputGroup, no need to insert <Label>、<ErrorMessage> inside Field, just add it at Group
if (isInInputGroup) {
return com;
}
if (pure) {
let pureCls = classNames(rest.className, {
[`${prefix}-field-pure`]: true,
[`${prefix}-field-${name}`]: Boolean(name),
[fieldClassName]: Boolean(fieldClassName)
});
newProps.className = pureCls;
return /*#__PURE__*/React.createElement(Component, Object.assign({}, newProps));
}
let withCol = mergeLabelCol && mergeWrapperCol;
const labelColCls = mergeLabelAlign ? `${prefix}-col-${mergeLabelAlign}` : '';
// get label
let labelContent = null;
if (!noLabel && mergeLabelPos !== 'inset') {
let needSpread = typeof label === 'object' && !isElement(label) ? label : {};
labelContent = /*#__PURE__*/React.createElement(Label, Object.assign({
text: label || field,
id: labelId,
required: required,
name: a11yId || name || field,
width: mergeLabelWidth,
align: mergeLabelAlign
}, needSpread));
}
const fieldMainContent = /*#__PURE__*/React.createElement("div", {
className: fieldMaincls
}, mergeExtraPos === 'middle' ? extraContent : null, com, !noErrorMessage ? (/*#__PURE__*/React.createElement(ErrorMessage, {
error: error,
validateStatus: blockStatus,
helpText: helpText,
helpTextId: helpTextId,
errorMessageId: errorMessageId,
showValidateIcon: formProps.showValidateIcon
})) : null, mergeExtraPos === 'bottom' ? extraContent : null);
const withColContent = /*#__PURE__*/React.createElement(React.Fragment, null, mergeLabelPos === 'top' ? (/*#__PURE__*/React.createElement("div", {
style: {
overflow: 'hidden'
}
}, /*#__PURE__*/React.createElement(Col, Object.assign({}, mergeLabelCol, {
className: labelColCls
}), labelContent))) : (/*#__PURE__*/React.createElement(Col, Object.assign({}, mergeLabelCol, {
className: labelColCls
}), labelContent)), /*#__PURE__*/React.createElement(Col, Object.assign({}, mergeWrapperCol), fieldMainContent));
return /*#__PURE__*/React.createElement("div", {
className: fieldCls,
style: fieldStyle,
"x-label-pos": mergeLabelPos,
"x-field-id": field,
"x-extra-pos": mergeExtraPos
}, withCol ? withColContent : (/*#__PURE__*/React.createElement(React.Fragment, null, labelContent, fieldMainContent)));
};
// !important optimization
const shouldUpdate = [...Object.values(fieldState), ...Object.values(props), field, mergeLabelPos, mergeLabelAlign, formProps.disabled];
if (options.shouldMemo) {
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(FieldComponent, [...shouldUpdate]);
} else {
// Some Custom Component with inner state shouldn't be memo, otherwise the component will not updated when the internal state is updated
return FieldComponent();
}
};
SemiField = /*#__PURE__*/forwardRef(SemiField);
SemiField.displayName = getDisplayName(Component);
return SemiField;
}
// eslint-disable-next-line
export default withField;