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(