From 98761dce9837fd44f303d1d4bc44f343af23172b Mon Sep 17 00:00:00 2001 From: Dream <1012377328@qq.com> Date: Thu, 6 Aug 2026 10:17:14 +0800 Subject: [PATCH] fix(@vben-core/popup-ui): stabilize content popups across routes Mount in-content drawers and modals to the persistent main content element. Initialize drawer open state when mounted after the API opens. Fixes vbenjs/vue-vben-admin#8210 --- .../src/drawer/__tests__/drawer.test.ts | 65 +++++++++++ .../ui-kit/popup-ui/src/drawer/drawer.vue | 5 +- .../src/modal/__tests__/modal.test.ts | 102 ++++++++++++++++++ .../@core/ui-kit/popup-ui/src/modal/modal.vue | 4 +- 4 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 packages/@core/ui-kit/popup-ui/src/drawer/__tests__/drawer.test.ts create mode 100644 packages/@core/ui-kit/popup-ui/src/modal/__tests__/modal.test.ts diff --git a/packages/@core/ui-kit/popup-ui/src/drawer/__tests__/drawer.test.ts b/packages/@core/ui-kit/popup-ui/src/drawer/__tests__/drawer.test.ts new file mode 100644 index 00000000..2a7a230d --- /dev/null +++ b/packages/@core/ui-kit/popup-ui/src/drawer/__tests__/drawer.test.ts @@ -0,0 +1,65 @@ +import type { App } from 'vue'; + +import { createApp, defineComponent, h, nextTick } from 'vue'; + +import { ELEMENT_ID_MAIN_CONTENT } from '@vben-core/shared/constants'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useVbenDrawer } from '../use-drawer'; + +vi.mock('@vben-core/preferences', () => ({ + usePreferences: () => ({ + globalEscapeShortcutKey: { value: true }, + }), +})); + +let activeApp: App | undefined; + +async function mountPreopenedDrawer() { + const mainContent = document.createElement('main'); + mainContent.id = ELEMENT_ID_MAIN_CONTENT; + mainContent.innerHTML = '
'; + document.body.append(mainContent); + + const Consumer = defineComponent(() => { + const [Drawer, drawerApi] = useVbenDrawer({ appendToMain: true }); + drawerApi.open(); + return () => h(Drawer); + }); + const host = document.createElement('div'); + document.body.append(host); + + activeApp = createApp(() => h(Consumer)); + activeApp.mount(host); + await nextTick(); + + return mainContent; +} + +afterEach(() => { + activeApp?.unmount(); + activeApp = undefined; + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('vben drawer', () => { + it('mounts an initially open drawer directly in the main content', async () => { + const mainContent = await mountPreopenedDrawer(); + const dialog = document.querySelector('[role="dialog"]'); + + expect(dialog).toBeInstanceOf(HTMLElement); + if (!(dialog instanceof HTMLElement)) return; + expect(dialog.parentElement).toBe(mainContent); + }); + + it('shows a drawer that is opened before mounting', async () => { + await mountPreopenedDrawer(); + const dialog = document.querySelector('[role="dialog"]'); + + expect(dialog).toBeInstanceOf(HTMLElement); + if (!(dialog instanceof HTMLElement)) return; + expect(dialog.classList.contains('hidden')).toBe(false); + }); +}); diff --git a/packages/@core/ui-kit/popup-ui/src/drawer/drawer.vue b/packages/@core/ui-kit/popup-ui/src/drawer/drawer.vue index 5abbf382..b2107277 100644 --- a/packages/@core/ui-kit/popup-ui/src/drawer/drawer.vue +++ b/packages/@core/ui-kit/popup-ui/src/drawer/drawer.vue @@ -148,9 +148,7 @@ function handleFocusOutside(e: Event) { } const getAppendTo = computed(() => { - return appendToMain.value - ? `#${ELEMENT_ID_MAIN_CONTENT}>div:not(.absolute)>div` - : undefined; + return appendToMain.value ? `#${ELEMENT_ID_MAIN_CONTENT}` : undefined; }); /** @@ -167,6 +165,7 @@ watch( hasOpened.value = true; } }, + { immediate: true }, ); function handleClosed() { isClosed.value = true; diff --git a/packages/@core/ui-kit/popup-ui/src/modal/__tests__/modal.test.ts b/packages/@core/ui-kit/popup-ui/src/modal/__tests__/modal.test.ts new file mode 100644 index 00000000..15ec0403 --- /dev/null +++ b/packages/@core/ui-kit/popup-ui/src/modal/__tests__/modal.test.ts @@ -0,0 +1,102 @@ +import type { App } from 'vue'; + +import { createApp, defineComponent, h, nextTick, onMounted } from 'vue'; + +import { ELEMENT_ID_MAIN_CONTENT } from '@vben-core/shared/constants'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useVbenModal } from '../use-modal'; + +vi.mock('@vben-core/preferences', () => ({ + usePreferences: () => ({ + globalEscapeShortcutKey: { value: true }, + }), +})); + +let activeApp: App | undefined; + +async function mountModal() { + const mainContent = document.createElement('main'); + mainContent.id = ELEMENT_ID_MAIN_CONTENT; + mainContent.innerHTML = '
'; + document.body.append(mainContent); + + const Consumer = defineComponent(() => { + const [Modal, modalApi] = useVbenModal({ + appendToMain: true, + draggable: true, + title: 'Draggable modal', + }); + onMounted(() => { + modalApi.open(); + }); + return () => h(Modal); + }); + const host = document.createElement('div'); + document.body.append(host); + + activeApp = createApp(() => h(Consumer)); + activeApp.mount(host); + await nextTick(); + await nextTick(); + + return mainContent; +} + +afterEach(() => { + activeApp?.unmount(); + activeApp = undefined; + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('vben modal', () => { + it('mounts an open modal directly in the main content', async () => { + const mainContent = await mountModal(); + const dialog = document.querySelector('[role="dialog"]'); + const overlay = document.querySelector('[data-dismissable-modal]'); + + expect(dialog).toBeInstanceOf(HTMLElement); + if (!(dialog instanceof HTMLElement)) return; + expect(overlay).toBeInstanceOf(HTMLElement); + if (!(overlay instanceof HTMLElement)) return; + expect(dialog.parentElement).toBe(mainContent); + expect(overlay.parentElement).toBe(mainContent); + }); + + it('constrains dragging to the main content', async () => { + const mainContent = await mountModal(); + const dialog = document.querySelector('[role="dialog"]'); + const header = document.querySelector('.cursor-move'); + + expect(dialog).toBeInstanceOf(HTMLElement); + if (!(dialog instanceof HTMLElement)) return; + expect(header).toBeInstanceOf(HTMLElement); + if (!(header instanceof HTMLElement)) return; + + vi.spyOn(mainContent, 'getBoundingClientRect').mockReturnValue( + new DOMRect(100, 100, 800, 600), + ); + vi.spyOn(dialog, 'getBoundingClientRect').mockReturnValue( + new DOMRect(300, 200, 400, 300), + ); + + header.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + clientX: 400, + clientY: 300, + }), + ); + document.dispatchEvent( + new MouseEvent('mousemove', { + clientX: 1400, + clientY: 1300, + }), + ); + + expect(dialog.style.transform).toBe('translate(200px, 200px)'); + document.dispatchEvent(new MouseEvent('mouseup')); + }); +}); diff --git a/packages/@core/ui-kit/popup-ui/src/modal/modal.vue b/packages/@core/ui-kit/popup-ui/src/modal/modal.vue index 850c901a..4a3e4453 100644 --- a/packages/@core/ui-kit/popup-ui/src/modal/modal.vue +++ b/packages/@core/ui-kit/popup-ui/src/modal/modal.vue @@ -107,9 +107,7 @@ const shouldCentered = computed( ); const getAppendTo = computed(() => { - return appendToMain.value - ? `#${ELEMENT_ID_MAIN_CONTENT}>div:not(.absolute)>div` - : undefined; + return appendToMain.value ? `#${ELEMENT_ID_MAIN_CONTENT}` : undefined; }); const { dragging, transform } = useModalDraggable(