first commit

This commit is contained in:
2025-09-22 01:39:01 +08:00
commit 8af3499484
1466 changed files with 126409 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: [
{
builder: 'mkdist',
input: './src',
loaders: ['vue'],
pattern: ['**/*.vue'],
},
{
builder: 'mkdist',
format: 'esm',
input: './src',
loaders: ['js'],
pattern: ['**/*.ts'],
},
],
});

View File

@@ -0,0 +1,48 @@
{
"name": "@vben-core/popup-ui",
"version": "5.2.1",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/uikit/popup-ui"
},
"license": "MIT",
"type": "module",
"scripts": {
"build": "pnpm unbuild",
"prepublishOnly": "npm run build"
},
"files": [
"dist"
],
"sideEffects": [
"**/*.css"
],
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"default": "./dist/index.mjs"
}
},
"publishConfig": {
"exports": {
".": {
"default": "./dist/index.mjs"
}
}
},
"dependencies": {
"@vben-core/composables": "workspace:*",
"@vben-core/icons": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/shared": "workspace:*",
"@vben-core/typings": "workspace:*",
"@vueuse/core": "catalog:",
"vue": "catalog:"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config/postcss';

View File

@@ -0,0 +1,244 @@
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[] = [];
staticContents.push(h(VbenRenderContent, { content, renderBr: true }));
const modelPropName = _modelPropName || 'modelValue';
const componentProps = { ..._componentProps };
// 每次渲染时都会重新计算的内容函数
const contentRenderer = () => {
const currentProps = { ...componentProps };
// 设置当前值
currentProps[modelPropName] = modelValue.value;
// 设置更新处理函数
currentProps[`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 = [];
}

View File

@@ -0,0 +1,99 @@
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;
/** 弹窗底部内容(与按钮在同一个容器中) */
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;
}

View File

@@ -0,0 +1,211 @@
<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 {
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,
containerClass: 'w-[520px]',
});
const emits = defineEmits(['closed', 'confirm', 'opened']);
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() {
isConfirm.value = false;
}
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 :open="open" @update:open="handleOpenChange">
<AlertDialogContent
:open="open"
:centered="centered"
:overlay-blur="overlayBlur"
@opened="emits('opened')"
@closed="onAlertClosed"
@escape-key-down="onEscapeKeyDown"
:class="
cn(
containerClass,
'left-0 right-0 mx-auto flex max-h-[80%] flex-col p-0 duration-300 sm:rounded-[var(--radius)] md:w-[520px] md:max-w-[80%]',
{
'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="text-muted-foreground size-4" />
</VbenButton>
</AlertDialogCancel>
</div>
</AlertDialogTitle>
<AlertDialogDescription>
<div class="m-4 min-h-[30px]">
<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="ghost"
@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>

View File

@@ -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';

View File

@@ -0,0 +1,116 @@
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 options: any;
constructor(initialState: DrawerState, options: any) {
this._state = initialState;
this.options = options;
}
batch(cb: () => void) {
cb();
}
setState(fn: (prev: DrawerState) => DrawerState) {
this._state = fn(this._state);
this.options.onUpdate();
}
},
};
});
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 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();
});
});

View File

@@ -0,0 +1,183 @@
import type { DrawerApiOptions, DrawerState } from './drawer';
import { Store } from '@vben-core/shared/store';
import { bindMethods, isFunction } from '@vben-core/shared/utils';
export class DrawerApi {
// 共享数据
public sharedData: Record<'payload', any> = {
payload: {},
};
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,
},
{
onUpdate: () => {
const state = this.store.state;
if (state?.isOpen === this.state?.isOpen) {
this.state = state;
} else {
this.state = state;
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<T extends object = Record<string, any>>() {
return (this.sharedData?.payload ?? {}) as T;
}
/**
* 锁定抽屉状态(用于提交过程中的等待状态)
* @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<T>(payload: T) {
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);
}
}

View File

@@ -0,0 +1,179 @@
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;
/**
* 是否显示底部
* @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;
/**
* 共享数据
*/
sharedData?: Record<string, any>;
}
export type ExtendedDrawerApi = DrawerApi & {
useStore: <T = NoInfer<DrawerState>>(
selector?: (state: NoInfer<DrawerState>) => T,
) => Readonly<Ref<T>>;
};
export interface DrawerApiOptions extends DrawerState {
/**
* 独立的抽屉组件
*/
connectedComponent?: Component;
/**
* 在关闭时销毁抽屉。仅在使用 connectedComponent 时有效
*/
destroyOnClose?: boolean;
/**
* 关闭前的回调,返回 false 可以阻止关闭
* @returns
*/
onBeforeClose?: () => MaybePromise<boolean | undefined>;
/**
* 点击取消按钮的回调
*/
onCancel?: () => void;
/**
* 弹窗关闭动画结束的回调
* @returns
*/
onClosed?: () => void;
/**
* 点击确定按钮的回调
*/
onConfirm?: () => void;
/**
* 弹窗状态变化回调
* @param isOpen
* @returns
*/
onOpenChange?: (isOpen: boolean) => void;
/**
* 弹窗打开动画结束的回调
* @returns
*/
onOpened?: () => void;
}

View File

@@ -0,0 +1,293 @@
<script lang="ts" setup>
import type { DrawerProps, ExtendedDrawerApi } from './drawer';
import { computed, provide, ref, 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',
drawerApi: undefined,
submitting: false,
zIndex: 1000,
});
const components = globalShareState.getComponents();
const id = useId();
provide('DISMISSABLE_DRAWER_ID', id);
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,
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,
});
}
},
);
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}>div:not(.absolute)>div`
: undefined;
});
</script>
<template>
<Sheet
:modal="false"
:open="state?.isOpen"
@update:open="() => drawerApi?.close()"
>
<SheetContent
:append-to="getAppendTo"
:class="
cn('flex w-[520px] flex-col', drawerClass, {
'!w-full': isMobile || placement === 'bottom' || placement === 'top',
'max-h-[100vh]': placement === 'bottom' || placement === 'top',
})
"
:modal="modal"
:open="state?.isOpen"
:side="placement"
:z-index="zIndex"
:overlay-blur="overlayBlur"
@close-auto-focus="handleFocusOutside"
@closed="() => drawerApi?.onClosed()"
@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="data-[state=open]:bg-secondary ml-[2px] cursor-pointer rounded-full opacity-80 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
>
<slot name="close-icon">
<VbenIconButton>
<X class="size-4" />
</VbenIconButton>
</slot>
</SheetClose>
<Separator
v-if="closable && closeIconPlacement === 'left'"
class="ml-1 mr-2 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="data-[state=open]:bg-secondary ml-[2px] cursor-pointer rounded-full opacity-80 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
>
<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, {
'overflow-hidden': showLoading,
})
"
>
<VbenLoading
v-if="showLoading || submitting"
class="size-full"
spinning
/>
<slot></slot>
</div>
<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>

View File

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

View File

@@ -0,0 +1,139 @@
import type {
DrawerApiOptions,
DrawerProps,
ExtendedDrawerApi,
} from './drawer';
import {
defineComponent,
h,
inject,
nextTick,
provide,
reactive,
ref,
} from 'vue';
import { useStore } from '@vben-core/shared/store';
import { DrawerApi } from './drawer-api';
import VbenDrawer from './drawer.vue';
const USER_DRAWER_INJECT_KEY = Symbol('VBEN_DRAWER_INJECT');
const DEFAULT_DRAWER_PROPS: Partial<DrawerProps> = {};
export function setDefaultDrawerProps(props: Partial<DrawerProps>) {
Object.assign(DEFAULT_DRAWER_PROPS, props);
}
export function useVbenDrawer<
TParentDrawerProps extends DrawerProps = DrawerProps,
>(options: DrawerApiOptions = {}) {
// Drawer一般会抽离出来所以如果有传入 connectedComponent则表示为外部调用与内部组件进行连接
// 外部的Drawer通过provide/inject传递api
const { connectedComponent } = options;
if (connectedComponent) {
const extendedApi = reactive({});
const isDrawerReady = ref(true);
const Drawer = defineComponent(
(props: TParentDrawerProps, { attrs, slots }) => {
provide(USER_DRAWER_INJECT_KEY, {
extendApi(api: ExtendedDrawerApi) {
// 不能直接给 reactive 赋值,会丢失响应
// 不能用 Object.assign,会丢失 api 的原型函数
Object.setPrototypeOf(extendedApi, api);
},
options,
async reCreateDrawer() {
isDrawerReady.value = false;
await nextTick();
isDrawerReady.value = true;
},
});
checkProps(extendedApi as ExtendedDrawerApi, {
...props,
...attrs,
...slots,
});
return () =>
h(
isDrawerReady.value ? connectedComponent : 'div',
{ ...props, ...attrs },
slots,
);
},
{
inheritAttrs: false,
name: 'VbenParentDrawer',
},
);
return [Drawer, extendedApi as ExtendedDrawerApi] as const;
}
const injectData = inject<any>(USER_DRAWER_INJECT_KEY, {});
const mergedOptions = {
...DEFAULT_DRAWER_PROPS,
...injectData.options,
...options,
} as DrawerApiOptions;
mergedOptions.onOpenChange = (isOpen: boolean) => {
options.onOpenChange?.(isOpen);
injectData.options?.onOpenChange?.(isOpen);
};
const onClosed = mergedOptions.onClosed;
mergedOptions.onClosed = () => {
onClosed?.();
if (mergedOptions.destroyOnClose) {
injectData.reCreateDrawer?.();
}
};
const api = new DrawerApi(mergedOptions);
const extendedApi: ExtendedDrawerApi = api as never;
extendedApi.useStore = (selector) => {
return useStore(api.store, selector);
};
const Drawer = defineComponent(
(props: DrawerProps, { attrs, slots }) => {
return () =>
h(VbenDrawer, { ...props, ...attrs, drawerApi: extendedApi }, slots);
},
{
inheritAttrs: false,
name: 'VbenDrawer',
},
);
injectData.extendApi?.(extendedApi);
return [Drawer, extendedApi] as const;
}
async function checkProps(api: ExtendedDrawerApi, 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.`,
);
}
}
}

View File

@@ -0,0 +1,3 @@
export * from './alert';
export * from './drawer';
export * from './modal';

View File

@@ -0,0 +1,117 @@
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 options: any;
constructor(initialState: ModalState, options: any) {
this._state = initialState;
this.options = options;
}
batch(cb: () => void) {
cb();
}
setState(fn: (prev: ModalState) => ModalState) {
this._state = fn(this._state);
this.options.onUpdate();
}
},
};
});
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 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();
});
});

View File

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

View File

@@ -0,0 +1,192 @@
import type { ModalApiOptions, ModalState } from './modal';
import { Store } from '@vben-core/shared/store';
import { bindMethods, isFunction } from '@vben-core/shared/utils';
export class ModalApi {
// 共享数据
public sharedData: Record<'payload', any> = {
payload: {},
};
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,
footer: true,
footerClass: '',
fullscreen: false,
fullscreenButton: true,
header: true,
headerClass: '',
isOpen: false,
loading: false,
modal: true,
openAutoFocus: false,
showCancelButton: true,
showConfirmButton: true,
title: '',
};
this.store = new Store<ModalState>(
{
...defaultState,
...storeState,
},
{
onUpdate: () => {
const state = this.store.state;
// 每次更新状态时,都会调用 onOpenChange 回调函数
if (state?.isOpen === this.state?.isOpen) {
this.state = state;
} else {
this.state = state;
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<T extends object = Record<string, any>>() {
return (this.sharedData?.payload ?? {}) as T;
}
/**
* 锁定弹窗状态(用于提交过程中的等待状态)
* @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 }));
}
setData<T>(payload: T) {
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);
}
}

View File

@@ -0,0 +1,189 @@
import type { Component, Ref } from 'vue';
import type { MaybePromise } from '@vben-core/typings';
import type { ModalApi } from './modal-api';
export interface ModalProps {
/**
* 是否要挂载到内容区域
* @default false
*/
appendToMain?: boolean;
/**
* 是否显示边框
* @default false
*/
bordered?: boolean;
/**
* 取消按钮文字
*/
cancelText?: string;
/**
* 是否居中
* @default false
*/
centered?: boolean;
class?: string;
/**
* 是否显示右上角的关闭按钮
* @default true
*/
closable?: boolean;
/**
* 点击弹窗遮罩是否关闭弹窗
* @default true
*/
closeOnClickModal?: boolean;
/**
* 按下 ESC 键是否关闭弹窗
* @default true
*/
closeOnPressEscape?: boolean;
/**
* 禁用确认按钮
*/
confirmDisabled?: boolean;
/**
* 确定按钮 loading
* @default false
*/
confirmLoading?: boolean;
/**
* 确定按钮文字
*/
confirmText?: string;
contentClass?: string;
/**
* 弹窗描述
*/
description?: string;
/**
* 在关闭时销毁弹窗
*/
destroyOnClose?: boolean;
/**
* 是否可拖拽
* @default false
*/
draggable?: boolean;
/**
* 是否显示底部
* @default true
*/
footer?: boolean;
footerClass?: string;
/**
* 是否全屏
* @default false
*/
fullscreen?: boolean;
/**
* 是否显示全屏按钮
* @default true
*/
fullscreenButton?: boolean;
/**
* 是否显示顶栏
* @default true
*/
header?: boolean;
headerClass?: string;
/**
* 弹窗是否显示
* @default false
*/
loading?: boolean;
/**
* 是否显示遮罩
* @default true
*/
modal?: boolean;
/**
* 是否自动聚焦
*/
openAutoFocus?: 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;
/**
* 共享数据
*/
sharedData?: Record<string, any>;
}
export type ExtendedModalApi = ModalApi & {
useStore: <T = NoInfer<ModalState>>(
selector?: (state: NoInfer<ModalState>) => T,
) => Readonly<Ref<T>>;
};
export interface ModalApiOptions extends ModalState {
/**
* 独立的弹窗组件
*/
connectedComponent?: Component;
/**
* 关闭前的回调,返回 false 可以阻止关闭
* @returns
*/
onBeforeClose?: () => MaybePromise<boolean | undefined>;
/**
* 点击取消按钮的回调
*/
onCancel?: () => void;
/**
* 弹窗关闭动画结束的回调
* @returns
*/
onClosed?: () => void;
/**
* 点击确定按钮的回调
*/
onConfirm?: () => void;
/**
* 弹窗状态变化回调
* @param isOpen
* @returns
*/
onOpenChange?: (isOpen: boolean) => void;
/**
* 弹窗打开动画结束的回调
* @returns
*/
onOpened?: () => void;
}

View File

@@ -0,0 +1,341 @@
<script lang="ts" setup>
import type { ExtendedModalApi, ModalProps } from './modal';
import { computed, nextTick, provide, ref, unref, useId, watch } from 'vue';
import {
useIsMobile,
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: true,
modalApi: undefined,
});
const components = globalShareState.getComponents();
const contentRef = ref();
const wrapperRef = ref<HTMLElement>();
const dialogRef = ref();
const headerRef = ref();
const footerRef = ref();
const id = useId();
provide('DISMISSABLE_MODAL_ID', id);
const { $t } = useSimpleLocale();
const { isMobile } = useIsMobile();
const state = props.modalApi?.useStore?.();
const {
appendToMain,
bordered,
cancelText,
centered,
class: modalClass,
closable,
closeOnClickModal,
closeOnPressEscape,
confirmDisabled,
confirmLoading,
confirmText,
contentClass,
description,
destroyOnClose,
draggable,
footer: showFooter,
footerClass,
fullscreen,
fullscreenButton,
header,
headerClass,
loading: showLoading,
modal,
openAutoFocus,
overlayBlur,
showCancelButton,
showConfirmButton,
submitting,
title,
titleTooltip,
zIndex,
} = usePriorityValues(props, state);
const shouldFullscreen = computed(
() => (fullscreen.value && header.value) || isMobile.value,
);
const shouldDraggable = computed(
() => draggable.value && !shouldFullscreen.value && header.value,
);
const { dragging, transform } = useModalDraggable(
dialogRef,
headerRef,
shouldDraggable,
);
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 = `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,
});
}
},
);
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 handerOpenAutoFocus(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();
}
const getAppendTo = computed(() => {
return appendToMain.value
? `#${ELEMENT_ID_MAIN_CONTENT}>div:not(.absolute)>div`
: undefined;
});
const getForceMount = computed(() => {
return !unref(destroyOnClose) && unref(firstOpened);
});
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(
'left-0 right-0 top-[10vh] mx-auto flex max-h-[80%] w-[520px] flex-col p-0 sm:rounded-[var(--radius)]',
modalClass,
{
'border-border border': bordered,
'shadow-3xl': !bordered,
'left-0 top-0 size-full max-h-full !translate-x-0 !translate-y-0':
shouldFullscreen,
'top-1/2 !-translate-y-1/2': centered && !shouldFullscreen,
'duration-300': !dragging,
hidden: isClosed,
},
)
"
:force-mount="getForceMount"
:modal="modal"
:open="state?.isOpen"
:show-close="closable"
:z-index="zIndex"
:overlay-blur="overlayBlur"
close-class="top-3"
@close-auto-focus="handleFocusOutside"
@closed="handleClosed"
:close-disabled="submitting"
@escape-key-down="escapeKeyDown"
@focus-outside="handleFocusOutside"
@interact-outside="interactOutside"
@open-auto-focus="handerOpenAutoFocus"
@opened="() => modalApi?.onOpened()"
@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, {
'overflow-hidden': showLoading || submitting,
})
"
>
<VbenLoading
v-if="showLoading || submitting"
class="size-full h-auto min-h-full"
spinning
/>
<slot></slot>
</div>
<VbenIconButton
v-if="fullscreenButton"
class="hover:bg-accent hover:text-accent-foreground text-foreground/80 flex-center absolute right-10 top-3 hidden size-6 rounded-full px-1 text-lg opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none sm:block"
@click="handleFullscreen"
>
<Shrink v-if="fullscreen" class="size-3.5" />
<Expand v-else class="size-3.5" />
</VbenIconButton>
<DialogFooter
v-if="showFooter"
ref="footerRef"
: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="ghost"
: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>

View File

@@ -0,0 +1,117 @@
/**
* @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>,
) {
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;
const docElement = document.documentElement;
const clientWidth = docElement.clientWidth;
const clientHeight = docElement.clientHeight;
const minLeft = -targetLeft + offsetX;
const minTop = -targetTop + offsetY;
const maxLeft = clientWidth - targetLeft - targetWidth + offsetX;
const maxTop = clientHeight - targetTop - targetHeight + offsetY;
const onMousemove = (e: MouseEvent) => {
let moveX = offsetX + e.clientX - downX;
let moveY = offsetY + e.clientY - downY;
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) {
targetRef.value.style.transform = `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 = 'none';
}
};
onMounted(() => {
watchEffect(() => {
if (draggable.value) {
onDraggable();
} else {
offDraggable();
}
});
});
onBeforeUnmount(() => {
offDraggable();
});
return {
dragging,
resetPosition,
transform,
};
}

View File

@@ -0,0 +1,146 @@
import type { ExtendedModalApi, ModalApiOptions, ModalProps } from './modal';
import {
defineComponent,
h,
inject,
nextTick,
provide,
reactive,
ref,
} from 'vue';
import { useStore } from '@vben-core/shared/store';
import { ModalApi } from './modal-api';
import VbenModal from './modal.vue';
const USER_MODAL_INJECT_KEY = Symbol('VBEN_MODAL_INJECT');
const DEFAULT_MODAL_PROPS: Partial<ModalProps> = {};
export function setDefaultModalProps(props: Partial<ModalProps>) {
Object.assign(DEFAULT_MODAL_PROPS, props);
}
export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
options: ModalApiOptions = {},
) {
// Modal一般会抽离出来所以如果有传入 connectedComponent则表示为外部调用与内部组件进行连接
// 外部的Modal通过provide/inject传递api
const { connectedComponent } = options;
if (connectedComponent) {
const extendedApi = reactive({});
const isModalReady = ref(true);
const Modal = defineComponent(
(props: TParentModalProps, { attrs, slots }) => {
provide(USER_MODAL_INJECT_KEY, {
extendApi(api: ExtendedModalApi) {
// 不能直接给 reactive 赋值,会丢失响应
// 不能用 Object.assign,会丢失 api 的原型函数
Object.setPrototypeOf(extendedApi, api);
},
options,
async reCreateModal() {
isModalReady.value = false;
await nextTick();
isModalReady.value = true;
},
});
checkProps(extendedApi as ExtendedModalApi, {
...props,
...attrs,
...slots,
});
return () =>
h(
isModalReady.value ? connectedComponent : 'div',
{
...props,
...attrs,
},
slots,
);
},
{
inheritAttrs: false,
name: 'VbenParentModal',
},
);
return [Modal, extendedApi as ExtendedModalApi] as const;
}
const injectData = inject<any>(USER_MODAL_INJECT_KEY, {});
const mergedOptions = {
...DEFAULT_MODAL_PROPS,
...injectData.options,
...options,
} as ModalApiOptions;
mergedOptions.onOpenChange = (isOpen: boolean) => {
options.onOpenChange?.(isOpen);
injectData.options?.onOpenChange?.(isOpen);
};
mergedOptions.onClosed = () => {
options.onClosed?.();
if (options.destroyOnClose) {
injectData.reCreateModal?.();
}
};
const api = new ModalApi(mergedOptions);
const extendedApi: ExtendedModalApi = api as never;
extendedApi.useStore = (selector) => {
return useStore(api.store, selector);
};
const Modal = defineComponent(
(props: ModalProps, { attrs, slots }) => {
return () =>
h(
VbenModal,
{
...props,
...attrs,
modalApi: extendedApi,
},
slots,
);
},
{
inheritAttrs: false,
name: 'VbenModal',
},
);
injectData.extendApi?.(extendedApi);
return [Modal, extendedApi] as const;
}
async function checkProps(api: ExtendedModalApi, 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.`,
);
}
}
}

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config';

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src"],
"exclude": ["node_modules"]
}