mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-05-20 15:33:37 +00:00
- 抽离每列渲染逻辑为 NavMenuColumn 组件,监听容器宽度 - 容器空间不足时自动隐藏溢出项,并通过更多按钮 Popover 展开 - ToolButton 暴露根元素引用,便于父级测量宽度 - design ButtonProps 新增 bg 属性,用于更多按钮的激活态样式 - 补充 NavMenuColumn / NavMenu / ToolButton 的单元测试 Co-authored-by: Cursor <cursoragent@cursor.com>
363 lines
11 KiB
TypeScript
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;
|
|
});
|
|
});
|