feat(editor): 斜杠命令空状态显示「无匹配命令」
搜索无结果时浮层显示居中提示(非空白卡片)。同步修两个空列表 bug: - ArrowUp/Down 对 % 0 产生 NaN(除零) - Enter 被吞(preventDefault 后无项可选,用户回车无反应) 空列表时方向键/回车放行(return false),仅 Escape 拦截关闭。 导出 createPopup 补 6 个 happy-dom 用例。
This commit is contained in:
parent
9a90514470
commit
7fbaae7622
@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isValidUrl, matchCommand } from '../slash-command';
|
||||
import type { SuggestionProps } from '@tiptap/suggestion';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { createPopup, isValidUrl, matchCommand } from '../slash-command';
|
||||
|
||||
/**
|
||||
* isValidUrl 纯函数测试。
|
||||
@ -133,3 +134,82 @@ describe('matchCommand', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* createPopup 空状态测试(happy-dom)。
|
||||
*
|
||||
* 搜索无结果时浮层应显示「无匹配命令」提示,而非空白卡片。
|
||||
*/
|
||||
describe('createPopup 空状态', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
/** 构造最小 SuggestionProps mock。 */
|
||||
function mockProps(
|
||||
items: Parameters<typeof matchCommand>[0][],
|
||||
): SuggestionProps<Parameters<typeof matchCommand>[0]> {
|
||||
return {
|
||||
items,
|
||||
editor: {} as any,
|
||||
range: {} as any,
|
||||
query: '',
|
||||
text: '',
|
||||
command: () => {},
|
||||
clientRect: () => null,
|
||||
} as unknown as SuggestionProps<Parameters<typeof matchCommand>[0]>;
|
||||
}
|
||||
|
||||
it('items 为空时显示「无匹配命令」提示', () => {
|
||||
const popup = createPopup(mockProps([]));
|
||||
document.body.appendChild(popup.component);
|
||||
const empty = document.querySelector('.slash-command-empty');
|
||||
expect(empty).not.toBeNull();
|
||||
expect(empty?.textContent).toBe('无匹配命令');
|
||||
// 且不渲染任何列表项
|
||||
expect(document.querySelectorAll('.slash-command-item')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('items 非空时不显示空状态提示', () => {
|
||||
const item = { title: '代码块', description: '插入代码块', icon: '<>', command: () => {} };
|
||||
const popup = createPopup(mockProps([item]));
|
||||
document.body.appendChild(popup.component);
|
||||
expect(document.querySelector('.slash-command-empty')).toBeNull();
|
||||
expect(document.querySelectorAll('.slash-command-item')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('空列表时 Enter 不被拦截(return false,让回车正常输入)', () => {
|
||||
const popup = createPopup(mockProps([]));
|
||||
const handled = popup.onKeyDown({
|
||||
event: new KeyboardEvent('keydown', { key: 'Enter' }),
|
||||
} as any);
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
it('空列表时 ArrowUp/Down 不被拦截(避免 % 0 产生 NaN)', () => {
|
||||
const popup = createPopup(mockProps([]));
|
||||
expect(
|
||||
popup.onKeyDown({ event: new KeyboardEvent('keydown', { key: 'ArrowUp' }) } as any),
|
||||
).toBe(false);
|
||||
expect(
|
||||
popup.onKeyDown({ event: new KeyboardEvent('keydown', { key: 'ArrowDown' }) } as any),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('空列表时 Escape 仍关闭浮层(return true)', () => {
|
||||
const popup = createPopup(mockProps([]));
|
||||
const handled = popup.onKeyDown({
|
||||
event: new KeyboardEvent('keydown', { key: 'Escape' }),
|
||||
} as any);
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
|
||||
it('updateItems 传空数组时切换到空状态', () => {
|
||||
const item = { title: '代码块', description: '插入代码块', icon: '<>', command: () => {} };
|
||||
const popup = createPopup(mockProps([item]));
|
||||
document.body.appendChild(popup.component);
|
||||
expect(document.querySelector('.slash-command-empty')).toBeNull();
|
||||
popup.updateItems([]);
|
||||
expect(document.querySelector('.slash-command-empty')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -298,7 +298,7 @@ export function isValidUrl(url: string): boolean {
|
||||
return /^https?:\/\//i.test(url) || /^data:image\//i.test(url);
|
||||
}
|
||||
|
||||
function createPopup(props: SuggestionProps<CommandItem>): SlashPopup {
|
||||
export function createPopup(props: SuggestionProps<CommandItem>): SlashPopup {
|
||||
const component = document.createElement('div');
|
||||
component.classList.add('slash-command');
|
||||
|
||||
@ -314,6 +314,15 @@ function createPopup(props: SuggestionProps<CommandItem>): SlashPopup {
|
||||
list.innerHTML = '';
|
||||
selectedIndex = 0;
|
||||
|
||||
// 空状态:显示提示,不渲染列表项。
|
||||
if (items.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.classList.add('slash-command-empty');
|
||||
empty.textContent = '无匹配命令';
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const el = document.createElement('div');
|
||||
el.classList.add('slash-command-item');
|
||||
@ -376,6 +385,15 @@ function createPopup(props: SuggestionProps<CommandItem>): SlashPopup {
|
||||
},
|
||||
updatePosition,
|
||||
onKeyDown({ event }: SuggestionKeyDownProps): boolean {
|
||||
// 空列表时不拦截键盘:避免 % 0 产生 NaN,也避免吞掉 Enter(让用户正常输入)。
|
||||
// Escape 仍拦截(关闭浮层)。
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (currentItems.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
selectedIndex = (selectedIndex - 1 + currentItems.length) % currentItems.length;
|
||||
@ -393,10 +411,6 @@ function createPopup(props: SuggestionProps<CommandItem>): SlashPopup {
|
||||
selectItem();
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
destroy() {
|
||||
|
||||
@ -347,6 +347,19 @@
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.slash-command-empty {
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
/* Latte overlay0 */
|
||||
color: #9ca0b0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dark .slash-command-empty {
|
||||
/* Mocha overlay0 */
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
/* ========== Task List (Enhanced) ========== */
|
||||
.tiptap-editor ul[data-type='taskList'] {
|
||||
list-style: none;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user