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:
21
packages/@core/ui-kit/popup-ui/build.config.ts
Normal file
21
packages/@core/ui-kit/popup-ui/build.config.ts
Normal 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'],
|
||||
},
|
||||
],
|
||||
});
|
47
packages/@core/ui-kit/popup-ui/package.json
Normal file
47
packages/@core/ui-kit/popup-ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
1
packages/@core/ui-kit/popup-ui/postcss.config.mjs
Normal file
1
packages/@core/ui-kit/popup-ui/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config/postcss';
|
@@ -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');
|
||||
});
|
||||
});
|
123
packages/@core/ui-kit/popup-ui/src/drawer/drawer-api.ts
Normal file
123
packages/@core/ui-kit/popup-ui/src/drawer/drawer-api.ts
Normal 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 }));
|
||||
}
|
||||
}
|
||||
}
|
93
packages/@core/ui-kit/popup-ui/src/drawer/drawer.ts
Normal file
93
packages/@core/ui-kit/popup-ui/src/drawer/drawer.ts
Normal 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;
|
||||
}
|
141
packages/@core/ui-kit/popup-ui/src/drawer/drawer.vue
Normal file
141
packages/@core/ui-kit/popup-ui/src/drawer/drawer.vue
Normal 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>
|
3
packages/@core/ui-kit/popup-ui/src/drawer/index.ts
Normal file
3
packages/@core/ui-kit/popup-ui/src/drawer/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type * from './drawer';
|
||||
export { default as VbenDrawer } from './drawer.vue';
|
||||
export { useVbenDrawer } from './use-drawer';
|
105
packages/@core/ui-kit/popup-ui/src/drawer/use-drawer.ts
Normal file
105
packages/@core/ui-kit/popup-ui/src/drawer/use-drawer.ts
Normal 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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
2
packages/@core/ui-kit/popup-ui/src/index.ts
Normal file
2
packages/@core/ui-kit/popup-ui/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './drawer';
|
||||
export * from './modal';
|
@@ -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');
|
||||
});
|
||||
});
|
3
packages/@core/ui-kit/popup-ui/src/modal/index.ts
Normal file
3
packages/@core/ui-kit/popup-ui/src/modal/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type * from './modal';
|
||||
export { default as VbenModal } from './modal.vue';
|
||||
export { useVbenModal } from './use-modal';
|
134
packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts
Normal file
134
packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts
Normal 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 }));
|
||||
}
|
||||
}
|
||||
}
|
123
packages/@core/ui-kit/popup-ui/src/modal/modal.ts
Normal file
123
packages/@core/ui-kit/popup-ui/src/modal/modal.ts
Normal 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;
|
||||
}
|
231
packages/@core/ui-kit/popup-ui/src/modal/modal.vue
Normal file
231
packages/@core/ui-kit/popup-ui/src/modal/modal.vue
Normal 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>
|
148
packages/@core/ui-kit/popup-ui/src/modal/use-modal-draggable.ts
Normal file
148
packages/@core/ui-kit/popup-ui/src/modal/use-modal-draggable.ts
Normal 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 };
|
||||
// }
|
101
packages/@core/ui-kit/popup-ui/src/modal/use-modal.ts
Normal file
101
packages/@core/ui-kit/popup-ui/src/modal/use-modal.ts
Normal 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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
1
packages/@core/ui-kit/popup-ui/tailwind.config.mjs
Normal file
1
packages/@core/ui-kit/popup-ui/tailwind.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config';
|
6
packages/@core/ui-kit/popup-ui/tsconfig.json
Normal file
6
packages/@core/ui-kit/popup-ui/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/web.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
Reference in New Issue
Block a user