diff --git a/packages/@core/ui-kit/popup-ui/src/drawer/__tests__/drawer-interaction.test.ts b/packages/@core/ui-kit/popup-ui/src/drawer/__tests__/drawer-interaction.test.ts new file mode 100644 index 000000000..e66100011 --- /dev/null +++ b/packages/@core/ui-kit/popup-ui/src/drawer/__tests__/drawer-interaction.test.ts @@ -0,0 +1,203 @@ +import type { App } from 'vue'; + +import { createApp, defineComponent, h, nextTick, onMounted } from 'vue'; + +import { ELEMENT_ID_MAIN_CONTENT } from '@vben-core/shared/constants'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useVbenDrawer } from '../use-drawer'; + +vi.mock('@vben-core/preferences', () => ({ + usePreferences: () => ({ + globalEscapeShortcutKey: { value: true }, + }), +})); + +let activeApp: App | undefined; + +interface MountOptions { + closeOnClickModal?: boolean; + closeOnPressEscape?: boolean; + onCancel?: () => void; + onConfirm?: () => void; +} + +/** + * 挂载一个受控的 Drawer 组件,用于验证鼠标点击与键盘交互后的关闭行为和事件副作用。 + */ +async function mountDrawer(options: MountOptions = {}) { + const mainContent = document.createElement('main'); + mainContent.id = ELEMENT_ID_MAIN_CONTENT; + mainContent.innerHTML = '
'; + document.body.append(mainContent); + + let capturedApi: ReturnType[1] | undefined; + const Consumer = defineComponent(() => { + const [Drawer, drawerApi] = useVbenDrawer({ + appendToMain: true, + cancelText: 'Cancel', + confirmText: 'Confirm', + closeOnClickModal: options.closeOnClickModal ?? true, + closeOnPressEscape: options.closeOnPressEscape ?? true, + title: 'Interaction drawer', + ...(options.onCancel ? { onCancel: options.onCancel } : {}), + ...(options.onConfirm ? { onConfirm: options.onConfirm } : {}), + }); + capturedApi = drawerApi; + onMounted(() => { + drawerApi.open(); + }); + return () => h(Drawer); + }); + const host = document.createElement('div'); + document.body.append(host); + + activeApp = createApp(() => h(Consumer)); + activeApp.mount(host); + await nextTick(); + await nextTick(); + // reka-ui 在 setTimeout(0) 之后才把 document 级的 pointerdown 监听挂上, + // 等待一个宏任务,确保遮罩交互在监听器就绪后触发。 + await new Promise((resolve) => setTimeout(resolve, 0)); + + if (!capturedApi) { + throw new Error('drawer api was not captured'); + } + return { drawerApi: capturedApi }; +} + +function buttonByText(text: string): HTMLElement | undefined { + return [...document.querySelectorAll('button')].find( + (element) => element.textContent?.trim() === text, + ); +} + +function pressEscape() { + document.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'Escape', + }), + ); +} + +function pointerDownOverlay() { + const overlay = document.querySelector('[data-dismissable-drawer]'); + if (!(overlay instanceof HTMLElement)) { + return false; + } + overlay.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, cancelable: true }), + ); + return true; +} + +async function settle() { + await nextTick(); + await new Promise((resolve) => setTimeout(resolve, 80)); +} + +afterEach(() => { + activeApp?.unmount(); + activeApp = undefined; + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('vben drawer interactions', () => { + it('fires confirm exactly once on confirm click and does not auto-close', async () => { + const onConfirm = vi.fn(); + const { drawerApi } = await mountDrawer({ onConfirm }); + const button = buttonByText('Confirm'); + + expect(button).toBeInstanceOf(HTMLElement); + if (!(button instanceof HTMLElement)) return; + button.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await nextTick(); + + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(drawerApi.store.state.isOpen).toBe(true); + }); + + it('closes the drawer when the confirm handler closes the api', async () => { + const onConfirm = vi.fn(); + const apiRef: { + current?: ReturnType[1]; + } = {}; + const { drawerApi } = await mountDrawer({ + onConfirm: () => { + onConfirm(); + void apiRef.current?.close(); + }, + }); + apiRef.current = drawerApi; + const button = buttonByText('Confirm'); + + expect(button).toBeInstanceOf(HTMLElement); + if (!(button instanceof HTMLElement)) return; + button.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(onConfirm).toHaveBeenCalledTimes(1); + await expect.poll(() => drawerApi.store.state.isOpen).toBe(false); + }); + + it('closes on cancel click when no cancel hook is provided', async () => { + const { drawerApi } = await mountDrawer(); + const button = buttonByText('Cancel'); + + expect(button).toBeInstanceOf(HTMLElement); + if (!(button instanceof HTMLElement)) return; + button.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + await expect.poll(() => drawerApi.store.state.isOpen).toBe(false); + }); + + it('fires cancel exactly once and does not auto-close when a cancel hook exists', async () => { + const onCancel = vi.fn(); + const { drawerApi } = await mountDrawer({ onCancel }); + const button = buttonByText('Cancel'); + + expect(button).toBeInstanceOf(HTMLElement); + if (!(button instanceof HTMLElement)) return; + button.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await nextTick(); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(drawerApi.store.state.isOpen).toBe(true); + }); + + it('closes when Escape is pressed', async () => { + const { drawerApi } = await mountDrawer(); + + pressEscape(); + + await expect.poll(() => drawerApi.store.state.isOpen).toBe(false); + }); + + it('stays open on Escape when closeOnPressEscape is false', async () => { + const { drawerApi } = await mountDrawer({ closeOnPressEscape: false }); + + pressEscape(); + await settle(); + + expect(drawerApi.store.state.isOpen).toBe(true); + }); + + it('closes when the dismissable overlay is pointer-downed', async () => { + const { drawerApi } = await mountDrawer(); + + expect(pointerDownOverlay()).toBe(true); + await expect.poll(() => drawerApi.store.state.isOpen).toBe(false); + }); + + it('stays open on overlay pointer-down when closeOnClickModal is false', async () => { + const { drawerApi } = await mountDrawer({ closeOnClickModal: false }); + + expect(pointerDownOverlay()).toBe(true); + await settle(); + + expect(drawerApi.store.state.isOpen).toBe(true); + }); +});