Browse Source

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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
pull/8251/head
Zehui Chan 2 weeks ago
committed by GitHub
parent
commit
51ce0ecdd6
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 3
      docs/src/components/common-ui/vben-form.md
  2. 140
      packages/@core/ui-kit/form-ui/__tests__/label-width.test.ts
  3. 7
      packages/@core/ui-kit/form-ui/src/form-render/context.ts
  4. 16
      packages/@core/ui-kit/form-ui/src/form-render/form-field.vue
  5. 5
      packages/@core/ui-kit/form-ui/src/form-render/form-label.vue
  6. 5
      packages/@core/ui-kit/form-ui/src/form-render/form.vue
  7. 170
      packages/@core/ui-kit/form-ui/src/form-render/utils.ts
  8. 10
      packages/@core/ui-kit/form-ui/src/types.ts
  9. 3
      playground/src/locales/langs/en-US/examples.json
  10. 3
      playground/src/locales/langs/zh-CN/examples.json
  11. 8
      playground/src/router/routes/modules/examples.ts
  12. 206
      playground/src/views/examples/form/label-width.vue

3
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"

140
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<FormLayout>,
},
},
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' });
});
});

7
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>('FormRenderProps');
export const [injectRenderFormProps, provideFormRenderProps] = createContext<
FormLabelWidthContext & FormRenderProps
>('FormRenderProps');
export const useFormContext = () => {
const formRenderProps = injectRenderFormProps();

16
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(() => {
>
<FormLabel
v-if="!hideLabel"
ref="labelRef"
:class="
cn(
'flex leading-6',
{
'mr-2 shrink-0 justify-end': !isVertical,
'flex-shrink-0 justify-end pr-3': !isVertical,
'mb-1 flex-row': isVertical,
'self-start': shouldCollapsible && !isVertical,
},

5
packages/@core/ui-kit/form-ui/src/form-render/form-label.vue

@ -1,6 +1,7 @@
<script setup lang="ts">
import type { CustomRenderType } from '../types';
import { useForwardExpose } from '@vben-core/composables';
import {
FormLabel,
VbenHelpTooltip,
@ -17,10 +18,12 @@ interface Props {
}
const props = defineProps<Props>();
const { forwardRef } = useForwardExpose();
</script>
<template>
<FormLabel :class="cn('flex items-center', props.class)">
<FormLabel :ref="forwardRef" :class="cn('flex items-center', props.class)">
<span v-if="required" class="mr-0.5 text-destructive">*</span>
<slot></slot>
<VbenHelpTooltip v-if="help" trigger-class="size-3.5 ml-1">

5
packages/@core/ui-kit/form-ui/src/form-render/form.vue

@ -4,7 +4,7 @@ import type { ZodType } from 'zod';
import type { FormCommonConfig, FormRenderProps, FormShape } from '../types';
import type { NormalizedFormFieldSchema } from './schema';
import { computed, toRaw } from 'vue';
import { computed, reactive, toRaw, toRefs } from 'vue';
import { cn, isString } from '@vben-core/shared/utils';
@ -13,6 +13,7 @@ import { useExpandable } from './expandable';
import FormField from './form-field.vue';
import { getBaseRules, getDefaultValueInZodStack } from './helper';
import { createFormFieldSchema } from './schema';
import { useFormLabelWidth } from './utils';
interface Props extends FormRenderProps {}
@ -41,7 +42,7 @@ const wrapperClass = computed(() => {
return cn(...cls, props.wrapperClass);
});
provideFormRenderProps(props);
provideFormRenderProps(reactive({ ...toRefs(props), ...useFormLabelWidth() }));
// @ts-expect-error unused
const { isCalculated, keepFormItemIndex, wrapperRef } = useExpandable(props);

170
packages/@core/ui-kit/form-ui/src/form-render/utils.ts

@ -0,0 +1,170 @@
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue';
import type { FormLabelWidthContext } from '../types';
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
onUpdated,
ref,
toValue,
watch,
} from 'vue';
import { isString } from '@vben-core/shared/utils';
import { useResizeObserver } from '@vueuse/core';
export function useFormLabelWidth() {
const potentialLabelWidthArr = ref<number[]>([]);
const autoLabelWidth = computed(() => {
if (potentialLabelWidthArr.value.length === 0) return '0';
const max = Math.max(...potentialLabelWidthArr.value);
return max ? `${max}px` : '';
});
function getLabelWidthIndex(width: number) {
const index = potentialLabelWidthArr.value.indexOf(width);
if (index === -1 && autoLabelWidth.value === '0') {
console.warn(`unexpected width ${width}`);
}
return index;
}
function registerLabelWidth(val: number, oldVal: number) {
if (val && oldVal) {
const index = getLabelWidthIndex(oldVal);
potentialLabelWidthArr.value.splice(index, 1, val);
} else if (val) {
potentialLabelWidthArr.value.push(val);
}
}
function deregisterLabelWidth(val: number) {
const index = getLabelWidthIndex(val);
if (index > -1) {
potentialLabelWidthArr.value.splice(index, 1);
}
}
return {
autoLabelWidth,
registerLabelWidth,
deregisterLabelWidth,
};
}
export interface ResolveLabelStyleInput {
autoLabelWidth: string;
computedWidth: number;
isVertical: boolean;
labelClass: string | undefined;
labelWidth: number | string | undefined;
}
export function resolveLabelStyle(
input: ResolveLabelStyleInput,
): Record<string, string> {
const { labelWidth, labelClass, isVertical, autoLabelWidth, computedWidth } =
input;
if (labelClass?.includes('w-') || isVertical) {
return {};
}
if (labelWidth === 'auto' && autoLabelWidth) {
const marginWidth = Math.max(
0,
Number.parseInt(autoLabelWidth, 10) - computedWidth,
);
const labelPosition = labelClass === 'justify-start' ? 'left' : 'right';
const marginPosition =
labelPosition === 'left' ? 'marginRight' : 'marginLeft';
return {
width: 'auto',
[marginPosition]: `${marginWidth}px`,
};
}
return {
width: isString(labelWidth) ? labelWidth : `${labelWidth}px`,
};
}
export function useFieldLabelWidth(options: {
isVertical: ComputedRef<boolean> | Ref<boolean>;
labelClass: MaybeRefOrGetter<string | undefined>;
labelWidth: MaybeRefOrGetter<number | string | undefined>;
labelWidthContext: FormLabelWidthContext;
}) {
const { labelWidthContext, isVertical } = options;
const labelRef = ref();
const computedWidth = ref(0);
const labelStyle = computed(() =>
resolveLabelStyle({
labelWidth: toValue(options.labelWidth),
labelClass: toValue(options.labelClass),
isVertical: isVertical.value,
autoLabelWidth: labelWidthContext.autoLabelWidth,
computedWidth: computedWidth.value,
}),
);
const getLabelWidth = () => {
if (labelRef.value?.$el) {
const width = window.getComputedStyle(labelRef.value.$el).width;
return Math.ceil(Number.parseFloat(width));
}
return 0;
};
const updateLabelWidth = (action: 'remove' | 'update' = 'update') => {
nextTick(() => {
if (toValue(options.labelWidth) !== 'auto') {
return;
}
if (action === 'update') {
computedWidth.value = getLabelWidth();
} else if (action === 'remove') {
labelWidthContext.deregisterLabelWidth(computedWidth.value);
}
});
};
const updateLabelWidthFn = () => updateLabelWidth('update');
onMounted(updateLabelWidthFn);
onBeforeUnmount(() => updateLabelWidth('remove'));
onUpdated(updateLabelWidthFn);
watch(computedWidth, (val, oldVal) => {
if (!isVertical.value && toValue(options.labelWidth) === 'auto') {
labelWidthContext.registerLabelWidth(val, oldVal);
}
});
watch(isVertical, (vertical) => {
if (toValue(options.labelWidth) !== 'auto' || computedWidth.value === 0) {
return;
}
if (vertical) {
labelWidthContext.deregisterLabelWidth(computedWidth.value);
} else {
labelWidthContext.registerLabelWidth(computedWidth.value, 0);
}
});
useResizeObserver(
computed(() => (labelRef.value?.$el ?? null) as HTMLElement | null),
updateLabelWidthFn,
);
return {
labelRef,
labelStyle,
};
}

10
packages/@core/ui-kit/form-ui/src/types.ts

@ -1,11 +1,16 @@
import type { ZodType } from 'zod';
import type { Component, HtmlHTMLAttributes, Ref } from 'vue';
import type { Component, HtmlHTMLAttributes, Ref, UnwrapNestedRefs } from 'vue';
import type { VbenButtonProps } from '@vben-core/shadcn-ui';
import type { ClassType, MaybeComputedRef } from '@vben-core/typings';
import type { FormApi } from './form-api';
import type { useFormLabelWidth } from './form-render/utils';
export type FormLabelWidthContext = UnwrapNestedRefs<
ReturnType<typeof useFormLabelWidth>
>;
export type FormValues = Record<string, any>;
@ -549,8 +554,9 @@ export interface FormCommonConfig<TValues extends FormValues = FormValues> {
labelClass?: string;
/**
* label宽度
* `auto` label
*/
labelWidth?: number;
labelWidth?: number | string;
/**
* model属性名
* @default "modelValue"

3
playground/src/locales/langs/en-US/examples.json

@ -26,7 +26,8 @@
"file": "file",
"crop-image": "Crop image",
"upload-image": "Click to upload image",
"collapsible": "Collapsible Form Field"
"collapsible": "Collapsible Form Field",
"labelWidth": "Label Auto Width"
},
"vxeTable": {
"title": "Vxe Table",

3
playground/src/locales/langs/zh-CN/examples.json

@ -29,7 +29,8 @@
"file": "文件",
"crop-image": "裁剪图片",
"upload-image": "点击上传图片",
"collapsible": "单项表单折叠"
"collapsible": "单项表单折叠",
"labelWidth": "Label 自动宽度"
},
"vxeTable": {
"title": "Vxe 表格",

8
playground/src/router/routes/modules/examples.ts

@ -110,6 +110,14 @@ const routes: RouteRecordRaw[] = [
title: $t('examples.form.collapsible'),
},
},
{
name: 'FormLabelWidthExample',
path: 'label-width',
component: () => import('#/views/examples/form/label-width.vue'),
meta: {
title: $t('examples.form.labelWidth'),
},
},
{
name: 'FormArrayDemo',
path: '/form-array-demo',

206
playground/src/views/examples/form/label-width.vue

@ -0,0 +1,206 @@
<script lang="ts" setup>
import type { RadioGroupProps } from 'antdv-next';
import type { FormLayout } from '@vben/common-ui';
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Card, message, RadioGroup, Space } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import DocButton from '../doc-button.vue';
type LabelWidthMode = '8rem' | '100' | '150' | 'auto';
const layouts: RadioGroupProps['options'] = [
{ label: 'Horizontal', value: 'horizontal' },
{ label: 'Vertical', value: 'vertical' },
];
const labelWidthModes: RadioGroupProps['options'] = [
{ label: 'auto', value: 'auto' },
{ label: '100px', value: '100' },
{ label: '150px', value: '150' },
{ label: '8rem', value: '8rem' },
];
const layout = ref<FormLayout>('horizontal');
const labelWidthMode = ref<LabelWidthMode>('auto');
function resolveLabelWidth(mode: LabelWidthMode): number | string {
switch (mode) {
case '8rem': {
return '8rem';
}
case '100': {
return 100;
}
case '150': {
return 150;
}
default: {
return 'auto';
}
}
}
const [BaseForm, formApi] = useVbenForm({
commonConfig: {
colon: true,
componentProps: {
class: 'w-full',
},
labelWidth: 'auto',
},
handleSubmit: onSubmit,
layout: 'horizontal',
schema: [
{
component: 'Switch',
componentProps: {
class: 'w-auto',
},
defaultValue: true,
fieldName: 'showExtra',
help: '关闭后超长标签会卸载,auto 宽度会按剩余 label 重算',
label: '显示超长字段',
},
{
component: 'Input',
componentProps: {
placeholder: '短标签',
},
fieldName: 'name',
label: '姓名',
},
{
component: 'Input',
componentProps: {
placeholder: '中等长度标签',
},
fieldName: 'email',
label: '电子邮箱',
},
{
component: 'Input',
componentProps: {
placeholder: '较长标签,用于撑开 auto 宽度',
},
fieldName: 'organization',
label: '所属组织 / 部门名称',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: [
{ label: '启用', value: 'enabled' },
{ label: '禁用', value: 'disabled' },
],
placeholder: '请选择',
},
fieldName: 'status',
label: '状态',
},
{
component: 'Input',
componentProps: {
placeholder: '使用 labelClass: w-32,不受 labelWidth 控制',
},
fieldName: 'fixedClass',
label: '固定 class',
labelClass: 'w-32',
},
{
component: 'Input',
componentProps: {
placeholder: '切换上方开关后,auto 宽度会重新计算',
},
dependencies: {
if(values) {
return !!values.showExtra;
},
triggerFields: ['showExtra'],
},
fieldName: 'extraLongLabel',
label: '这是一个会动态显示的超长标签字段',
},
{
component: 'Textarea',
componentProps: {
placeholder: '备注',
rows: 3,
},
fieldName: 'remark',
formItemClass: 'items-start',
label: '备注',
},
],
wrapperClass: 'grid-cols-1',
});
function onSubmit(values: Record<string, any>) {
message.success({
content: `form values: ${JSON.stringify(values)}`,
});
}
function syncFormState() {
formApi.setState({
commonConfig: {
labelWidth: resolveLabelWidth(labelWidthMode.value),
},
layout: layout.value,
});
}
</script>
<template>
<Page
content-class="flex flex-col gap-4"
description="设置 labelWidth: 'auto' 后,水平布局会按当前可见 label 的最大宽度自动对齐。"
title="Label 自动宽度"
>
<template #description>
<div class="text-muted-foreground">
<p>
设置
<code>labelWidth: 'auto'</code>
水平布局会按当前可见 label 的最大宽度自动对齐垂直布局或
<code>labelClass</code>
<code>w-*</code>
时不生效
</p>
</div>
</template>
<template #extra>
<DocButton class="mb-2" path="/components/common-ui/vben-form" />
</template>
<Card title="labelWidth: auto">
<template #extra>
<Space wrap>
<RadioGroup
v-model:value="layout"
:options="layouts"
option-type="button"
@change="syncFormState"
/>
<RadioGroup
v-model:value="labelWidthMode"
:options="labelWidthModes"
option-type="button"
@change="syncFormState"
/>
</Space>
</template>
<div class="max-w-2xl">
<BaseForm />
</div>
</Card>
</Page>
</template>
Loading…
Cancel
Save