fix(@vben-core/form-ui)!: group field slot component props (#8215)

* refactor(@vben-core/form-ui)!: group field slot component props

* fix(@vben-core/form-ui): stabilize grouped field slot bindings

Resolve function-based common component props with field context.

Preserve internal model and event bindings when custom field slots spread componentProps.

Add regression coverage for array contexts, disabled precedence, and stale binding isolation.

* feat(@vben/vite-config): warn about field slot migration

Print the temporary field-slot migration warning when the development server starts.

Inject the same message into the browser console and keep the plugin serve-only.

* docs(@vben/docs): document field slot migration

Place the breaking field-slot notice at the start of both form documents.

Explain old and new bindings, development warnings, and planned notice removal.

* feat(@vben/playground): extend custom form example

Add a switchable input and select field to demonstrate schema component updates.

Update the composite phone field to emit new tuple values so validation observes changes.
This commit is contained in:
dream-weave
2026-07-31 18:13:49 +08:00
committed by GitHub
parent 32495e40b2
commit 9f5b1cd9fb
19 changed files with 498 additions and 50 deletions

View File

@@ -4,6 +4,24 @@ outline: deep
# Vben Form 表单
::: warning 字段插槽破坏性变更
字段命名 slot 的控件绑定已统一收拢到 `slotProps.componentProps`。旧写法会把 `field``formApi``values` 等表单元数据一并传给实际控件,可能产生无效属性和 Vue 运行时警告。
```vue
<!-- 旧写法 -->
<Input v-bind="slotProps" />
<!-- 新写法 -->
<Input v-bind="slotProps.componentProps" />
```
请将所有字段 slot 的 `v-bind="slotProps"` 迁移为 `v-bind="slotProps.componentProps"`。根级的 `field``componentField``modelValue``name``disabled``isInValid``values``formApi` 仍可用于模板逻辑,但不会再自动传入实际控件。
当前版本启动 Vben 应用或 Playground 开发服务器时会在终端输出一次迁移警告,页面加载时浏览器控制台也会提示。该提示不会进入生产构建,并计划在下个版本移除。
:::
框架提供的表单组件,可适配 `Element Plus``Ant Design Vue``Naive UI` 等框架。
> 如果文档内没有参数说明,可以尝试在在线示例内寻找
@@ -385,7 +403,9 @@ async function fillForm() {
</template>
```
字段命名插槽提供 `field``componentField``modelValue``name``disabled``isInValid``values``formApi`。默认插槽提供 `shapes``values``formApi``reset-before``submit-before``expand-before``expand-after` 提供 `values``formApi`未声明 `TValues` 时仍兼容任意字段名,但 slot props 会回退为宽泛类型。
字段命名插槽提供完整控件绑定 `componentProps`,以及 `field``componentField``modelValue``name``disabled``isInValid``values``formApi`。默认插槽提供 `shapes``values``formApi``reset-before``submit-before``expand-before``expand-after` 提供 `values``formApi`
建议为表单声明没有字符串索引签名的精确接口,使每个字段插槽都能推导自己的值类型。使用 `Record<string, unknown>` 等宽泛类型时slot props 仍保持完整结构,不再整体退化为 `any`,但字段值只能推导为索引值类型。
### FormApi
@@ -746,6 +766,18 @@ import { z } from '#/adapter/form';
::: tip 字段插槽
除了以上内置插槽之外,`schema`属性中每个字段的`fieldName`都可以作为插槽名称这些字段插槽的优先级高于`component`定义的组件。也就是说,当提供了与`fieldName`同名的插槽时,这些插槽的内容将会作为这些字段的组件,此时`component`的值将会被忽略。
除了以上内置插槽之外,`schema` 属性中每个字段的 `fieldName` 都可以作为插槽名称这些字段插槽的优先级高于 `component` 定义的组件。
字段 slot 的控件绑定统一收拢在 `componentProps` 中,其中包含模型值、对应的 `update:*` 事件、schema/common/dependencies props 和 disabled 状态:
```vue
<Form>
<template #fieldName="slotProps">
<Input v-bind="slotProps.componentProps" />
</template>
</Form>
```
`field``componentField``modelValue``name``disabled``isInValid``values``formApi` 保留在 slot 根级,供模板逻辑使用,不会自动传入实际控件。
:::

View File

@@ -62,7 +62,7 @@ function onSubmit(values: Record<string, any>) {
<template>
<Form>
<template #field3="slotProps">
<Input placeholder="请输入" v-bind="slotProps" />
<Input placeholder="请输入" v-bind="slotProps.componentProps" />
</template>
</Form>
</template>

View File

@@ -4,6 +4,24 @@ outline: deep
# Vben Form
::: warning Field Slot Breaking Change
Named field slot control bindings are now grouped under `slotProps.componentProps`. The old binding forwards form metadata such as `field`, `formApi`, and `values` to the rendered control, which can produce invalid attributes and Vue runtime warnings.
```vue
<!-- Old usage -->
<Input v-bind="slotProps" />
<!-- New usage -->
<Input v-bind="slotProps.componentProps" />
```
Migrate every field slot from `v-bind="slotProps"` to `v-bind="slotProps.componentProps"`. Root metadata remains available for template logic through `field`, `componentField`, `modelValue`, `name`, `disabled`, `isInValid`, `values`, and `formApi`, but it is no longer forwarded automatically to the rendered control.
In this release, starting a Vben application or Playground development server prints this migration warning in the terminal, and loading the page prints the same warning in the browser console. The warning is excluded from production builds and is planned for removal in the next release.
:::
`Vben Form` is the shared form abstraction used across different UI-library variants such as `Ant Design Vue`, `Element Plus`, `Naive UI`, and other adapters added inside this repository.
It uses [TanStack Form](https://tanstack.com/form/latest/docs/framework/vue/overview) internally for state and validation lifecycles, with [Zod 4](https://zod.dev/v4) schemas. Application code should continue using `useVbenForm`, `FormApi`, and the adapter layer instead of depending on the raw TanStack instance.
@@ -252,7 +270,23 @@ async function fillForm() {
</template>
```
Named field slots expose `field`, `componentField`, `modelValue`, `name`, `disabled`, `isInValid`, `values`, and `formApi`. The default slot exposes `shapes`, `values`, and `formApi`; action slots expose `values` and `formApi`. Forms without an explicit `TValues` remain compatible with arbitrary slot names and broad props.
Named field slots expose grouped control bindings through `componentProps`, together with `field`, `componentField`, `modelValue`, `name`, `disabled`, `isInValid`, `values`, and `formApi`. The default slot exposes `shapes`, `values`, and `formApi`; action slots expose `values` and `formApi`.
Use a precise form-value interface without a string index signature to infer each field value. A broad type such as `Record<string, unknown>` keeps the complete slot-prop structure instead of degrading the whole scope to `any`, but field values can only use the declared index value type.
## Field Slots
Control bindings are grouped under `componentProps`. It contains the model value, matching `update:*` event, schema/common/dependency props, and disabled state:
```vue
<Form>
<template #fieldName="slotProps">
<Input v-bind="slotProps.componentProps" />
</template>
</Form>
```
Root metadata remains available for template logic through `field`, `componentField`, `modelValue`, `name`, `disabled`, `isInValid`, `values`, and `formApi`; it is not forwarded automatically to the rendered control.
## Form Codec

View File

@@ -0,0 +1,43 @@
import type { ResolvedConfig } from 'vite';
import { describe, expect, it, vi } from 'vitest';
import {
FORM_FIELD_SLOT_MIGRATION_WARNING,
viteFormFieldSlotMigrationWarningPlugin,
} from './form-field-slot-migration-warning';
describe('form field slot migration warning plugin', () => {
it('warns in serve mode and injects the same browser message', () => {
const plugin = viteFormFieldSlotMigrationWarningPlugin();
const warning = vi.fn();
expect(plugin.apply).toBe('serve');
expect(plugin.configResolved).toBeTypeOf('function');
if (typeof plugin.configResolved !== 'function') return;
plugin.configResolved({ logger: { warn: warning } } as ResolvedConfig);
expect(warning).toHaveBeenCalledOnce();
expect(warning).toHaveBeenCalledWith(FORM_FIELD_SLOT_MIGRATION_WARNING);
expect(plugin.transformIndexHtml).toBeTypeOf('function');
if (typeof plugin.transformIndexHtml !== 'function') return;
const result = plugin.transformIndexHtml('', {} as never);
expect(result).toEqual([
{
attrs: {
'data-vben-form-field-slot-migration-warning': '',
},
children: `console.warn(${JSON.stringify(FORM_FIELD_SLOT_MIGRATION_WARNING)});`,
injectTo: 'body',
tag: 'script',
},
]);
expect(FORM_FIELD_SLOT_MIGRATION_WARNING).toContain('`v-bind="slotProps"`');
expect(FORM_FIELD_SLOT_MIGRATION_WARNING).toContain(
'`v-bind="slotProps.componentProps"`',
);
});
});

View File

@@ -0,0 +1,31 @@
import type { Plugin } from 'vite';
const FORM_FIELD_SLOT_MIGRATION_WARNING =
'[Vben Form] BREAKING CHANGE: Named field slot control bindings moved to `slotProps.componentProps`. Replace `v-bind="slotProps"` with `v-bind="slotProps.componentProps"`. See https://doc.vben.pro/components/common-ui/vben-form.html';
function viteFormFieldSlotMigrationWarningPlugin(): Plugin {
return {
apply: 'serve',
configResolved(config) {
config.logger.warn(FORM_FIELD_SLOT_MIGRATION_WARNING);
},
name: 'vite:form-field-slot-migration-warning',
transformIndexHtml() {
return [
{
attrs: {
'data-vben-form-field-slot-migration-warning': '',
},
children: `console.warn(${JSON.stringify(FORM_FIELD_SLOT_MIGRATION_WARNING)});`,
injectTo: 'body',
tag: 'script',
},
];
},
};
}
export {
FORM_FIELD_SLOT_MIGRATION_WARNING,
viteFormFieldSlotMigrationWarningPlugin,
};

View File

@@ -20,6 +20,7 @@ import viteVueDevTools from 'vite-plugin-vue-devtools';
import { viteArchiverPlugin } from './archiver';
import { viteDayjsPlugin } from './dayjs';
import { viteExtraAppConfigPlugin } from './extra-app-config';
import { viteFormFieldSlotMigrationWarningPlugin } from './form-field-slot-migration-warning';
import { viteHtmlPlugin } from './html';
import { viteImportMapPlugin } from './importmap';
import { viteInjectAppLoadingPlugin } from './inject-app-loading';
@@ -144,6 +145,10 @@ async function loadApplicationPlugins(
return [await vitePrintPlugin({ infoMap: printInfoMap })];
},
},
{
condition: !isBuild,
plugins: () => [viteFormFieldSlotMigrationWarningPlugin()],
},
{
condition: vxeTableLazyImport,
plugins: async () => {

View File

@@ -65,7 +65,9 @@ const dependencySchema: FormSchema[] = [
component: TestInput,
defaultValue: `Value ${index}`,
dependencies: {
resolve: ({ values }) => ({ disabled: values.mode === 'locked' }),
resolve: ({ values }: { values: Record<string, string> }) => ({
disabled: values.mode === 'locked',
}),
triggerFields: ['mode'],
},
fieldName: `dependent${index}`,

View File

@@ -1,6 +1,6 @@
import type { VueWrapper } from '@vue/test-utils';
import type { FormSchemaRuleType } from '../src/types';
import type { FormSchemaRuleType, VbenFormFieldSlotProps } from '../src/types';
import { flushPromises, mount } from '@vue/test-utils';
import { defineComponent, h, nextTick } from 'vue';
@@ -104,18 +104,27 @@ describe('useVbenForm integration', () => {
expect(validateValue).toHaveBeenCalledTimes(initialValidationCount + 1);
});
it('keeps only the active model protocol and boolean disabled in field slots', async () => {
it('keeps only the active model protocol in field slot componentProps', async () => {
interface ModelProtocolValues {
defaultField: string;
valueField: string;
}
const staleDefaultBlur = vi.fn();
const staleDefaultUpdate = vi.fn();
const staleValueUpdate = vi.fn();
let defaultSlotProps: Record<string, any> | undefined;
let valueSlotProps: Record<string, any> | undefined;
const [Form, formApi] = useVbenForm<ModelProtocolValues>({
schema: [
{
component: TestInput,
componentProps: {
modelValue: 'stale-default-value',
name: 'stale-default-name',
onBlur: staleDefaultBlur,
'onUpdate:modelValue': staleDefaultUpdate,
},
defaultValue: 'default-initial',
fieldName: 'defaultField',
},
@@ -124,6 +133,8 @@ describe('useVbenForm integration', () => {
componentProps: {
eventMode: 'value-and-change',
modelValue: 'stale-model-value',
'onUpdate:value': staleValueUpdate,
value: 'stale-value',
},
defaultValue: 'value-initial',
fieldName: 'valueField',
@@ -136,18 +147,15 @@ describe('useVbenForm integration', () => {
defaultField(slotProps: Record<string, any>) {
defaultSlotProps = slotProps;
return h(TestInput, {
...slotProps.componentProps,
class: 'default-protocol-input',
modelValue: slotProps.modelValue,
'onUpdate:modelValue': slotProps['onUpdate:modelValue'],
});
},
valueField(slotProps: Record<string, any>) {
valueSlotProps = slotProps;
return h(TestInput, {
...slotProps.componentProps,
class: 'value-protocol-input',
eventMode: slotProps.eventMode,
value: slotProps.value,
'onUpdate:value': slotProps['onUpdate:value'],
});
},
},
@@ -159,21 +167,31 @@ describe('useVbenForm integration', () => {
expect(valueSlotProps).toBeDefined();
if (!defaultSlotProps || !valueSlotProps) return;
expect(defaultSlotProps.disabled).toBe(false);
expect(defaultSlotProps.modelValue).toBe('default-initial');
expect(defaultSlotProps).toHaveProperty('onUpdate:modelValue');
expect(defaultSlotProps).not.toHaveProperty('value');
expect(defaultSlotProps.componentProps.disabled).toBe(false);
expect(defaultSlotProps.componentProps.modelValue).toBe('default-initial');
expect(defaultSlotProps.componentProps.name).toBe('defaultField');
expect(defaultSlotProps.componentProps).toHaveProperty(
'onUpdate:modelValue',
);
expect(defaultSlotProps.componentProps).not.toHaveProperty('value');
expect(valueSlotProps.disabled).toBe(false);
expect(valueSlotProps.value).toBe('value-initial');
expect(valueSlotProps).toHaveProperty('onUpdate:value');
expect(valueSlotProps).not.toHaveProperty('modelValue');
expect(valueSlotProps).not.toHaveProperty('onUpdate:modelValue');
expect(valueSlotProps.componentProps.disabled).toBe(false);
expect(valueSlotProps.componentProps.value).toBe('value-initial');
expect(valueSlotProps.componentProps).toHaveProperty('onUpdate:value');
expect(valueSlotProps.componentProps).not.toHaveProperty('modelValue');
expect(valueSlotProps.componentProps).not.toHaveProperty(
'onUpdate:modelValue',
);
await wrapper.get('.default-protocol-input').trigger('blur');
await wrapper.get('.default-protocol-input').setValue('default-updated');
await wrapper.get('.value-protocol-input').setValue('value-updated');
await flushPromises();
expect(defaultSlotProps.field.state.meta.isTouched).toBe(true);
expect(staleDefaultBlur).not.toHaveBeenCalled();
expect(staleDefaultUpdate).not.toHaveBeenCalled();
expect(staleValueUpdate).not.toHaveBeenCalled();
expect(await formApi.getValues()).toEqual({
defaultField: 'default-updated',
valueField: 'value-updated',
@@ -208,6 +226,109 @@ describe('useVbenForm integration', () => {
expect(wrapper.get('.slot-value').text()).toBe('Grace');
});
it('groups control bindings in field slot componentProps', async () => {
interface SlotFormValues {
name: string;
}
let latestSlotProps:
| undefined
| VbenFormFieldSlotProps<SlotFormValues, 'name'>;
const [Form, formApi] = useVbenForm<SlotFormValues>({
schema: [
{
component: TestInput,
defaultValue: 'Ada',
fieldName: 'name',
},
],
});
const wrapper = mount(Form, {
slots: {
name(slotProps: VbenFormFieldSlotProps<SlotFormValues, 'name'>) {
latestSlotProps = slotProps;
return h(TestInput, {
...slotProps.componentProps,
class: 'slot-component',
});
},
},
});
wrappers.push(wrapper);
await flushPromises();
expect(latestSlotProps).toBeDefined();
if (!latestSlotProps) return;
expect(latestSlotProps.formApi).toBe(formApi);
expect(latestSlotProps.values).toEqual({ name: 'Ada' });
expect(latestSlotProps.modelValue).toBe('Ada');
expect(latestSlotProps.componentProps.modelValue).toBe('Ada');
expect(latestSlotProps.componentProps.disabled).toBe(false);
expect(latestSlotProps.componentProps).toHaveProperty(
'onUpdate:modelValue',
);
expect(latestSlotProps.componentProps).not.toHaveProperty('formApi');
expect(latestSlotProps.componentProps).not.toHaveProperty('values');
expect(latestSlotProps).not.toHaveProperty('onUpdate:modelValue');
await wrapper.get('.slot-component').setValue('Grace');
await flushPromises();
expect(latestSlotProps.modelValue).toBe('Grace');
expect(latestSlotProps.values.name).toBe('Grace');
});
it('normalizes disabled state in field slot componentProps', async () => {
interface DisabledFormValues {
commonDisabled: string;
dependencyDisabled: string;
}
const fieldSlotProps: Record<string, Record<string, any>> = {};
const [Form] = useVbenForm<DisabledFormValues>({
commonConfig: { disabled: true },
schema: [
{
component: TestInput,
componentProps: { disabled: false },
fieldName: 'commonDisabled',
},
{
component: TestInput,
componentProps: { disabled: false },
dependencies: {
resolve: () => ({ disabled: true }),
triggerFields: [],
},
fieldName: 'dependencyDisabled',
},
],
});
const wrapper = mount(Form, {
slots: {
commonDisabled(slotProps: Record<string, any>) {
fieldSlotProps.commonDisabled = slotProps;
return h(TestInput, slotProps.componentProps);
},
dependencyDisabled(slotProps: Record<string, any>) {
fieldSlotProps.dependencyDisabled = slotProps;
return h(TestInput, slotProps.componentProps);
},
},
});
wrappers.push(wrapper);
await flushPromises();
expect(fieldSlotProps.commonDisabled).toBeDefined();
expect(fieldSlotProps.dependencyDisabled).toBeDefined();
expect(fieldSlotProps.commonDisabled?.disabled).toBe(true);
expect(fieldSlotProps.commonDisabled?.componentProps.disabled).toBe(true);
expect(fieldSlotProps.dependencyDisabled?.disabled).toBe(true);
expect(fieldSlotProps.dependencyDisabled?.componentProps.disabled).toBe(
true,
);
});
it('supports a field-level change event fallback for legacy components', async () => {
const [Form, formApi] = useVbenForm({
schema: [

View File

@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest';
import {
createArrayChildSchema,
createFormFieldSchema,
} from '../src/form-render/schema';
describe('form schema normalization', () => {
it('resolves common component props with the field context', () => {
const componentProps = vi.fn(({ fieldName }) => ({
placeholder: `Enter ${fieldName}`,
}));
const schema = createFormFieldSchema(
{ component: 'VbenInput', fieldName: 'name' },
{ commonConfig: { componentProps } },
);
expect(componentProps).toHaveBeenCalledWith({ fieldName: 'name' });
expect(schema.commonComponentProps).toEqual({
placeholder: 'Enter name',
});
});
it('preserves common component props objects', () => {
const schema = createFormFieldSchema(
{ component: 'VbenInput', fieldName: 'name' },
{ commonConfig: { componentProps: { placeholder: 'Enter a name' } } },
);
expect(schema.commonComponentProps).toEqual({
placeholder: 'Enter a name',
});
});
it('resolves global common component props functions', () => {
const componentProps = vi.fn(({ fieldName }) => ({
title: `Global ${fieldName}`,
}));
const schema = createFormFieldSchema(
{ component: 'VbenInput', fieldName: 'email' },
{ globalCommonConfig: { componentProps } },
);
expect(componentProps).toHaveBeenCalledWith({ fieldName: 'email' });
expect(schema.commonComponentProps).toEqual({ title: 'Global email' });
});
it('resolves array common props with the row context', () => {
const componentProps = vi.fn(() => ({ placeholder: 'Contact name' }));
const schema = createArrayChildSchema(
{ component: 'VbenInput', fieldName: 'name' },
{
arrayField: 'contacts',
commonConfig: { componentProps },
index: 1,
},
);
expect(componentProps).toHaveBeenCalledWith({
arrayField: 'contacts',
fieldName: 'contacts[1].name',
originalFieldName: 'name',
rowIndex: 1,
rowPath: 'contacts[1]',
});
expect(schema.commonComponentProps).toEqual({
placeholder: 'Contact name',
});
});
});

View File

@@ -141,6 +141,9 @@ describe('form public types', () => {
EmailSlotProps['field']['state']['value']
>().toEqualTypeOf<string>();
expectTypeOf<EmailSlotProps['values']>().toEqualTypeOf<AccountFormValues>();
expectTypeOf<
EmailSlotProps['componentProps']['modelValue']
>().toEqualTypeOf<string | undefined>();
expectTypeOf<EmailSlotProps['formApi']>().toEqualTypeOf<
ExtendedFormApi<AccountFormValues>
>();

View File

@@ -361,9 +361,10 @@ function createComponentProps(slotProps: RuntimeFieldSlotProps) {
);
const binds = {
...normalizedSlotProps.componentField,
...computedProps.value,
...normalizedSlotProps.componentField,
...bindEvents,
disabled: shouldDisabled.value,
...(Reflect.has(computedProps.value, 'onChange')
? { onChange: computedProps.value.onChange }
: {}),
@@ -379,6 +380,17 @@ function createComponentProps(slotProps: RuntimeFieldSlotProps) {
return binds;
}
function createFieldSlotScope(slotProps: RuntimeFieldSlotProps) {
return {
...createFieldSlotProps(slotProps),
componentProps: createComponentProps(slotProps),
disabled: shouldDisabled.value,
isInValid: isInValid.value,
modelValue: fieldValue.value,
name: fieldName,
};
}
function autofocus() {
if (
fieldComponentRef.value &&
@@ -484,14 +496,7 @@ onUnmounted(() => {
:class="cn('relative flex w-full items-center', wrapperClass)"
>
<FormControl :class="cn(controlClass)">
<slot
v-bind="{
...createFieldSlotProps(slotProps),
...createComponentProps(slotProps),
disabled: shouldDisabled,
isInValid,
}"
>
<slot v-bind="createFieldSlotScope(slotProps)">
<component
:is="FieldComponent"
ref="fieldComponentRef"
@@ -500,7 +505,6 @@ onUnmounted(() => {
shouldApplyInvalidStyle,
}"
v-bind="createComponentProps(slotProps)"
:disabled="shouldDisabled"
>
<template
v-for="name in renderContentKey"

View File

@@ -86,6 +86,23 @@ function wrapComponentProps(
return () => componentProps(baseContext);
}
function wrapCommonConfig(
commonConfig: FormCommonConfig | undefined,
baseContext: FormSchemaContext,
) {
if (!commonConfig || !isFunction(commonConfig.componentProps)) {
return commonConfig;
}
return {
...commonConfig,
componentProps: wrapComponentProps(
commonConfig.componentProps,
baseContext,
),
};
}
function wrapCustomParamsRender(
render: AnyFormSchema['help'],
baseContext: FormSchemaContext,
@@ -350,6 +367,9 @@ export function createFormFieldSchema(
const normalizedSchema = isFormArraySchema(schema)
? createArrayFieldSchema(schema, options)
: schema;
const commonComponentProps = isFunction(componentProps)
? componentProps({ fieldName: normalizedSchema.fieldName })
: componentProps;
let resolvedSchemaFormItemClass = normalizedSchema.formItemClass;
if (isFunction(normalizedSchema.formItemClass)) {
@@ -370,7 +390,7 @@ export function createFormFieldSchema(
modelPropName,
wrapperClass,
...normalizedSchema,
commonComponentProps: componentProps as MaybeComponentProps,
commonComponentProps,
componentProps: normalizedSchema.componentProps,
controlClass: [controlClass, normalizedSchema.controlClass]
.filter(Boolean)
@@ -423,10 +443,13 @@ export function createArrayChildSchema(
),
},
{
commonConfig: options.commonConfig,
commonConfig: wrapCommonConfig(options.commonConfig, baseContext),
disabled: options.disabled || schema.disabled,
forceHideLabel: true,
globalCommonConfig: options.globalCommonConfig,
globalCommonConfig: wrapCommonConfig(
options.globalCommonConfig,
baseContext,
),
},
);
}

View File

@@ -19,6 +19,7 @@ export type {
VbenFormFieldArrayProps,
VbenFormFieldSlotProps,
VbenFormProps,
VbenFormResolvedComponentProps,
FormSchema as VbenFormSchema,
VbenFormSlots,
} from './types';

View File

@@ -244,6 +244,10 @@ export interface VbenFormFieldSlotProps<
FormFieldValue<TValues, TFieldName>,
TFieldName
>;
componentProps: VbenFormResolvedComponentProps<
FormFieldValue<TValues, TFieldName>,
TFieldName
>;
disabled: boolean;
field: FormRuntimeField<FormFieldValue<TValues, TFieldName>>;
isInValid: boolean;
@@ -251,6 +255,16 @@ export interface VbenFormFieldSlotProps<
name: TFieldName;
}
export type VbenFormResolvedComponentProps<
TValue = unknown,
TFieldName extends string = string,
> = MaybeComponentProps & {
disabled: boolean;
modelValue?: TValue;
name: TFieldName;
'onUpdate:modelValue'?: (value: TValue) => void;
};
type VbenFormFieldSlots<
TValues extends FormValues,
T extends BaseFormComponentType,

View File

@@ -91,7 +91,7 @@ function updateMenuBadge() {
<Card title="徽标更新">
<Form>
<template #badgeVariants="slotProps">
<RadioGroup v-bind="slotProps">
<RadioGroup v-bind="slotProps.componentProps">
<Radio
v-for="color in colors"
:key="color.value"

View File

@@ -1,9 +1,9 @@
<script lang="ts" setup>
import { h, markRaw } from 'vue';
import { h, markRaw, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Card, Input, message } from 'antdv-next';
import { Button, Card, Input, message, Select } from 'antdv-next';
import { useVbenForm, z } from '#/adapter/form';
@@ -14,7 +14,8 @@ interface CustomFormValues extends Record<string, unknown> {
field1?: string;
field2?: string;
field3?: string;
field4?: [string | undefined, string];
field4?: [string | undefined, string | undefined];
field5?: string;
}
function encodeCustomFormValues(values: Readonly<CustomFormValues>) {
@@ -28,6 +29,8 @@ function encodeCustomFormValues(values: Readonly<CustomFormValues>) {
type CustomSubmitValues = ReturnType<typeof encodeCustomFormValues>;
const dynamicComponentType = ref<'input' | 'select'>('input');
function decodeCustomFormValues(
values: Readonly<CustomSubmitValues>,
): CustomFormValues {
@@ -38,7 +41,7 @@ function decodeCustomFormValues(
};
}
const [Form] = useVbenForm({
const [Form, formApi] = useVbenForm({
codec: {
decode: decodeCustomFormValues,
encode: encodeCustomFormValues,
@@ -89,7 +92,6 @@ const [Form] = useVbenForm({
{
component: markRaw(TwoFields),
defaultValue: [undefined, ''],
changeEventFallback: true,
fieldName: 'field4',
formItemClass: 'col-span-1',
label: '组合字段',
@@ -107,11 +109,55 @@ const [Form] = useVbenForm({
message: '       号码格式不正确',
}),
},
{
component: markRaw(Input),
componentProps: {
placeholder: '请输入动态组件值',
},
fieldName: 'field5',
label: '动态组件',
modelPropName: 'value',
},
],
// 中屏一行显示2个小屏一行显示1个
wrapperClass: 'grid-cols-1 md:grid-cols-2',
});
function handleToggleDynamicComponent() {
const nextType = dynamicComponentType.value === 'input' ? 'select' : 'input';
dynamicComponentType.value = nextType;
if (nextType === 'select') {
formApi.updateSchema([
{
component: markRaw(Select),
componentProps: {
allowClear: true,
options: [
{ label: '选项一', value: 'option-1' },
{ label: '选项二', value: 'option-2' },
],
placeholder: '请选择动态组件值',
},
fieldName: 'field5',
modelPropName: 'value',
},
]);
return;
}
formApi.updateSchema([
{
component: markRaw(Input),
componentProps: {
placeholder: '请输入动态组件值',
},
fieldName: 'field5',
modelPropName: 'value',
},
]);
}
function onSubmit(values: CustomSubmitValues) {
message.success({
content: `form values: ${JSON.stringify(values)}`,
@@ -122,9 +168,16 @@ function onSubmit(values: CustomSubmitValues) {
<template>
<Page description="表单组件自定义示例" title="表单组件">
<Card title="基础示例">
<template #extra>
<Button @click="handleToggleDynamicComponent">
{{
dynamicComponentType === 'input' ? '切换为下拉框' : '切换为输入框'
}}
</Button>
</template>
<Form>
<template #field3="slotProps">
<Input placeholder="请输入" v-bind="slotProps" />
<Input placeholder="请输入" v-bind="slotProps.componentProps" />
</template>
</Form>
</Card>

View File

@@ -1,20 +1,29 @@
<script lang="ts" setup>
import type { SelectValue } from 'antdv-next';
import { Input, Select } from 'antdv-next';
const emit = defineEmits(['blur', 'change']);
const emit = defineEmits(['blur']);
const modelValue = defineModel<[string | undefined, string | undefined]>({
default: () => [undefined, undefined],
});
function onChange() {
emit('change', modelValue.value);
function handlePhoneChange(value: string | undefined) {
modelValue.value = [modelValue.value[0], value];
}
function handleTypeChange(value: SelectValue) {
modelValue.value = [
typeof value === 'string' ? value : undefined,
modelValue.value[1],
];
}
</script>
<template>
<div class="flex w-full gap-1">
<Select
v-model:value="modelValue[0]"
:value="modelValue[0]"
class="w-20"
placeholder="类型"
allow-clear
@@ -25,18 +34,18 @@ function onChange() {
{ label: '私密', value: 'private' },
]"
@blur="emit('blur')"
@change="onChange"
@update:value="handleTypeChange"
/>
<Input
placeholder="请输入11位手机号码"
class="flex-1"
allow-clear
:class="{ 'valid-success': modelValue[1]?.match(/^1[3-9]\d{9}$/) }"
v-model:value="modelValue[1]"
:value="modelValue[1]"
:maxlength="11"
type="tel"
@blur="emit('blur')"
@change="onChange"
@update:value="handlePhoneChange"
/>
</div>
</template>

View File

@@ -108,7 +108,7 @@ function getNodeClass(node: Recordable<any>) {
bordered
:default-expanded-level="2"
:get-node-class="getNodeClass"
v-bind="slotProps"
v-bind="slotProps.componentProps"
value-field="id"
label-field="meta.title"
icon-field="meta.icon"

View File

@@ -107,7 +107,7 @@ function getNodeClass(node: Recordable<any>) {
bordered
:default-expanded-level="2"
:get-node-class="getNodeClass"
v-bind="slotProps"
v-bind="slotProps.componentProps"
value-field="id"
label-field="name"
/>