Browse Source

fix(@vben-core/shadcn-ui): 修复弹窗关闭动画被取消时 closed 事件丢失导致弹窗无法关闭

closed/opened 事件仅由内容元素自身的 animationend 确认,当退出动画被跳过或
取消(reduced motion、动画中断、关闭中 class 变化)时事件永远不会触发,
弹窗保持可见、destroy-on-close 重挂载和 onClosed 监听器挂起。

提取共享 useDialogStateEvents 组合式函数(dialog/alert-dialog/sheet 三处
重复实现统一),同时监听 animationend/animationcancel(对齐 reka-ui Presence
语义),并在动画窗口后增加 300ms 兜底触发,配合 exactly-once 守卫保证关闭
链路恰好完成一次;onScopeDispose 清理定时器。补充 popup-ui 单测覆盖兜底
与恰好一次两条路径。
pull/8259/head
Harold Zhang 2 weeks ago
parent
commit
fbd8ffac75
  1. 64
      packages/@core/ui-kit/popup-ui/src/modal/__tests__/modal.test.ts
  2. 22
      packages/@core/ui-kit/shadcn-ui/src/ui/alert-dialog/AlertDialogContent.vue
  3. 22
      packages/@core/ui-kit/shadcn-ui/src/ui/dialog/DialogContent.vue
  4. 84
      packages/@core/ui-kit/shadcn-ui/src/ui/dialog/use-dialog-state-events.ts
  5. 21
      packages/@core/ui-kit/shadcn-ui/src/ui/sheet/SheetContent.vue

64
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 = '<div><div></div></div>';
document.body.append(mainContent);
let capturedApi: ReturnType<typeof useVbenModal>[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();
}
});
});

22
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<InstanceType<typeof AlertDialogContent> | 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(

22
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<InstanceType<typeof DialogContent> | 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(

84
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<null | { $el: Element | null }>;
isOpen: () => boolean;
onClosed: () => void;
onOpened: () => void;
}) {
const { contentRef, isOpen, onClosed, onOpened } = options;
let closeFallbackTimer: null | ReturnType<typeof setTimeout> = 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 };
}

21
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<InstanceType<typeof DialogContent> | 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'),
});
</script>
<template>
@ -92,7 +90,8 @@ function onAnimationEnd(event: AnimationEvent) {
...(zIndex ? { zIndex } : {}),
position,
}"
@animationend="onAnimationEnd"
@animationend="handleAnimationEvent"
@animationcancel="handleAnimationEvent"
v-bind="{ ...forwarded, ...$attrs }"
>
<slot></slot>

Loading…
Cancel
Save