From 577f0e238d9121ffd6e92915342aaae5ffaaec7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9E=E5=8F=AF=20=C2=B7=20YU=20KE?= <1798456934@qq.com> Date: Mon, 31 Aug 2026 10:31:22 +0800 Subject: [PATCH] fix(@vben-core/layout-ui): guard sidebar hover handlers in mobile drawer mode (#8274) (#8326) * fix(@vben-core/layout-ui): guard sidebar hover handlers in mobile drawer mode (#8274) Co-Authored-By: EvoX * test(@vben-core/layout-ui): correct useSidebarDrag mock path per review Co-Authored-By: EvoX --------- Co-authored-by: EvoX --- .changeset/lucky-pandas-hover.md | 7 + .../layout-sidebar-mobile-hover.test.ts | 138 ++++++++++++++++++ .../src/components/layout-sidebar.vue | 8 +- 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 .changeset/lucky-pandas-hover.md create mode 100644 packages/@core/ui-kit/layout-ui/src/__tests__/layout-sidebar-mobile-hover.test.ts diff --git a/.changeset/lucky-pandas-hover.md b/.changeset/lucky-pandas-hover.md new file mode 100644 index 000000000..67d4bec8c --- /dev/null +++ b/.changeset/lucky-pandas-hover.md @@ -0,0 +1,7 @@ +--- +'@vben-core/layout-ui': patch +--- + +fix(@vben-core/layout-ui): guard sidebar hover handlers in mobile drawer mode + +移动端抽屉模式不存在 hover 语义。resize 跨断点时浏览器会对正在卸载/重排的侧栏派发合成 mouseenter/mouseleave,`handleMouseleave` 缺少 `isMobile` 守卫会把折叠态写入 `collapse` 并经 v-model 链持久化,导致窗口放大后侧栏保持折叠(#8274)。本次为 `handleMouseenter`/`handleMouseleave` 增加 `isMobile` 守卫,并附 4 项回归测试(移动端 mouseenter/mouseleave 不写状态、桌面端行为不变)。 diff --git a/packages/@core/ui-kit/layout-ui/src/__tests__/layout-sidebar-mobile-hover.test.ts b/packages/@core/ui-kit/layout-ui/src/__tests__/layout-sidebar-mobile-hover.test.ts new file mode 100644 index 000000000..c4da8d84e --- /dev/null +++ b/packages/@core/ui-kit/layout-ui/src/__tests__/layout-sidebar-mobile-hover.test.ts @@ -0,0 +1,138 @@ +import type { App } from 'vue'; + +import { computed, createApp, defineComponent, h, nextTick, ref } from 'vue'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import LayoutSidebar from '../components/layout-sidebar.vue'; + +vi.mock('@vben-core/composables', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useScrollLock: () => computed({ get: () => false, set: () => {} }), + }; +}); + +vi.mock('../hooks/use-sidebar-drag', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useSidebarDrag: () => ({ startDrag: vi.fn(), endDrag: vi.fn() }), + }; +}); + +let activeApp: App | undefined; + +interface MountOptions { + collapse?: boolean; + isMobile?: boolean; +} + +/** + * 挂载一个受控的 LayoutSidebar,验证移动端/桌面端 hover 事件对折叠状态的副作用(#8274)。 + */ +function mountSidebar(options: MountOptions = {}) { + const collapse = ref(options.collapse ?? false); + const collapseWrites: boolean[] = []; + const onUpdateCollapse = vi.fn((value: boolean) => { + collapseWrites.push(value); + collapse.value = value; + }); + const Consumer = defineComponent( + () => () => + h(LayoutSidebar, { + collapse: collapse.value, + expandOnHover: false, + expandOnHovering: false, + extraCollapse: false, + extraVisible: false, + theme: 'dark', + themeSub: 'dark', + width: 180, + headerHeight: 50, + extraWidth: 60, + isMobile: options.isMobile ?? false, + 'onUpdate:collapse': onUpdateCollapse, + }), + ); + const host = document.createElement('div'); + document.body.append(host); + activeApp = createApp(Consumer); + activeApp.mount(host); + return { collapseWrites, onUpdateCollapse }; +} + +function getAside(): HTMLElement { + const aside = document.querySelector('aside'); + if (!(aside instanceof HTMLElement)) { + throw new TypeError('aside not rendered'); + } + return aside; +} + +async function fireMouse(type: 'mouseenter' | 'mouseleave') { + const event = new MouseEvent(type); + // jsdom 的合成事件 offsetX 恒为 0,会命中 handleMouseenter 的左边缘 10px 守卫; + // 注入 20 模拟真实悬停在菜单区 + Object.defineProperty(event, 'offsetX', { value: 20 }); + getAside().dispatchEvent(event); + await nextTick(); + await nextTick(); +} + +afterEach(() => { + activeApp?.unmount(); + activeApp = undefined; + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('layout-sidebar mobile hover guard (#8274)', () => { + it('mobile: mouseleave must not write collapsed state', async () => { + const { collapseWrites, onUpdateCollapse } = await mountSidebar({ + isMobile: true, + }); + + await fireMouse('mouseleave'); + + expect(collapseWrites).toHaveLength(0); + expect(onUpdateCollapse).not.toHaveBeenCalled(); + }); + + it('mobile: mouseenter must not write collapsed state', async () => { + const { collapseWrites, onUpdateCollapse } = await mountSidebar({ + isMobile: true, + }); + + await fireMouse('mouseenter'); + + expect(collapseWrites).toHaveLength(0); + expect(onUpdateCollapse).not.toHaveBeenCalled(); + }); + + it('desktop: mouseleave still collapses the hover-expanded rail', async () => { + const { collapseWrites, onUpdateCollapse } = await mountSidebar({ + isMobile: false, + }); + + await fireMouse('mouseleave'); + + expect(collapseWrites).toContain(true); + expect(onUpdateCollapse).toHaveBeenCalledWith(true); + }); + + it('desktop: mouseenter expands a collapsed rail (collapse=false)', async () => { + const { collapseWrites, onUpdateCollapse } = await mountSidebar({ + isMobile: false, + collapse: true, + }); + + await fireMouse('mouseenter'); + + expect(collapseWrites).toContain(false); + expect(onUpdateCollapse).toHaveBeenCalledWith(false); + }); +}); diff --git a/packages/@core/ui-kit/layout-ui/src/components/layout-sidebar.vue b/packages/@core/ui-kit/layout-ui/src/components/layout-sidebar.vue index 1065a1193..33f72ffd9 100644 --- a/packages/@core/ui-kit/layout-ui/src/components/layout-sidebar.vue +++ b/packages/@core/ui-kit/layout-ui/src/components/layout-sidebar.vue @@ -283,6 +283,11 @@ function calcMenuWidthStyle(): CSSProperties { } function handleMouseenter(e: MouseEvent) { + // 移动端抽屉模式不存在 hover 语义:合成 mouse 事件不得改写折叠状态 + // (resize 跨断点时浏览器会对正在卸载/重排的侧栏派发 mouseenter/mouseleave) + if (props.isMobile) { + return; + } if (e?.offsetX < 10) { return; } @@ -305,7 +310,8 @@ function handleMouseleave() { if (props.isSidebarMixed) { isLocked.value = false; } - if (expandOnHover.value) { + // isMobile 守卫:防止断点切换窗口期的合成 mouseleave 把折叠态写入并持久化(#8274) + if (expandOnHover.value || props.isMobile) { return; }