feat(form): 容器透传 labelPosition

容器未单独配置时回落到父级/表单级,子项自身配置优先。
This commit is contained in:
roymondchen 2026-09-01 11:59:41 +08:00
parent 93dc0d2187
commit c34ed6aafc
24 changed files with 264 additions and 8 deletions

View File

@ -74,7 +74,7 @@ const props = defineProps<{
disabledShowSrc?: boolean; disabledShowSrc?: boolean;
labelWidth?: string; labelWidth?: string;
codeValueKey?: string; codeValueKey?: string;
labelPosition?: string; labelPosition?: 'top' | 'left' | 'right';
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>; extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
}>(); }>();

View File

@ -23,6 +23,7 @@
:last-values="lastValuesProcessed" :last-values="lastValuesProcessed"
:is-compare="isCompare" :is-compare="isCompare"
:label-width="item.labelWidth || labelWidth" :label-width="item.labelWidth || labelWidth"
:label-position="item.labelPosition || labelPosition"
:step-active="stepActive" :step-active="stepActive"
:size="size" :size="size"
@change="changeHandler" @change="changeHandler"
@ -92,7 +93,7 @@ const props = withDefaults(
stepActive?: string | number; stepActive?: string | number;
size?: 'small' | 'default' | 'large'; size?: 'small' | 'default' | 'large';
inline?: boolean; inline?: boolean;
labelPosition?: string; labelPosition?: 'top' | 'left' | 'right';
keyProp?: string; keyProp?: string;
popperClass?: string; popperClass?: string;
preventSubmitDefault?: boolean; preventSubmitDefault?: boolean;

View File

@ -66,7 +66,7 @@ const props = withDefaults(
size?: 'small' | 'default' | 'large'; size?: 'small' | 'default' | 'large';
confirmText?: string; confirmText?: string;
inline?: boolean; inline?: boolean;
labelPosition?: string; labelPosition?: 'top' | 'left' | 'right';
preventSubmitDefault?: boolean; preventSubmitDefault?: boolean;
/** 透传给内部 `MForm`,控制表单校验失败时错误提示前缀是否使用字段的 text 文案 */ /** 透传给内部 `MForm`,控制表单校验失败时错误提示前缀是否使用字段的 text 文案 */
useFieldTextInError?: boolean; useFieldTextInError?: boolean;

View File

@ -94,7 +94,7 @@ const props = withDefaults(
disabled?: boolean; disabled?: boolean;
title?: string; title?: string;
inline?: boolean; inline?: boolean;
labelPosition?: string; labelPosition?: 'top' | 'left' | 'right';
zIndex?: number; zIndex?: number;
size?: 'small' | 'default' | 'large'; size?: 'small' | 'default' | 'large';
confirmText?: string; confirmText?: string;

View File

@ -88,7 +88,7 @@ const props = withDefaults(
size?: 'small' | 'default' | 'large'; size?: 'small' | 'default' | 'large';
confirmText?: string; confirmText?: string;
inline?: boolean; inline?: boolean;
labelPosition?: string; labelPosition?: 'top' | 'left' | 'right';
preventSubmitDefault?: boolean; preventSubmitDefault?: boolean;
/** 透传给内部 `MForm`,控制表单校验失败时错误提示前缀是否使用字段的 text 文案 */ /** 透传给内部 `MForm`,控制表单校验失败时错误提示前缀是否使用字段的 text 文案 */
useFieldTextInError?: boolean; useFieldTextInError?: boolean;

View File

@ -7,6 +7,7 @@
:config="config" :config="config"
:prop="prop" :prop="prop"
:label-width="config.labelWidth || labelWidth" :label-width="config.labelWidth || labelWidth"
:label-position="config.labelPosition || labelPosition"
:expand-more="expandMore" :expand-more="expandMore"
:size="size" :size="size"
:disabled="disabled" :disabled="disabled"
@ -37,6 +38,7 @@ const props = defineProps<{
isCompare?: boolean; isCompare?: boolean;
config: FormItemConfig; config: FormItemConfig;
labelWidth?: string | number; labelWidth?: string | number;
labelPosition?: 'top' | 'left' | 'right';
expandMore?: boolean; expandMore?: boolean;
span?: number; span?: number;
size?: string; size?: string;

View File

@ -19,6 +19,7 @@
:step-active="stepActive" :step-active="stepActive"
:expand-more="expand" :expand-more="expand"
:label-width="itemLabelWidth" :label-width="itemLabelWidth"
:label-position="itemLabelPosition"
:style="config.fieldStyle" :style="config.fieldStyle"
@change="onChangeHandler" @change="onChangeHandler"
@addDiffCount="onAddDiffCount" @addDiffCount="onAddDiffCount"
@ -35,7 +36,7 @@
:label-title="config.labelTitle" :label-title="config.labelTitle"
:text="text" :text="text"
></FormLabel> ></FormLabel>
<span class="m-form-tip m-form-title-extra" v-if="config.titleExtra && config.labelPosition === 'top'"> <span class="m-form-tip m-form-title-extra" v-if="config.titleExtra && itemLabelPosition === 'top'">
{{ config.titleExtra }}</span {{ config.titleExtra }}</span
> >
</slot> </slot>
@ -217,6 +218,7 @@
:step-active="stepActive" :step-active="stepActive"
:expand-more="expand" :expand-more="expand"
:label-width="itemLabelWidth" :label-width="itemLabelWidth"
:label-position="itemLabelPosition"
:prop="itemProp" :prop="itemProp"
@change="onChangeHandler" @change="onChangeHandler"
@addDiffCount="onAddDiffCount" @addDiffCount="onAddDiffCount"
@ -293,6 +295,7 @@ const props = withDefaults(
prop?: string; prop?: string;
disabled?: boolean; disabled?: boolean;
labelWidth?: string | number; labelWidth?: string | number;
labelPosition?: 'top' | 'left' | 'right';
expandMore?: boolean; expandMore?: boolean;
stepActive?: string | number; stepActive?: string | number;
size?: string; size?: string;
@ -445,7 +448,7 @@ const fieldsProps = computed(() => ({
const formItemProps = computed(() => ({ const formItemProps = computed(() => ({
prop: itemProp.value, prop: itemProp.value,
labelWidth: itemLabelWidth.value, labelWidth: itemLabelWidth.value,
labelPosition: props.config.labelPosition, labelPosition: itemLabelPosition.value,
rules: rule.value, rules: rule.value,
extra: filterFunction(mForm, props.config.extra, props), extra: filterFunction(mForm, props.config.extra, props),
@ -454,6 +457,8 @@ const formItemProps = computed(() => ({
const itemLabelWidth = computed(() => props.config.labelWidth ?? props.labelWidth); const itemLabelWidth = computed(() => props.config.labelWidth ?? props.labelWidth);
const itemLabelPosition = computed(() => props.config.labelPosition ?? props.labelPosition);
watchEffect(() => { watchEffect(() => {
expand.value = props.expandMore; expand.value = props.expandMore;
}); });

View File

@ -28,6 +28,7 @@
:prop="prop" :prop="prop"
:disabled="disabled" :disabled="disabled"
:labelWidth="lWidth" :labelWidth="lWidth"
:labelPosition="lPosition"
:size="size" :size="size"
@change="changeHandler" @change="changeHandler"
@add-diff-count="onAddDiffCount()" @add-diff-count="onAddDiffCount()"
@ -48,6 +49,7 @@
:config="item" :config="item"
:prop="prop" :prop="prop"
:labelWidth="lWidth" :labelWidth="lWidth"
:labelPosition="lPosition"
:size="size" :size="size"
:disabled="disabled" :disabled="disabled"
@change="changeHandler" @change="changeHandler"
@ -74,6 +76,7 @@ defineOptions({
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
labelWidth?: string; labelWidth?: string;
labelPosition?: 'top' | 'left' | 'right';
prop: string; prop: string;
size?: string; size?: string;
model: Record<string, any>; model: Record<string, any>;
@ -140,6 +143,8 @@ const lWidth = computed(() => {
return props.config.labelWidth || props.labelWidth || (props.config.text ? undefined : '0'); return props.config.labelWidth || props.labelWidth || (props.config.text ? undefined : '0');
}); });
const lPosition = computed(() => props.config.labelPosition || props.labelPosition);
const valueChangeHandler = (value: number | boolean) => { const valueChangeHandler = (value: number | boolean) => {
emit('change', value, { modifyKey: checkboxName.value }); emit('change', value, { modifyKey: checkboxName.value });
}; };

View File

@ -11,6 +11,7 @@
:size="size" :size="size"
:disabled="disabled" :disabled="disabled"
:label-width="config.labelWidth || labelWidth" :label-width="config.labelWidth || labelWidth"
:label-position="config.labelPosition || labelPosition"
@change="changeHandler" @change="changeHandler"
@addDiffCount="onAddDiffCount()" @addDiffCount="onAddDiffCount()"
/> />
@ -37,6 +38,7 @@ const props = defineProps<{
config: FlexLayoutConfig; config: FlexLayoutConfig;
name?: string; name?: string;
labelWidth?: string; labelWidth?: string;
labelPosition?: 'top' | 'left' | 'right';
prop?: string; prop?: string;
size?: string; size?: string;
disabled?: boolean; disabled?: boolean;

View File

@ -16,6 +16,7 @@
:prop="prop" :prop="prop"
:index="Number(index)" :index="Number(index)"
:label-width="labelWidth" :label-width="labelWidth"
:label-position="labelPosition"
:size="size" :size="size"
:disabled="disabled" :disabled="disabled"
:group-model="model[name]" :group-model="model[name]"
@ -53,6 +54,7 @@ const props = defineProps<{
config: GroupListConfig; config: GroupListConfig;
name: string; name: string;
labelWidth?: string; labelWidth?: string;
labelPosition?: 'top' | 'left' | 'right';
prop?: string; prop?: string;
size?: string; size?: string;
disabled?: boolean; disabled?: boolean;

View File

@ -99,6 +99,7 @@
:lastValues="lastValues" :lastValues="lastValues"
:is-compare="isCompare" :is-compare="isCompare"
:labelWidth="labelWidth" :labelWidth="labelWidth"
:labelPosition="labelPosition"
:prop="rowProp" :prop="rowProp"
:size="size" :size="size"
:disabled="disabled" :disabled="disabled"
@ -131,6 +132,7 @@ const props = defineProps<{
groupModel: any[]; groupModel: any[];
config: GroupListConfig; config: GroupListConfig;
labelWidth?: string; labelWidth?: string;
labelPosition?: 'top' | 'left' | 'right';
prop?: string; prop?: string;
size?: string; size?: string;
index: number; index: number;

View File

@ -39,6 +39,7 @@
:size="size" :size="size"
:disabled="disabled" :disabled="disabled"
:label-width="config.labelWidth || labelWidth" :label-width="config.labelWidth || labelWidth"
:label-position="config.labelPosition || labelPosition"
@change="changeHandler" @change="changeHandler"
@addDiffCount="onAddDiffCount()" @addDiffCount="onAddDiffCount()"
></Container> ></Container>
@ -59,6 +60,7 @@
:size="size" :size="size"
:disabled="disabled" :disabled="disabled"
:label-width="config.labelWidth || labelWidth" :label-width="config.labelWidth || labelWidth"
:label-position="config.labelPosition || labelPosition"
@change="changeHandler" @change="changeHandler"
@addDiffCount="onAddDiffCount()" @addDiffCount="onAddDiffCount()"
></Container> ></Container>
@ -89,6 +91,7 @@ const props = defineProps<{
config: PanelConfig; config: PanelConfig;
name?: string; name?: string;
labelWidth?: string; labelWidth?: string;
labelPosition?: 'top' | 'left' | 'right';
prop?: string; prop?: string;
size?: string; size?: string;
disabled?: boolean; disabled?: boolean;

View File

@ -6,6 +6,7 @@
:span="col.span || config.span || 24 / config.items.length" :span="col.span || config.span || 24 / config.items.length"
:config="col" :config="col"
:labelWidth="config.labelWidth || labelWidth" :labelWidth="config.labelWidth || labelWidth"
:labelPosition="config.labelPosition || labelPosition"
:expandMore="expandMore" :expandMore="expandMore"
:model="name ? model[name] : model" :model="name ? model[name] : model"
:lastValues="name ? lastValues[name] : lastValues" :lastValues="name ? lastValues[name] : lastValues"
@ -39,6 +40,7 @@ const props = defineProps<{
config: RowConfig; config: RowConfig;
name: string; name: string;
labelWidth?: string; labelWidth?: string;
labelPosition?: 'top' | 'left' | 'right';
prop?: string; prop?: string;
size?: string; size?: string;
expandMore?: boolean; expandMore?: boolean;

View File

@ -24,6 +24,7 @@
:size="size" :size="size"
:disabled="disabled" :disabled="disabled"
:label-width="config.labelWidth || labelWidth" :label-width="config.labelWidth || labelWidth"
:label-position="config.labelPosition || labelPosition"
@change="changeHandler" @change="changeHandler"
@addDiffCount="onAddDiffCount()" @addDiffCount="onAddDiffCount()"
></Container> ></Container>
@ -54,6 +55,7 @@ const props = withDefaults(
config: StepConfig; config: StepConfig;
stepActive?: number; stepActive?: number;
labelWidth?: string; labelWidth?: string;
labelPosition?: 'top' | 'left' | 'right';
size?: string; size?: string;
disabled?: boolean; disabled?: boolean;
}>(), }>(),

View File

@ -70,6 +70,7 @@
" "
:size="size" :size="size"
:label-width="tab.labelWidth || labelWidth" :label-width="tab.labelWidth || labelWidth"
:label-position="tab.labelPosition || labelPosition"
:expand-more="expandMore" :expand-more="expandMore"
@change="changeHandler" @change="changeHandler"
@addDiffCount="onAddDiffCount(Number(tabIndex))" @addDiffCount="onAddDiffCount(Number(tabIndex))"
@ -106,6 +107,7 @@ const props = withDefaults(
name: string; name: string;
size?: string; size?: string;
labelWidth?: string; labelWidth?: string;
labelPosition?: 'top' | 'left' | 'right';
prop?: string; prop?: string;
expandMore?: boolean; expandMore?: boolean;
disabled?: boolean; disabled?: boolean;

View File

@ -12,6 +12,7 @@
:last-values="lastValues" :last-values="lastValues"
:prop="prop" :prop="prop"
:label-width="labelWidth" :label-width="labelWidth"
:label-position="labelPosition"
:show-index="showIndex" :show-index="showIndex"
:sort-key="sortKey" :sort-key="sortKey"
:sort="sort" :sort="sort"
@ -80,6 +81,7 @@ const props = defineProps<{
name: string; name: string;
prop?: string; prop?: string;
labelWidth?: string; labelWidth?: string;
labelPosition?: 'top' | 'left' | 'right';
disabled?: boolean; disabled?: boolean;
size?: string; size?: string;
enableToggleMode?: true; enableToggleMode?: true;

View File

@ -43,7 +43,7 @@ export interface SubmitFormOptions {
stepActive?: string | number; stepActive?: string | number;
size?: 'small' | 'default' | 'large'; size?: 'small' | 'default' | 'large';
inline?: boolean; inline?: boolean;
labelPosition?: string; labelPosition?: 'top' | 'left' | 'right';
keyProp?: string; keyProp?: string;
popperClass?: string; popperClass?: string;
preventSubmitDefault?: boolean; preventSubmitDefault?: boolean;

View File

@ -94,6 +94,7 @@ export const getGroupListRowConfig = (config: GroupListConfig, index: number, ke
span: config.span || 24, span: config.span || 24,
items: config.items, items: config.items,
labelWidth: config.labelWidth, labelWidth: config.labelWidth,
labelPosition: config.labelPosition,
[key]: `${(config as Record<string, any>)[key]}${String(index)}`, [key]: `${(config as Record<string, any>)[key]}${String(index)}`,
}; };
}; };

View File

@ -51,6 +51,26 @@ describe('Form.vue —— 默认 props', () => {
const formEl = wrapper.find('.m-form').element as HTMLElement; const formEl = wrapper.find('.m-form').element as HTMLElement;
expect(formEl.getAttribute('style') || '').toContain('height: 300px'); expect(formEl.getAttribute('style') || '').toContain('height: 300px');
}); });
test('labelPosition 与 labelWidth 一样,未配置时回落到表单级,配置了则用自身', async () => {
const wrapper = mountForm({
labelPosition: 'left',
labelWidth: '120px',
config: [
{ name: 'a', type: 'text', text: 'a' },
{ name: 'b', type: 'text', text: 'b', labelPosition: 'top' },
],
initValues: { a: '1', b: '2' },
});
await nextTick();
const containers = wrapper.findAllComponents({ name: 'MFormContainer' });
const byName = Object.fromEntries(containers.map((c) => [c.props('config')?.name, c]));
expect(byName.a.props('labelWidth')).toBe('120px');
expect(byName.a.props('labelPosition')).toBe('left');
expect(byName.b.props('labelPosition')).toBe('top');
});
}); });
describe('Form.vue —— formState getter 行为', () => { describe('Form.vue —— formState getter 行为', () => {

View File

@ -98,4 +98,50 @@ describe('GroupList container', () => {
expect(wrapper.text()).not.toContain('下移'); expect(wrapper.text()).not.toContain('下移');
}); });
}); });
describe('labelPosition 透传', () => {
const getFormItemLabelPosition = (wrapper: ReturnType<typeof mountForm>, prop: string) => {
const item = wrapper.findAllComponents({ name: 'TMFormItem' }).find((w) => w.props('prop') === prop);
return item?.props('labelPosition');
};
test('group-list 的 labelPosition 透传到子表单项', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
labelPosition: 'left',
labelWidth: '80px',
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
expect(getFormItemLabelPosition(wrapper, 'list.0.text')).toBe('left');
});
test('子项自身的 labelPosition 优先于 group-list', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
labelPosition: 'left',
items: [
{ name: 'text', type: 'text', text: 'text' },
{ name: 'title', type: 'text', text: 'title', labelPosition: 'top' },
],
},
],
{ list: [{ text: 'a', title: 'b' }] },
);
await nextTick();
expect(getFormItemLabelPosition(wrapper, 'list.0.text')).toBe('left');
expect(getFormItemLabelPosition(wrapper, 'list.0.title')).toBe('top');
});
});
}); });

View File

@ -89,4 +89,92 @@ describe('Panel container', () => {
await nextTick(); await nextTick();
expect(wrapper.exists()).toBe(true); expect(wrapper.exists()).toBe(true);
}); });
describe('labelPosition 透传', () => {
const getFormItemLabelPosition = (wrapper: ReturnType<typeof mountForm>, prop: string) => {
const item = wrapper.findAllComponents({ name: 'TMFormItem' }).find((w) => w.props('prop') === prop);
return item?.props('labelPosition');
};
test('panel 的 labelPosition 透传到子表单项', async () => {
const wrapper = mountForm(
[
{
type: 'panel',
title: 'group',
labelPosition: 'left',
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ text: 'hello' },
);
await nextTick();
expect(getFormItemLabelPosition(wrapper, 'text')).toBe('left');
});
test('fieldset 的 labelPosition 透传到子表单项', async () => {
const wrapper = mountForm(
[
{
type: 'fieldset',
legend: 'fs',
labelPosition: 'left',
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ text: 'fs' },
);
await nextTick();
expect(getFormItemLabelPosition(wrapper, 'text')).toBe('left');
});
test('flex-layout 的 labelPosition 透传到子表单项', async () => {
const wrapper = mountForm(
[
{
type: 'flex-layout',
labelPosition: 'left',
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ text: 'fl' },
);
await nextTick();
expect(getFormItemLabelPosition(wrapper, 'text')).toBe('left');
});
test('row 的 labelPosition 透传到子表单项', async () => {
const wrapper = mountForm(
[
{
type: 'row',
labelPosition: 'left',
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ text: 'r' },
);
await nextTick();
expect(getFormItemLabelPosition(wrapper, 'text')).toBe('left');
});
test('无 type 的 items 容器把 labelPosition 透传到子项', async () => {
const wrapper = mountForm(
[
{
labelPosition: 'left',
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ text: 'x' },
);
await nextTick();
expect(getFormItemLabelPosition(wrapper, 'text')).toBe('left');
});
});
}); });

View File

@ -43,4 +43,28 @@ describe('Step container', () => {
expect(wrapper.text()).toContain('Step 1'); expect(wrapper.text()).toContain('Step 1');
expect(wrapper.text()).toContain('Step 2'); expect(wrapper.text()).toContain('Step 2');
}); });
test('step 的 labelPosition 透传到子表单项', async () => {
const wrapper = mountForm(
[
{
type: 'step',
labelPosition: 'left',
items: [
{
title: 'Step 1',
name: 's1',
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
},
],
{ s1: { text: 'a' } },
{ stepActive: 1 },
);
await nextTick();
const item = wrapper.findAllComponents({ name: 'TMFormItem' }).find((w) => w.props('prop') === 's1.text');
expect(item?.props('labelPosition')).toBe('left');
});
}); });

View File

@ -63,4 +63,24 @@ describe('Tabs', () => {
const value = await (wrapper.vm as any).submitForm(); const value = await (wrapper.vm as any).submitForm();
expect(value.text).toBe('text'); expect(value.text).toBe('text');
}); });
test('tab 的 labelPosition 透传到子表单项', async () => {
const wrapper = getWrapper([
{
type: 'tab',
items: [
{
title: 'tab1',
labelPosition: 'left',
items: [{ name: 'text', text: 'text' }],
},
],
},
]);
await nextTick();
const item = wrapper.findAllComponents({ name: 'TMFormItem' }).find((w) => w.props('prop') === 'text');
expect(item?.props('labelPosition')).toBe('left');
});
}); });

View File

@ -0,0 +1,27 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { describe, expect, test } from 'vitest';
import { getGroupListRowConfig } from '@form/utils/tableGroupList';
describe('getGroupListRowConfig', () => {
test('把 group-list 的 labelWidth / labelPosition 复制到 row 配置', () => {
const row = getGroupListRowConfig(
{
type: 'group-list',
name: 'list',
labelWidth: '80px',
labelPosition: 'left',
items: [{ name: 'text', type: 'text', text: 'text' }],
} as any,
0,
);
expect(row.type).toBe('row');
expect(row.labelWidth).toBe('80px');
expect(row.labelPosition).toBe('left');
expect(row.items).toHaveLength(1);
});
});