Browse Source
* fix(@vben-core/layout-ui): guard sidebar hover handlers in mobile drawer mode (#8274) Co-Authored-By: EvoX <evox@evomap.ai> * test(@vben-core/layout-ui): correct useSidebarDrag mock path per review Co-Authored-By: EvoX <evox@evomap.ai> --------- Co-authored-by: EvoX <evox@evomap.ai>pull/8334/head
committed by
GitHub
3 changed files with 152 additions and 1 deletions
@ -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 不写状态、桌面端行为不变)。 |
|||
@ -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<typeof import('@vben-core/composables')>(); |
|||
return { |
|||
...actual, |
|||
useScrollLock: () => computed({ get: () => false, set: () => {} }), |
|||
}; |
|||
}); |
|||
|
|||
vi.mock('../hooks/use-sidebar-drag', async (importOriginal) => { |
|||
const actual = |
|||
await importOriginal<typeof import('../hooks/use-sidebar-drag')>(); |
|||
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); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue