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;
labelWidth?: string;
codeValueKey?: string;
labelPosition?: string;
labelPosition?: 'top' | 'left' | 'right';
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
}>();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -94,6 +94,7 @@ export const getGroupListRowConfig = (config: GroupListConfig, index: number, ke
span: config.span || 24,
items: config.items,
labelWidth: config.labelWidth,
labelPosition: config.labelPosition,
[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;
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 行为', () => {

View File

@ -98,4 +98,50 @@ describe('GroupList container', () => {
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();
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 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();
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);
});
});