Skip to content

Commit c2bba2e

Browse files
committed
refactor: form
1 parent b68bb81 commit c2bba2e

File tree

11 files changed

+419
-7
lines changed

11 files changed

+419
-7
lines changed

components/_util/hooks/useConfigInject.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { RequiredMark } from '../../form/Form';
12
import { computed, ComputedRef, inject, UnwrapRef } from 'vue';
23
import {
34
ConfigProviderProps,
@@ -17,6 +18,9 @@ export default (
1718
getTargetContainer: ComputedRef<() => HTMLElement>;
1819
space: ComputedRef<{ size: SizeType | number }>;
1920
pageHeader: ComputedRef<{ ghost: boolean }>;
21+
form?: ComputedRef<{
22+
requiredMark?: RequiredMark;
23+
}>;
2024
} => {
2125
const configProvider = inject<UnwrapRef<ConfigProviderProps>>(
2226
'configProvider',
@@ -26,7 +30,17 @@ export default (
2630
const direction = computed(() => configProvider.direction);
2731
const space = computed(() => configProvider.space);
2832
const pageHeader = computed(() => configProvider.pageHeader);
33+
const form = computed(() => configProvider.form);
2934
const size = computed(() => props.size || configProvider.componentSize);
3035
const getTargetContainer = computed(() => props.getTargetContainer);
31-
return { configProvider, prefixCls, direction, size, getTargetContainer, space, pageHeader };
36+
return {
37+
configProvider,
38+
prefixCls,
39+
direction,
40+
size,
41+
getTargetContainer,
42+
space,
43+
pageHeader,
44+
form,
45+
};
3246
};

components/config-provider/index.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import LocaleProvider, { Locale, ANT_MARK } from '../locale-provider';
1313
import { TransformCellTextProps } from '../table/interface';
1414
import LocaleReceiver from '../locale-provider/LocaleReceiver';
1515
import { withInstall } from '../_util/type';
16+
import { RequiredMark } from '../form/Form';
1617

1718
export type SizeType = 'small' | 'middle' | 'large' | undefined;
1819

@@ -99,6 +100,9 @@ export const configProviderProps = {
99100
},
100101
virtual: PropTypes.looseBool,
101102
dropdownMatchSelectWidth: PropTypes.looseBool,
103+
form: {
104+
type: Object as PropType<{ requiredMark?: RequiredMark }>,
105+
},
102106
};
103107

104108
export type ConfigProviderProps = Partial<ExtractPropTypes<typeof configProviderProps>>;
@@ -159,7 +163,7 @@ const ConfigProvider = defineComponent({
159163
export const defaultConfigProvider: UnwrapRef<ConfigProviderProps> = reactive({
160164
getPrefixCls: (suffixCls: string, customizePrefixCls?: string) => {
161165
if (customizePrefixCls) return customizePrefixCls;
162-
return `ant-${suffixCls}`;
166+
return suffixCls ? `ant-${suffixCls}` : 'ant';
163167
},
164168
renderEmpty: defaultRenderEmpty,
165169
direction: 'ltr',

components/form/ErrorList.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useInjectFormItemPrefix } from './context';
2+
import { VueNode } from '../_util/type';
3+
import { computed, defineComponent, ref, watch } from '@vue/runtime-core';
4+
import classNames from '../_util/classNames';
5+
import Transition, { getTransitionProps } from '../_util/transition';
6+
7+
export interface ErrorListProps {
8+
errors?: VueNode[];
9+
/** @private Internal Usage. Do not use in your production */
10+
help?: VueNode;
11+
/** @private Internal Usage. Do not use in your production */
12+
onDomErrorVisibleChange?: (visible: boolean) => void;
13+
}
14+
15+
export default defineComponent<ErrorListProps>({
16+
name: 'ErrorList',
17+
setup(props) {
18+
const { prefixCls, status } = useInjectFormItemPrefix();
19+
const visible = computed(() => props.errors && props.errors.length);
20+
const innerStatus = ref(status.value);
21+
// Memo status in same visible
22+
watch([() => visible, () => status], () => {
23+
if (visible.value && status.value) {
24+
innerStatus.value = status.value;
25+
}
26+
});
27+
return () => {
28+
const baseClassName = `${prefixCls.value}-item-explain`;
29+
const transitionProps = getTransitionProps('show-help', {
30+
onAfterLeave: () => props.onDomErrorVisibleChange?.(false),
31+
});
32+
return (
33+
<Transition {...transitionProps}>
34+
{visible ? (
35+
<div
36+
class={classNames(baseClassName, {
37+
[`${baseClassName}-${innerStatus}`]: innerStatus,
38+
})}
39+
key="help"
40+
>
41+
{props.errors?.map((error: any, index: number) => (
42+
// eslint-disable-next-line react/no-array-index-key
43+
<div key={index} role="alert">
44+
{error}
45+
</div>
46+
))}
47+
</div>
48+
) : null}
49+
</Transition>
50+
);
51+
};
52+
},
53+
});

components/form/Form.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { defineComponent, inject, provide, PropType, computed, ExtractPropTypes } from 'vue';
1+
import {
2+
defineComponent,
3+
inject,
4+
provide,
5+
PropType,
6+
computed,
7+
ExtractPropTypes,
8+
HTMLAttributes,
9+
} from 'vue';
210
import PropTypes from '../_util/vue-types';
311
import classNames from '../_util/classNames';
412
import warning from '../_util/warning';
@@ -16,6 +24,9 @@ import { tuple, VueNode } from '../_util/type';
1624
import { ColProps } from '../grid/Col';
1725
import { InternalNamePath, NamePath, ValidateErrorEntity, ValidateOptions } from './interface';
1826

27+
export type RequiredMark = boolean | 'optional';
28+
export type FormLayout = 'horizontal' | 'inline' | 'vertical';
29+
1930
export type ValidationRule = {
2031
/** validation error message */
2132
message?: VueNode;
@@ -45,11 +56,13 @@ export type ValidationRule = {
4556

4657
export const formProps = {
4758
layout: PropTypes.oneOf(tuple('horizontal', 'inline', 'vertical')),
48-
labelCol: { type: Object as PropType<ColProps> },
49-
wrapperCol: { type: Object as PropType<ColProps> },
59+
labelCol: { type: Object as PropType<ColProps & HTMLAttributes> },
60+
wrapperCol: { type: Object as PropType<ColProps & HTMLAttributes> },
5061
colon: PropTypes.looseBool,
5162
labelAlign: PropTypes.oneOf(tuple('left', 'right')),
5263
prefixCls: PropTypes.string,
64+
requiredMark: { type: [String, Boolean] as PropType<RequiredMark> },
65+
/** @deprecated Will warning in future branch. Pls use `requiredMark` instead. */
5366
hideRequiredMark: PropTypes.looseBool,
5467
model: PropTypes.object,
5568
rules: { type: Object as PropType<{ [k: string]: ValidationRule[] | ValidationRule }> },

components/form/FormItem.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ import find from 'lodash-es/find';
3636
import { tuple, VueNode } from '../_util/type';
3737
import { ValidateOptions } from './interface';
3838

39+
const ValidateStatuses = tuple('success', 'warning', 'error', 'validating', '');
40+
export type ValidateStatus = typeof ValidateStatuses[number];
41+
3942
const iconMap = {
4043
success: CheckCircleFilled,
4144
warning: ExclamationCircleFilled,

components/form/FormItemInput.tsx

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import LoadingOutlined from '@ant-design/icons-vue/LoadingOutlined';
2+
import CloseCircleFilled from '@ant-design/icons-vue/CloseCircleFilled';
3+
import CheckCircleFilled from '@ant-design/icons-vue/CheckCircleFilled';
4+
import ExclamationCircleFilled from '@ant-design/icons-vue/ExclamationCircleFilled';
5+
6+
import Col, { ColProps } from '../grid/col';
7+
import { useProvideForm, useInjectForm, useProvideFormItemPrefix } from './context';
8+
import ErrorList from './ErrorList';
9+
import classNames from '../_util/classNames';
10+
import { ValidateStatus } from './FormItem';
11+
import { VueNode } from '../_util/type';
12+
import { computed, defineComponent, HTMLAttributes, onUnmounted } from 'vue';
13+
14+
interface FormItemInputMiscProps {
15+
prefixCls: string;
16+
errors: VueNode[];
17+
hasFeedback?: boolean;
18+
validateStatus?: ValidateStatus;
19+
onDomErrorVisibleChange: (visible: boolean) => void;
20+
}
21+
22+
export interface FormItemInputProps {
23+
wrapperCol?: ColProps;
24+
help?: VueNode;
25+
extra?: VueNode;
26+
status?: ValidateStatus;
27+
}
28+
29+
const iconMap: { [key: string]: any } = {
30+
success: CheckCircleFilled,
31+
warning: ExclamationCircleFilled,
32+
error: CloseCircleFilled,
33+
validating: LoadingOutlined,
34+
};
35+
const FormItemInput = defineComponent<FormItemInputProps & FormItemInputMiscProps>({
36+
slots: ['help', 'extra', 'errors'],
37+
setup(props, { slots }) {
38+
const formContext = useInjectForm();
39+
const { wrapperCol: contextWrapperCol } = formContext;
40+
41+
// Pass to sub FormItem should not with col info
42+
const subFormContext = { ...formContext };
43+
delete subFormContext.labelCol;
44+
delete subFormContext.wrapperCol;
45+
useProvideForm(subFormContext);
46+
47+
useProvideFormItemPrefix({
48+
prefixCls: computed(() => props.prefixCls),
49+
status: computed(() => props.status),
50+
});
51+
52+
return () => {
53+
const {
54+
prefixCls,
55+
wrapperCol,
56+
help = slots.help?.(),
57+
errors = slots.errors?.(),
58+
onDomErrorVisibleChange,
59+
hasFeedback,
60+
validateStatus,
61+
extra = slots.extra?.(),
62+
} = props;
63+
const baseClassName = `${prefixCls}-item`;
64+
65+
const mergedWrapperCol: ColProps & HTMLAttributes =
66+
wrapperCol || contextWrapperCol?.value || {};
67+
68+
const className = classNames(`${baseClassName}-control`, mergedWrapperCol.class);
69+
70+
onUnmounted(() => {
71+
onDomErrorVisibleChange(false);
72+
});
73+
74+
// Should provides additional icon if `hasFeedback`
75+
const IconNode = validateStatus && iconMap[validateStatus];
76+
const icon =
77+
hasFeedback && IconNode ? (
78+
<span class={`${baseClassName}-children-icon`}>
79+
<IconNode />
80+
</span>
81+
) : null;
82+
83+
const inputDom = (
84+
<div class={`${baseClassName}-control-input`}>
85+
<div class={`${baseClassName}-control-input-content`}>{slots.default?.()}</div>
86+
{icon}
87+
</div>
88+
);
89+
const errorListDom = (
90+
<ErrorList errors={errors} help={help} onDomErrorVisibleChange={onDomErrorVisibleChange} />
91+
);
92+
93+
// If extra = 0, && will goes wrong
94+
// 0&&error -> 0
95+
const extraDom = extra ? <div class={`${baseClassName}-extra`}>{extra}</div> : null;
96+
97+
return (
98+
<Col {...mergedWrapperCol} class={className}>
99+
{inputDom}
100+
{errorListDom}
101+
{extraDom}
102+
</Col>
103+
);
104+
};
105+
},
106+
});
107+
108+
export default FormItemInput;

components/form/FormItemLabel.tsx

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import Col, { ColProps } from '../grid/col';
2+
import { FormLabelAlign } from './interface';
3+
import { useInjectForm } from './context';
4+
import { RequiredMark } from './Form';
5+
import { useLocaleReceiver } from '../locale-provider/LocaleReceiver';
6+
import defaultLocale from '../locale/default';
7+
import classNames from '../_util/classNames';
8+
import { VueNode } from '../_util/type';
9+
import { FunctionalComponent, HTMLAttributes } from 'vue';
10+
11+
export interface FormItemLabelProps {
12+
colon?: boolean;
13+
htmlFor?: string;
14+
label?: VueNode;
15+
labelAlign?: FormLabelAlign;
16+
labelCol?: ColProps & HTMLAttributes;
17+
requiredMark?: RequiredMark;
18+
required?: boolean;
19+
prefixCls: string;
20+
}
21+
22+
const FormItemLabel: FunctionalComponent<FormItemLabelProps> = (props, { slots }) => {
23+
const { prefixCls, htmlFor, labelCol, labelAlign, colon, required, requiredMark } = props;
24+
const [formLocale] = useLocaleReceiver('Form');
25+
const label = props.label ?? slots.label?.();
26+
if (!label) return null;
27+
const {
28+
vertical,
29+
labelAlign: contextLabelAlign,
30+
labelCol: contextLabelCol,
31+
colon: contextColon,
32+
} = useInjectForm();
33+
const mergedLabelCol: FormItemLabelProps['labelCol'] = labelCol || contextLabelCol?.value || {};
34+
35+
const mergedLabelAlign: FormLabelAlign | undefined = labelAlign || contextLabelAlign?.value;
36+
37+
const labelClsBasic = `${prefixCls}-item-label`;
38+
const labelColClassName = classNames(
39+
labelClsBasic,
40+
mergedLabelAlign === 'left' && `${labelClsBasic}-left`,
41+
mergedLabelCol.class,
42+
);
43+
44+
let labelChildren = label;
45+
// Keep label is original where there should have no colon
46+
const computedColon = colon === true || (contextColon?.value !== false && colon !== false);
47+
const haveColon = computedColon && !vertical.value;
48+
// Remove duplicated user input colon
49+
if (haveColon && typeof label === 'string' && (label as string).trim() !== '') {
50+
labelChildren = (label as string).replace(/[:|]\s*$/, '');
51+
}
52+
53+
labelChildren = (
54+
<>
55+
{labelChildren}
56+
{slots.tooltip?.({ class: `${prefixCls}-item-tooltip` })}
57+
</>
58+
);
59+
60+
// Add required mark if optional
61+
if (requiredMark === 'optional' && !required) {
62+
labelChildren = (
63+
<>
64+
{labelChildren}
65+
<span class={`${prefixCls}-item-optional`}>
66+
{formLocale.value?.optional || defaultLocale.Form?.optional}
67+
</span>
68+
</>
69+
);
70+
}
71+
72+
const labelClassName = classNames({
73+
[`${prefixCls}-item-required`]: required,
74+
[`${prefixCls}-item-required-mark-optional`]: requiredMark === 'optional',
75+
[`${prefixCls}-item-no-colon`]: !computedColon,
76+
});
77+
return (
78+
<Col {...mergedLabelCol} class={labelColClassName}>
79+
<label
80+
html-for={htmlFor}
81+
class={labelClassName}
82+
title={typeof label === 'string' ? label : ''}
83+
>
84+
{labelChildren}
85+
</label>
86+
</Col>
87+
);
88+
};
89+
90+
FormItemLabel.displayName = 'FormItemLabel';
91+
92+
export default FormItemLabel;

0 commit comments

Comments
 (0)