feat(@vben-core/popup-ui)!: add typed popup data contracts

Bind shared data to modal and drawer APIs and infer it from exposed connected components.

BREAKING CHANGE: getData<T>() is replaced by getData().
Shared data defaults to unknown, and an unset payload returns undefined instead
of an empty object.
This commit is contained in:
Dream
2026-08-05 09:45:44 +08:00
parent 9f5b1cd9fb
commit 21d9132703
16 changed files with 352 additions and 52 deletions

View File

@@ -81,6 +81,16 @@ describe('drawerApi', () => {
expect(drawerApi.getData()).toEqual(testData);
});
it('should return undefined before shared data is set', () => {
expect(drawerApi.getData()).toBeUndefined();
});
it('should preserve null shared data', () => {
const nullableDrawerApi = new DrawerApi<null | Record<string, unknown>>();
nullableDrawerApi.setData(null);
expect(nullableDrawerApi.getData()).toBeNull();
});
it('should set state correctly using an object', () => {
drawerApi.setState({ title: 'New Title' });
expect(drawerApi.store.state.title).toBe('New Title');

View File

@@ -0,0 +1,59 @@
import type { ExtendedDrawerApi, InferDrawerData } from '../drawer';
import type { createVbenDrawer, useVbenDrawer } from '../use-drawer';
import type TypedDrawer from './fixtures/typed-drawer.vue';
import { describe, expectTypeOf, it } from 'vitest';
interface TypedDrawerData {
id: number;
mode: 'edit' | 'view';
}
type DrawerData = null | TypedDrawerData;
declare const createDrawer: typeof createVbenDrawer;
declare const useDrawer: typeof useVbenDrawer;
describe('drawer public data types', () => {
it('infers data from the connected component exposed api', () => {
function assertInferredData(connectedComponent: typeof TypedDrawer) {
const [, drawerApi] = useDrawer({ connectedComponent });
expectTypeOf(drawerApi).toEqualTypeOf<ExtendedDrawerApi<DrawerData>>();
expectTypeOf(drawerApi.getData()).toEqualTypeOf<DrawerData | undefined>();
expectTypeOf(drawerApi.setData).parameter(0).toEqualTypeOf<DrawerData>();
expectTypeOf(drawerApi.setData(null).open()).toBeVoid();
// @ts-expect-error invalid payload type
drawerApi.setData({ id: '1', mode: 'edit' });
}
expectTypeOf<
InferDrawerData<typeof TypedDrawer>
>().toEqualTypeOf<DrawerData>();
expectTypeOf(assertInferredData).toBeFunction();
});
it('supports explicit and pre-bound data contracts', () => {
function assertExplicitData() {
const [, explicitApi] = useDrawer<DrawerData>();
const useTypedDrawer = createDrawer<DrawerData>();
const [, preBoundApi] = useTypedDrawer();
expectTypeOf(explicitApi).toEqualTypeOf<ExtendedDrawerApi<DrawerData>>();
expectTypeOf(preBoundApi).toEqualTypeOf<ExtendedDrawerApi<DrawerData>>();
}
expectTypeOf(assertExplicitData).toBeFunction();
});
it('falls back to unknown without a data contract', () => {
function assertUnknownData() {
const [, drawerApi] = useDrawer();
expectTypeOf(drawerApi).toEqualTypeOf<ExtendedDrawerApi<unknown>>();
expectTypeOf(drawerApi.getData()).toBeUnknown();
}
expectTypeOf(assertUnknownData).toBeFunction();
});
});

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { useVbenDrawer } from '../../use-drawer';
interface TypedDrawerData {
id: number;
mode: 'edit' | 'view';
}
const [, drawerApi] = useVbenDrawer<TypedDrawerData | null>();
defineExpose({ drawerApi });
</script>
<template>
<div />
</template>

View File

@@ -79,6 +79,9 @@ describe('useVbenDrawer', () => {
expect(initialApi).toBeDefined();
if (!initialApi) return;
expect(parentApi.store).toBe(initialApi.store);
const initialData = { id: 1 };
parentApi.setData(initialData);
expect(initialApi.getData()).toBe(initialData);
await remountConsumer(consumerKey);
const recreatedApi = getCurrentApi();
@@ -88,6 +91,9 @@ describe('useVbenDrawer', () => {
expect(recreatedApi).not.toBe(initialApi);
expect(recreatedApi.store.state.title).toBe('Parent drawer title');
expect(parentApi.store).toBe(recreatedApi.store);
const recreatedData = { id: 2 };
parentApi.setData(recreatedData);
expect(recreatedApi.getData()).toBe(recreatedData);
parentApi.open();
expect(onOpenChange).toHaveBeenCalledWith(true);

View File

@@ -3,10 +3,10 @@ import type { DrawerApiOptions, DrawerState } from './drawer';
import { Store } from '@vben-core/shared/store';
import { bindMethods, isFunction } from '@vben-core/shared/utils';
export class DrawerApi {
export class DrawerApi<TData = unknown> {
// 共享数据
public sharedData: Record<'payload', any> = {
payload: {},
public sharedData: Record<'payload', TData | undefined> = {
payload: undefined,
};
public store: Store<DrawerState>;
@@ -97,8 +97,8 @@ export class DrawerApi {
}
}
getData<T extends object = Record<string, any>>() {
return (this.sharedData?.payload ?? {}) as T;
getData(): TData | undefined {
return this.sharedData.payload;
}
/**
@@ -150,7 +150,7 @@ export class DrawerApi {
this.store.setState((prev) => ({ ...prev, isOpen: true }));
}
setData<T>(payload: T) {
setData(payload: TData) {
this.sharedData.payload = payload;
return this;
}

View File

@@ -130,23 +130,36 @@ export interface DrawerProps {
export interface DrawerState extends DrawerProps {
/** 弹窗打开状态 */
isOpen?: boolean;
/**
* 共享数据
*/
sharedData?: Record<string, any>;
}
export type ExtendedDrawerApi = DrawerApi & {
export type ExtendedDrawerApi<TData = unknown> = DrawerApi<TData> & {
useStore: <T = NoInfer<DrawerState>>(
selector?: (state: NoInfer<DrawerState>) => T,
) => Readonly<Ref<T>>;
};
export interface DrawerApiOptions extends DrawerState {
type DrawerComponentInstance<TComponent extends Component> =
TComponent extends abstract new (...args: any[]) => infer TInstance
? TInstance
: never;
export type InferDrawerData<TComponent extends Component> = [
DrawerComponentInstance<TComponent>,
] extends [never]
? unknown
: DrawerComponentInstance<TComponent> extends {
drawerApi: ExtendedDrawerApi<infer TData>;
}
? TData
: unknown;
export interface DrawerApiOptions<
TConnectedComponent extends Component = Component,
> extends DrawerState {
/**
* 独立的抽屉组件
*/
connectedComponent?: Component;
connectedComponent?: TConnectedComponent;
/**
* 关闭前的回调,返回 false 可以阻止关闭
* @returns

View File

@@ -1,3 +1,7 @@
export type * from './drawer';
export { default as VbenDrawer } from './drawer.vue';
export { setDefaultDrawerProps, useVbenDrawer } from './use-drawer';
export {
createVbenDrawer,
setDefaultDrawerProps,
useVbenDrawer,
} from './use-drawer';

View File

@@ -1,7 +1,10 @@
import type { Component } from 'vue';
import type {
DrawerApiOptions,
DrawerProps,
ExtendedDrawerApi,
InferDrawerData,
} from './drawer';
import {
@@ -24,6 +27,26 @@ import VbenDrawer from './drawer.vue';
const USER_DRAWER_INJECT_KEY = Symbol('VBEN_DRAWER_INJECT');
declare const DRAWER_DATA_NOT_PROVIDED: unique symbol;
type DrawerDataNotProvided = {
readonly [DRAWER_DATA_NOT_PROVIDED]: true;
};
type ResolvedDrawerData<
TData,
TConnectedComponent extends Component,
> = TData extends DrawerDataNotProvided
? InferDrawerData<TConnectedComponent>
: TData;
interface DrawerInjectData<TData> {
consumed?: boolean;
extendApi?: (api: ExtendedDrawerApi<TData>) => void;
options?: DrawerApiOptions;
reCreateDrawer?: () => Promise<void>;
}
const { globalEscapeShortcutKey } = usePreferences();
/**
@@ -36,8 +59,11 @@ export function setDefaultDrawerProps(props: Partial<DrawerProps>) {
}
export function useVbenDrawer<
TParentDrawerProps extends DrawerProps = DrawerProps,
>(options: DrawerApiOptions = {}) {
TData = DrawerDataNotProvided,
TConnectedComponent extends Component = Component,
>(options: DrawerApiOptions<TConnectedComponent> = {}) {
type TResolvedData = ResolvedDrawerData<TData, TConnectedComponent>;
// Drawer一般会抽离出来所以如果有传入 connectedComponent则表示为外部调用与内部组件进行连接
// 外部的Drawer通过provide/inject传递api
@@ -47,11 +73,11 @@ export function useVbenDrawer<
};
const { connectedComponent } = options;
if (connectedComponent) {
const extendedApi = shallowReactive({});
const extendedApi = shallowReactive({}) as ExtendedDrawerApi<TResolvedData>;
const isDrawerReady = ref(true);
const Drawer = defineComponent(
(props: TParentDrawerProps, { attrs, slots }) => {
function rebindApi(api: ExtendedDrawerApi) {
(props: DrawerProps, { attrs, slots }) => {
function rebindApi(api: ExtendedDrawerApi<TResolvedData>) {
Object.setPrototypeOf(extendedApi, markRaw(api));
}
@@ -65,7 +91,7 @@ export function useVbenDrawer<
isDrawerReady.value = true;
},
});
checkProps(extendedApi as ExtendedDrawerApi, {
checkProps(extendedApi, {
...props,
...attrs,
...slots,
@@ -84,10 +110,13 @@ export function useVbenDrawer<
},
);
return [Drawer, extendedApi as ExtendedDrawerApi] as const;
return [Drawer, extendedApi] as const;
}
const injectData = inject<any>(USER_DRAWER_INJECT_KEY, {});
const injectData = inject<DrawerInjectData<TResolvedData>>(
USER_DRAWER_INJECT_KEY,
{},
);
const isConsumed = injectData.consumed;
const effectiveOptions = isConsumed ? {} : injectData.options;
if (!isConsumed && injectData.consumed !== undefined) {
@@ -122,9 +151,9 @@ export function useVbenDrawer<
injectData.reCreateDrawer?.();
}
};
const api = new DrawerApi(mergedOptions);
const api = new DrawerApi<TResolvedData>(mergedOptions);
const extendedApi: ExtendedDrawerApi = api as never;
const extendedApi = api as ExtendedDrawerApi<TResolvedData>;
extendedApi.useStore = (selector) => {
return useSelector(api.store, selector);
@@ -145,7 +174,18 @@ export function useVbenDrawer<
return [Drawer, extendedApi] as const;
}
async function checkProps(api: ExtendedDrawerApi, attrs: Record<string, any>) {
export function createVbenDrawer<TData = unknown>() {
return function useTypedVbenDrawer<
TConnectedComponent extends Component = Component,
>(options: DrawerApiOptions<TConnectedComponent> = {}) {
return useVbenDrawer<TData, TConnectedComponent>(options);
};
}
async function checkProps<TData>(
api: ExtendedDrawerApi<TData>,
attrs: Record<string, any>,
) {
if (!attrs || Object.keys(attrs).length === 0) {
return;
}

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { useVbenModal } from '../../use-modal';
interface TypedModalData {
id: number;
mode: 'edit' | 'view';
}
const [, modalApi] = useVbenModal<TypedModalData | null>();
defineExpose({ modalApi });
</script>
<template>
<div />
</template>

View File

@@ -82,6 +82,16 @@ describe('modalApi', () => {
expect(modalApi.getData()).toEqual(testData);
});
it('should return undefined before shared data is set', () => {
expect(modalApi.getData()).toBeUndefined();
});
it('should preserve null shared data', () => {
const nullableModalApi = new ModalApi<null | Record<string, unknown>>();
nullableModalApi.setData(null);
expect(nullableModalApi.getData()).toBeNull();
});
it('should set state correctly using an object', () => {
modalApi.setState({ title: 'New Title' });
expect(modalApi.store.state.title).toBe('New Title');

View File

@@ -0,0 +1,59 @@
import type { ExtendedModalApi, InferModalData } from '../modal';
import type { createVbenModal, useVbenModal } from '../use-modal';
import type TypedModal from './fixtures/typed-modal.vue';
import { describe, expectTypeOf, it } from 'vitest';
interface TypedModalData {
id: number;
mode: 'edit' | 'view';
}
type ModalData = null | TypedModalData;
declare const createModal: typeof createVbenModal;
declare const useModal: typeof useVbenModal;
describe('modal public data types', () => {
it('infers data from the connected component exposed api', () => {
function assertInferredData(connectedComponent: typeof TypedModal) {
const [, modalApi] = useModal({ connectedComponent });
expectTypeOf(modalApi).toEqualTypeOf<ExtendedModalApi<ModalData>>();
expectTypeOf(modalApi.getData()).toEqualTypeOf<ModalData | undefined>();
expectTypeOf(modalApi.setData).parameter(0).toEqualTypeOf<ModalData>();
expectTypeOf(modalApi.setData(null).open()).toBeVoid();
// @ts-expect-error invalid payload type
modalApi.setData({ id: '1', mode: 'edit' });
}
expectTypeOf<
InferModalData<typeof TypedModal>
>().toEqualTypeOf<ModalData>();
expectTypeOf(assertInferredData).toBeFunction();
});
it('supports explicit and pre-bound data contracts', () => {
function assertExplicitData() {
const [, explicitApi] = useModal<ModalData>();
const useTypedModal = createModal<ModalData>();
const [, preBoundApi] = useTypedModal();
expectTypeOf(explicitApi).toEqualTypeOf<ExtendedModalApi<ModalData>>();
expectTypeOf(preBoundApi).toEqualTypeOf<ExtendedModalApi<ModalData>>();
}
expectTypeOf(assertExplicitData).toBeFunction();
});
it('falls back to unknown without a data contract', () => {
function assertUnknownData() {
const [, modalApi] = useModal();
expectTypeOf(modalApi).toEqualTypeOf<ExtendedModalApi<unknown>>();
expectTypeOf(modalApi.getData()).toBeUnknown();
}
expectTypeOf(assertUnknownData).toBeFunction();
});
});

View File

@@ -79,6 +79,9 @@ describe('useVbenModal', () => {
expect(initialApi).toBeDefined();
if (!initialApi) return;
expect(toRaw(parentApi.store)).toBe(initialApi.store);
const initialData = { id: 1 };
parentApi.setData(initialData);
expect(initialApi.getData()).toBe(initialData);
await remountConsumer(consumerKey);
const recreatedApi = getCurrentApi();
@@ -88,6 +91,9 @@ describe('useVbenModal', () => {
expect(recreatedApi).not.toBe(initialApi);
expect(recreatedApi.store.state.title).toBe('Parent modal title');
expect(toRaw(parentApi.store)).toBe(recreatedApi.store);
const recreatedData = { id: 2 };
parentApi.setData(recreatedData);
expect(recreatedApi.getData()).toBe(recreatedData);
parentApi.open();
expect(onOpenChange).toHaveBeenCalledWith(true);

View File

@@ -1,3 +1,7 @@
export type * from './modal';
export { default as VbenModal } from './modal.vue';
export { setDefaultModalProps, useVbenModal } from './use-modal';
export {
createVbenModal,
setDefaultModalProps,
useVbenModal,
} from './use-modal';

View File

@@ -3,10 +3,10 @@ import type { ModalApiOptions, ModalState } from './modal';
import { Store } from '@vben-core/shared/store';
import { bindMethods, isFunction } from '@vben-core/shared/utils';
export class ModalApi {
export class ModalApi<TData = unknown> {
// 共享数据
public sharedData: Record<'payload', any> = {
payload: {},
public sharedData: Record<'payload', TData | undefined> = {
payload: undefined,
};
public store: Store<ModalState>;
@@ -106,8 +106,8 @@ export class ModalApi {
}
}
getData<T extends object = Record<string, any>>() {
return (this.sharedData?.payload ?? {}) as T;
getData(): TData | undefined {
return this.sharedData.payload;
}
/**
@@ -163,7 +163,7 @@ export class ModalApi {
}));
}
setData<T>(payload: T) {
setData(payload: TData) {
this.sharedData.payload = payload;
return this;
}

View File

@@ -150,23 +150,36 @@ export interface ModalProps {
export interface ModalState extends ModalProps {
/** 弹窗打开状态 */
isOpen?: boolean;
/**
* 共享数据
*/
sharedData?: Record<string, any>;
}
export type ExtendedModalApi = ModalApi & {
export type ExtendedModalApi<TData = unknown> = ModalApi<TData> & {
useStore: <T = NoInfer<ModalState>>(
selector?: (state: NoInfer<ModalState>) => T,
) => Readonly<Ref<T>>;
};
export interface ModalApiOptions extends ModalState {
type ModalComponentInstance<TComponent extends Component> =
TComponent extends abstract new (...args: any[]) => infer TInstance
? TInstance
: never;
export type InferModalData<TComponent extends Component> = [
ModalComponentInstance<TComponent>,
] extends [never]
? unknown
: ModalComponentInstance<TComponent> extends {
modalApi: ExtendedModalApi<infer TData>;
}
? TData
: unknown;
export interface ModalApiOptions<
TConnectedComponent extends Component = Component,
> extends ModalState {
/**
* 独立的弹窗组件
*/
connectedComponent?: Component;
connectedComponent?: TConnectedComponent;
/**
* 关闭前的回调,返回 false 可以阻止关闭
* @returns

View File

@@ -1,4 +1,11 @@
import type { ExtendedModalApi, ModalApiOptions, ModalProps } from './modal';
import type { Component } from 'vue';
import type {
ExtendedModalApi,
InferModalData,
ModalApiOptions,
ModalProps,
} from './modal';
import {
defineComponent,
@@ -20,6 +27,26 @@ import VbenModal from './modal.vue';
const USER_MODAL_INJECT_KEY = Symbol('VBEN_MODAL_INJECT');
declare const MODAL_DATA_NOT_PROVIDED: unique symbol;
type ModalDataNotProvided = {
readonly [MODAL_DATA_NOT_PROVIDED]: true;
};
type ResolvedModalData<
TData,
TConnectedComponent extends Component,
> = TData extends ModalDataNotProvided
? InferModalData<TConnectedComponent>
: TData;
interface ModalInjectData<TData> {
consumed?: boolean;
extendApi?: (api: ExtendedModalApi<TData>) => void;
options?: ModalApiOptions;
reCreateModal?: () => Promise<void>;
}
const { globalEscapeShortcutKey } = usePreferences();
/**
* 默认配置
@@ -30,9 +57,12 @@ export function setDefaultModalProps(props: Partial<ModalProps>) {
Object.assign(DEFAULT_MODAL_PROPS, props);
}
export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
options: ModalApiOptions = {},
) {
export function useVbenModal<
TData = ModalDataNotProvided,
TConnectedComponent extends Component = Component,
>(options: ModalApiOptions<TConnectedComponent> = {}) {
type TResolvedData = ResolvedModalData<TData, TConnectedComponent>;
// Modal一般会抽离出来所以如果有传入 connectedComponent则表示为外部调用与内部组件进行连接
// 外部的Modal通过provide/inject传递api
@@ -42,11 +72,11 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
};
const { connectedComponent } = options;
if (connectedComponent) {
const extendedApi = shallowReactive({});
const extendedApi = shallowReactive({}) as ExtendedModalApi<TResolvedData>;
const isModalReady = ref(true);
const Modal = defineComponent(
(props: TParentModalProps, { attrs, slots }) => {
function rebindApi(api: ExtendedModalApi) {
(props: ModalProps, { attrs, slots }) => {
function rebindApi(api: ExtendedModalApi<TResolvedData>) {
Object.setPrototypeOf(extendedApi, markRaw(api));
}
@@ -60,7 +90,7 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
isModalReady.value = true;
},
});
checkProps(extendedApi as ExtendedModalApi, {
checkProps(extendedApi, {
...props,
...attrs,
...slots,
@@ -82,10 +112,13 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
},
);
return [Modal, extendedApi as ExtendedModalApi] as const;
return [Modal, extendedApi] as const;
}
const injectData = inject<any>(USER_MODAL_INJECT_KEY, {});
const injectData = inject<ModalInjectData<TResolvedData>>(
USER_MODAL_INJECT_KEY,
{},
);
const isConsumed = injectData.consumed;
const effectiveOptions = isConsumed ? {} : injectData.options;
if (!isConsumed && injectData.consumed !== undefined) {
@@ -121,9 +154,9 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
}
};
const api = new ModalApi(mergedOptions);
const api = new ModalApi<TResolvedData>(mergedOptions);
const extendedApi: ExtendedModalApi = api as never;
const extendedApi = api as ExtendedModalApi<TResolvedData>;
extendedApi.useStore = (selector) => {
return useSelector(api.store, selector);
@@ -153,7 +186,18 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
return [Modal, extendedApi] as const;
}
async function checkProps(api: ExtendedModalApi, attrs: Record<string, any>) {
export function createVbenModal<TData = unknown>() {
return function useTypedVbenModal<
TConnectedComponent extends Component = Component,
>(options: ModalApiOptions<TConnectedComponent> = {}) {
return useVbenModal<TData, TConnectedComponent>(options);
};
}
async function checkProps<TData>(
api: ExtendedModalApi<TData>,
attrs: Record<string, any>,
) {
if (!attrs || Object.keys(attrs).length === 0) {
return;
}