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
This commit is contained in:
Dream
2026-08-06 10:17:14 +08:00
parent 9f5b1cd9fb
commit 98761dce98
4 changed files with 170 additions and 6 deletions

View File

@@ -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 = '<div><div></div></div>';
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);
});
});

View File

@@ -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;

View File

@@ -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 = '<div><div></div></div>';
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'));
});
});

View File

@@ -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(