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
index 15ec04038..20a95bc31 100644
--- 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
@@ -16,18 +16,21 @@ vi.mock('@vben-core/preferences', () => ({
let activeApp: App | undefined;
-async function mountModal() {
+async function mountModal(options: { onClosed?: () => void } = {}) {
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 [Modal, modalApi] = useVbenModal({
appendToMain: true,
draggable: true,
title: 'Draggable modal',
+ ...(options.onClosed ? { onClosed: options.onClosed } : {}),
});
+ capturedApi = modalApi;
onMounted(() => {
modalApi.open();
});
@@ -41,7 +44,10 @@ async function mountModal() {
await nextTick();
await nextTick();
- return mainContent;
+ if (!capturedApi) {
+ throw new Error('modal api was not captured');
+ }
+ return { mainContent, modalApi: capturedApi };
}
afterEach(() => {
@@ -53,7 +59,7 @@ afterEach(() => {
describe('vben modal', () => {
it('mounts an open modal directly in the main content', async () => {
- const mainContent = await mountModal();
+ const { mainContent } = await mountModal();
const dialog = document.querySelector('[role="dialog"]');
const overlay = document.querySelector('[data-dismissable-modal]');
@@ -66,7 +72,7 @@ describe('vben modal', () => {
});
it('constrains dragging to the main content', async () => {
- const mainContent = await mountModal();
+ const { mainContent } = await mountModal();
const dialog = document.querySelector('[role="dialog"]');
const header = document.querySelector('.cursor-move');
@@ -99,4 +105,54 @@ describe('vben modal', () => {
expect(dialog.style.transform).toBe('translate(200px, 200px)');
document.dispatchEvent(new MouseEvent('mouseup'));
});
+
+ it('fires onClosed via the fallback when no animation event arrives', async () => {
+ vi.useFakeTimers({
+ toFake: [
+ 'cancelAnimationFrame',
+ 'clearTimeout',
+ 'requestAnimationFrame',
+ 'setTimeout',
+ ],
+ });
+ try {
+ const onClosed = vi.fn();
+ const { modalApi } = await mountModal({ onClosed });
+ // happy-dom fires no animation events — without the fallback the
+ // `closed` event (and with it `onClosed`) would never fire and the
+ // close chain would hang. The fallback timer must acknowledge it.
+ await modalApi.close();
+ await vi.advanceTimersByTimeAsync(400);
+ expect(onClosed).toHaveBeenCalledTimes(1);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('emits closed exactly once when the animation event and the fallback both fire', async () => {
+ vi.useFakeTimers({
+ toFake: [
+ 'cancelAnimationFrame',
+ 'clearTimeout',
+ 'requestAnimationFrame',
+ 'setTimeout',
+ ],
+ });
+ try {
+ const onClosed = vi.fn();
+ const { modalApi } = await mountModal({ onClosed });
+ await modalApi.close();
+ // The exit animation ends normally — acknowledged immediately...
+ const dialog = document.querySelector('[role="dialog"]');
+ if (!(dialog instanceof HTMLElement)) {
+ throw new Error('dialog content not found');
+ }
+ dialog.dispatchEvent(new Event('animationend'));
+ // ...and the fallback must not emit a second `closed`.
+ await vi.advanceTimersByTimeAsync(400);
+ expect(onClosed).toHaveBeenCalledTimes(1);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
});
diff --git a/packages/@core/ui-kit/shadcn-ui/src/ui/alert-dialog/AlertDialogContent.vue b/packages/@core/ui-kit/shadcn-ui/src/ui/alert-dialog/AlertDialogContent.vue
index c0775f408..364e22460 100644
--- a/packages/@core/ui-kit/shadcn-ui/src/ui/alert-dialog/AlertDialogContent.vue
+++ b/packages/@core/ui-kit/shadcn-ui/src/ui/alert-dialog/AlertDialogContent.vue
@@ -14,6 +14,7 @@ import {
useForwardPropsEmits,
} from 'reka-ui';
+import { useDialogStateEvents } from '../dialog/use-dialog-state-events';
import AlertDialogOverlay from './AlertDialogOverlay.vue';
defineOptions({
@@ -48,16 +49,14 @@ const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const contentRef = ref | null>(null);
-function onAnimationEnd(event: AnimationEvent) {
- // 只有在 contentRef 的动画结束时才触发 opened/closed 事件
- if (event.target === contentRef.value?.$el) {
- if (props.open) {
- emits('opened');
- } else {
- emits('closed');
- }
- }
-}
+
+const { handleAnimationEvent } = useDialogStateEvents({
+ contentRef,
+ isOpen: () => props.open,
+ onClosed: () => emits('closed'),
+ onOpened: () => emits('opened'),
+});
+
defineExpose({
getContentRef: () => contentRef.value,
});
@@ -78,7 +77,8 @@ defineExpose({
data-slot="alert-dialog-content"
ref="contentRef"
:style="{ ...(zIndex ? { zIndex } : {}), position: 'fixed' }"
- @animationend="onAnimationEnd"
+ @animationend="handleAnimationEvent"
+ @animationcancel="handleAnimationEvent"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
diff --git a/packages/@core/ui-kit/shadcn-ui/src/ui/dialog/DialogContent.vue b/packages/@core/ui-kit/shadcn-ui/src/ui/dialog/DialogContent.vue
index fbfa93c7b..147bf6bf2 100644
--- a/packages/@core/ui-kit/shadcn-ui/src/ui/dialog/DialogContent.vue
+++ b/packages/@core/ui-kit/shadcn-ui/src/ui/dialog/DialogContent.vue
@@ -18,6 +18,7 @@ import {
} from 'reka-ui';
import DialogOverlay from './DialogOverlay.vue';
+import { useDialogStateEvents } from './use-dialog-state-events';
defineOptions({
inheritAttrs: false,
@@ -81,16 +82,14 @@ const position = computed(() => {
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const contentRef = ref | null>(null);
-function onAnimationEnd(event: AnimationEvent) {
- // 只有在 contentRef 的动画结束时才触发 opened/closed 事件
- if (event.target === contentRef.value?.$el) {
- if (props.open) {
- emits('opened');
- } else {
- emits('closed');
- }
- }
-}
+
+const { handleAnimationEvent } = useDialogStateEvents({
+ contentRef,
+ isOpen: () => props.open,
+ onClosed: () => emits('closed'),
+ onOpened: () => emits('opened'),
+});
+
defineExpose({
getContentRef: () => contentRef.value,
});
@@ -111,7 +110,8 @@ defineExpose({
ref="contentRef"
:style="{ ...(zIndex ? { zIndex } : {}), position }"
data-slot="dialog-content"
- @animationend="onAnimationEnd"
+ @animationend="handleAnimationEvent"
+ @animationcancel="handleAnimationEvent"
v-bind="forwarded"
:class="
cn(
diff --git a/packages/@core/ui-kit/shadcn-ui/src/ui/dialog/use-dialog-state-events.ts b/packages/@core/ui-kit/shadcn-ui/src/ui/dialog/use-dialog-state-events.ts
new file mode 100644
index 000000000..6941136ac
--- /dev/null
+++ b/packages/@core/ui-kit/shadcn-ui/src/ui/dialog/use-dialog-state-events.ts
@@ -0,0 +1,84 @@
+import type { Ref } from 'vue';
+
+import { onScopeDispose, watch } from 'vue';
+
+/**
+ * 弹窗的 `closed`/`opened` 事件由内容元素自身的退出动画(`animationend`)确认。
+ * 当动画被跳过或取消(reduced motion、动画被中断、关闭过程中 class 变化)时,
+ * 事件永远不会触发——弹窗会一直保持可见,`hidden` class 不会被应用,
+ * destroy-on-close 重挂载和 `onClosed` 监听器会一直挂起。这里对齐 reka-ui
+ * Presence 的行为(`animationcancel` 视为结束),并在动画窗口结束后增加兜底
+ * 触发,保证关闭链路始终恰好完成一次。
+ */
+
+/** 本仓库弹窗的退出动画时长为 150ms;300ms 可以安全覆盖。 */
+const CLOSED_EVENT_FALLBACK_MS = 300;
+
+export function useDialogStateEvents(options: {
+ contentRef: Ref;
+ isOpen: () => boolean;
+ onClosed: () => void;
+ onOpened: () => void;
+}) {
+ const { contentRef, isOpen, onClosed, onOpened } = options;
+
+ let closeFallbackTimer: null | ReturnType = null;
+ let closeAcknowledged = false;
+
+ function emitOpenStateChange() {
+ if (closeFallbackTimer !== null) {
+ clearTimeout(closeFallbackTimer);
+ closeFallbackTimer = null;
+ }
+ if (isOpen()) {
+ onOpened();
+ return;
+ }
+ // 关闭已由动画事件或兜底确认——同一关闭周期内 `closed` 只触发一次。
+ if (closeAcknowledged) {
+ return;
+ }
+ closeAcknowledged = true;
+ onClosed();
+ }
+
+ /**
+ * 同时监听内容元素的 `animationend` 和 `animationcancel` ——被取消的动画
+ * 同样视为结束(reka-ui 的 Presence 也是这么处理的),关闭链路不会一直等待。
+ */
+ function handleAnimationEvent(event: AnimationEvent) {
+ if (event.target === contentRef.value?.$el) {
+ emitOpenStateChange();
+ }
+ }
+
+ watch(isOpen, (open) => {
+ if (open) {
+ closeAcknowledged = false;
+ if (closeFallbackTimer !== null) {
+ clearTimeout(closeFallbackTimer);
+ closeFallbackTimer = null;
+ }
+ return;
+ }
+ // 启动兜底定时器:如果没有动画事件确认关闭(退出动画被跳过/取消),
+ // 则在动画窗口结束后触发 `closed`。
+ if (closeFallbackTimer === null) {
+ closeFallbackTimer = setTimeout(() => {
+ closeFallbackTimer = null;
+ if (!isOpen()) {
+ emitOpenStateChange();
+ }
+ }, CLOSED_EVENT_FALLBACK_MS);
+ }
+ });
+
+ onScopeDispose(() => {
+ if (closeFallbackTimer !== null) {
+ clearTimeout(closeFallbackTimer);
+ closeFallbackTimer = null;
+ }
+ });
+
+ return { handleAnimationEvent };
+}
diff --git a/packages/@core/ui-kit/shadcn-ui/src/ui/sheet/SheetContent.vue b/packages/@core/ui-kit/shadcn-ui/src/ui/sheet/SheetContent.vue
index 49074b0d8..fe8e85f38 100644
--- a/packages/@core/ui-kit/shadcn-ui/src/ui/sheet/SheetContent.vue
+++ b/packages/@core/ui-kit/shadcn-ui/src/ui/sheet/SheetContent.vue
@@ -9,6 +9,7 @@ import { cn } from '@vben-core/shared/utils';
import { DialogContent, useForwardPropsEmits } from 'reka-ui';
+import { useDialogStateEvents } from '../dialog/use-dialog-state-events';
import { sheetVariants } from './sheet';
import SheetOverlay from './SheetOverlay.vue';
@@ -60,16 +61,13 @@ const position = computed(() => {
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const contentRef = ref | null>(null);
-function onAnimationEnd(event: AnimationEvent) {
- // 只有在 contentRef 的动画结束时才触发 opened/closed 事件
- if (event.target === contentRef.value?.$el) {
- if (props.open) {
- emits('opened');
- } else {
- emits('closed');
- }
- }
-}
+
+const { handleAnimationEvent } = useDialogStateEvents({
+ contentRef,
+ isOpen: () => props.open,
+ onClosed: () => emits('closed'),
+ onOpened: () => emits('opened'),
+});
@@ -92,7 +90,8 @@ function onAnimationEnd(event: AnimationEvent) {
...(zIndex ? { zIndex } : {}),
position,
}"
- @animationend="onAnimationEnd"
+ @animationend="handleAnimationEvent"
+ @animationcancel="handleAnimationEvent"
v-bind="{ ...forwarded, ...$attrs }"
>