Browse Source

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 <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
俞可 · YU KE 1 month ago
committed by GitHub
parent
commit
577f0e238d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 7
      .changeset/lucky-pandas-hover.md
  2. 138
      packages/@core/ui-kit/layout-ui/src/__tests__/layout-sidebar-mobile-hover.test.ts
  3. 8
      packages/@core/ui-kit/layout-ui/src/components/layout-sidebar.vue

7
.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 不写状态、桌面端行为不变)。

138
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<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);
});
});

8
packages/@core/ui-kit/layout-ui/src/components/layout-sidebar.vue

@ -283,6 +283,11 @@ function calcMenuWidthStyle(): CSSProperties {
} }
function handleMouseenter(e: MouseEvent) { function handleMouseenter(e: MouseEvent) {
// 移动端抽屉模式不存在 hover 语义:合成 mouse 事件不得改写折叠状态
// (resize 跨断点时浏览器会对正在卸载/重排的侧栏派发 mouseenter/mouseleave)
if (props.isMobile) {
return;
}
if (e?.offsetX < 10) { if (e?.offsetX < 10) {
return; return;
} }
@ -305,7 +310,8 @@ function handleMouseleave() {
if (props.isSidebarMixed) { if (props.isSidebarMixed) {
isLocked.value = false; isLocked.value = false;
} }
if (expandOnHover.value) { // isMobile 守卫:防止断点切换窗口期的合成 mouseleave 把折叠态写入并持久化(#8274)
if (expandOnHover.value || props.isMobile) {
return; return;
} }

Loading…
Cancel
Save