Browse Source

refactor: form

pull/4137/head
tanjinzhou 4 years ago
parent
commit
c2bba2eb28
  1. 16
      components/_util/hooks/useConfigInject.ts
  2. 6
      components/config-provider/index.tsx
  3. 53
      components/form/ErrorList.tsx
  4. 19
      components/form/Form.tsx
  5. 3
      components/form/FormItem.tsx
  6. 108
      components/form/FormItemInput.tsx
  7. 92
      components/form/FormItemLabel.tsx
  8. 49
      components/form/context.ts
  9. 2
      components/form/interface.ts
  10. 9
      components/locale-provider/index.tsx
  11. 69
      components/locale/default.ts

16
components/_util/hooks/useConfigInject.ts

@ -1,3 +1,4 @@
import { RequiredMark } from '../../form/Form';
import { computed, ComputedRef, inject, UnwrapRef } from 'vue';
import {
ConfigProviderProps,
@ -17,6 +18,9 @@ export default (
getTargetContainer: ComputedRef<() => HTMLElement>;
space: ComputedRef<{ size: SizeType | number }>;
pageHeader: ComputedRef<{ ghost: boolean }>;
form?: ComputedRef<{
requiredMark?: RequiredMark;
}>;
} => {
const configProvider = inject<UnwrapRef<ConfigProviderProps>>(
'configProvider',
@ -26,7 +30,17 @@ export default (
const direction = computed(() => configProvider.direction);
const space = computed(() => configProvider.space);
const pageHeader = computed(() => configProvider.pageHeader);
const form = computed(() => configProvider.form);
const size = computed(() => props.size || configProvider.componentSize);
const getTargetContainer = computed(() => props.getTargetContainer);
return { configProvider, prefixCls, direction, size, getTargetContainer, space, pageHeader };
return {
configProvider,
prefixCls,
direction,
size,
getTargetContainer,
space,
pageHeader,
form,
};
};

6
components/config-provider/index.tsx

@ -13,6 +13,7 @@ import LocaleProvider, { Locale, ANT_MARK } from '../locale-provider';
import { TransformCellTextProps } from '../table/interface';
import LocaleReceiver from '../locale-provider/LocaleReceiver';
import { withInstall } from '../_util/type';
import { RequiredMark } from '../form/Form';
export type SizeType = 'small' | 'middle' | 'large' | undefined;
@ -99,6 +100,9 @@ export const configProviderProps = {
},
virtual: PropTypes.looseBool,
dropdownMatchSelectWidth: PropTypes.looseBool,
form: {
type: Object as PropType<{ requiredMark?: RequiredMark }>,
},
};
export type ConfigProviderProps = Partial<ExtractPropTypes<typeof configProviderProps>>;
@ -159,7 +163,7 @@ const ConfigProvider = defineComponent({
export const defaultConfigProvider: UnwrapRef<ConfigProviderProps> = reactive({
getPrefixCls: (suffixCls: string, customizePrefixCls?: string) => {
if (customizePrefixCls) return customizePrefixCls;
return `ant-${suffixCls}`;
return suffixCls ? `ant-${suffixCls}` : 'ant';
},
renderEmpty: defaultRenderEmpty,
direction: 'ltr',

53
components/form/ErrorList.tsx

@ -0,0 +1,53 @@
import { useInjectFormItemPrefix } from './context';
import { VueNode } from '../_util/type';
import { computed, defineComponent, ref, watch } from '@vue/runtime-core';
import classNames from '../_util/classNames';
import Transition, { getTransitionProps } from '../_util/transition';
export interface ErrorListProps {
errors?: VueNode[];
/** @private Internal Usage. Do not use in your production */
help?: VueNode;
/** @private Internal Usage. Do not use in your production */
onDomErrorVisibleChange?: (visible: boolean) => void;
}
export default defineComponent<ErrorListProps>({
name: 'ErrorList',
setup(props) {
const { prefixCls, status } = useInjectFormItemPrefix();
const visible = computed(() => props.errors && props.errors.length);
const innerStatus = ref(status.value);
// Memo status in same visible
watch([() => visible, () => status], () => {
if (visible.value && status.value) {
innerStatus.value = status.value;
}
});
return () => {
const baseClassName = `${prefixCls.value}-item-explain`;
const transitionProps = getTransitionProps('show-help', {
onAfterLeave: () => props.onDomErrorVisibleChange?.(false),
});
return (
<Transition {...transitionProps}>
{visible ? (
<div
class={classNames(baseClassName, {
[`${baseClassName}-${innerStatus}`]: innerStatus,
})}
key="help"
>
{props.errors?.map((error: any, index: number) => (
// eslint-disable-next-line react/no-array-index-key
<div key={index} role="alert">
{error}
</div>
))}
</div>
) : null}
</Transition>
);
};
},
});

19
components/form/Form.tsx

@ -1,4 +1,12 @@
import { defineComponent, inject, provide, PropType, computed, ExtractPropTypes } from 'vue';
import {
defineComponent,
inject,
provide,
PropType,
computed,
ExtractPropTypes,
HTMLAttributes,
} from 'vue';
import PropTypes from '../_util/vue-types';
import classNames from '../_util/classNames';
import warning from '../_util/warning';
@ -16,6 +24,9 @@ import { tuple, VueNode } from '../_util/type';
import { ColProps } from '../grid/Col';
import { InternalNamePath, NamePath, ValidateErrorEntity, ValidateOptions } from './interface';
export type RequiredMark = boolean | 'optional';
export type FormLayout = 'horizontal' | 'inline' | 'vertical';
export type ValidationRule = {
/** validation error message */
message?: VueNode;
@ -45,11 +56,13 @@ export type ValidationRule = {
export const formProps = {
layout: PropTypes.oneOf(tuple('horizontal', 'inline', 'vertical')),
labelCol: { type: Object as PropType<ColProps> },
wrapperCol: { type: Object as PropType<ColProps> },
labelCol: { type: Object as PropType<ColProps & HTMLAttributes> },
wrapperCol: { type: Object as PropType<ColProps & HTMLAttributes> },
colon: PropTypes.looseBool,
labelAlign: PropTypes.oneOf(tuple('left', 'right')),
prefixCls: PropTypes.string,
requiredMark: { type: [String, Boolean] as PropType<RequiredMark> },
/** @deprecated Will warning in future branch. Pls use `requiredMark` instead. */
hideRequiredMark: PropTypes.looseBool,
model: PropTypes.object,
rules: { type: Object as PropType<{ [k: string]: ValidationRule[] | ValidationRule }> },

3
components/form/FormItem.tsx

@ -36,6 +36,9 @@ import find from 'lodash-es/find';
import { tuple, VueNode } from '../_util/type';
import { ValidateOptions } from './interface';
const ValidateStatuses = tuple('success', 'warning', 'error', 'validating', '');
export type ValidateStatus = typeof ValidateStatuses[number];
const iconMap = {
success: CheckCircleFilled,
warning: ExclamationCircleFilled,

108
components/form/FormItemInput.tsx

@ -0,0 +1,108 @@
import LoadingOutlined from '@ant-design/icons-vue/LoadingOutlined';
import CloseCircleFilled from '@ant-design/icons-vue/CloseCircleFilled';
import CheckCircleFilled from '@ant-design/icons-vue/CheckCircleFilled';
import ExclamationCircleFilled from '@ant-design/icons-vue/ExclamationCircleFilled';
import Col, { ColProps } from '../grid/col';
import { useProvideForm, useInjectForm, useProvideFormItemPrefix } from './context';
import ErrorList from './ErrorList';
import classNames from '../_util/classNames';
import { ValidateStatus } from './FormItem';
import { VueNode } from '../_util/type';
import { computed, defineComponent, HTMLAttributes, onUnmounted } from 'vue';
interface FormItemInputMiscProps {
prefixCls: string;
errors: VueNode[];
hasFeedback?: boolean;
validateStatus?: ValidateStatus;
onDomErrorVisibleChange: (visible: boolean) => void;
}
export interface FormItemInputProps {
wrapperCol?: ColProps;
help?: VueNode;
extra?: VueNode;
status?: ValidateStatus;
}
const iconMap: { [key: string]: any } = {
success: CheckCircleFilled,
warning: ExclamationCircleFilled,
error: CloseCircleFilled,
validating: LoadingOutlined,
};
const FormItemInput = defineComponent<FormItemInputProps & FormItemInputMiscProps>({
slots: ['help', 'extra', 'errors'],
setup(props, { slots }) {
const formContext = useInjectForm();
const { wrapperCol: contextWrapperCol } = formContext;
// Pass to sub FormItem should not with col info
const subFormContext = { ...formContext };
delete subFormContext.labelCol;
delete subFormContext.wrapperCol;
useProvideForm(subFormContext);
useProvideFormItemPrefix({
prefixCls: computed(() => props.prefixCls),
status: computed(() => props.status),
});
return () => {
const {
prefixCls,
wrapperCol,
help = slots.help?.(),
errors = slots.errors?.(),
onDomErrorVisibleChange,
hasFeedback,
validateStatus,
extra = slots.extra?.(),
} = props;
const baseClassName = `${prefixCls}-item`;
const mergedWrapperCol: ColProps & HTMLAttributes =
wrapperCol || contextWrapperCol?.value || {};
const className = classNames(`${baseClassName}-control`, mergedWrapperCol.class);
onUnmounted(() => {
onDomErrorVisibleChange(false);
});
// Should provides additional icon if `hasFeedback`
const IconNode = validateStatus && iconMap[validateStatus];
const icon =
hasFeedback && IconNode ? (
<span class={`${baseClassName}-children-icon`}>
<IconNode />
</span>
) : null;
const inputDom = (
<div class={`${baseClassName}-control-input`}>
<div class={`${baseClassName}-control-input-content`}>{slots.default?.()}</div>
{icon}
</div>
);
const errorListDom = (
<ErrorList errors={errors} help={help} onDomErrorVisibleChange={onDomErrorVisibleChange} />
);
// If extra = 0, && will goes wrong
// 0&&error -> 0
const extraDom = extra ? <div class={`${baseClassName}-extra`}>{extra}</div> : null;
return (
<Col {...mergedWrapperCol} class={className}>
{inputDom}
{errorListDom}
{extraDom}
</Col>
);
};
},
});
export default FormItemInput;

92
components/form/FormItemLabel.tsx

@ -0,0 +1,92 @@
import Col, { ColProps } from '../grid/col';
import { FormLabelAlign } from './interface';
import { useInjectForm } from './context';
import { RequiredMark } from './Form';
import { useLocaleReceiver } from '../locale-provider/LocaleReceiver';
import defaultLocale from '../locale/default';
import classNames from '../_util/classNames';
import { VueNode } from '../_util/type';
import { FunctionalComponent, HTMLAttributes } from 'vue';
export interface FormItemLabelProps {
colon?: boolean;
htmlFor?: string;
label?: VueNode;
labelAlign?: FormLabelAlign;
labelCol?: ColProps & HTMLAttributes;
requiredMark?: RequiredMark;
required?: boolean;
prefixCls: string;
}
const FormItemLabel: FunctionalComponent<FormItemLabelProps> = (props, { slots }) => {
const { prefixCls, htmlFor, labelCol, labelAlign, colon, required, requiredMark } = props;
const [formLocale] = useLocaleReceiver('Form');
const label = props.label ?? slots.label?.();
if (!label) return null;
const {
vertical,
labelAlign: contextLabelAlign,
labelCol: contextLabelCol,
colon: contextColon,
} = useInjectForm();
const mergedLabelCol: FormItemLabelProps['labelCol'] = labelCol || contextLabelCol?.value || {};
const mergedLabelAlign: FormLabelAlign | undefined = labelAlign || contextLabelAlign?.value;
const labelClsBasic = `${prefixCls}-item-label`;
const labelColClassName = classNames(
labelClsBasic,
mergedLabelAlign === 'left' && `${labelClsBasic}-left`,
mergedLabelCol.class,
);
let labelChildren = label;
// Keep label is original where there should have no colon
const computedColon = colon === true || (contextColon?.value !== false && colon !== false);
const haveColon = computedColon && !vertical.value;
// Remove duplicated user input colon
if (haveColon && typeof label === 'string' && (label as string).trim() !== '') {
labelChildren = (label as string).replace(/[:|:]\s*$/, '');
}
labelChildren = (
<>
{labelChildren}
{slots.tooltip?.({ class: `${prefixCls}-item-tooltip` })}
</>
);
// Add required mark if optional
if (requiredMark === 'optional' && !required) {
labelChildren = (
<>
{labelChildren}
<span class={`${prefixCls}-item-optional`}>
{formLocale.value?.optional || defaultLocale.Form?.optional}
</span>
</>
);
}
const labelClassName = classNames({
[`${prefixCls}-item-required`]: required,
[`${prefixCls}-item-required-mark-optional`]: requiredMark === 'optional',
[`${prefixCls}-item-no-colon`]: !computedColon,
});
return (
<Col {...mergedLabelCol} class={labelColClassName}>
<label
html-for={htmlFor}
class={labelClassName}
title={typeof label === 'string' ? label : ''}
>
{labelChildren}
</label>
</Col>
);
};
FormItemLabel.displayName = 'FormItemLabel';
export default FormItemLabel;

49
components/form/context.ts

@ -0,0 +1,49 @@
import { inject, InjectionKey, provide, ComputedRef, computed } from 'vue';
import { ColProps } from '../grid';
import { RequiredMark } from './Form';
import { ValidateStatus } from './FormItem';
import { FormLabelAlign } from './interface';
export interface FormContextProps {
vertical: ComputedRef<boolean>;
name?: ComputedRef<string>;
colon?: ComputedRef<boolean>;
labelAlign?: ComputedRef<FormLabelAlign>;
labelCol?: ComputedRef<ColProps>;
wrapperCol?: ComputedRef<ColProps>;
requiredMark?: ComputedRef<RequiredMark>;
//itemRef: (name: (string | number)[]) => (node: React.ReactElement) => void;
}
export const FormContextKey: InjectionKey<FormContextProps> = Symbol('formContextKey');
export const useProvideForm = (state: FormContextProps) => {
provide(FormContextKey, state);
};
export const useInjectForm = () => {
return inject(FormContextKey, {
labelAlign: computed(() => 'right' as FormLabelAlign),
vertical: computed(() => false),
});
};
/** Used for ErrorList only */
export interface FormItemPrefixContextProps {
prefixCls: ComputedRef<string>;
status?: ComputedRef<ValidateStatus>;
}
export const FormItemPrefixContextKey: InjectionKey<FormItemPrefixContextProps> = Symbol(
'formItemPrefixContextKey',
);
export const useProvideFormItemPrefix = (state: FormItemPrefixContextProps) => {
provide(FormItemPrefixContextKey, state);
};
export const useInjectFormItemPrefix = () => {
return inject(FormItemPrefixContextKey, {
prefixCls: computed(() => ''),
});
};

2
components/form/interface.ts

@ -1,5 +1,7 @@
import { VueNode } from '../_util/type';
export type FormLabelAlign = 'left' | 'right';
export type InternalNamePath = (string | number)[];
export type NamePath = string | number | InternalNamePath;

9
components/locale-provider/index.tsx

@ -5,6 +5,7 @@ import interopDefault from '../_util/interopDefault';
import { ModalLocale, changeConfirmLocale } from '../modal/locale';
import warning from '../_util/warning';
import { withInstall } from '../_util/type';
import { ValidateMessages } from '../form/interface';
export interface Locale {
locale: string;
Pagination?: Object;
@ -17,6 +18,14 @@ export interface Locale {
Transfer?: Object;
Select?: Object;
Upload?: Object;
Form?: {
optional?: string;
defaultValidateMessages: ValidateMessages;
};
Image?: {
preview: string;
};
}
export interface LocaleProviderProps {

69
components/locale/default.ts

@ -3,14 +3,13 @@ import DatePicker from '../date-picker/locale/en_US';
import TimePicker from '../time-picker/locale/en_US';
import Calendar from '../calendar/locale/en_US';
// import ColorPicker from '../color-picker/locale/en_US';
const typeTemplate = '${label} is not a valid ${type}';
export default {
locale: 'en',
Pagination,
DatePicker,
TimePicker,
Calendar,
// ColorPicker,
global: {
placeholder: 'Please select',
},
@ -18,11 +17,18 @@ export default {
filterTitle: 'Filter menu',
filterConfirm: 'OK',
filterReset: 'Reset',
filterEmptyText: 'No filters',
emptyText: 'No data',
selectAll: 'Select current page',
selectInvert: 'Invert current page',
selectNone: 'Clear all data',
selectionAll: 'Select all data',
sortTitle: 'Sort',
expand: 'Expand row',
collapse: 'Collapse row',
triggerDesc: 'Click to sort descending',
triggerAsc: 'Click to sort ascending',
cancelSort: 'Click to cancel sorting',
},
Modal: {
okText: 'OK',
@ -38,6 +44,12 @@ export default {
searchPlaceholder: 'Search here',
itemUnit: 'item',
itemsUnit: 'items',
remove: 'Remove',
selectCurrent: 'Select current page',
removeCurrent: 'Remove current page',
selectAll: 'Select all data',
removeAll: 'Remove all data',
selectInvert: 'Invert current page',
},
Upload: {
uploading: 'Uploading...',
@ -61,4 +73,57 @@ export default {
PageHeader: {
back: 'Back',
},
Form: {
optional: '(optional)',
defaultValidateMessages: {
default: 'Field validation error for ${label}',
required: 'Please enter ${label}',
enum: '${label} must be one of [${enum}]',
whitespace: '${label} cannot be a blank character',
date: {
format: '${label} date format is invalid',
parse: '${label} cannot be converted to a date',
invalid: '${label} is an invalid date',
},
types: {
string: typeTemplate,
method: typeTemplate,
array: typeTemplate,
object: typeTemplate,
number: typeTemplate,
date: typeTemplate,
boolean: typeTemplate,
integer: typeTemplate,
float: typeTemplate,
regexp: typeTemplate,
email: typeTemplate,
url: typeTemplate,
hex: typeTemplate,
},
string: {
len: '${label} must be ${len} characters',
min: '${label} must be at least ${min} characters',
max: '${label} must be up to ${max} characters',
range: '${label} must be between ${min}-${max} characters',
},
number: {
len: '${label} must be equal to ${len}',
min: '${label} must be minimum ${min}',
max: '${label} must be maximum ${max}',
range: '${label} must be between ${min}-${max}',
},
array: {
len: 'Must be ${len} ${label}',
min: 'At least ${min} ${label}',
max: 'At most ${max} ${label}',
range: 'The amount of ${label} must be between ${min}-${max}',
},
pattern: {
mismatch: '${label} does not match the pattern ${pattern}',
},
},
},
Image: {
preview: 'Preview',
},
};

Loading…
Cancel
Save