feat: add modal and drawer components and examples (#4229)

* feat: add modal component

* feat: add drawer component

* feat: apply new modal and drawer components to the layout

* chore: typo

* feat: add some unit tests
This commit is contained in:
Vben
2024-08-25 23:40:52 +08:00
committed by GitHub
parent edb55b1fc0
commit 20a3868594
96 changed files with 2700 additions and 743 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,47 @@
{
"name": "@vben-core/popup-ui",
"version": "5.1.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:*",
"@vueuse/core": "^11.0.1",
"vue": "^3.4.38"
}
}

View File

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

View File

@@ -0,0 +1,113 @@
import type { DrawerState } from '../drawer';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DrawerApi } from '../drawer-api';
// 模拟 Store 类
vi.mock('@vben-core/shared', () => {
return {
isFunction: (fn: any) => typeof fn === 'function',
Store: class {
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();
}
get state() {
return this._state;
}
},
};
});
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('取消');
expect(drawerState.confirmText).toBe('确定');
});
it('should open the drawer', () => {
drawerApi.open();
expect(drawerApi.store.state.isOpen).toBe(true);
});
it('should close the drawer if onBeforeClose allows it', () => {
drawerApi.open();
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 batch state updates', () => {
const batchSpy = vi.spyOn(drawerApi.store, 'batch');
drawerApi.batchStore(() => {
drawerApi.setState({ title: 'Batch Title' });
drawerApi.setState({ confirmText: 'Batch Confirm' });
});
expect(batchSpy).toHaveBeenCalled();
expect(drawerApi.store.state.title).toBe('Batch Title');
expect(drawerApi.store.state.confirmText).toBe('Batch Confirm');
});
});

View File

@@ -0,0 +1,123 @@
import type { DrawerApiOptions, DrawerState } from './drawer';
import { isFunction, Store } from '@vben-core/shared';
export class DrawerApi {
private api: Pick<
DrawerApiOptions,
'onBeforeClose' | 'onCancel' | 'onConfirm' | 'onOpenChange'
>;
// private prevState!: DrawerState;
private state!: DrawerState;
// 共享数据
public sharedData: Record<'payload', any> = {
payload: {},
};
public store: Store<DrawerState>;
constructor(options: DrawerApiOptions = {}) {
const {
connectedComponent: _,
onBeforeClose,
onCancel,
onConfirm,
onOpenChange,
...storeState
} = options;
const defaultState: DrawerState = {
cancelText: '取消',
closable: true,
confirmLoading: false,
confirmText: '确定',
footer: true,
isOpen: false,
loading: false,
modal: true,
sharedData: {},
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.api = {
onBeforeClose,
onCancel,
onConfirm,
onOpenChange,
};
}
// 如果需要多次更新状态,可以使用 batch 方法
batchStore(cb: () => void) {
this.store.batch(cb);
}
/**
* 关闭弹窗
*/
close() {
// 通过 onBeforeClose 钩子函数来判断是否允许关闭弹窗
// 如果 onBeforeClose 返回 false则不关闭弹窗
const allowClose = this.api.onBeforeClose?.() ?? true;
if (allowClose) {
this.store.setState((prev) => ({ ...prev, isOpen: false }));
}
}
getData<T extends object = Record<string, any>>() {
return (this.sharedData?.payload ?? {}) as T;
}
/**
* 取消操作
*/
onCancel() {
this.api.onCancel?.();
}
/**
* 确认操作
*/
onConfirm() {
this.api.onConfirm?.();
}
open() {
this.store.setState((prev) => ({ ...prev, isOpen: true }));
}
setData<T>(payload: T) {
this.sharedData.payload = payload;
}
setState(
stateOrFn:
| ((prev: DrawerState) => Partial<DrawerState>)
| Partial<DrawerState>,
) {
if (isFunction(stateOrFn)) {
this.store.setState(stateOrFn);
} else {
this.store.setState((prev) => ({ ...prev, ...stateOrFn }));
}
}
}

View File

@@ -0,0 +1,93 @@
import type { DrawerApi } from './drawer-api';
import type { Component, Ref } from 'vue';
export interface DrawerProps {
/**
* 取消按钮文字
*/
cancelText?: string;
/**
* 是否显示右上角的关闭按钮
* @default true
*/
closable?: boolean;
/**
* 确定按钮 loading
* @default false
*/
confirmLoading?: boolean;
/**
* 确定按钮文字
*/
confirmText?: string;
/**
* 弹窗描述
*/
description?: string;
/**
* 是否显示底部
* @default true
*/
footer?: boolean;
/**
* 弹窗是否显示
* @default false
*/
loading?: boolean;
/**
* 是否显示遮罩
* @default true
*/
modal?: boolean;
/**
* 弹窗标题
*/
title?: string;
/**
* 弹窗标题提示
*/
titleTooltip?: string;
}
export interface DrawerState extends DrawerProps {
/** 弹窗打开状态 */
isOpen?: boolean;
/**
* 共享数据
*/
sharedData?: Record<string, any>;
}
export type ExtendedDrawerApi = {
useStore: <T = NoInfer<DrawerState>>(
selector?: (state: NoInfer<DrawerState>) => T,
) => Readonly<Ref<T>>;
} & DrawerApi;
export interface DrawerApiOptions extends DrawerState {
/**
* 独立的弹窗组件
*/
connectedComponent?: Component;
/**
* 关闭前的回调,返回 false 可以阻止关闭
* @returns
*/
onBeforeClose?: () => void;
/**
* 点击取消按钮的回调
*/
onCancel?: () => void;
/**
* 点击确定按钮的回调
*/
onConfirm?: () => void;
/**
* 弹窗状态变化回调
* @param isOpen
* @returns
*/
onOpenChange?: (isOpen: boolean) => void;
}

View File

@@ -0,0 +1,141 @@
<script lang="ts" setup>
import type { DrawerProps, ExtendedDrawerApi } from './drawer';
import { usePriorityValue } from '@vben-core/composables';
import { Info, X } from '@vben-core/icons';
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
VbenButton,
VbenIconButton,
VbenLoading,
VbenTooltip,
VisuallyHidden,
} from '@vben-core/shadcn-ui';
import { cn } from '@vben-core/shared';
interface Props extends DrawerProps {
class?: string;
contentClass?: string;
drawerApi?: ExtendedDrawerApi;
}
const props = withDefaults(defineProps<Props>(), {
class: '',
contentClass: '',
drawerApi: undefined,
});
const state = props.drawerApi?.useStore?.();
const title = usePriorityValue('title', props, state);
const description = usePriorityValue('description', props, state);
const titleTooltip = usePriorityValue('titleTooltip', props, state);
const showFooter = usePriorityValue('footer', props, state);
const showLoading = usePriorityValue('loading', props, state);
const closable = usePriorityValue('closable', props, state);
const modal = usePriorityValue('modal', props, state);
const confirmLoading = usePriorityValue('confirmLoading', props, state);
const cancelText = usePriorityValue('cancelText', props, state);
const confirmText = usePriorityValue('confirmText', props, state);
</script>
<template>
<Sheet
:modal="modal"
:open="state?.isOpen"
@update:open="() => drawerApi?.close()"
>
<SheetContent :class="cn('flex w-[520px] flex-col', props.class, {})">
<SheetHeader
:class="
cn('!flex flex-row items-center justify-between border-b px-6 py-5', {
'px-4 py-3': closable,
})
"
>
<div>
<SheetTitle v-if="title">
<slot name="title">
{{ title }}
<VbenTooltip v-if="titleTooltip" side="right">
<template #trigger>
<Info class="inline-flex size-5 cursor-pointer pb-1" />
</template>
{{ titleTooltip }}
</VbenTooltip>
</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"
as-child
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"
>
<VbenIconButton>
<X class="size-4" />
</VbenIconButton>
</SheetClose>
</div>
</SheetHeader>
<div
:class="
cn('relative flex-1 p-3', contentClass, {
'overflow-y-auto': !showLoading,
})
"
>
<VbenLoading v-if="showLoading" class="size-full" spinning />
<slot></slot>
</div>
<SheetFooter
v-if="showFooter"
class="w-full items-center border-t p-2 px-3"
>
<slot name="prepend-footer"></slot>
<slot name="footer">
<VbenButton
size="sm"
variant="ghost"
@click="() => drawerApi?.onCancel()"
>
<slot name="cancelText">
{{ cancelText }}
</slot>
</VbenButton>
<VbenButton
:loading="confirmLoading"
size="sm"
@click="() => drawerApi?.onConfirm()"
>
<slot name="confirmText">
{{ confirmText }}
</slot>
</VbenButton>
</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 { useVbenDrawer } from './use-drawer';

View File

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

View File

@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ModalApi } from '../modal-api'; // 假设 ModalApi 位于同一目录
import type { ModalState } from '../modal';
vi.mock('@vben-core/shared', () => {
return {
isFunction: (fn: any) => typeof fn === 'function',
Store: class {
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();
}
get state() {
return this._state;
}
},
};
});
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('取消');
expect(modalState.confirmText).toBe('确定');
});
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 batch state updates', () => {
const batchSpy = vi.spyOn(modalApi.store, 'batch');
modalApi.batchStore(() => {
modalApi.setState({ title: 'Batch Title' });
modalApi.setState({ confirmText: 'Batch Confirm' });
});
expect(batchSpy).toHaveBeenCalled();
expect(modalApi.store.state.title).toBe('Batch Title');
expect(modalApi.store.state.confirmText).toBe('Batch Confirm');
});
});

View File

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

View File

@@ -0,0 +1,134 @@
import type { ModalApiOptions, ModalState } from './modal';
import { isFunction, Store } from '@vben-core/shared';
export class ModalApi {
private api: Pick<
ModalApiOptions,
'onBeforeClose' | 'onCancel' | 'onConfirm' | 'onOpenChange'
>;
// private prevState!: ModalState;
private state!: ModalState;
// 共享数据
public sharedData: Record<'payload', any> = {
payload: {},
};
public store: Store<ModalState>;
constructor(options: ModalApiOptions = {}) {
const {
connectedComponent: _,
onBeforeClose,
onCancel,
onConfirm,
onOpenChange,
...storeState
} = options;
const defaultState: ModalState = {
cancelText: '取消',
centered: false,
closeOnClickModal: true,
closeOnPressEscape: true,
confirmLoading: false,
confirmText: '确定',
draggable: false,
footer: true,
fullscreen: false,
fullscreenButton: true,
isOpen: false,
loading: false,
modal: true,
sharedData: {},
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.api = {
onBeforeClose,
onCancel,
onConfirm,
onOpenChange,
};
}
// 如果需要多次更新状态,可以使用 batch 方法
batchStore(cb: () => void) {
this.store.batch(cb);
}
/**
* 关闭弹窗
*/
close() {
// 通过 onBeforeClose 钩子函数来判断是否允许关闭弹窗
// 如果 onBeforeClose 返回 false则不关闭弹窗
const allowClose = this.api.onBeforeClose?.() ?? true;
if (allowClose) {
this.store.setState((prev) => ({ ...prev, isOpen: false }));
}
}
getData<T extends object = Record<string, any>>() {
return (this.sharedData?.payload ?? {}) as T;
}
/**
* 取消操作
*/
onCancel() {
if (this.api.onCancel) {
this.api.onCancel?.();
} else {
this.close();
}
}
/**
* 确认操作
*/
onConfirm() {
this.api.onConfirm?.();
}
open() {
this.store.setState((prev) => ({ ...prev, isOpen: true }));
}
setData<T>(payload: T) {
this.sharedData.payload = payload;
}
setState(
stateOrFn:
| ((prev: ModalState) => Partial<ModalState>)
| Partial<ModalState>,
) {
if (isFunction(stateOrFn)) {
this.store.setState(stateOrFn);
} else {
this.store.setState((prev) => ({ ...prev, ...stateOrFn }));
}
}
}

View File

@@ -0,0 +1,123 @@
import type { ModalApi } from './modal-api';
import type { Component, Ref } from 'vue';
export interface ModalProps {
/**
* 取消按钮文字
*/
cancelText?: string;
/**
* 是否居中
* @default false
*/
centered?: boolean;
/**
* 是否显示右上角的关闭按钮
* @default true
*/
closable?: boolean;
/**
* 点击弹窗遮罩是否关闭弹窗
* @default true
*/
closeOnClickModal?: boolean;
/**
* 按下 ESC 键是否关闭弹窗
* @default true
*/
closeOnPressEscape?: boolean;
/**
* 确定按钮 loading
* @default false
*/
confirmLoading?: boolean;
/**
* 确定按钮文字
*/
confirmText?: string;
/**
* 弹窗描述
*/
description?: string;
/**
* 是否可拖拽
* @default false
*/
draggable?: boolean;
/**
* 是否显示底部
* @default true
*/
footer?: boolean;
/**
* 是否全屏
* @default false
*/
fullscreen?: boolean;
/**
* 是否显示全屏按钮
* @default true
*/
fullscreenButton?: boolean;
/**
* 弹窗是否显示
* @default false
*/
loading?: boolean;
/**
* 是否显示遮罩
* @default true
*/
modal?: boolean;
/**
* 弹窗标题
*/
title?: string;
/**
* 弹窗标题提示
*/
titleTooltip?: string;
}
export interface ModalState extends ModalProps {
/** 弹窗打开状态 */
isOpen?: boolean;
/**
* 共享数据
*/
sharedData?: Record<string, any>;
}
export type ExtendedModalApi = {
useStore: <T = NoInfer<ModalState>>(
selector?: (state: NoInfer<ModalState>) => T,
) => Readonly<Ref<T>>;
} & ModalApi;
export interface ModalApiOptions extends ModalState {
/**
* 独立的弹窗组件
*/
connectedComponent?: Component;
/**
* 关闭前的回调,返回 false 可以阻止关闭
* @returns
*/
onBeforeClose?: () => void;
/**
* 点击取消按钮的回调
*/
onCancel?: () => void;
/**
* 点击确定按钮的回调
*/
onConfirm?: () => void;
/**
* 弹窗状态变化回调
* @param isOpen
* @returns
*/
onOpenChange?: (isOpen: boolean) => void;
}

View File

@@ -0,0 +1,231 @@
<script lang="ts" setup>
import type { ExtendedModalApi, ModalProps } from './modal';
import { computed, nextTick, ref, watch } from 'vue';
import { usePriorityValue } from '@vben-core/composables';
import { Expand, Info, Shrink } from '@vben-core/icons';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
VbenButton,
VbenIconButton,
VbenLoading,
VbenTooltip,
VisuallyHidden,
} from '@vben-core/shadcn-ui';
import { cn } from '@vben-core/shared';
// import { useElementSize } from '@vueuse/core';
import { useModalDraggable } from './use-modal-draggable';
interface Props extends ModalProps {
class?: string;
contentClass?: string;
footerClass?: string;
headerClass?: string;
modalApi?: ExtendedModalApi;
}
const props = withDefaults(defineProps<Props>(), {
class: '',
contentClass: '',
footerClass: '',
headerClass: '',
modalApi: undefined,
});
const contentRef = ref();
const dialogRef = ref();
const headerRef = ref();
const footerRef = ref();
// const { height: headerHeight } = useElementSize(headerRef);
// const { height: footerHeight } = useElementSize(footerRef);
const state = props.modalApi?.useStore?.();
const title = usePriorityValue('title', props, state);
const fullscreen = usePriorityValue('fullscreen', props, state);
const description = usePriorityValue('description', props, state);
const titleTooltip = usePriorityValue('titleTooltip', props, state);
const showFooter = usePriorityValue('footer', props, state);
const showLoading = usePriorityValue('loading', props, state);
const closable = usePriorityValue('closable', props, state);
const modal = usePriorityValue('modal', props, state);
const centered = usePriorityValue('centered', props, state);
const confirmLoading = usePriorityValue('confirmLoading', props, state);
const cancelText = usePriorityValue('cancelText', props, state);
const confirmText = usePriorityValue('confirmText', props, state);
const draggable = usePriorityValue('draggable', props, state);
const fullscreenButton = usePriorityValue('fullscreenButton', props, state);
const closeOnClickModal = usePriorityValue('closeOnClickModal', props, state);
const closeOnPressEscape = usePriorityValue('closeOnPressEscape', props, state);
const shouldDraggable = computed(() => draggable.value && !fullscreen.value);
const { dragging } = useModalDraggable(dialogRef, headerRef, shouldDraggable);
// const loadingStyle = computed(() => {
// // py-5 4px*5*2
// const headerPadding = 40;
// // p-2 4px*2*2
// const footerPadding = 16;
// return {
// bottom: `${footerHeight.value + footerPadding}px`,
// height: `calc(100% - ${footerHeight.value + headerHeight.value + headerPadding + footerPadding}px)`,
// top: `${headerHeight.value + headerPadding}px`,
// };
// });
watch(
() => state?.value?.isOpen,
async (v) => {
if (v) {
await nextTick();
if (contentRef.value) {
const innerContentRef = contentRef.value.getContentRef();
dialogRef.value = innerContentRef.$el;
}
}
},
);
function handleFullscreen() {
props.modalApi?.setState((prev) => {
// if (prev.fullscreen) {
// resetPosition();
// }
return { ...prev, fullscreen: !fullscreen.value };
});
}
function interactOutside(e: Event) {
if (!closeOnClickModal.value) {
e.preventDefault();
}
}
function escapeKeyDown(e: KeyboardEvent) {
if (!closeOnPressEscape.value) {
e.preventDefault();
}
}
</script>
<template>
<Dialog
:modal="modal"
:open="state?.isOpen"
@update:open="() => modalApi?.close()"
>
<DialogTrigger v-if="$slots.trigger" as-child>
<slot name="trigger"> </slot>
</DialogTrigger>
<DialogContent
ref="contentRef"
:class="
cn(
'left-0 right-0 top-[10vh] mx-auto flex max-h-[80%] w-[520px] flex-col p-0',
props.class,
{
'left-0 top-0 size-full max-h-full !translate-x-0 !translate-y-0':
fullscreen,
'top-1/2 -translate-y-1/2': centered && !fullscreen,
'duration-300': !dragging,
},
)
"
:show-close="closable"
close-class="top-4"
@escape-key-down="escapeKeyDown"
@interact-outside="interactOutside"
>
<DialogHeader
ref="headerRef"
:class="
cn(
'border-b px-6 py-5',
{
'cursor-move select-none': shouldDraggable,
},
props.headerClass,
)
"
>
<DialogTitle v-if="title">
<slot name="title">
{{ title }}
<VbenTooltip v-if="titleTooltip" side="right">
<template #trigger>
<Info class="inline-flex size-5 cursor-pointer pb-1" />
</template>
{{ titleTooltip }}
</VbenTooltip>
</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
:class="
cn('relative min-h-40 flex-1 p-3', contentClass, {
'overflow-y-auto': !showLoading,
})
"
>
<VbenLoading v-if="showLoading" class="size-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-4 size-6 rounded-full px-1 text-lg opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
@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('items-center border-t p-2', props.footerClass)"
>
<slot name="prepend-footer"></slot>
<slot name="footer">
<VbenButton
size="sm"
variant="ghost"
@click="() => modalApi?.onCancel()"
>
<slot name="cancelText">
{{ cancelText }}
</slot>
</VbenButton>
<VbenButton
:loading="confirmLoading"
size="sm"
@click="() => modalApi?.onConfirm()"
>
<slot name="confirmText">
{{ confirmText }}
</slot>
</VbenButton>
</slot>
<slot name="append-footer"></slot>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,148 @@
/**
* @copy https://github.com/element-plus/element-plus/blob/dev/packages/hooks/use-draggable/index.ts
* 调整部分细节
*/
import { onBeforeUnmount, onMounted, ref, watchEffect } from 'vue';
import type { ComputedRef, Ref } from 'vue';
import { unrefElement } from '@vueuse/core';
export function useModalDraggable(
targetRef: Ref<HTMLElement | undefined>,
dragRef: Ref<HTMLElement | undefined>,
draggable: ComputedRef<boolean>,
) {
let transform = {
offsetX: 0,
offsetY: 0,
};
const dragging = ref(false);
// let isFirstDrag = true;
// let initialX = 0;
// let initialY = 0;
const onMousedown = (e: MouseEvent) => {
const downX = e.clientX;
const downY = e.clientY;
if (!targetRef.value) {
return;
}
// if (isFirstDrag) {
// const { x, y } = getInitialTransform(targetRef.value);
// initialX = x;
// initialY = y;
// }
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;
// const x = isFirstDrag ? initialX : 0;
// const y = isFirstDrag ? initialY : 0;
moveX = Math.min(Math.max(moveX, minLeft), maxLeft);
// + x;
moveY = Math.min(Math.max(moveY, minTop), maxTop);
// + y;
transform = {
offsetX: moveX,
offsetY: moveY,
};
if (targetRef.value) {
targetRef.value.style.transform = `translate(${moveX}px, ${moveY}px)`;
dragging.value = true;
}
};
const onMouseup = () => {
// isFirstDrag = false;
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,
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,
};
}
// function getInitialTransform(target: HTMLElement) {
// let x = 0;
// let y = 0;
// const transformValue = window.getComputedStyle(target)?.transform;
// if (transformValue) {
// const match = transformValue.match(/matrix\(([^)]+)\)/);
// if (match) {
// const values = match[1]?.split(', ') ?? [];
// // 获取 translateX 值
// x = Number.parseFloat(`${values[4]}`);
// // 获取 translateY 值
// y = Number.parseFloat(`${values[5]}`);
// }
// }
// return { x, y };
// }

View File

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