tmagic-editor/packages/editor/tests/unit/layouts/NavMenuColumn.spec.ts
roymondchen df8790042f feat(editor): 导航菜单支持菜单项溢出收纳,新增 NavMenuColumn 组件
- 抽离每列渲染逻辑为 NavMenuColumn 组件,监听容器宽度
- 容器空间不足时自动隐藏溢出项,并通过更多按钮 Popover 展开
- ToolButton 暴露根元素引用,便于父级测量宽度
- design ButtonProps 新增 bg 属性,用于更多按钮的激活态样式
- 补充 NavMenuColumn / NavMenu / ToolButton 的单元测试

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 19:33:53 +08:00

363 lines
11 KiB
TypeScript

/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import NavMenuColumn from '@editor/layouts/NavMenuColumn.vue';
vi.mock('@editor/components/ToolButton.vue', () => ({
default: defineComponent({
name: 'ToolButton',
props: ['data'],
setup(props, { expose }) {
const rootEl = ref<HTMLElement | null>(null);
expose({ getElRef: () => rootEl });
return () =>
h(
'button',
{
ref: rootEl,
class: ['tool-btn', (props.data as any)?.className],
},
(props.data as any)?.text || '',
);
},
}),
}));
vi.mock('@tmagic/design', () => ({
TMagicButton: defineComponent({
name: 'TMagicButton',
props: ['icon', 'bg', 'type', 'size', 'text'],
setup(_, { slots }) {
return () => h('span', { class: 'tmagic-btn' }, slots.default?.());
},
}),
TMagicPopover: defineComponent({
name: 'TMagicPopover',
props: ['placement', 'popperClass', 'width', 'visible'],
setup(props, { slots }) {
return () =>
h('div', { class: 'tmagic-popover', 'data-visible': String(props.visible) }, [
slots.reference?.(),
h('div', { class: 'tmagic-popover-content' }, slots.default?.()),
]);
},
}),
}));
let roCallbacks: Array<(entries?: any) => void> = [];
class FakeResizeObserver {
cb: any;
constructor(cb: any) {
this.cb = cb;
roCallbacks.push(cb);
}
observe() {}
unobserve() {}
disconnect() {}
}
(globalThis as any).ResizeObserver = FakeResizeObserver;
const flushRaf = async () => {
await new Promise((r) => requestAnimationFrame(() => r(null)));
await nextTick();
};
beforeEach(() => {
roCallbacks = [];
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('NavMenuColumn', () => {
test('渲染 columnKey 对应类名与所有 ToolButton', () => {
const items = [
{ type: 'button', className: 'a', text: 'A' },
{ type: 'button', className: 'b', text: 'B' },
];
const wrapper = mount(NavMenuColumn as any, {
props: { columnKey: 'left', items },
});
expect(wrapper.find('.menu-left').exists()).toBe(true);
expect(wrapper.find('.m-editor-nav-menu-column').exists()).toBe(true);
expect(wrapper.findAll('.tool-btn')).toHaveLength(items.length + /* overflow popover slot */ 0);
expect(wrapper.find('.a').exists()).toBe(true);
expect(wrapper.find('.b').exists()).toBe(true);
});
test('提供 width 时设置宽度样式', () => {
const wrapper = mount(NavMenuColumn as any, {
props: { columnKey: 'center', items: [], width: 240 },
});
const column = wrapper.find('.m-editor-nav-menu-column');
expect((column.element as HTMLElement).getAttribute('style')).toContain('width: 240px');
});
test('未提供 width 时不设置宽度样式', () => {
const wrapper = mount(NavMenuColumn as any, {
props: { columnKey: 'center', items: [] },
});
const column = wrapper.find('.m-editor-nav-menu-column');
const style = (column.element as HTMLElement).getAttribute('style') || '';
expect(style).not.toContain('width');
});
test('始终渲染 more 按钮容器,无 overflow 时通过 hidden 类隐藏', () => {
const wrapper = mount(NavMenuColumn as any, {
props: { columnKey: 'left', items: [{ type: 'button', text: 'A' }] },
});
const moreWrapper = wrapper.find('.m-editor-nav-menu-more-wrapper');
expect(moreWrapper.exists()).toBe(true);
expect(moreWrapper.classes()).toContain('m-editor-nav-menu-more-wrapper-hidden');
});
test('点击 more 引用元素切换 popover 显示状态', async () => {
const wrapper = mount(NavMenuColumn as any, {
props: { columnKey: 'left', items: [{ type: 'button', text: 'A' }] },
});
const popover = wrapper.find('.tmagic-popover');
expect(popover.attributes('data-visible')).toBe('false');
await wrapper.find('.m-editor-nav-menu-more').trigger('click');
expect(wrapper.find('.tmagic-popover').attributes('data-visible')).toBe('true');
await wrapper.find('.m-editor-nav-menu-more').trigger('click');
expect(wrapper.find('.tmagic-popover').attributes('data-visible')).toBe('false');
});
test('items 为空时仍可正常渲染,更多按钮容器隐藏', () => {
const wrapper = mount(NavMenuColumn as any, {
props: { columnKey: 'right', items: [] },
});
expect(wrapper.findAll('.tool-btn')).toHaveLength(0);
expect(wrapper.find('.m-editor-nav-menu-more-wrapper').classes()).toContain(
'm-editor-nav-menu-more-wrapper-hidden',
);
});
test('items 变化时重置 overflow 状态(无 hidden 类)', async () => {
const wrapper = mount(NavMenuColumn as any, {
props: {
columnKey: 'left',
items: [
{ type: 'button', text: 'A' },
{ type: 'button', text: 'B' },
],
},
});
await wrapper.setProps({
items: [
{ type: 'button', text: 'X' },
{ type: 'button', text: 'Y' },
{ type: 'button', text: 'Z' },
],
});
await nextTick();
const buttons = wrapper.findAll('.tool-btn');
expect(buttons).toHaveLength(3);
buttons.forEach((b) => {
expect(b.classes()).not.toContain('m-editor-nav-menu-slot-hidden');
});
});
test('容器宽度不足时应将溢出项标记为 hidden 并在 popover 中显示', async () => {
const items = Array.from({ length: 5 }).map((_, i) => ({
type: 'button',
className: `btn-${i}`,
text: `B${i}`,
}));
const wrapper = mount(NavMenuColumn as any, {
props: { columnKey: 'left', items, width: 100 },
attachTo: document.body,
});
const columnEl = wrapper.find('.m-editor-nav-menu-column').element as HTMLElement;
Object.defineProperty(columnEl, 'clientWidth', { configurable: true, get: () => 100 });
const moreEl = wrapper.find('.m-editor-nav-menu-more-wrapper').element as HTMLElement;
moreEl.getBoundingClientRect = () => ({
width: 30,
height: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
x: 0,
y: 0,
toJSON: () => ({}),
});
const itemEls = wrapper.findAll('.tool-btn');
itemEls.forEach((b) => {
const el = b.element as HTMLElement;
el.getBoundingClientRect = () => ({
width: 40,
height: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
x: 0,
y: 0,
toJSON: () => ({}),
});
});
roCallbacks.forEach((cb) => cb());
await flushRaf();
await flushRaf();
const updated = wrapper.findAll('.tool-btn');
const hiddenCount = updated.filter((b) => b.classes().includes('m-editor-nav-menu-slot-hidden')).length;
expect(hiddenCount).toBeGreaterThan(0);
expect(wrapper.find('.m-editor-nav-menu-more-wrapper').classes()).not.toContain(
'm-editor-nav-menu-more-wrapper-hidden',
);
wrapper.unmount();
});
test('容器宽度足够时不隐藏任何项', async () => {
const items = Array.from({ length: 3 }).map((_, i) => ({
type: 'button',
className: `btn-${i}`,
text: `B${i}`,
}));
const wrapper = mount(NavMenuColumn as any, {
props: { columnKey: 'left', items, width: 1000 },
attachTo: document.body,
});
const columnEl = wrapper.find('.m-editor-nav-menu-column').element as HTMLElement;
Object.defineProperty(columnEl, 'clientWidth', { configurable: true, get: () => 1000 });
const moreEl = wrapper.find('.m-editor-nav-menu-more-wrapper').element as HTMLElement;
moreEl.getBoundingClientRect = () => ({
width: 30,
height: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
x: 0,
y: 0,
toJSON: () => ({}),
});
wrapper.findAll('.tool-btn').forEach((b) => {
const el = b.element as HTMLElement;
el.getBoundingClientRect = () => ({
width: 40,
height: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
x: 0,
y: 0,
toJSON: () => ({}),
});
});
roCallbacks.forEach((cb) => cb());
await flushRaf();
await flushRaf();
wrapper.findAll('.tool-btn').forEach((b) => {
expect(b.classes()).not.toContain('m-editor-nav-menu-slot-hidden');
});
expect(wrapper.find('.m-editor-nav-menu-more-wrapper').classes()).toContain(
'm-editor-nav-menu-more-wrapper-hidden',
);
wrapper.unmount();
});
test('overflow 消失时自动关闭 popover', async () => {
const items = Array.from({ length: 4 }).map((_, i) => ({ type: 'button', text: `B${i}` }));
const wrapper = mount(NavMenuColumn as any, {
props: { columnKey: 'left', items, width: 80 },
attachTo: document.body,
});
const columnEl = wrapper.find('.m-editor-nav-menu-column').element as HTMLElement;
let containerW = 80;
Object.defineProperty(columnEl, 'clientWidth', { configurable: true, get: () => containerW });
const moreEl = wrapper.find('.m-editor-nav-menu-more-wrapper').element as HTMLElement;
moreEl.getBoundingClientRect = () => ({
width: 30,
height: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
x: 0,
y: 0,
toJSON: () => ({}),
});
wrapper.findAll('.tool-btn').forEach((b) => {
const el = b.element as HTMLElement;
el.getBoundingClientRect = () => ({
width: 40,
height: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
x: 0,
y: 0,
toJSON: () => ({}),
});
});
roCallbacks.forEach((cb) => cb());
await flushRaf();
await flushRaf();
await wrapper.find('.m-editor-nav-menu-more').trigger('click');
expect(wrapper.find('.tmagic-popover').attributes('data-visible')).toBe('true');
containerW = 1000;
roCallbacks.forEach((cb) => cb());
await flushRaf();
await flushRaf();
expect(wrapper.find('.tmagic-popover').attributes('data-visible')).toBe('false');
wrapper.unmount();
});
test('卸载时清理 ResizeObserver', () => {
const wrapper = mount(NavMenuColumn as any, {
props: { columnKey: 'left', items: [{ type: 'button', text: 'A' }] },
});
const disconnectSpy = vi.fn();
const originalDisconnect = FakeResizeObserver.prototype.disconnect;
FakeResizeObserver.prototype.disconnect = disconnectSpy;
wrapper.unmount();
expect(disconnectSpy).toHaveBeenCalled();
FakeResizeObserver.prototype.disconnect = originalDisconnect;
});
});