From fbd8ffac75420af8b8f86136d83fb6fccbb037df Mon Sep 17 00:00:00 2001 From: Harold Zhang Date: Fri, 14 Aug 2026 16:15:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(@vben-core/shadcn-ui):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E5=85=B3=E9=97=AD=E5=8A=A8=E7=94=BB=E8=A2=AB?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E6=97=B6=20closed=20=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E4=B8=A2=E5=A4=B1=E5=AF=BC=E8=87=B4=E5=BC=B9=E7=AA=97=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 单测覆盖兜底 与恰好一次两条路径。 --- .../src/modal/__tests__/modal.test.ts | 64 +++++++++++++- .../ui/alert-dialog/AlertDialogContent.vue | 22 ++--- .../shadcn-ui/src/ui/dialog/DialogContent.vue | 22 ++--- .../src/ui/dialog/use-dialog-state-events.ts | 84 +++++++++++++++++++ .../shadcn-ui/src/ui/sheet/SheetContent.vue | 21 +++-- 5 files changed, 176 insertions(+), 37 deletions(-) create mode 100644 packages/@core/ui-kit/shadcn-ui/src/ui/dialog/use-dialog-state-events.ts 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'), +});