Browse Source
* 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
committed by
GitHub
12 changed files with 558 additions and 18 deletions
@ -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' }); |
|||
}); |
|||
}); |
|||
@ -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, |
|||
}; |
|||
} |
|||
@ -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…
Reference in new issue