From 51ce0ecdd6b1c3f3afe45d7f061aa6f4d981eb71 Mon Sep 17 00:00:00 2001 From: Zehui Chan Date: Wed, 12 Aug 2026 08:37:30 +0800 Subject: [PATCH] feat(@vben-core/form-ui): support labelWidth auto alignment (#8245) * feat(@vben-core/form-ui): support labelWidth auto alignment Measure horizontal labels and align them to the widest one when labelWidth is set to auto, allow string widths, and expose a single form-render context that also carries the label-width registry. Co-authored-by: Cursor * feat(@vben/playground): add form labelWidth auto demo Provide an interactive example under Examples -> Form -> Label Auto Width, with locales and route wiring, to showcase auto alignment. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- docs/src/components/common-ui/vben-form.md | 3 +- .../form-ui/__tests__/label-width.test.ts | 140 ++++++++++++ .../ui-kit/form-ui/src/form-render/context.ts | 7 +- .../form-ui/src/form-render/form-field.vue | 16 +- .../form-ui/src/form-render/form-label.vue | 5 +- .../ui-kit/form-ui/src/form-render/form.vue | 5 +- .../ui-kit/form-ui/src/form-render/utils.ts | 170 +++++++++++++++ packages/@core/ui-kit/form-ui/src/types.ts | 10 +- .../src/locales/langs/en-US/examples.json | 3 +- .../src/locales/langs/zh-CN/examples.json | 3 +- .../src/router/routes/modules/examples.ts | 8 + .../src/views/examples/form/label-width.vue | 206 ++++++++++++++++++ 12 files changed, 558 insertions(+), 18 deletions(-) create mode 100644 packages/@core/ui-kit/form-ui/__tests__/label-width.test.ts create mode 100644 packages/@core/ui-kit/form-ui/src/form-render/utils.ts create mode 100644 playground/src/views/examples/form/label-width.vue diff --git a/docs/src/components/common-ui/vben-form.md b/docs/src/components/common-ui/vben-form.md index ba3613b0e..3e4415600 100644 --- a/docs/src/components/common-ui/vben-form.md +++ b/docs/src/components/common-ui/vben-form.md @@ -586,8 +586,9 @@ export interface FormCommonConfig { labelClass?: string; /** * 所有表单项的label宽度 + * 设置为 `auto` 时,水平布局下会按当前表单可见 label 的最大宽度自动对齐 */ - labelWidth?: number; + labelWidth?: number | string; /** * 所有表单项的model属性名。使用自定义组件时可通过此配置指定组件的model属性名。已经在modelPropNameMap中注册的组件不受此配置影响 * @default "modelValue" diff --git a/packages/@core/ui-kit/form-ui/__tests__/label-width.test.ts b/packages/@core/ui-kit/form-ui/__tests__/label-width.test.ts new file mode 100644 index 000000000..bd32a1834 --- /dev/null +++ b/packages/@core/ui-kit/form-ui/__tests__/label-width.test.ts @@ -0,0 +1,140 @@ +/* eslint-disable vue/one-component-per-file */ + +import type { PropType } from 'vue'; + +import type { FormLayout, FormRenderProps } from '../src/types'; + +import { mount } from '@vue/test-utils'; +import { defineComponent, h, reactive, toRefs } from 'vue'; + +import { describe, expect, it } from 'vitest'; + +import { + provideFormRenderProps, + useFormContext, +} from '../src/form-render/context'; +import { resolveLabelStyle, useFormLabelWidth } from '../src/form-render/utils'; + +describe('form label width context', () => { + it('keeps layout reactive when label width context is provided', async () => { + const Consumer = defineComponent({ + setup() { + const { isVertical } = useFormContext(); + return () => + h('div', { + 'data-layout': isVertical.value ? 'vertical' : 'horizontal', + }); + }, + }); + const Provider = defineComponent({ + props: { + layout: { + required: true, + type: String as PropType, + }, + }, + setup(props) { + provideFormRenderProps( + reactive({ + ...toRefs(props as FormRenderProps), + ...useFormLabelWidth(), + }), + ); + return () => h(Consumer); + }, + }); + const wrapper = mount(Provider, { + props: { layout: 'horizontal' }, + }); + + expect(wrapper.get('[data-layout]').attributes('data-layout')).toBe( + 'horizontal', + ); + + await wrapper.setProps({ layout: 'vertical' }); + + expect(wrapper.get('[data-layout]').attributes('data-layout')).toBe( + 'vertical', + ); + }); +}); + +describe('resolveLabelStyle', () => { + it('returns empty style for vertical layout', () => { + expect( + resolveLabelStyle({ + labelWidth: 'auto', + labelClass: undefined, + isVertical: true, + autoLabelWidth: '120px', + computedWidth: 80, + }), + ).toEqual({}); + }); + + it('returns empty style when labelClass includes w-', () => { + expect( + resolveLabelStyle({ + labelWidth: 100, + labelClass: 'w-32', + isVertical: false, + autoLabelWidth: '120px', + computedWidth: 80, + }), + ).toEqual({}); + }); + + it('aligns auto width with max label using marginLeft by default', () => { + expect( + resolveLabelStyle({ + labelWidth: 'auto', + labelClass: undefined, + isVertical: false, + autoLabelWidth: '120px', + computedWidth: 80, + }), + ).toEqual({ + width: 'auto', + marginLeft: '40px', + }); + }); + + it('uses marginRight when labelClass is justify-start', () => { + expect( + resolveLabelStyle({ + labelWidth: 'auto', + labelClass: 'justify-start', + isVertical: false, + autoLabelWidth: '100px', + computedWidth: 60, + }), + ).toEqual({ + width: 'auto', + marginRight: '40px', + }); + }); + + it('uses numeric labelWidth as px', () => { + expect( + resolveLabelStyle({ + labelWidth: 100, + labelClass: undefined, + isVertical: false, + autoLabelWidth: '0', + computedWidth: 0, + }), + ).toEqual({ width: '100px' }); + }); + + it('passes through string labelWidth', () => { + expect( + resolveLabelStyle({ + labelWidth: '8rem', + labelClass: undefined, + isVertical: false, + autoLabelWidth: '0', + computedWidth: 0, + }), + ).toEqual({ width: '8rem' }); + }); +}); diff --git a/packages/@core/ui-kit/form-ui/src/form-render/context.ts b/packages/@core/ui-kit/form-ui/src/form-render/context.ts index af4b15cdf..75cdc6516 100644 --- a/packages/@core/ui-kit/form-ui/src/form-render/context.ts +++ b/packages/@core/ui-kit/form-ui/src/form-render/context.ts @@ -1,11 +1,12 @@ -import type { FormRenderProps } from '../types'; +import type { FormLabelWidthContext, FormRenderProps } from '../types'; import { computed } from 'vue'; import { createContext } from '@vben-core/shadcn-ui'; -export const [injectRenderFormProps, provideFormRenderProps] = - createContext('FormRenderProps'); +export const [injectRenderFormProps, provideFormRenderProps] = createContext< + FormLabelWidthContext & FormRenderProps +>('FormRenderProps'); export const useFormContext = () => { const formRenderProps = injectRenderFormProps(); diff --git a/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue b/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue index 645661927..28856cae2 100644 --- a/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue +++ b/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue @@ -40,6 +40,7 @@ import { injectRenderFormProps, useFormContext } from './context'; import useDependencies from './dependencies'; import FormLabel from './form-label.vue'; import { getBaseRules, isEventObjectLike } from './helper'; +import { useFieldLabelWidth } from './utils'; interface Props extends FormFieldProps {} @@ -127,12 +128,12 @@ const { () => ({ fieldName }), ); -const labelStyle = computed(() => { - return labelClass?.includes('w-') || isVertical.value - ? {} - : { - width: `${labelWidth}px`, - }; +// @ts-expect-error unused +const { labelRef, labelStyle } = useFieldLabelWidth({ + labelWidth: () => labelWidth, + labelClass: () => labelClass, + isVertical, + labelWidthContext: formRenderProps, }); const currentRules = computed(() => { @@ -451,11 +452,12 @@ onUnmounted(() => { > import type { CustomRenderType } from '../types'; +import { useForwardExpose } from '@vben-core/composables'; import { FormLabel, VbenHelpTooltip, @@ -17,10 +18,12 @@ interface Props { } const props = defineProps(); + +const { forwardRef } = useForwardExpose();