gengx
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
import type { Component, VNode } from 'vue';
|
||||
|
||||
import type { Recordable } from '@vben-core/typings';
|
||||
|
||||
import type { AlertProps, BeforeCloseScope, PromptProps } from './alert';
|
||||
|
||||
import { h, nextTick, ref, render } from 'vue';
|
||||
|
||||
import { useSimpleLocale } from '@vben-core/composables';
|
||||
import { Input, VbenRenderContent } from '@vben-core/shadcn-ui';
|
||||
import { isFunction, isString } from '@vben-core/shared/utils';
|
||||
|
||||
import Alert from './alert.vue';
|
||||
|
||||
const alerts = ref<Array<{ container: HTMLElement; instance: Component }>>([]);
|
||||
|
||||
const { $t } = useSimpleLocale();
|
||||
|
||||
export function vbenAlert(options: AlertProps): Promise<void>;
|
||||
export function vbenAlert(
|
||||
message: string,
|
||||
options?: Partial<AlertProps>,
|
||||
): Promise<void>;
|
||||
export function vbenAlert(
|
||||
message: string,
|
||||
title?: string,
|
||||
options?: Partial<AlertProps>,
|
||||
): Promise<void>;
|
||||
|
||||
export function vbenAlert(
|
||||
arg0: AlertProps | string,
|
||||
arg1?: Partial<AlertProps> | string,
|
||||
arg2?: Partial<AlertProps>,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options: AlertProps = isString(arg0)
|
||||
? {
|
||||
content: arg0,
|
||||
}
|
||||
: { ...arg0 };
|
||||
if (arg1) {
|
||||
if (isString(arg1)) {
|
||||
options.title = arg1;
|
||||
} else if (!isString(arg1)) {
|
||||
// 如果第二个参数是对象,则合并到选项中
|
||||
Object.assign(options, arg1);
|
||||
}
|
||||
}
|
||||
|
||||
if (arg2 && !isString(arg2)) {
|
||||
Object.assign(options, arg2);
|
||||
}
|
||||
// 创建容器元素
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
|
||||
// 创建一个引用,用于在回调中访问实例
|
||||
const alertRef = { container, instance: null as any };
|
||||
|
||||
const props: AlertProps & Recordable<any> = {
|
||||
onClosed: (isConfirm: boolean) => {
|
||||
// 移除组件实例以及创建的所有dom(恢复页面到打开前的状态)
|
||||
// 从alerts数组中移除该实例
|
||||
alerts.value = alerts.value.filter((item) => item !== alertRef);
|
||||
|
||||
// 从DOM中移除容器
|
||||
render(null, container);
|
||||
if (container.parentNode) {
|
||||
container.remove();
|
||||
}
|
||||
|
||||
// 解析 Promise,传递用户操作结果
|
||||
if (isConfirm) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('dialog cancelled'));
|
||||
}
|
||||
},
|
||||
...options,
|
||||
open: true,
|
||||
title: options.title ?? $t.value('prompt'),
|
||||
};
|
||||
|
||||
// 创建Alert组件的VNode
|
||||
const vnode = h(Alert, props);
|
||||
|
||||
// 渲染组件到容器
|
||||
render(vnode, container);
|
||||
|
||||
// 保存组件实例引用
|
||||
alertRef.instance = vnode.component?.proxy as Component;
|
||||
|
||||
// 将实例和容器添加到alerts数组中
|
||||
alerts.value.push(alertRef);
|
||||
});
|
||||
}
|
||||
|
||||
export function vbenConfirm(options: AlertProps): Promise<void>;
|
||||
export function vbenConfirm(
|
||||
message: string,
|
||||
options?: Partial<AlertProps>,
|
||||
): Promise<void>;
|
||||
export function vbenConfirm(
|
||||
message: string,
|
||||
title?: string,
|
||||
options?: Partial<AlertProps>,
|
||||
): Promise<void>;
|
||||
|
||||
export function vbenConfirm(
|
||||
arg0: AlertProps | string,
|
||||
arg1?: Partial<AlertProps> | string,
|
||||
arg2?: Partial<AlertProps>,
|
||||
): Promise<void> {
|
||||
const defaultProps: Partial<AlertProps> = {
|
||||
showCancel: true,
|
||||
};
|
||||
if (!arg1) {
|
||||
return isString(arg0)
|
||||
? vbenAlert(arg0, defaultProps)
|
||||
: vbenAlert({ ...defaultProps, ...arg0 });
|
||||
} else if (!arg2) {
|
||||
return isString(arg1)
|
||||
? vbenAlert(arg0 as string, arg1, defaultProps)
|
||||
: vbenAlert(arg0 as string, { ...defaultProps, ...arg1 });
|
||||
}
|
||||
return vbenAlert(arg0 as string, arg1 as string, {
|
||||
...defaultProps,
|
||||
...arg2,
|
||||
});
|
||||
}
|
||||
|
||||
export async function vbenPrompt<T = any>(
|
||||
options: PromptProps<T>,
|
||||
): Promise<T | undefined> {
|
||||
const {
|
||||
component: _component,
|
||||
componentProps: _componentProps,
|
||||
componentSlots,
|
||||
content,
|
||||
defaultValue,
|
||||
modelPropName: _modelPropName,
|
||||
...delegated
|
||||
} = options;
|
||||
|
||||
const modelValue = ref<T | undefined>(defaultValue);
|
||||
const inputComponentRef = ref<null | VNode>(null);
|
||||
const staticContents: Component[] = [
|
||||
h(VbenRenderContent, { content, renderBr: true }),
|
||||
];
|
||||
|
||||
const modelPropName = _modelPropName || 'modelValue';
|
||||
const componentProps = { ..._componentProps };
|
||||
|
||||
// 每次渲染时都会重新计算的内容函数
|
||||
const contentRenderer = () => {
|
||||
const currentProps = {
|
||||
...componentProps,
|
||||
[modelPropName]: modelValue.value,
|
||||
[`onUpdate:${modelPropName}`]: (val: T) => {
|
||||
modelValue.value = val;
|
||||
},
|
||||
};
|
||||
|
||||
// 设置当前值
|
||||
|
||||
// 设置更新处理函数
|
||||
|
||||
// 创建输入组件
|
||||
inputComponentRef.value = h(
|
||||
_component || Input,
|
||||
currentProps,
|
||||
componentSlots,
|
||||
);
|
||||
|
||||
// 返回包含静态内容和输入组件的数组
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'flex flex-col gap-2' },
|
||||
{ default: () => [...staticContents, inputComponentRef.value] },
|
||||
);
|
||||
};
|
||||
|
||||
const props: AlertProps & Recordable<any> = {
|
||||
...delegated,
|
||||
async beforeClose(scope: BeforeCloseScope) {
|
||||
if (delegated.beforeClose) {
|
||||
return await delegated.beforeClose({
|
||||
...scope,
|
||||
value: modelValue.value,
|
||||
});
|
||||
}
|
||||
},
|
||||
// 使用函数形式,每次渲染都会重新计算内容
|
||||
content: contentRenderer,
|
||||
contentMasking: true,
|
||||
async onOpened() {
|
||||
await nextTick();
|
||||
const componentRef: null | VNode = inputComponentRef.value;
|
||||
if (componentRef) {
|
||||
if (
|
||||
componentRef.component?.exposed &&
|
||||
isFunction(componentRef.component.exposed.focus)
|
||||
) {
|
||||
componentRef.component.exposed.focus();
|
||||
} else {
|
||||
if (componentRef.el) {
|
||||
if (
|
||||
isFunction(componentRef.el.focus) &&
|
||||
['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'].includes(
|
||||
componentRef.el.tagName,
|
||||
)
|
||||
) {
|
||||
componentRef.el.focus();
|
||||
} else if (isFunction(componentRef.el.querySelector)) {
|
||||
const focusableElement = componentRef.el.querySelector(
|
||||
'input, select, textarea, button',
|
||||
);
|
||||
if (focusableElement && isFunction(focusableElement.focus)) {
|
||||
focusableElement.focus();
|
||||
}
|
||||
} else if (
|
||||
componentRef.el.nextElementSibling &&
|
||||
isFunction(componentRef.el.nextElementSibling.focus)
|
||||
) {
|
||||
componentRef.el.nextElementSibling.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
await vbenConfirm(props);
|
||||
return modelValue.value;
|
||||
}
|
||||
|
||||
export function clearAllAlerts() {
|
||||
alerts.value.forEach((alert) => {
|
||||
// 从DOM中移除容器
|
||||
render(null, alert.container);
|
||||
if (alert.container.parentNode) {
|
||||
alert.container.remove();
|
||||
}
|
||||
});
|
||||
alerts.value = [];
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Component, VNode, VNodeArrayChildren } from 'vue';
|
||||
|
||||
import type { Recordable } from '@vben-core/typings';
|
||||
|
||||
import { createContext } from '@vben-core/shadcn-ui';
|
||||
|
||||
export type IconType = 'error' | 'info' | 'question' | 'success' | 'warning';
|
||||
|
||||
export type BeforeCloseScope = {
|
||||
isConfirm: boolean;
|
||||
};
|
||||
|
||||
export type AlertProps = {
|
||||
/** 关闭前的回调,如果返回false,则终止关闭 */
|
||||
beforeClose?: (
|
||||
scope: BeforeCloseScope,
|
||||
) => boolean | Promise<boolean | undefined> | undefined;
|
||||
/** 边框 */
|
||||
bordered?: boolean;
|
||||
/**
|
||||
* 按钮对齐方式
|
||||
* @default 'end'
|
||||
*/
|
||||
buttonAlign?: 'center' | 'end' | 'start';
|
||||
/** 取消按钮的标题 */
|
||||
cancelText?: string;
|
||||
/** 是否居中显示 */
|
||||
centered?: boolean;
|
||||
/** 确认按钮的标题 */
|
||||
confirmText?: string;
|
||||
/** 弹窗容器的额外样式 */
|
||||
containerClass?: string;
|
||||
/** 弹窗提示内容 */
|
||||
content: Component | string;
|
||||
/** 弹窗内容的额外样式 */
|
||||
contentClass?: string;
|
||||
/** 执行beforeClose回调期间,在内容区域显示一个loading遮罩*/
|
||||
contentMasking?: boolean;
|
||||
/** 按下Esc时是否关闭弹窗 */
|
||||
escapeKeyClose?: boolean;
|
||||
/** 弹窗底部内容(与按钮在同一个容器中) */
|
||||
footer?: Component | string;
|
||||
/** 弹窗的图标(在标题的前面) */
|
||||
icon?: Component | IconType;
|
||||
/**
|
||||
* 弹窗遮罩模糊效果
|
||||
*/
|
||||
overlayBlur?: number;
|
||||
/** 是否显示取消按钮 */
|
||||
showCancel?: boolean;
|
||||
/** 弹窗标题 */
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/** Prompt属性 */
|
||||
export type PromptProps<T = any> = {
|
||||
/** 关闭前的回调,如果返回false,则终止关闭 */
|
||||
beforeClose?: (scope: {
|
||||
isConfirm: boolean;
|
||||
value: T | undefined;
|
||||
}) => boolean | Promise<boolean | undefined> | undefined;
|
||||
/** 用于接受用户输入的组件 */
|
||||
component?: Component;
|
||||
/** 输入组件的属性 */
|
||||
componentProps?: Recordable<any>;
|
||||
/** 输入组件的插槽 */
|
||||
componentSlots?:
|
||||
| (() => any)
|
||||
| Recordable<unknown>
|
||||
| VNode
|
||||
| VNodeArrayChildren;
|
||||
/** 默认值 */
|
||||
defaultValue?: T;
|
||||
/** 输入组件的值属性名 */
|
||||
modelPropName?: string;
|
||||
} & Omit<AlertProps, 'beforeClose'>;
|
||||
|
||||
/**
|
||||
* Alert上下文
|
||||
*/
|
||||
export type AlertContext = {
|
||||
/** 执行取消操作 */
|
||||
doCancel: () => void;
|
||||
/** 执行确认操作 */
|
||||
doConfirm: () => void;
|
||||
};
|
||||
|
||||
export const [injectAlertContext, provideAlertContext] =
|
||||
createContext<AlertContext>('VbenAlertContext');
|
||||
|
||||
/**
|
||||
* 获取Alert上下文
|
||||
* @returns AlertContext
|
||||
*/
|
||||
export function useAlertContext() {
|
||||
const context = injectAlertContext();
|
||||
if (!context) {
|
||||
throw new Error('useAlertContext must be used within an AlertProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import type { AlertProps } from './alert';
|
||||
|
||||
import { computed, h, nextTick, ref } from 'vue';
|
||||
|
||||
import { useSimpleLocale } from '@vben-core/composables';
|
||||
import {
|
||||
CircleAlert,
|
||||
CircleCheckBig,
|
||||
CircleHelp,
|
||||
CircleX,
|
||||
Info,
|
||||
X,
|
||||
} from '@vben-core/icons';
|
||||
import { usePreferences } from '@vben-core/preferences';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
VbenButton,
|
||||
VbenLoading,
|
||||
VbenRenderContent,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { globalShareState } from '@vben-core/shared/global-state';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { provideAlertContext } from './alert';
|
||||
|
||||
const props = withDefaults(defineProps<AlertProps>(), {
|
||||
bordered: true,
|
||||
buttonAlign: 'end',
|
||||
centered: true,
|
||||
escapeKeyClose: true,
|
||||
});
|
||||
const emits = defineEmits(['closed', 'confirm', 'opened']);
|
||||
const { globalEscapeShortcutKey } = usePreferences();
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
const { $t } = useSimpleLocale();
|
||||
const components = globalShareState.getComponents();
|
||||
const isConfirm = ref(false);
|
||||
|
||||
function onAlertClosed() {
|
||||
emits('closed', isConfirm.value);
|
||||
isConfirm.value = false;
|
||||
}
|
||||
|
||||
function onEscapeKeyDown(e: KeyboardEvent) {
|
||||
// 先标记是按 Esc 触发的(用于后续 isConfirm 判断等)
|
||||
isConfirm.value = false;
|
||||
|
||||
// 只有当组件参数和全局配置都为false时才阻止关闭,其任意一个为true都需要让esc生效
|
||||
if (!props.escapeKeyClose && !globalEscapeShortcutKey.value) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
const getIconRender = computed(() => {
|
||||
let iconRender: Component | null = null;
|
||||
if (props.icon) {
|
||||
if (typeof props.icon === 'string') {
|
||||
switch (props.icon) {
|
||||
case 'error': {
|
||||
iconRender = h(CircleX, {
|
||||
style: { color: 'hsl(var(--destructive))' },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'info': {
|
||||
iconRender = h(Info, { style: { color: 'hsl(var(--info))' } });
|
||||
break;
|
||||
}
|
||||
case 'question': {
|
||||
iconRender = CircleHelp;
|
||||
break;
|
||||
}
|
||||
case 'success': {
|
||||
iconRender = h(CircleCheckBig, {
|
||||
style: { color: 'hsl(var(--success))' },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'warning': {
|
||||
iconRender = h(CircleAlert, {
|
||||
style: { color: 'hsl(var(--warning))' },
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
iconRender = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
iconRender = props.icon ?? null;
|
||||
}
|
||||
return iconRender;
|
||||
});
|
||||
|
||||
function doCancel() {
|
||||
handleCancel();
|
||||
handleOpenChange(false);
|
||||
}
|
||||
|
||||
function doConfirm() {
|
||||
handleConfirm();
|
||||
handleOpenChange(false);
|
||||
}
|
||||
|
||||
provideAlertContext({
|
||||
doCancel,
|
||||
doConfirm,
|
||||
});
|
||||
|
||||
function handleConfirm() {
|
||||
isConfirm.value = true;
|
||||
emits('confirm');
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
isConfirm.value = false;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
async function handleOpenChange(val: boolean) {
|
||||
await nextTick(); // 等待标记isConfirm状态
|
||||
if (!val && props.beforeClose) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await props.beforeClose({ isConfirm: isConfirm.value });
|
||||
if (res !== false) {
|
||||
open.value = false;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
} else {
|
||||
open.value = val;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<AlertDialog :modal="false" :open="open" @update:open="handleOpenChange">
|
||||
<AlertDialogContent
|
||||
:open="open"
|
||||
:centered="centered"
|
||||
:overlay-blur="overlayBlur"
|
||||
@opened="emits('opened')"
|
||||
@closed="onAlertClosed"
|
||||
@escape-key-down="onEscapeKeyDown($event)"
|
||||
@close="handleOpenChange(false)"
|
||||
:class="
|
||||
cn(
|
||||
containerClass,
|
||||
'flex max-h-[80%] flex-col p-0 duration-300 sm:w-130 sm:max-w-[80%] sm:rounded-(--radius)',
|
||||
{
|
||||
'border border-border': bordered,
|
||||
'shadow-3xl': !bordered,
|
||||
},
|
||||
)
|
||||
"
|
||||
>
|
||||
<div :class="cn('relative flex-1 overflow-y-auto p-3', contentClass)">
|
||||
<AlertDialogTitle v-if="title">
|
||||
<div class="flex items-center">
|
||||
<component :is="getIconRender" class="mr-2" />
|
||||
<span class="flex-auto">{{ $t(title) }}</span>
|
||||
<AlertDialogCancel v-if="showCancel" as-child>
|
||||
<VbenButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="rounded-full"
|
||||
:disabled="loading"
|
||||
@click="handleCancel"
|
||||
>
|
||||
<X class="size-4 text-muted-foreground" />
|
||||
</VbenButton>
|
||||
</AlertDialogCancel>
|
||||
</div>
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<div class="m-4 min-h-7.5">
|
||||
<VbenRenderContent :content="content" render-br />
|
||||
</div>
|
||||
<VbenLoading v-if="loading && contentMasking" :spinning="loading" />
|
||||
</AlertDialogDescription>
|
||||
<div
|
||||
class="flex items-center justify-end gap-x-2"
|
||||
:class="`justify-${buttonAlign}`"
|
||||
>
|
||||
<VbenRenderContent :content="footer" />
|
||||
<AlertDialogCancel v-if="showCancel" as-child>
|
||||
<component
|
||||
:is="components.DefaultButton || VbenButton"
|
||||
:disabled="loading"
|
||||
variant="outline"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ cancelText || $t('cancel') }}
|
||||
</component>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction as-child>
|
||||
<component
|
||||
:is="components.PrimaryButton || VbenButton"
|
||||
:loading="loading"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ confirmText || $t('confirm') }}
|
||||
</component>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
export type {
|
||||
AlertProps,
|
||||
BeforeCloseScope,
|
||||
IconType,
|
||||
PromptProps,
|
||||
} from './alert';
|
||||
export { useAlertContext } from './alert';
|
||||
export { default as Alert } from './alert.vue';
|
||||
export {
|
||||
vbenAlert as alert,
|
||||
clearAllAlerts,
|
||||
vbenConfirm as confirm,
|
||||
vbenPrompt as prompt,
|
||||
} from './AlertBuilder';
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { DrawerState } from '../drawer';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DrawerApi } from '../drawer-api';
|
||||
|
||||
// 模拟 Store 类
|
||||
vi.mock('@vben-core/shared/store', () => {
|
||||
return {
|
||||
isFunction: (fn: any) => typeof fn === 'function',
|
||||
Store: class {
|
||||
get state() {
|
||||
return this._state;
|
||||
}
|
||||
private _state: DrawerState;
|
||||
private subscribers: Array<(state: DrawerState) => void> = [];
|
||||
|
||||
constructor(initialState: DrawerState) {
|
||||
this._state = initialState;
|
||||
}
|
||||
|
||||
setState(fn: (prev: DrawerState) => DrawerState) {
|
||||
this._state = fn(this._state);
|
||||
this.subscribers.forEach((sub) => sub(this._state));
|
||||
}
|
||||
|
||||
subscribe(fn: (state: DrawerState) => void) {
|
||||
this.subscribers.push(fn);
|
||||
return { unsubscribe: () => {} };
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('drawerApi', () => {
|
||||
let drawerApi: DrawerApi;
|
||||
let drawerState: DrawerState;
|
||||
|
||||
beforeEach(() => {
|
||||
drawerApi = new DrawerApi();
|
||||
drawerState = drawerApi.store.state;
|
||||
});
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
expect(drawerState.isOpen).toBe(false);
|
||||
expect(drawerState.cancelText).toBe(undefined);
|
||||
expect(drawerState.confirmText).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should open the drawer', () => {
|
||||
drawerApi.open();
|
||||
expect(drawerApi.store.state.isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should close the drawer if onBeforeClose allows it', () => {
|
||||
drawerApi.close();
|
||||
expect(drawerApi.store.state.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should not close the drawer if onBeforeClose returns false', () => {
|
||||
const onBeforeClose = vi.fn(() => false);
|
||||
const drawerApiWithHook = new DrawerApi({ onBeforeClose });
|
||||
drawerApiWithHook.open();
|
||||
drawerApiWithHook.close();
|
||||
expect(drawerApiWithHook.store.state.isOpen).toBe(true);
|
||||
expect(onBeforeClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trigger onCancel and keep drawer open if onCancel is provided', () => {
|
||||
const onCancel = vi.fn();
|
||||
const drawerApiWithHook = new DrawerApi({ onCancel });
|
||||
drawerApiWithHook.open();
|
||||
drawerApiWithHook.onCancel();
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
expect(drawerApiWithHook.store.state.isOpen).toBe(true); // 关闭逻辑不在 onCancel 内
|
||||
});
|
||||
|
||||
it('should update shared data correctly', () => {
|
||||
const testData = { key: 'value' };
|
||||
drawerApi.setData(testData);
|
||||
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');
|
||||
});
|
||||
|
||||
it('should set state correctly using a function', () => {
|
||||
drawerApi.setState((prev) => ({ ...prev, confirmText: 'Yes' }));
|
||||
expect(drawerApi.store.state.confirmText).toBe('Yes');
|
||||
});
|
||||
|
||||
it('should call onOpenChange when state changes', () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const drawerApiWithHook = new DrawerApi({ onOpenChange });
|
||||
drawerApiWithHook.open();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should call onClosed callback when provided', () => {
|
||||
const onClosed = vi.fn();
|
||||
const drawerApiWithHook = new DrawerApi({ onClosed });
|
||||
drawerApiWithHook.onClosed();
|
||||
expect(onClosed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onOpened callback when provided', () => {
|
||||
const onOpened = vi.fn();
|
||||
const drawerApiWithHook = new DrawerApi({ onOpened });
|
||||
drawerApiWithHook.open();
|
||||
drawerApiWithHook.onOpened();
|
||||
expect(onOpened).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { App } from 'vue';
|
||||
|
||||
import { createApp, defineComponent, h, nextTick } from 'vue';
|
||||
|
||||
import { ELEMENT_ID_MAIN_CONTENT } from '@vben-core/shared/constants';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useVbenDrawer } from '../use-drawer';
|
||||
|
||||
vi.mock('@vben-core/preferences', () => ({
|
||||
usePreferences: () => ({
|
||||
globalEscapeShortcutKey: { value: true },
|
||||
}),
|
||||
}));
|
||||
|
||||
let activeApp: App | undefined;
|
||||
|
||||
async function mountPreopenedDrawer() {
|
||||
const mainContent = document.createElement('main');
|
||||
mainContent.id = ELEMENT_ID_MAIN_CONTENT;
|
||||
mainContent.innerHTML = '<div><div></div></div>';
|
||||
document.body.append(mainContent);
|
||||
|
||||
const Consumer = defineComponent(() => {
|
||||
const [Drawer, drawerApi] = useVbenDrawer({ appendToMain: true });
|
||||
drawerApi.open();
|
||||
return () => h(Drawer);
|
||||
});
|
||||
const host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
|
||||
activeApp = createApp(() => h(Consumer));
|
||||
activeApp.mount(host);
|
||||
await nextTick();
|
||||
|
||||
return mainContent;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
activeApp?.unmount();
|
||||
activeApp = undefined;
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('vben drawer', () => {
|
||||
it('mounts an initially open drawer directly in the main content', async () => {
|
||||
const mainContent = await mountPreopenedDrawer();
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
|
||||
expect(dialog).toBeInstanceOf(HTMLElement);
|
||||
if (!(dialog instanceof HTMLElement)) return;
|
||||
expect(dialog.parentElement).toBe(mainContent);
|
||||
});
|
||||
|
||||
it('shows a drawer that is opened before mounting', async () => {
|
||||
await mountPreopenedDrawer();
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
|
||||
expect(dialog).toBeInstanceOf(HTMLElement);
|
||||
if (!(dialog instanceof HTMLElement)) return;
|
||||
expect(dialog.classList.contains('hidden')).toBe(false);
|
||||
});
|
||||
});
|
||||
+16
@@ -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>
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { App, Ref } from 'vue';
|
||||
|
||||
import type { ExtendedDrawerApi } from '../drawer';
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, ref } from 'vue';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useVbenDrawer } from '../use-drawer';
|
||||
|
||||
vi.mock('@vben-core/preferences', () => ({
|
||||
usePreferences: () => ({
|
||||
globalEscapeShortcutKey: { value: true },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../drawer.vue', () => ({
|
||||
default: {
|
||||
name: 'VbenDrawerStub',
|
||||
render: () => null,
|
||||
},
|
||||
}));
|
||||
|
||||
let activeApp: App | undefined;
|
||||
|
||||
async function mountRebindingHarness() {
|
||||
const consumerKey = ref(0);
|
||||
let currentApi: ExtendedDrawerApi | undefined;
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
const Consumer = defineComponent(() => {
|
||||
const [Drawer, drawerApi] = useVbenDrawer();
|
||||
currentApi = drawerApi;
|
||||
return () => h(Drawer);
|
||||
});
|
||||
|
||||
const ConnectedDrawer = defineComponent(() => {
|
||||
return () => h(Consumer, { key: consumerKey.value });
|
||||
});
|
||||
|
||||
const [ParentDrawer, parentApi] = useVbenDrawer({
|
||||
connectedComponent: ConnectedDrawer,
|
||||
onOpenChange,
|
||||
title: 'Parent drawer title',
|
||||
});
|
||||
const host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
|
||||
activeApp = createApp(() => h(ParentDrawer));
|
||||
activeApp.mount(host);
|
||||
await nextTick();
|
||||
|
||||
return {
|
||||
consumerKey,
|
||||
getCurrentApi: () => currentApi,
|
||||
onOpenChange,
|
||||
parentApi,
|
||||
};
|
||||
}
|
||||
|
||||
async function remountConsumer(consumerKey: Ref<number>) {
|
||||
consumerKey.value += 1;
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
activeApp?.unmount();
|
||||
activeApp = undefined;
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('useVbenDrawer', () => {
|
||||
it('rebinds the parent api when the consumer is recreated', async () => {
|
||||
const { consumerKey, getCurrentApi, onOpenChange, parentApi } =
|
||||
await mountRebindingHarness();
|
||||
const initialApi = getCurrentApi();
|
||||
|
||||
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();
|
||||
|
||||
expect(recreatedApi).toBeDefined();
|
||||
if (!recreatedApi) return;
|
||||
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);
|
||||
expect(recreatedApi.store.state.isOpen).toBe(true);
|
||||
expect(initialApi.store.state.isOpen).toBe(false);
|
||||
|
||||
await parentApi.close();
|
||||
expect(recreatedApi.store.state.isOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { DrawerApiOptions, DrawerState } from './drawer';
|
||||
|
||||
import { Store } from '@vben-core/shared/store';
|
||||
import { bindMethods, isFunction } from '@vben-core/shared/utils';
|
||||
|
||||
export class DrawerApi<TData = unknown> {
|
||||
// 共享数据
|
||||
public sharedData: Record<'payload', TData | undefined> = {
|
||||
payload: undefined,
|
||||
};
|
||||
public store: Store<DrawerState>;
|
||||
|
||||
private api: Pick<
|
||||
DrawerApiOptions,
|
||||
| 'onBeforeClose'
|
||||
| 'onCancel'
|
||||
| 'onClosed'
|
||||
| 'onConfirm'
|
||||
| 'onOpenChange'
|
||||
| 'onOpened'
|
||||
>;
|
||||
|
||||
// private prevState!: DrawerState;
|
||||
private state!: DrawerState;
|
||||
|
||||
constructor(options: DrawerApiOptions = {}) {
|
||||
const {
|
||||
connectedComponent: _,
|
||||
onBeforeClose,
|
||||
onCancel,
|
||||
onClosed,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
onOpened,
|
||||
...storeState
|
||||
} = options;
|
||||
|
||||
const defaultState: DrawerState = {
|
||||
class: '',
|
||||
closable: true,
|
||||
closeIconPlacement: 'right',
|
||||
closeOnClickModal: true,
|
||||
closeOnPressEscape: true,
|
||||
confirmLoading: false,
|
||||
contentClass: '',
|
||||
footer: true,
|
||||
header: true,
|
||||
isOpen: false,
|
||||
loading: false,
|
||||
modal: true,
|
||||
openAutoFocus: false,
|
||||
placement: 'right',
|
||||
showCancelButton: true,
|
||||
showConfirmButton: true,
|
||||
submitting: false,
|
||||
title: '',
|
||||
};
|
||||
|
||||
this.store = new Store<DrawerState>({
|
||||
...defaultState,
|
||||
...storeState,
|
||||
});
|
||||
|
||||
this.store.subscribe((state) => {
|
||||
const prevIsOpen = this.state?.isOpen;
|
||||
this.state = state;
|
||||
if (state?.isOpen !== prevIsOpen) {
|
||||
this.api.onOpenChange?.(!!state?.isOpen);
|
||||
}
|
||||
});
|
||||
this.state = this.store.state;
|
||||
this.api = {
|
||||
onBeforeClose,
|
||||
onCancel,
|
||||
onClosed,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
onOpened,
|
||||
};
|
||||
bindMethods(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭抽屉
|
||||
* @description 关闭抽屉时会调用 onBeforeClose 钩子函数,如果 onBeforeClose 返回 false,则不关闭弹窗
|
||||
*/
|
||||
async close() {
|
||||
// 通过 onBeforeClose 钩子函数来判断是否允许关闭弹窗
|
||||
// 如果 onBeforeClose 返回 false,则不关闭弹窗
|
||||
const allowClose = (await this.api.onBeforeClose?.()) ?? true;
|
||||
if (allowClose) {
|
||||
this.store.setState((prev) => ({
|
||||
...prev,
|
||||
isOpen: false,
|
||||
submitting: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
getData(): TData | undefined {
|
||||
return this.sharedData.payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁定抽屉状态(用于提交过程中的等待状态)
|
||||
* @description 锁定状态将禁用默认的取消按钮,使用spinner覆盖抽屉内容,隐藏关闭按钮,阻止手动关闭弹窗,将默认的提交按钮标记为loading状态
|
||||
* @param isLocked 是否锁定
|
||||
*/
|
||||
lock(isLocked: boolean = true) {
|
||||
return this.setState({ submitting: isLocked });
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消操作
|
||||
*/
|
||||
onCancel() {
|
||||
if (this.api.onCancel) {
|
||||
this.api.onCancel?.();
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗关闭动画播放完毕后的回调
|
||||
*/
|
||||
onClosed() {
|
||||
if (!this.state.isOpen) {
|
||||
this.api.onClosed?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认操作
|
||||
*/
|
||||
onConfirm() {
|
||||
this.api.onConfirm?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗打开动画播放完毕后的回调
|
||||
*/
|
||||
onOpened() {
|
||||
if (this.state.isOpen) {
|
||||
this.api.onOpened?.();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.store.setState((prev) => ({ ...prev, isOpen: true }));
|
||||
}
|
||||
|
||||
setData(payload: TData) {
|
||||
this.sharedData.payload = payload;
|
||||
return this;
|
||||
}
|
||||
|
||||
setState(
|
||||
stateOrFn:
|
||||
| ((prev: DrawerState) => Partial<DrawerState>)
|
||||
| Partial<DrawerState>,
|
||||
) {
|
||||
if (isFunction(stateOrFn)) {
|
||||
this.store.setState(stateOrFn);
|
||||
} else {
|
||||
this.store.setState((prev) => ({ ...prev, ...stateOrFn }));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除抽屉的锁定状态
|
||||
* @description 解除由lock方法设置的锁定状态,是lock(false)的别名
|
||||
*/
|
||||
unlock() {
|
||||
return this.lock(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { Component, Ref } from 'vue';
|
||||
|
||||
import type { ClassType, MaybePromise } from '@vben-core/typings';
|
||||
|
||||
import type { DrawerApi } from './drawer-api';
|
||||
|
||||
export type DrawerPlacement = 'bottom' | 'left' | 'right' | 'top';
|
||||
|
||||
export type CloseIconPlacement = 'left' | 'right';
|
||||
|
||||
export interface DrawerProps {
|
||||
/**
|
||||
* 是否挂载到内容区域
|
||||
* @default false
|
||||
*/
|
||||
appendToMain?: boolean;
|
||||
/**
|
||||
* 取消按钮文字
|
||||
*/
|
||||
cancelText?: string;
|
||||
class?: ClassType;
|
||||
/**
|
||||
* 是否显示关闭按钮
|
||||
* @default true
|
||||
*/
|
||||
closable?: boolean;
|
||||
/**
|
||||
* 关闭按钮的位置
|
||||
*/
|
||||
closeIconPlacement?: CloseIconPlacement;
|
||||
/**
|
||||
* 点击弹窗遮罩是否关闭弹窗
|
||||
* @default true
|
||||
*/
|
||||
closeOnClickModal?: boolean;
|
||||
/**
|
||||
* 按下 ESC 键是否关闭弹窗
|
||||
* @default true
|
||||
*/
|
||||
closeOnPressEscape?: boolean;
|
||||
/**
|
||||
* 确定按钮 loading
|
||||
* @default false
|
||||
*/
|
||||
confirmLoading?: boolean;
|
||||
/**
|
||||
* 确定按钮文字
|
||||
*/
|
||||
confirmText?: string;
|
||||
contentClass?: string;
|
||||
/**
|
||||
* 弹窗描述
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* 在关闭时销毁抽屉
|
||||
*/
|
||||
destroyOnClose?: boolean;
|
||||
/**
|
||||
* 是否显示底部
|
||||
* @default true
|
||||
*/
|
||||
footer?: boolean;
|
||||
/**
|
||||
* 弹窗底部样式
|
||||
*/
|
||||
footerClass?: ClassType;
|
||||
/**
|
||||
* 是否显示顶栏
|
||||
* @default true
|
||||
*/
|
||||
header?: boolean;
|
||||
/**
|
||||
* 弹窗头部样式
|
||||
*/
|
||||
headerClass?: ClassType;
|
||||
/**
|
||||
* 抽屉加载状态
|
||||
* @default false
|
||||
*/
|
||||
loading?: boolean;
|
||||
/**
|
||||
* 是否显示遮罩
|
||||
* @default true
|
||||
*/
|
||||
modal?: boolean;
|
||||
|
||||
/**
|
||||
* 是否自动聚焦
|
||||
*/
|
||||
openAutoFocus?: boolean;
|
||||
/**
|
||||
* 弹窗遮罩模糊效果
|
||||
*/
|
||||
overlayBlur?: number;
|
||||
/**
|
||||
* 抽屉位置
|
||||
* @default right
|
||||
*/
|
||||
placement?: DrawerPlacement;
|
||||
|
||||
/**
|
||||
* 是否显示取消按钮
|
||||
* @default true
|
||||
*/
|
||||
showCancelButton?: boolean;
|
||||
/**
|
||||
* 是否显示确认按钮
|
||||
* @default true
|
||||
*/
|
||||
showConfirmButton?: boolean;
|
||||
/**
|
||||
* 提交中(锁定抽屉状态)
|
||||
*/
|
||||
submitting?: boolean;
|
||||
/**
|
||||
* 弹窗标题
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* 弹窗标题提示
|
||||
*/
|
||||
titleTooltip?: string;
|
||||
/**
|
||||
* 抽屉层级
|
||||
*/
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
export interface DrawerState extends DrawerProps {
|
||||
/** 弹窗打开状态 */
|
||||
isOpen?: boolean;
|
||||
}
|
||||
|
||||
export type ExtendedDrawerApi<TData = unknown> = DrawerApi<TData> & {
|
||||
useStore: <T = NoInfer<DrawerState>>(
|
||||
selector?: (state: NoInfer<DrawerState>) => T,
|
||||
) => Readonly<Ref<T>>;
|
||||
};
|
||||
|
||||
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?: TConnectedComponent;
|
||||
/**
|
||||
* 关闭前的回调,返回 false 可以阻止关闭
|
||||
* @returns
|
||||
*/
|
||||
onBeforeClose?: () => MaybePromise<boolean | undefined>;
|
||||
/**
|
||||
* 点击取消按钮的回调
|
||||
*/
|
||||
onCancel?: () => void;
|
||||
/**
|
||||
* 弹窗关闭动画结束的回调
|
||||
* @returns
|
||||
*/
|
||||
onClosed?: () => void;
|
||||
/**
|
||||
* 点击确定按钮的回调
|
||||
*/
|
||||
onConfirm?: () => void;
|
||||
/**
|
||||
* 弹窗状态变化回调
|
||||
* @param isOpen
|
||||
* @returns
|
||||
*/
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
/**
|
||||
* 弹窗打开动画结束的回调
|
||||
* @returns
|
||||
*/
|
||||
onOpened?: () => void;
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DrawerProps, ExtendedDrawerApi } from './drawer';
|
||||
|
||||
import {
|
||||
computed,
|
||||
onDeactivated,
|
||||
provide,
|
||||
ref,
|
||||
unref,
|
||||
useId,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import {
|
||||
useIsMobile,
|
||||
usePriorityValues,
|
||||
useSimpleLocale,
|
||||
} from '@vben-core/composables';
|
||||
import { X } from '@vben-core/icons';
|
||||
import {
|
||||
Separator,
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
VbenButton,
|
||||
VbenHelpTooltip,
|
||||
VbenIconButton,
|
||||
VbenLoading,
|
||||
VisuallyHidden,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { ELEMENT_ID_MAIN_CONTENT } from '@vben-core/shared/constants';
|
||||
import { globalShareState } from '@vben-core/shared/global-state';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
interface Props extends DrawerProps {
|
||||
drawerApi?: ExtendedDrawerApi;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
appendToMain: false,
|
||||
closeIconPlacement: 'right',
|
||||
destroyOnClose: false,
|
||||
drawerApi: undefined,
|
||||
submitting: false,
|
||||
zIndex: 1000,
|
||||
});
|
||||
|
||||
const components = globalShareState.getComponents();
|
||||
|
||||
const id = useId();
|
||||
provide('DISMISSABLE_DRAWER_ID', id);
|
||||
|
||||
// @ts-expect-error unused
|
||||
const wrapperRef = ref<HTMLElement>();
|
||||
const { $t } = useSimpleLocale();
|
||||
const { isMobile } = useIsMobile();
|
||||
|
||||
const state = props.drawerApi?.useStore?.();
|
||||
|
||||
const {
|
||||
appendToMain,
|
||||
cancelText,
|
||||
class: drawerClass,
|
||||
closable,
|
||||
closeIconPlacement,
|
||||
closeOnClickModal,
|
||||
closeOnPressEscape,
|
||||
confirmLoading,
|
||||
confirmText,
|
||||
contentClass,
|
||||
description,
|
||||
destroyOnClose,
|
||||
footer: showFooter,
|
||||
footerClass,
|
||||
header: showHeader,
|
||||
headerClass,
|
||||
loading: showLoading,
|
||||
modal,
|
||||
openAutoFocus,
|
||||
overlayBlur,
|
||||
placement,
|
||||
showCancelButton,
|
||||
showConfirmButton,
|
||||
submitting,
|
||||
title,
|
||||
titleTooltip,
|
||||
zIndex,
|
||||
} = usePriorityValues(props, state);
|
||||
|
||||
// watch(
|
||||
// () => showLoading.value,
|
||||
// (v) => {
|
||||
// if (v && wrapperRef.value) {
|
||||
// wrapperRef.value.scrollTo({
|
||||
// // behavior: 'smooth',
|
||||
// top: 0,
|
||||
// });
|
||||
// }
|
||||
// },
|
||||
// );
|
||||
|
||||
/**
|
||||
* 在开启keepAlive情况下 直接通过浏览器按钮/手势等返回 不会关闭弹窗
|
||||
*/
|
||||
onDeactivated(() => {
|
||||
// 如果弹窗没有被挂载到内容区域,则关闭弹窗
|
||||
if (!appendToMain.value) {
|
||||
props.drawerApi?.close();
|
||||
}
|
||||
});
|
||||
|
||||
function interactOutside(e: Event) {
|
||||
if (!closeOnClickModal.value || submitting.value) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
function escapeKeyDown(e: KeyboardEvent) {
|
||||
if (!closeOnPressEscape.value || submitting.value) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
// pointer-down-outside
|
||||
function pointerDownOutside(e: Event) {
|
||||
const target = e.target as HTMLElement;
|
||||
const dismissableDrawer = target?.dataset.dismissableDrawer;
|
||||
if (
|
||||
submitting.value ||
|
||||
!closeOnClickModal.value ||
|
||||
dismissableDrawer !== id
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handerOpenAutoFocus(e: Event) {
|
||||
if (!openAutoFocus.value) {
|
||||
e?.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocusOutside(e: Event) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
const getAppendTo = computed(() => {
|
||||
return appendToMain.value ? `#${ELEMENT_ID_MAIN_CONTENT}` : undefined;
|
||||
});
|
||||
|
||||
/**
|
||||
* destroyOnClose功能完善
|
||||
*/
|
||||
// 是否打开过
|
||||
const hasOpened = ref(false);
|
||||
const isClosed = ref(true);
|
||||
watch(
|
||||
() => state?.value?.isOpen,
|
||||
(value) => {
|
||||
isClosed.value = false;
|
||||
if (value && !unref(hasOpened)) {
|
||||
hasOpened.value = true;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
function handleClosed() {
|
||||
isClosed.value = true;
|
||||
props.drawerApi?.onClosed();
|
||||
}
|
||||
const getForceMount = computed(() => {
|
||||
return !unref(destroyOnClose) && unref(hasOpened);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Sheet
|
||||
:modal="false"
|
||||
:open="state?.isOpen"
|
||||
@update:open="() => drawerApi?.close()"
|
||||
>
|
||||
<SheetContent
|
||||
:append-to="getAppendTo"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-130 flex-col',
|
||||
{
|
||||
'w-full!':
|
||||
isMobile || placement === 'bottom' || placement === 'top',
|
||||
'max-h-screen': placement === 'bottom' || placement === 'top',
|
||||
hidden: isClosed,
|
||||
},
|
||||
drawerClass,
|
||||
)
|
||||
"
|
||||
:modal="modal"
|
||||
:open="state?.isOpen"
|
||||
:side="placement"
|
||||
:z-index="zIndex"
|
||||
:force-mount="getForceMount"
|
||||
:overlay-blur="overlayBlur"
|
||||
@close-auto-focus="handleFocusOutside"
|
||||
@closed="handleClosed"
|
||||
@escape-key-down="escapeKeyDown"
|
||||
@focus-outside="handleFocusOutside"
|
||||
@interact-outside="interactOutside"
|
||||
@open-auto-focus="handerOpenAutoFocus"
|
||||
@opened="() => drawerApi?.onOpened()"
|
||||
@pointer-down-outside="pointerDownOutside"
|
||||
>
|
||||
<SheetHeader
|
||||
v-if="showHeader"
|
||||
:class="
|
||||
cn(
|
||||
'flex! flex-row items-center justify-between border-b px-6 py-5',
|
||||
headerClass,
|
||||
{
|
||||
'px-4 py-3': closable,
|
||||
'pl-2': closable && closeIconPlacement === 'left',
|
||||
},
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<SheetClose
|
||||
v-if="closable && closeIconPlacement === 'left'"
|
||||
as-child
|
||||
:disabled="submitting"
|
||||
class="ml-0.5 cursor-pointer rounded-full opacity-80 transition-opacity hover:opacity-100 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary"
|
||||
>
|
||||
<slot name="close-icon">
|
||||
<VbenIconButton>
|
||||
<X class="size-4" />
|
||||
</VbenIconButton>
|
||||
</slot>
|
||||
</SheetClose>
|
||||
<Separator
|
||||
v-if="closable && closeIconPlacement === 'left'"
|
||||
class="mr-2 ml-1 h-8"
|
||||
decorative
|
||||
orientation="vertical"
|
||||
/>
|
||||
<SheetTitle v-if="title" class="text-left">
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
|
||||
<VbenHelpTooltip v-if="titleTooltip" trigger-class="pb-1">
|
||||
{{ titleTooltip }}
|
||||
</VbenHelpTooltip>
|
||||
</slot>
|
||||
</SheetTitle>
|
||||
<SheetDescription v-if="description" class="mt-1 text-xs">
|
||||
<slot name="description">
|
||||
{{ description }}
|
||||
</slot>
|
||||
</SheetDescription>
|
||||
</div>
|
||||
|
||||
<VisuallyHidden v-if="!title || !description">
|
||||
<SheetTitle v-if="!title" />
|
||||
<SheetDescription v-if="!description" />
|
||||
</VisuallyHidden>
|
||||
|
||||
<div class="flex-center">
|
||||
<slot name="extra"></slot>
|
||||
<SheetClose
|
||||
v-if="closable && closeIconPlacement === 'right'"
|
||||
as-child
|
||||
:disabled="submitting"
|
||||
class="ml-0.5 cursor-pointer rounded-full opacity-80 transition-opacity hover:opacity-100 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary"
|
||||
>
|
||||
<slot name="close-icon">
|
||||
<VbenIconButton>
|
||||
<X class="size-4" />
|
||||
</VbenIconButton>
|
||||
</slot>
|
||||
</SheetClose>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<template v-else>
|
||||
<VisuallyHidden>
|
||||
<SheetTitle />
|
||||
<SheetDescription />
|
||||
</VisuallyHidden>
|
||||
</template>
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
:class="
|
||||
cn('relative flex-1 overflow-y-auto p-3', contentClass, {
|
||||
'pointer-events-none': showLoading || submitting,
|
||||
})
|
||||
"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
<VbenLoading v-if="showLoading || submitting" spinning />
|
||||
<SheetFooter
|
||||
v-if="showFooter"
|
||||
:class="
|
||||
cn(
|
||||
'w-full flex-row items-center justify-end border-t p-2 px-3',
|
||||
footerClass,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="prepend-footer"></slot>
|
||||
<slot name="footer">
|
||||
<component
|
||||
:is="components.DefaultButton || VbenButton"
|
||||
v-if="showCancelButton"
|
||||
variant="ghost"
|
||||
:disabled="submitting"
|
||||
@click="() => drawerApi?.onCancel()"
|
||||
>
|
||||
<slot name="cancelText">
|
||||
{{ cancelText || $t('cancel') }}
|
||||
</slot>
|
||||
</component>
|
||||
<slot name="center-footer"></slot>
|
||||
<component
|
||||
:is="components.PrimaryButton || VbenButton"
|
||||
v-if="showConfirmButton"
|
||||
:loading="confirmLoading || submitting"
|
||||
@click="() => drawerApi?.onConfirm()"
|
||||
>
|
||||
<slot name="confirmText">
|
||||
{{ confirmText || $t('confirm') }}
|
||||
</slot>
|
||||
</component>
|
||||
</slot>
|
||||
<slot name="append-footer"></slot>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
export type * from './drawer';
|
||||
export { default as VbenDrawer } from './drawer.vue';
|
||||
export {
|
||||
createVbenDrawer,
|
||||
setDefaultDrawerProps,
|
||||
useVbenDrawer,
|
||||
} from './use-drawer';
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import type {
|
||||
DrawerApiOptions,
|
||||
DrawerProps,
|
||||
ExtendedDrawerApi,
|
||||
InferDrawerData,
|
||||
} from './drawer';
|
||||
|
||||
import {
|
||||
defineComponent,
|
||||
h,
|
||||
inject,
|
||||
markRaw,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
provide,
|
||||
ref,
|
||||
shallowReactive,
|
||||
} from 'vue';
|
||||
|
||||
import { usePreferences } from '@vben-core/preferences';
|
||||
import { useSelector } from '@vben-core/shared/store';
|
||||
|
||||
import { DrawerApi } from './drawer-api';
|
||||
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();
|
||||
|
||||
/**
|
||||
* 默认配置
|
||||
*/
|
||||
const DEFAULT_DRAWER_PROPS: Partial<DrawerProps> = {};
|
||||
|
||||
export function setDefaultDrawerProps(props: Partial<DrawerProps>) {
|
||||
Object.assign(DEFAULT_DRAWER_PROPS, props);
|
||||
}
|
||||
|
||||
export function useVbenDrawer<
|
||||
TData = DrawerDataNotProvided,
|
||||
TConnectedComponent extends Component = Component,
|
||||
>(options: DrawerApiOptions<TConnectedComponent> = {}) {
|
||||
type TResolvedData = ResolvedDrawerData<TData, TConnectedComponent>;
|
||||
|
||||
// Drawer一般会抽离出来,所以如果有传入 connectedComponent,则表示为外部调用,与内部组件进行连接
|
||||
// 外部的Drawer通过provide/inject传递api
|
||||
|
||||
const defaultOptions = {
|
||||
closeOnPressEscape: globalEscapeShortcutKey.value, // 全局Esc快捷键配置
|
||||
...options,
|
||||
};
|
||||
const { connectedComponent } = options;
|
||||
if (connectedComponent) {
|
||||
const extendedApi = shallowReactive({}) as ExtendedDrawerApi<TResolvedData>;
|
||||
const isDrawerReady = ref(true);
|
||||
const Drawer = defineComponent(
|
||||
(props: DrawerProps, { attrs, slots }) => {
|
||||
function rebindApi(api: ExtendedDrawerApi<TResolvedData>) {
|
||||
Object.setPrototypeOf(extendedApi, markRaw(api));
|
||||
}
|
||||
|
||||
provide(USER_DRAWER_INJECT_KEY, {
|
||||
extendApi: rebindApi,
|
||||
consumed: false,
|
||||
options: defaultOptions,
|
||||
async reCreateDrawer() {
|
||||
isDrawerReady.value = false;
|
||||
await nextTick();
|
||||
isDrawerReady.value = true;
|
||||
},
|
||||
});
|
||||
checkProps(extendedApi, {
|
||||
...props,
|
||||
...attrs,
|
||||
...slots,
|
||||
});
|
||||
return () =>
|
||||
h(
|
||||
isDrawerReady.value ? connectedComponent : 'div',
|
||||
{ ...props, ...attrs },
|
||||
slots,
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
{
|
||||
name: 'VbenParentDrawer',
|
||||
inheritAttrs: false,
|
||||
},
|
||||
);
|
||||
|
||||
return [Drawer, extendedApi] as const;
|
||||
}
|
||||
|
||||
const injectData = inject<DrawerInjectData<TResolvedData>>(
|
||||
USER_DRAWER_INJECT_KEY,
|
||||
{},
|
||||
);
|
||||
const isConsumed = injectData.consumed;
|
||||
const effectiveOptions = isConsumed ? {} : injectData.options;
|
||||
if (!isConsumed && injectData.consumed !== undefined) {
|
||||
injectData.consumed = true;
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
if (!isConsumed && injectData.consumed !== undefined) {
|
||||
injectData.consumed = false;
|
||||
}
|
||||
});
|
||||
|
||||
const mergedOptions = {
|
||||
...DEFAULT_DRAWER_PROPS,
|
||||
...effectiveOptions,
|
||||
...defaultOptions,
|
||||
} as DrawerApiOptions;
|
||||
|
||||
mergedOptions.onOpenChange = (isOpen: boolean) => {
|
||||
options.onOpenChange?.(isOpen);
|
||||
if (!isConsumed) {
|
||||
injectData.options?.onOpenChange?.(isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
const onClosed = mergedOptions.onClosed;
|
||||
mergedOptions.onClosed = () => {
|
||||
onClosed?.();
|
||||
if (mergedOptions.destroyOnClose && !isConsumed) {
|
||||
if (injectData.consumed !== undefined) {
|
||||
injectData.consumed = false;
|
||||
}
|
||||
injectData.reCreateDrawer?.();
|
||||
}
|
||||
};
|
||||
const api = new DrawerApi<TResolvedData>(mergedOptions);
|
||||
|
||||
const extendedApi = api as ExtendedDrawerApi<TResolvedData>;
|
||||
|
||||
extendedApi.useStore = (selector) => {
|
||||
return useSelector(api.store, selector);
|
||||
};
|
||||
|
||||
const Drawer = defineComponent(
|
||||
(props: DrawerProps, { attrs, slots }) => {
|
||||
return () =>
|
||||
h(VbenDrawer, { ...props, ...attrs, drawerApi: extendedApi }, slots);
|
||||
},
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
{
|
||||
name: 'VbenDrawer',
|
||||
inheritAttrs: false,
|
||||
},
|
||||
);
|
||||
injectData.extendApi?.(extendedApi);
|
||||
return [Drawer, extendedApi] as const;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
await nextTick();
|
||||
|
||||
const state = api?.store?.state;
|
||||
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stateKeys = new Set(Object.keys(state));
|
||||
|
||||
for (const attr of Object.keys(attrs)) {
|
||||
if (stateKeys.has(attr) && !['class'].includes(attr)) {
|
||||
// connectedComponent存在时,不要传入Drawer的props,会造成复杂度提升,如果你需要修改Drawer的props,请使用 useVbenDrawer 或者api
|
||||
console.warn(
|
||||
`[Vben Drawer]: When 'connectedComponent' exists, do not set props or slots '${attr}', which will increase complexity. If you need to modify the props of Drawer, please use useVbenDrawer or api.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './alert';
|
||||
export * from './drawer';
|
||||
export * from './modal';
|
||||
@@ -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>
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { ModalState } from '../modal';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ModalApi } from '../modal-api';
|
||||
|
||||
vi.mock('@vben-core/shared/store', () => {
|
||||
return {
|
||||
isFunction: (fn: any) => typeof fn === 'function',
|
||||
Store: class {
|
||||
get state() {
|
||||
return this._state;
|
||||
}
|
||||
private _state: ModalState;
|
||||
private subscribers: Array<(state: ModalState) => void> = [];
|
||||
|
||||
constructor(initialState: ModalState) {
|
||||
this._state = initialState;
|
||||
}
|
||||
|
||||
setState(fn: (prev: ModalState) => ModalState) {
|
||||
this._state = fn(this._state);
|
||||
this.subscribers.forEach((sub) => sub(this._state));
|
||||
}
|
||||
|
||||
subscribe(fn: (state: ModalState) => void) {
|
||||
this.subscribers.push(fn);
|
||||
return { unsubscribe: () => {} };
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('modalApi', () => {
|
||||
let modalApi: ModalApi;
|
||||
// 使用 modalState 而不是 state
|
||||
let modalState: ModalState;
|
||||
|
||||
beforeEach(() => {
|
||||
modalApi = new ModalApi();
|
||||
// 获取 modalApi 内的 state
|
||||
modalState = modalApi.store.state;
|
||||
});
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
expect(modalState.isOpen).toBe(false);
|
||||
expect(modalState.cancelText).toBe(undefined);
|
||||
expect(modalState.confirmText).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should open the modal', () => {
|
||||
modalApi.open();
|
||||
expect(modalApi.store.state.isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should close the modal if onBeforeClose allows it', () => {
|
||||
modalApi.close();
|
||||
expect(modalApi.store.state.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should not close the modal if onBeforeClose returns false', () => {
|
||||
const onBeforeClose = vi.fn(() => false);
|
||||
const modalApiWithHook = new ModalApi({ onBeforeClose });
|
||||
modalApiWithHook.open();
|
||||
modalApiWithHook.close();
|
||||
expect(modalApiWithHook.store.state.isOpen).toBe(true);
|
||||
expect(onBeforeClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trigger onCancel and close the modal if no onCancel hook is provided', () => {
|
||||
const onCancel = vi.fn();
|
||||
const modalApiWithHook = new ModalApi({ onCancel });
|
||||
modalApiWithHook.open();
|
||||
modalApiWithHook.onCancel();
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
expect(modalApiWithHook.store.state.isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should update shared data correctly', () => {
|
||||
const testData = { key: 'value' };
|
||||
modalApi.setData(testData);
|
||||
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');
|
||||
});
|
||||
|
||||
it('should set state correctly using a function', () => {
|
||||
modalApi.setState((prev) => ({ ...prev, confirmText: 'Yes' }));
|
||||
expect(modalApi.store.state.confirmText).toBe('Yes');
|
||||
});
|
||||
|
||||
it('should call onOpenChange when state changes', () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const modalApiWithHook = new ModalApi({ onOpenChange });
|
||||
modalApiWithHook.open();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should call onClosed callback when provided', () => {
|
||||
const onClosed = vi.fn();
|
||||
const modalApiWithHook = new ModalApi({ onClosed });
|
||||
modalApiWithHook.onClosed();
|
||||
expect(onClosed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onOpened callback when provided', () => {
|
||||
const onOpened = vi.fn();
|
||||
const modalApiWithHook = new ModalApi({ onOpened });
|
||||
modalApiWithHook.open();
|
||||
modalApiWithHook.onOpened();
|
||||
expect(onOpened).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { App } from 'vue';
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, onMounted } from 'vue';
|
||||
|
||||
import { ELEMENT_ID_MAIN_CONTENT } from '@vben-core/shared/constants';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useVbenModal } from '../use-modal';
|
||||
|
||||
vi.mock('@vben-core/preferences', () => ({
|
||||
usePreferences: () => ({
|
||||
globalEscapeShortcutKey: { value: true },
|
||||
}),
|
||||
}));
|
||||
|
||||
let activeApp: App | undefined;
|
||||
|
||||
async function mountModal(options: { onClosed?: () => void } = {}) {
|
||||
const mainContent = document.createElement('main');
|
||||
mainContent.id = ELEMENT_ID_MAIN_CONTENT;
|
||||
mainContent.innerHTML = '<div><div></div></div>';
|
||||
document.body.append(mainContent);
|
||||
|
||||
let capturedApi: ReturnType<typeof useVbenModal>[1] | undefined;
|
||||
const Consumer = defineComponent(() => {
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
appendToMain: true,
|
||||
draggable: true,
|
||||
title: 'Draggable modal',
|
||||
...(options.onClosed ? { onClosed: options.onClosed } : {}),
|
||||
});
|
||||
capturedApi = modalApi;
|
||||
onMounted(() => {
|
||||
modalApi.open();
|
||||
});
|
||||
return () => h(Modal);
|
||||
});
|
||||
const host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
|
||||
activeApp = createApp(() => h(Consumer));
|
||||
activeApp.mount(host);
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
if (!capturedApi) {
|
||||
throw new Error('modal api was not captured');
|
||||
}
|
||||
return { mainContent, modalApi: capturedApi };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
activeApp?.unmount();
|
||||
activeApp = undefined;
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('vben modal', () => {
|
||||
it('mounts an open modal directly in the main content', async () => {
|
||||
const { mainContent } = await mountModal();
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
const overlay = document.querySelector('[data-dismissable-modal]');
|
||||
|
||||
expect(dialog).toBeInstanceOf(HTMLElement);
|
||||
if (!(dialog instanceof HTMLElement)) return;
|
||||
expect(overlay).toBeInstanceOf(HTMLElement);
|
||||
if (!(overlay instanceof HTMLElement)) return;
|
||||
expect(dialog.parentElement).toBe(mainContent);
|
||||
expect(overlay.parentElement).toBe(mainContent);
|
||||
});
|
||||
|
||||
it('constrains dragging to the main content', async () => {
|
||||
const { mainContent } = await mountModal();
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
const header = document.querySelector('.cursor-move');
|
||||
|
||||
expect(dialog).toBeInstanceOf(HTMLElement);
|
||||
if (!(dialog instanceof HTMLElement)) return;
|
||||
expect(header).toBeInstanceOf(HTMLElement);
|
||||
if (!(header instanceof HTMLElement)) return;
|
||||
|
||||
vi.spyOn(mainContent, 'getBoundingClientRect').mockReturnValue(
|
||||
new DOMRect(100, 100, 800, 600),
|
||||
);
|
||||
vi.spyOn(dialog, 'getBoundingClientRect').mockReturnValue(
|
||||
new DOMRect(300, 200, 400, 300),
|
||||
);
|
||||
|
||||
header.dispatchEvent(
|
||||
new MouseEvent('mousedown', {
|
||||
bubbles: true,
|
||||
clientX: 400,
|
||||
clientY: 300,
|
||||
}),
|
||||
);
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('mousemove', {
|
||||
clientX: 1400,
|
||||
clientY: 1300,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(dialog.style.transform).toBe('translate(200px, 200px)');
|
||||
document.dispatchEvent(new MouseEvent('mouseup'));
|
||||
});
|
||||
|
||||
it('fires onClosed via the fallback when no animation event arrives', async () => {
|
||||
vi.useFakeTimers({
|
||||
toFake: [
|
||||
'cancelAnimationFrame',
|
||||
'clearTimeout',
|
||||
'requestAnimationFrame',
|
||||
'setTimeout',
|
||||
],
|
||||
});
|
||||
try {
|
||||
const onClosed = vi.fn();
|
||||
const { modalApi } = await mountModal({ onClosed });
|
||||
// happy-dom fires no animation events — without the fallback the
|
||||
// `closed` event (and with it `onClosed`) would never fire and the
|
||||
// close chain would hang. The fallback timer must acknowledge it.
|
||||
await modalApi.close();
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
expect(onClosed).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('emits closed exactly once when the animation event and the fallback both fire', async () => {
|
||||
vi.useFakeTimers({
|
||||
toFake: [
|
||||
'cancelAnimationFrame',
|
||||
'clearTimeout',
|
||||
'requestAnimationFrame',
|
||||
'setTimeout',
|
||||
],
|
||||
});
|
||||
try {
|
||||
const onClosed = vi.fn();
|
||||
const { modalApi } = await mountModal({ onClosed });
|
||||
await modalApi.close();
|
||||
// The exit animation ends normally — acknowledged immediately...
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
if (!(dialog instanceof HTMLElement)) {
|
||||
throw new Error('dialog content not found');
|
||||
}
|
||||
dialog.dispatchEvent(new Event('animationend'));
|
||||
// ...and the fallback must not emit a second `closed`.
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
expect(onClosed).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { App, Ref } from 'vue';
|
||||
|
||||
import type { ExtendedModalApi } from '../modal';
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, ref, toRaw } from 'vue';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useVbenModal } from '../use-modal';
|
||||
|
||||
vi.mock('@vben-core/preferences', () => ({
|
||||
usePreferences: () => ({
|
||||
globalEscapeShortcutKey: { value: true },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../modal.vue', () => ({
|
||||
default: {
|
||||
name: 'VbenModalStub',
|
||||
render: () => null,
|
||||
},
|
||||
}));
|
||||
|
||||
let activeApp: App | undefined;
|
||||
|
||||
async function mountRebindingHarness() {
|
||||
const consumerKey = ref(0);
|
||||
let currentApi: ExtendedModalApi | undefined;
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
const Consumer = defineComponent(() => {
|
||||
const [Modal, modalApi] = useVbenModal();
|
||||
currentApi = modalApi;
|
||||
return () => h(Modal);
|
||||
});
|
||||
|
||||
const ConnectedModal = defineComponent(() => {
|
||||
return () => h(Consumer, { key: consumerKey.value });
|
||||
});
|
||||
|
||||
const [ParentModal, parentApi] = useVbenModal({
|
||||
connectedComponent: ConnectedModal,
|
||||
onOpenChange,
|
||||
title: 'Parent modal title',
|
||||
});
|
||||
const host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
|
||||
activeApp = createApp(() => h(ParentModal));
|
||||
activeApp.mount(host);
|
||||
await nextTick();
|
||||
|
||||
return {
|
||||
consumerKey,
|
||||
getCurrentApi: () => currentApi,
|
||||
onOpenChange,
|
||||
parentApi,
|
||||
};
|
||||
}
|
||||
|
||||
async function remountConsumer(consumerKey: Ref<number>) {
|
||||
consumerKey.value += 1;
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
activeApp?.unmount();
|
||||
activeApp = undefined;
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('useVbenModal', () => {
|
||||
it('rebinds the parent api when the consumer is recreated', async () => {
|
||||
const { consumerKey, getCurrentApi, onOpenChange, parentApi } =
|
||||
await mountRebindingHarness();
|
||||
const initialApi = getCurrentApi();
|
||||
|
||||
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();
|
||||
|
||||
expect(recreatedApi).toBeDefined();
|
||||
if (!recreatedApi) return;
|
||||
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);
|
||||
expect(recreatedApi.store.state.isOpen).toBe(true);
|
||||
expect(initialApi.store.state.isOpen).toBe(false);
|
||||
|
||||
await parentApi.close();
|
||||
expect(recreatedApi.store.state.isOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export type * from './modal';
|
||||
export { default as VbenModal } from './modal.vue';
|
||||
export {
|
||||
createVbenModal,
|
||||
setDefaultModalProps,
|
||||
useVbenModal,
|
||||
} from './use-modal';
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { ModalApiOptions, ModalState } from './modal';
|
||||
|
||||
import { Store } from '@vben-core/shared/store';
|
||||
import { bindMethods, isFunction } from '@vben-core/shared/utils';
|
||||
|
||||
export class ModalApi<TData = unknown> {
|
||||
// 共享数据
|
||||
public sharedData: Record<'payload', TData | undefined> = {
|
||||
payload: undefined,
|
||||
};
|
||||
public store: Store<ModalState>;
|
||||
|
||||
private api: Pick<
|
||||
ModalApiOptions,
|
||||
| 'onBeforeClose'
|
||||
| 'onCancel'
|
||||
| 'onClosed'
|
||||
| 'onConfirm'
|
||||
| 'onOpenChange'
|
||||
| 'onOpened'
|
||||
>;
|
||||
|
||||
// private prevState!: ModalState;
|
||||
private state!: ModalState;
|
||||
|
||||
constructor(options: ModalApiOptions = {}) {
|
||||
const {
|
||||
connectedComponent: _,
|
||||
onBeforeClose,
|
||||
onCancel,
|
||||
onClosed,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
onOpened,
|
||||
...storeState
|
||||
} = options;
|
||||
|
||||
const defaultState: ModalState = {
|
||||
bordered: true,
|
||||
centered: false,
|
||||
class: '',
|
||||
closeOnClickModal: true,
|
||||
closeOnPressEscape: true,
|
||||
confirmDisabled: false,
|
||||
confirmLoading: false,
|
||||
contentClass: '',
|
||||
destroyOnClose: true,
|
||||
draggable: false,
|
||||
overflow: false,
|
||||
footer: true,
|
||||
footerClass: '',
|
||||
fullscreen: false,
|
||||
fullscreenButton: true,
|
||||
header: true,
|
||||
headerClass: '',
|
||||
isOpen: false,
|
||||
loading: false,
|
||||
modal: true,
|
||||
openAutoFocus: false,
|
||||
showCancelButton: true,
|
||||
showConfirmButton: true,
|
||||
title: '',
|
||||
animationType: 'slide',
|
||||
};
|
||||
|
||||
this.store = new Store<ModalState>({
|
||||
...defaultState,
|
||||
...storeState,
|
||||
});
|
||||
|
||||
this.store.subscribe((state) => {
|
||||
// 每次更新状态时,都会调用 onOpenChange 回调函数
|
||||
const prevIsOpen = this.state?.isOpen;
|
||||
this.state = state;
|
||||
if (state?.isOpen !== prevIsOpen) {
|
||||
this.api.onOpenChange?.(!!state?.isOpen);
|
||||
}
|
||||
});
|
||||
|
||||
this.state = this.store.state;
|
||||
|
||||
this.api = {
|
||||
onBeforeClose,
|
||||
onCancel,
|
||||
onClosed,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
onOpened,
|
||||
};
|
||||
bindMethods(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭弹窗
|
||||
* @description 关闭弹窗时会调用 onBeforeClose 钩子函数,如果 onBeforeClose 返回 false,则不关闭弹窗
|
||||
*/
|
||||
async close() {
|
||||
// 通过 onBeforeClose 钩子函数来判断是否允许关闭弹窗
|
||||
// 如果 onBeforeClose 返回 false,则不关闭弹窗
|
||||
const allowClose = (await this.api.onBeforeClose?.()) ?? true;
|
||||
if (allowClose) {
|
||||
this.store.setState((prev) => ({
|
||||
...prev,
|
||||
isOpen: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
getData(): TData | undefined {
|
||||
return this.sharedData.payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁定弹窗状态(用于提交过程中的等待状态)
|
||||
* @description 锁定状态将禁用默认的取消按钮,使用spinner覆盖弹窗内容,隐藏关闭按钮,阻止手动关闭弹窗,将默认的提交按钮标记为loading状态
|
||||
* @param isLocked 是否锁定
|
||||
*/
|
||||
lock(isLocked = true) {
|
||||
return this.setState({ submitting: isLocked });
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消操作
|
||||
*/
|
||||
onCancel() {
|
||||
if (this.api.onCancel) {
|
||||
this.api.onCancel?.();
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗关闭动画播放完毕后的回调
|
||||
*/
|
||||
onClosed() {
|
||||
if (!this.state.isOpen) {
|
||||
this.api.onClosed?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认操作
|
||||
*/
|
||||
onConfirm() {
|
||||
this.api.onConfirm?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗打开动画播放完毕后的回调
|
||||
*/
|
||||
onOpened() {
|
||||
if (this.state.isOpen) {
|
||||
this.api.onOpened?.();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.store.setState((prev) => ({
|
||||
...prev,
|
||||
isOpen: true,
|
||||
submitting: false,
|
||||
}));
|
||||
}
|
||||
|
||||
setData(payload: TData) {
|
||||
this.sharedData.payload = payload;
|
||||
return this;
|
||||
}
|
||||
|
||||
setState(
|
||||
stateOrFn:
|
||||
| ((prev: ModalState) => Partial<ModalState>)
|
||||
| Partial<ModalState>,
|
||||
) {
|
||||
if (isFunction(stateOrFn)) {
|
||||
this.store.setState(stateOrFn);
|
||||
} else {
|
||||
this.store.setState((prev) => ({ ...prev, ...stateOrFn }));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除弹窗的锁定状态
|
||||
* @description 解除由lock方法设置的锁定状态,是lock(false)的别名
|
||||
*/
|
||||
unlock() {
|
||||
return this.lock(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { Component, Ref } from 'vue';
|
||||
|
||||
import type { ClassType, MaybePromise } from '@vben-core/typings';
|
||||
|
||||
import type { ModalApi } from './modal-api';
|
||||
|
||||
export interface ModalProps {
|
||||
/**
|
||||
* 动画类型
|
||||
* @default 'slide'
|
||||
*/
|
||||
animationType?: 'scale' | 'slide';
|
||||
/**
|
||||
* 是否要挂载到内容区域
|
||||
* @default false
|
||||
*/
|
||||
appendToMain?: boolean;
|
||||
/**
|
||||
* 是否显示边框
|
||||
* @default false
|
||||
*/
|
||||
bordered?: boolean;
|
||||
/**
|
||||
* 取消按钮文字
|
||||
*/
|
||||
cancelText?: string;
|
||||
/**
|
||||
* 是否居中
|
||||
* @default false
|
||||
*/
|
||||
centered?: boolean;
|
||||
|
||||
class?: ClassType;
|
||||
|
||||
/**
|
||||
* 是否显示右上角的关闭按钮
|
||||
* @default true
|
||||
*/
|
||||
closable?: boolean;
|
||||
/**
|
||||
* 点击弹窗遮罩是否关闭弹窗
|
||||
* @default true
|
||||
*/
|
||||
closeOnClickModal?: boolean;
|
||||
/**
|
||||
* 按下 ESC 键是否关闭弹窗
|
||||
* @default true
|
||||
*/
|
||||
closeOnPressEscape?: boolean;
|
||||
/**
|
||||
* 禁用确认按钮
|
||||
*/
|
||||
confirmDisabled?: boolean;
|
||||
/**
|
||||
* 确定按钮 loading
|
||||
* @default false
|
||||
*/
|
||||
confirmLoading?: boolean;
|
||||
/**
|
||||
* 确定按钮文字
|
||||
*/
|
||||
confirmText?: string;
|
||||
contentClass?: ClassType;
|
||||
/**
|
||||
* 弹窗描述
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* 在关闭时销毁弹窗
|
||||
*/
|
||||
destroyOnClose?: boolean;
|
||||
/**
|
||||
* 是否可拖拽
|
||||
* @default false
|
||||
*/
|
||||
draggable?: boolean;
|
||||
/**
|
||||
* 是否显示底部
|
||||
* @default true
|
||||
*/
|
||||
footer?: boolean;
|
||||
footerClass?: ClassType;
|
||||
/**
|
||||
* 是否全屏
|
||||
* @default false
|
||||
*/
|
||||
fullscreen?: boolean;
|
||||
/**
|
||||
* 是否显示全屏按钮
|
||||
* @default true
|
||||
*/
|
||||
fullscreenButton?: boolean;
|
||||
/**
|
||||
* 是否显示顶栏
|
||||
* @default true
|
||||
*/
|
||||
header?: boolean;
|
||||
headerClass?: ClassType;
|
||||
/**
|
||||
* 弹窗加载状态
|
||||
* @default false
|
||||
*/
|
||||
loading?: boolean;
|
||||
/**
|
||||
* 是否显示遮罩
|
||||
* @default true
|
||||
*/
|
||||
modal?: boolean;
|
||||
/**
|
||||
* 是否自动聚焦
|
||||
*/
|
||||
openAutoFocus?: boolean;
|
||||
/**
|
||||
* 拖动范围是否可以超出可视区
|
||||
* @default false
|
||||
*/
|
||||
overflow?: boolean;
|
||||
/**
|
||||
* 弹窗遮罩模糊效果
|
||||
*/
|
||||
overlayBlur?: number;
|
||||
/**
|
||||
* 是否显示取消按钮
|
||||
* @default true
|
||||
*/
|
||||
showCancelButton?: boolean;
|
||||
/**
|
||||
* 是否显示确认按钮
|
||||
* @default true
|
||||
*/
|
||||
showConfirmButton?: boolean;
|
||||
/**
|
||||
* 提交中(锁定弹窗状态)
|
||||
*/
|
||||
submitting?: boolean;
|
||||
/**
|
||||
* 弹窗标题
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* 弹窗标题提示
|
||||
*/
|
||||
titleTooltip?: string;
|
||||
/**
|
||||
* 弹窗层级
|
||||
*/
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
export interface ModalState extends ModalProps {
|
||||
/** 弹窗打开状态 */
|
||||
isOpen?: boolean;
|
||||
}
|
||||
|
||||
export type ExtendedModalApi<TData = unknown> = ModalApi<TData> & {
|
||||
useStore: <T = NoInfer<ModalState>>(
|
||||
selector?: (state: NoInfer<ModalState>) => T,
|
||||
) => Readonly<Ref<T>>;
|
||||
};
|
||||
|
||||
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?: TConnectedComponent;
|
||||
/**
|
||||
* 关闭前的回调,返回 false 可以阻止关闭
|
||||
* @returns
|
||||
*/
|
||||
onBeforeClose?: () => MaybePromise<boolean | undefined>;
|
||||
/**
|
||||
* 点击取消按钮的回调
|
||||
*/
|
||||
onCancel?: () => void;
|
||||
/**
|
||||
* 弹窗关闭动画结束的回调
|
||||
* @returns
|
||||
*/
|
||||
onClosed?: () => void;
|
||||
/**
|
||||
* 点击确定按钮的回调
|
||||
*/
|
||||
onConfirm?: () => void;
|
||||
/**
|
||||
* 弹窗状态变化回调
|
||||
* @param isOpen
|
||||
* @returns
|
||||
*/
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
/**
|
||||
* 弹窗打开动画结束的回调
|
||||
* @returns
|
||||
*/
|
||||
onOpened?: () => void;
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ExtendedModalApi, ModalProps } from './modal';
|
||||
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onDeactivated,
|
||||
provide,
|
||||
ref,
|
||||
unref,
|
||||
useId,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import { usePriorityValues, useSimpleLocale } from '@vben-core/composables';
|
||||
import { Expand, Shrink } from '@vben-core/icons';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
VbenButton,
|
||||
VbenHelpTooltip,
|
||||
VbenIconButton,
|
||||
VbenLoading,
|
||||
VisuallyHidden,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { ELEMENT_ID_MAIN_CONTENT } from '@vben-core/shared/constants';
|
||||
import { globalShareState } from '@vben-core/shared/global-state';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { useModalDraggable } from './use-modal-draggable';
|
||||
|
||||
interface Props extends ModalProps {
|
||||
modalApi?: ExtendedModalApi;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
appendToMain: false,
|
||||
destroyOnClose: false,
|
||||
modalApi: undefined,
|
||||
});
|
||||
|
||||
const components = globalShareState.getComponents();
|
||||
|
||||
const contentRef = ref();
|
||||
// @ts-expect-error unused
|
||||
const wrapperRef = ref<HTMLElement>();
|
||||
const dialogRef = ref();
|
||||
const headerRef = ref();
|
||||
// @ts-expect-error unused
|
||||
const footerRef = ref();
|
||||
|
||||
const { $t } = useSimpleLocale();
|
||||
const state = props.modalApi?.useStore?.();
|
||||
|
||||
const id = useId();
|
||||
// 遮罩层通过该 id 标记,仅当点击发生在当前 Modal 的遮罩上时才允许关闭
|
||||
provide('DISMISSABLE_MODAL_ID', id);
|
||||
|
||||
const {
|
||||
appendToMain,
|
||||
bordered,
|
||||
cancelText,
|
||||
centered,
|
||||
class: modalClass,
|
||||
closable,
|
||||
closeOnClickModal,
|
||||
closeOnPressEscape,
|
||||
confirmDisabled,
|
||||
confirmLoading,
|
||||
confirmText,
|
||||
contentClass,
|
||||
description,
|
||||
destroyOnClose,
|
||||
draggable,
|
||||
overflow,
|
||||
footer: showFooter,
|
||||
footerClass,
|
||||
fullscreen,
|
||||
fullscreenButton,
|
||||
header,
|
||||
headerClass,
|
||||
loading: showLoading,
|
||||
modal,
|
||||
openAutoFocus,
|
||||
overlayBlur,
|
||||
showCancelButton,
|
||||
showConfirmButton,
|
||||
submitting,
|
||||
title,
|
||||
titleTooltip,
|
||||
animationType,
|
||||
zIndex,
|
||||
} = usePriorityValues(props, state);
|
||||
|
||||
const shouldFullscreen = computed(() => fullscreen.value);
|
||||
|
||||
const shouldDraggable = computed(
|
||||
() => draggable.value && !shouldFullscreen.value && header.value,
|
||||
);
|
||||
|
||||
const shouldCentered = computed(
|
||||
() => centered.value && !shouldFullscreen.value,
|
||||
);
|
||||
|
||||
const getAppendTo = computed(() => {
|
||||
return appendToMain.value ? `#${ELEMENT_ID_MAIN_CONTENT}` : undefined;
|
||||
});
|
||||
|
||||
const { dragging, transform } = useModalDraggable(
|
||||
dialogRef,
|
||||
headerRef,
|
||||
shouldDraggable,
|
||||
getAppendTo,
|
||||
shouldCentered,
|
||||
overflow,
|
||||
);
|
||||
|
||||
const firstOpened = ref(false);
|
||||
const isClosed = ref(true);
|
||||
|
||||
watch(
|
||||
() => state?.value?.isOpen,
|
||||
async (v) => {
|
||||
if (v) {
|
||||
isClosed.value = false;
|
||||
if (!firstOpened.value) firstOpened.value = true;
|
||||
await nextTick();
|
||||
if (!contentRef.value) return;
|
||||
const innerContentRef = contentRef.value.getContentRef();
|
||||
dialogRef.value = innerContentRef.$el;
|
||||
// reopen modal reassign value
|
||||
const { offsetX, offsetY } = transform;
|
||||
dialogRef.value.style.transform = shouldCentered.value
|
||||
? `translate(${offsetX}px, calc(-50% + ${offsetY}px))`
|
||||
: `translate(${offsetX}px, ${offsetY}px)`;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// watch(
|
||||
// () => [showLoading.value, submitting.value],
|
||||
// ([l, s]) => {
|
||||
// if ((s || l) && wrapperRef.value) {
|
||||
// wrapperRef.value.scrollTo({
|
||||
// // behavior: 'smooth',
|
||||
// top: 0,
|
||||
// });
|
||||
// }
|
||||
// },
|
||||
// );
|
||||
|
||||
/**
|
||||
* 在开启keepAlive情况下 直接通过浏览器按钮/手势等返回 不会关闭弹窗
|
||||
*/
|
||||
onDeactivated(() => {
|
||||
// 如果弹窗没有被挂载到内容区域,则关闭弹窗
|
||||
if (!appendToMain.value) {
|
||||
props.modalApi?.close();
|
||||
}
|
||||
});
|
||||
|
||||
function handleFullscreen() {
|
||||
props.modalApi?.setState((prev) => {
|
||||
// if (prev.fullscreen) {
|
||||
// resetPosition();
|
||||
// }
|
||||
return { ...prev, fullscreen: !fullscreen.value };
|
||||
});
|
||||
}
|
||||
function interactOutside(e: Event) {
|
||||
if (!closeOnClickModal.value || submitting.value) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
function escapeKeyDown(e: KeyboardEvent) {
|
||||
if (!closeOnPressEscape.value || submitting.value) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenAutoFocus(e: Event) {
|
||||
if (!openAutoFocus.value) {
|
||||
e?.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// pointer-down-outside
|
||||
function pointerDownOutside(e: Event) {
|
||||
const target = e.target as HTMLElement;
|
||||
const isDismissableModal = target?.dataset.dismissableModal;
|
||||
if (
|
||||
!closeOnClickModal.value ||
|
||||
isDismissableModal !== id ||
|
||||
submitting.value
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocusOutside(e: Event) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
function handleCloseAutoFocus(_e: Event) {
|
||||
// allow reka-ui to return focus to the trigger element on close
|
||||
}
|
||||
|
||||
const getForceMount = computed(() => {
|
||||
return !unref(destroyOnClose) && unref(firstOpened);
|
||||
});
|
||||
|
||||
const handleOpened = () => {
|
||||
requestAnimationFrame(() => {
|
||||
props.modalApi?.onOpened();
|
||||
});
|
||||
};
|
||||
|
||||
function handleClosed() {
|
||||
isClosed.value = true;
|
||||
props.modalApi?.onClosed();
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Dialog
|
||||
:modal="false"
|
||||
:open="state?.isOpen"
|
||||
@update:open="() => (!submitting ? modalApi?.close() : undefined)"
|
||||
>
|
||||
<DialogContent
|
||||
ref="contentRef"
|
||||
:append-to="getAppendTo"
|
||||
:class="
|
||||
cn(
|
||||
'inset-x-0 top-[10vh] mx-auto flex w-130 flex-col p-0',
|
||||
shouldFullscreen ? 'rounded-none' : 'rounded-(--radius)',
|
||||
{
|
||||
'border border-border': bordered,
|
||||
'shadow-3xl': !bordered,
|
||||
'max-h-[min(80%,calc(100dvh-20px))] max-w-[calc(100vw-20px)]':
|
||||
!shouldFullscreen,
|
||||
'top-0 left-0 size-full! max-h-full! max-w-full! transform-[translate(0,0)]!':
|
||||
shouldFullscreen,
|
||||
'top-1/2': centered && !shouldFullscreen,
|
||||
'duration-300': !dragging,
|
||||
hidden: isClosed,
|
||||
},
|
||||
modalClass,
|
||||
)
|
||||
"
|
||||
:force-mount="getForceMount"
|
||||
:modal="modal"
|
||||
:open="state?.isOpen"
|
||||
:show-close-button="closable"
|
||||
:animation-type="animationType"
|
||||
:z-index="zIndex"
|
||||
:overlay-blur="overlayBlur"
|
||||
close-class="top-3"
|
||||
@close-auto-focus="handleCloseAutoFocus"
|
||||
@closed="handleClosed"
|
||||
:close-disabled="submitting"
|
||||
@escape-key-down="escapeKeyDown"
|
||||
@focus-outside="handleFocusOutside"
|
||||
@interact-outside="interactOutside"
|
||||
@open-auto-focus="handleOpenAutoFocus"
|
||||
@opened="handleOpened"
|
||||
@pointer-down-outside="pointerDownOutside"
|
||||
>
|
||||
<DialogHeader
|
||||
ref="headerRef"
|
||||
:class="
|
||||
cn(
|
||||
'px-5 py-4',
|
||||
{
|
||||
'border-b': bordered,
|
||||
hidden: !header,
|
||||
'cursor-move select-none': shouldDraggable,
|
||||
},
|
||||
headerClass,
|
||||
)
|
||||
"
|
||||
>
|
||||
<DialogTitle v-if="title" class="text-left">
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
|
||||
<slot v-if="titleTooltip" name="titleTooltip">
|
||||
<VbenHelpTooltip trigger-class="pb-1">
|
||||
{{ titleTooltip }}
|
||||
</VbenHelpTooltip>
|
||||
</slot>
|
||||
</slot>
|
||||
</DialogTitle>
|
||||
<DialogDescription v-if="description">
|
||||
<slot name="description">
|
||||
{{ description }}
|
||||
</slot>
|
||||
</DialogDescription>
|
||||
<VisuallyHidden v-if="!title || !description">
|
||||
<DialogTitle v-if="!title" />
|
||||
<DialogDescription v-if="!description" />
|
||||
</VisuallyHidden>
|
||||
</DialogHeader>
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
:class="
|
||||
cn('relative min-h-40 flex-1 overflow-y-auto p-3', contentClass, {
|
||||
'pointer-events-none': showLoading || submitting,
|
||||
})
|
||||
"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
<VbenLoading v-if="showLoading || submitting" spinning />
|
||||
<VbenIconButton
|
||||
v-if="fullscreenButton"
|
||||
class="absolute top-3 right-10 flex-center size-6 rounded-full px-1 text-lg text-foreground/80 opacity-70 transition-opacity hover:bg-accent hover:text-accent-foreground hover:opacity-100 focus:outline-hidden disabled:pointer-events-none"
|
||||
@click="handleFullscreen"
|
||||
>
|
||||
<Shrink v-if="fullscreen" class="size-3.5" />
|
||||
<Expand v-else class="size-3.5" />
|
||||
</VbenIconButton>
|
||||
|
||||
<DialogFooter
|
||||
ref="footerRef"
|
||||
v-if="showFooter"
|
||||
:class="
|
||||
cn(
|
||||
'flex-row items-center justify-end p-2',
|
||||
{
|
||||
'border-t': bordered,
|
||||
},
|
||||
footerClass,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="prepend-footer"></slot>
|
||||
<slot name="footer">
|
||||
<component
|
||||
:is="components.DefaultButton || VbenButton"
|
||||
v-if="showCancelButton"
|
||||
variant="outline"
|
||||
:disabled="submitting"
|
||||
@click="() => modalApi?.onCancel()"
|
||||
>
|
||||
<slot name="cancelText">
|
||||
{{ cancelText || $t('cancel') }}
|
||||
</slot>
|
||||
</component>
|
||||
<slot name="center-footer"></slot>
|
||||
<component
|
||||
:is="components.PrimaryButton || VbenButton"
|
||||
v-if="showConfirmButton"
|
||||
:disabled="confirmDisabled"
|
||||
:loading="confirmLoading || submitting"
|
||||
@click="() => modalApi?.onConfirm()"
|
||||
>
|
||||
<slot name="confirmText">
|
||||
{{ confirmText || $t('confirm') }}
|
||||
</slot>
|
||||
</component>
|
||||
</slot>
|
||||
<slot name="append-footer"></slot>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @copy https://github.com/element-plus/element-plus/blob/dev/packages/hooks/use-draggable/index.ts
|
||||
* 调整部分细节
|
||||
*/
|
||||
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
|
||||
import { onBeforeUnmount, onMounted, reactive, ref, watchEffect } from 'vue';
|
||||
|
||||
import { unrefElement } from '@vueuse/core';
|
||||
|
||||
export function useModalDraggable(
|
||||
targetRef: Ref<HTMLElement | undefined>,
|
||||
dragRef: Ref<HTMLElement | undefined>,
|
||||
draggable: ComputedRef<boolean>,
|
||||
containerSelector?: ComputedRef<string | undefined>,
|
||||
centered?: ComputedRef<boolean>,
|
||||
overflow?: ComputedRef<boolean>,
|
||||
) {
|
||||
const transform = reactive({
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
|
||||
const dragging = ref(false);
|
||||
|
||||
const onMousedown = (e: MouseEvent) => {
|
||||
const downX = e.clientX;
|
||||
const downY = e.clientY;
|
||||
|
||||
if (!targetRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRect = targetRef.value.getBoundingClientRect();
|
||||
const { offsetX, offsetY } = transform;
|
||||
const targetLeft = targetRect.left;
|
||||
const targetTop = targetRect.top;
|
||||
const targetWidth = targetRect.width;
|
||||
const targetHeight = targetRect.height;
|
||||
|
||||
let containerRect: DOMRect | null = null;
|
||||
|
||||
if (containerSelector?.value) {
|
||||
const container = document.querySelector(containerSelector.value);
|
||||
if (container) {
|
||||
containerRect = container.getBoundingClientRect();
|
||||
}
|
||||
}
|
||||
|
||||
let maxLeft, maxTop, minLeft, minTop;
|
||||
if (containerRect) {
|
||||
minLeft = containerRect.left - targetLeft + offsetX;
|
||||
maxLeft = containerRect.right - targetLeft - targetWidth + offsetX;
|
||||
minTop = containerRect.top - targetTop + offsetY;
|
||||
maxTop = containerRect.bottom - targetTop - targetHeight + offsetY;
|
||||
} else {
|
||||
const docElement = document.documentElement;
|
||||
const clientWidth = docElement.clientWidth;
|
||||
const clientHeight = docElement.clientHeight;
|
||||
minLeft = -targetLeft + offsetX;
|
||||
minTop = -targetTop + offsetY;
|
||||
maxLeft = clientWidth - targetLeft - targetWidth + offsetX;
|
||||
maxTop = clientHeight - targetTop - targetHeight + offsetY;
|
||||
}
|
||||
|
||||
const onMousemove = (e: MouseEvent) => {
|
||||
let moveX = offsetX + e.clientX - downX;
|
||||
let moveY = offsetY + e.clientY - downY;
|
||||
|
||||
if (!overflow?.value) {
|
||||
moveX = Math.min(Math.max(moveX, minLeft), maxLeft);
|
||||
moveY = Math.min(Math.max(moveY, minTop), maxTop);
|
||||
}
|
||||
|
||||
transform.offsetX = moveX;
|
||||
transform.offsetY = moveY;
|
||||
|
||||
if (targetRef.value) {
|
||||
const isCentered = centered?.value;
|
||||
targetRef.value.style.transform = isCentered
|
||||
? `translate(${moveX}px, calc(-50% + ${moveY}px))`
|
||||
: `translate(${moveX}px, ${moveY}px)`;
|
||||
dragging.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseup = () => {
|
||||
dragging.value = false;
|
||||
document.removeEventListener('mousemove', onMousemove);
|
||||
document.removeEventListener('mouseup', onMouseup);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMousemove);
|
||||
document.addEventListener('mouseup', onMouseup);
|
||||
};
|
||||
|
||||
const onDraggable = () => {
|
||||
const dragDom = unrefElement(dragRef);
|
||||
if (dragDom && targetRef.value) {
|
||||
dragDom.addEventListener('mousedown', onMousedown);
|
||||
}
|
||||
};
|
||||
|
||||
const offDraggable = () => {
|
||||
const dragDom = unrefElement(dragRef);
|
||||
if (dragDom && targetRef.value) {
|
||||
dragDom.removeEventListener('mousedown', onMousedown);
|
||||
}
|
||||
};
|
||||
|
||||
const resetPosition = () => {
|
||||
transform.offsetX = 0;
|
||||
transform.offsetY = 0;
|
||||
|
||||
const target = unrefElement(targetRef);
|
||||
if (target) {
|
||||
target.style.transform = '';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
watchEffect(() => {
|
||||
if (draggable.value) {
|
||||
onDraggable();
|
||||
} else {
|
||||
offDraggable();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
offDraggable();
|
||||
});
|
||||
|
||||
return {
|
||||
dragging,
|
||||
resetPosition,
|
||||
transform,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import type {
|
||||
ExtendedModalApi,
|
||||
InferModalData,
|
||||
ModalApiOptions,
|
||||
ModalProps,
|
||||
} from './modal';
|
||||
|
||||
import {
|
||||
defineComponent,
|
||||
h,
|
||||
inject,
|
||||
markRaw,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
provide,
|
||||
ref,
|
||||
shallowReactive,
|
||||
} from 'vue';
|
||||
|
||||
import { usePreferences } from '@vben-core/preferences';
|
||||
import { useSelector } from '@vben-core/shared/store';
|
||||
|
||||
import { ModalApi } from './modal-api';
|
||||
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();
|
||||
/**
|
||||
* 默认配置
|
||||
*/
|
||||
const DEFAULT_MODAL_PROPS: Partial<ModalProps> = {};
|
||||
|
||||
export function setDefaultModalProps(props: Partial<ModalProps>) {
|
||||
Object.assign(DEFAULT_MODAL_PROPS, props);
|
||||
}
|
||||
|
||||
export function useVbenModal<
|
||||
TData = ModalDataNotProvided,
|
||||
TConnectedComponent extends Component = Component,
|
||||
>(options: ModalApiOptions<TConnectedComponent> = {}) {
|
||||
type TResolvedData = ResolvedModalData<TData, TConnectedComponent>;
|
||||
|
||||
// Modal一般会抽离出来,所以如果有传入 connectedComponent,则表示为外部调用,与内部组件进行连接
|
||||
// 外部的Modal通过provide/inject传递api
|
||||
|
||||
const defaultOptions = {
|
||||
closeOnPressEscape: globalEscapeShortcutKey.value, // 全局Esc快捷键配置
|
||||
...options,
|
||||
};
|
||||
const { connectedComponent } = options;
|
||||
if (connectedComponent) {
|
||||
const extendedApi = shallowReactive({}) as ExtendedModalApi<TResolvedData>;
|
||||
const isModalReady = ref(true);
|
||||
const Modal = defineComponent(
|
||||
(props: ModalProps, { attrs, slots }) => {
|
||||
function rebindApi(api: ExtendedModalApi<TResolvedData>) {
|
||||
Object.setPrototypeOf(extendedApi, markRaw(api));
|
||||
}
|
||||
|
||||
provide(USER_MODAL_INJECT_KEY, {
|
||||
extendApi: rebindApi,
|
||||
consumed: false,
|
||||
options: defaultOptions,
|
||||
async reCreateModal() {
|
||||
isModalReady.value = false;
|
||||
await nextTick();
|
||||
isModalReady.value = true;
|
||||
},
|
||||
});
|
||||
checkProps(extendedApi, {
|
||||
...props,
|
||||
...attrs,
|
||||
...slots,
|
||||
});
|
||||
return () =>
|
||||
h(
|
||||
isModalReady.value ? connectedComponent : 'div',
|
||||
{
|
||||
...props,
|
||||
...attrs,
|
||||
},
|
||||
slots,
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
{
|
||||
name: 'VbenParentModal',
|
||||
inheritAttrs: false,
|
||||
},
|
||||
);
|
||||
|
||||
return [Modal, extendedApi] as const;
|
||||
}
|
||||
|
||||
const injectData = inject<ModalInjectData<TResolvedData>>(
|
||||
USER_MODAL_INJECT_KEY,
|
||||
{},
|
||||
);
|
||||
const isConsumed = injectData.consumed;
|
||||
const effectiveOptions = isConsumed ? {} : injectData.options;
|
||||
if (!isConsumed && injectData.consumed !== undefined) {
|
||||
injectData.consumed = true;
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
if (!isConsumed && injectData.consumed !== undefined) {
|
||||
injectData.consumed = false;
|
||||
}
|
||||
});
|
||||
|
||||
const mergedOptions = {
|
||||
...DEFAULT_MODAL_PROPS,
|
||||
...effectiveOptions,
|
||||
...defaultOptions,
|
||||
} as ModalApiOptions;
|
||||
|
||||
mergedOptions.onOpenChange = (isOpen: boolean) => {
|
||||
options.onOpenChange?.(isOpen);
|
||||
if (!isConsumed) {
|
||||
injectData.options?.onOpenChange?.(isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
const onClosed = mergedOptions.onClosed;
|
||||
mergedOptions.onClosed = () => {
|
||||
onClosed?.();
|
||||
if (mergedOptions.destroyOnClose && !isConsumed) {
|
||||
if (injectData.consumed !== undefined) {
|
||||
injectData.consumed = false;
|
||||
}
|
||||
injectData.reCreateModal?.();
|
||||
}
|
||||
};
|
||||
|
||||
const api = new ModalApi<TResolvedData>(mergedOptions);
|
||||
|
||||
const extendedApi = api as ExtendedModalApi<TResolvedData>;
|
||||
|
||||
extendedApi.useStore = (selector) => {
|
||||
return useSelector(api.store, selector);
|
||||
};
|
||||
|
||||
const Modal = defineComponent(
|
||||
(props: ModalProps, { attrs, slots }) => {
|
||||
return () =>
|
||||
h(
|
||||
VbenModal,
|
||||
{
|
||||
...props,
|
||||
...attrs,
|
||||
modalApi: extendedApi,
|
||||
},
|
||||
slots,
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
{
|
||||
name: 'VbenModal',
|
||||
inheritAttrs: false,
|
||||
},
|
||||
);
|
||||
injectData.extendApi?.(extendedApi);
|
||||
|
||||
return [Modal, extendedApi] as const;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
await nextTick();
|
||||
|
||||
const state = api?.store?.state;
|
||||
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stateKeys = new Set(Object.keys(state));
|
||||
|
||||
for (const attr of Object.keys(attrs)) {
|
||||
if (stateKeys.has(attr) && !['class'].includes(attr)) {
|
||||
// connectedComponent存在时,不要传入Modal的props,会造成复杂度提升,如果你需要修改Modal的props,请使用 useModal 或者api
|
||||
console.warn(
|
||||
`[Vben Modal]: When 'connectedComponent' exists, do not set props or slots '${attr}', which will increase complexity. If you need to modify the props of Modal, please use useVbenModal or api.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user