Skip to content

Commit bde807d

Browse files
committed
refactor(shell): use modal in command palette
1 parent 4a804a5 commit bde807d

7 files changed

Lines changed: 312 additions & 248 deletions

‎app/src/shell/CommandPalette.css‎

Lines changed: 0 additions & 102 deletions
This file was deleted.

‎app/src/shell/CommandPalette.kb.test.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ describe("① 防抖取样(180ms —— 本批唯一允许的计时器)", ()
6868
expect(invokeMock, "输入即查(防抖没生效)").not.toHaveBeenCalled();
6969
await afterDebounce();
7070
expect(invokeMock).toHaveBeenCalledWith("kb_search", { query: "眼影", limit: 10 });
71-
expect(screen.getByTestId("command-hit:7").querySelector(".ed-cmdk__label")?.textContent).toBe("📄 眼影入门 · 晕染手法");
71+
// T9(选项 (b)):行内容改由 `Text` 原语表达、`.ed-cmdk*` 残类已删除 ⇒ 判据从「类选择器」改成
72+
// **结构 + 文本**(选项的首个元素子节点恰是标签;强度不降:原判据只锁类名与文本,新判据另锁结构)。
73+
expect(screen.getByTestId("command-hit:7").firstElementChild?.textContent).toBe("📄 眼影入门 · 晕染手法");
7274
});
7375

7476
it("连打三次只查一次(没有防抖的实现会查三次 ⇒ 本判据变红)", async () => {
@@ -137,7 +139,9 @@ describe("④ 与页面命令合并展示(页面命令恒在最前)", () =>
137139
render(<CommandPalette open onClose={onClose} onPick={onPick} />);
138140
fireEvent.change(screen.getByTestId("command-palette-input"), { target: { value: "笔" } });
139141
await afterDebounce();
140-
const ids = Array.from(screen.getByTestId("command-palette-list").querySelectorAll("li")).map((li) => li.getAttribute("data-testid"));
142+
// T9(选项 (b)):行元素由 `li` 改成 `div[role="option"]`(本文件不许自带 CSS,`list-style` 无处安放)
143+
// ⇒ 选择器改走**语义角色**(强度不降:原判据只认标签名,新判据认的是列表项的角色)。
144+
const ids = Array.from(screen.getByTestId("command-palette-list").querySelectorAll('[role="option"]')).map((li) => li.getAttribute("data-testid"));
141145
expect(ids.indexOf("command-hit:7"), "结果命令不在列表里").toBeGreaterThan(-1);
142146
expect(ids.indexOf("command-hit:7"), "结果命令排到了页面命令前面").toBeGreaterThan(ids.indexOf("command-page:notes"));
143147
fireEvent.click(screen.getByTestId("command-hit:7"));

‎app/src/shell/CommandPalette.test.tsx‎

Lines changed: 55 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,41 @@
22
/**
33
* @ai-context ⌘K 命令面板的契约守卫(规格 §6.1;批 3 Task 11)。
44
*
5-
* 判据分五层,每层都能**独立变红**(每条判据的变异体实测见 `task-11-report.md` §5):
6-
* ① 契约:`role="dialog"` + `aria-modal` + 输入框自动聚焦(**与 `Modal` 对齐的五条契约之三**,
7-
* 另两条 = ESC 关 / 点遮罩关,在 ② 层);
8-
* ② 关闭路径:Esc 关(window 冒泡相)· 点遮罩关(`mousedown`,与 `Modal` 同款)· 面板内点击**不**关;
5+
* 判据分五层,每层都能**独立变红**(每条判据的变异体实测见 `task-11-report.md` §5;
6+
* **批 4 T9 的改写与各自的新变异体见 `task-9-report.md` §6.2**):
7+
* ① 契约:`role="dialog"` + `aria-modal` + 无障碍名整条链(`aria-labelledby` → 可见标题)+
8+
* 输入框自动聚焦(**与 `Modal` 对齐的五条契约之三**,另两条 = ESC 关 / 点遮罩关,在 ② 层);
9+
* ② 关闭路径:Esc 关(`Modal` 的 `document` 冒泡相 + 栈顶)· 点遮罩关(`mousedown`,与 `Modal` 同款)·
10+
* 面板内点击**不**关 · 遮罩的层级落在六档标尺上;
911
* ③ 键盘导航:↑↓ 改选中项(`aria-selected`)· Enter 执行并关闭 · 过滤后选中位回到首项;
1012
* ④ IME:**组合中按 Enter 不提交**(中文输入法不能误触)—— 判据自带**阳性对照**(同一条命令
1113
* 在不带 `isComposing` 的 Enter 下必须提交),否则「没提交」可能只是「Enter 从来没生效」;
12-
* ⑤ 静态纪律:9 个页面跳转命令来自注册表(不硬编码 label)· 不 import 原语层 · 层级走 `zIndex()`
13-
* 且无裸数字 · CSS 无 transition/animation(动效属批 6)。
14+
* ⑤ 静态纪律:9 个页面跳转命令来自注册表(不硬编码 label)· **只经 barrel 导入原语**(T9 起;
15+
* 批 3 的「不许 import 原语层」是非目标 2 的临时守卫,前提已被批 4 解除)· **不留第二套
16+
* 遮罩/ESC/焦点**(ADR-033 §7)· 层级无裸数字 · 自带 CSS 整份删除且无 `.ed-cmdk*` 残类。
17+
* ⚠️ 判据前**先剥注释**(`CODE`):文件头逐字点到 `createPortal` / `aria-modal` / `.ed-cmdk*`
18+
* 这些形态,注释不算犯规(与 `TopBar.test.tsx:34` 同一口径)。
1419
*
1520
* ⚠️ 本仓测试底座(照抄勿改):`vitest.config.ts` 全局 `environment: "node"` ⇒ 首行必须
1621
* `// @vitest-environment jsdom`;**未装** `jest-dom` 与 `user-event` ⇒ 断言用原生 DOM API、
1722
* 交互用 `fireEvent`;**没有** `globals` ⇒ RTL 的 auto-cleanup 不生效,必须显式 `afterEach(cleanup)`。
1823
*/
19-
import { readFileSync } from "node:fs";
24+
import { existsSync, readFileSync } from "node:fs";
2025
import { dirname, join } from "node:path";
2126
import { fileURLToPath } from "node:url";
2227
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
2328
import { afterEach, describe, expect, it, vi } from "vitest";
2429
import { CommandPalette } from "./CommandPalette";
2530
import { ALL_ENTRIES } from "./navRegistry";
31+
import { zIndex } from "../ui/zIndex";
2632

2733
const HERE = dirname(fileURLToPath(import.meta.url));
2834
const TSX = readFileSync(join(HERE, "CommandPalette.tsx"), "utf8");
29-
/** 判据前先剥注释(与 `TopBar.test.tsx:34` 同一口径):文件头解释「不得出现 transition」时点到了那个词 */
30-
const CSS = readFileSync(join(HERE, "CommandPalette.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
35+
/** 迁移面的**只留代码**版本(剥块注释 + 整行 `//`)—— 与 `TopBar.test.tsx:34` 同一口径:
36+
* 文件头解释「不许再自带 CSS / 不许留 `.ed-cmdk*` 残类」时会点到那些字面量,注释不算犯规。 */
37+
const CODE = TSX.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^[ \t]*\/\/.*$/gm, "");
38+
/** T9:`CommandPalette.css` 已整份删除(计划 V1)⇒ 原「CSS 里没有动效声明」判据改建为
39+
* 「CSS 层只剩原语那一份」(同一 `it`,用例数不减;判据强度不降,见该用例的说明)。 */
3140
/** `App.tsx` 的**只留代码**版本(剥块注释 + 整行 `//`)—— 与 `TopBar.test.tsx:43` 同一口径 */
3241
const APP_CODE = readFileSync(join(HERE, "..", "App.tsx"), "utf8")
3342
.replace(/\/\*[\s\S]*?\*\//g, "")
@@ -43,7 +52,11 @@ describe("CommandPalette 契约(规格 §6.1)", () => {
4352
const dialog = screen.getByTestId("command-palette");
4453
expect(dialog.getAttribute("role")).toBe("dialog");
4554
expect(dialog.getAttribute("aria-modal")).toBe("true");
46-
expect(dialog.getAttribute("aria-label")).toBe("命令面板");
55+
// T9:无障碍名从 `aria-label` 搬到 `aria-labelledby` → 可见标题(`Modal` 契约)。**强度不降**:
56+
// 旧判据只验一个属性字面量;新判据验「属性 → 标题节点 → 文本」**整条链**(任一处断链即红)。
57+
const labelledBy = dialog.getAttribute("aria-labelledby");
58+
expect(labelledBy, "面板没有无障碍名(aria-labelledby)").toBeTruthy();
59+
expect(document.getElementById(labelledBy ?? "")?.textContent, "无障碍名不指向可见标题").toBe("命令面板");
4760
// 焦点:面板打开即落在输入框(键盘用户不必先按 Tab 找入口)
4861
expect(document.activeElement).toBe(screen.getByTestId("command-palette-input"));
4962
});
@@ -56,10 +69,10 @@ describe("CommandPalette 契约(规格 §6.1)", () => {
5669
it("② Esc 关闭;关闭后 Esc 不再触发(监听随 open 解绑,避免常驻 window 监听)", () => {
5770
const onClose = vi.fn();
5871
const { unmount } = render(<CommandPalette {...base} onClose={onClose} />);
59-
fireEvent.keyDown(window, { key: "Escape" });
72+
fireEvent.keyDown(document, { key: "Escape" }); // T9:ESC 归 `Modal`(document 冒泡相 + 栈顶)
6073
expect(onClose).toHaveBeenCalledTimes(1);
6174
unmount();
62-
fireEvent.keyDown(window, { key: "Escape" });
75+
fireEvent.keyDown(document, { key: "Escape" }); // T9:ESC 归 `Modal`(document 冒泡相 + 栈顶)
6376
expect(onClose, "卸载后 Esc 仍触发 ⇒ window 监听没解绑").toHaveBeenCalledTimes(1);
6477
});
6578

@@ -73,6 +86,9 @@ describe("CommandPalette 契约(规格 §6.1)", () => {
7386
expect(overlay.contains(panel), "面板不在遮罩里 ⇒ 点遮罩判据测的不是本组件").toBe(true);
7487
fireEvent.mouseDown(overlay);
7588
expect(onClose).toHaveBeenCalledTimes(1);
89+
// T9:层级判据从「源码里出现 `zIndex(`」搬到**渲染出的遮罩**(强于源码文本判据:它验的是
90+
// 「层级真的落在六档标尺上」,而不是「本文件写没写那五个字符」;判据对象随迁移搬到原语层)。
91+
expect(overlay.style.zIndex, "遮罩的层级不在六档标尺上").toBe(String(zIndex("modal")));
7692
});
7793

7894
it("③ ↑↓ 改选中项(aria-selected 单一真源),Enter 执行并关闭", () => {
@@ -130,24 +146,40 @@ describe("CommandPalette 契约(规格 §6.1)", () => {
130146
expect(ALL_ENTRIES, "注册表条目数变了 ⇒ 本判据的锚点失效").toHaveLength(9);
131147
for (const e of ALL_ENTRIES) {
132148
const item = screen.getByTestId(`command-page:${e.key}`);
133-
expect(item.querySelector(".ed-cmdk__label")?.textContent, e.key).toBe(e.label);
149+
// T9(选项 (b)):行内容改由 `Text` 原语表达、`.ed-cmdk*` 残类全部删除 ⇒ 判据从「类选择器」
150+
// 改成**结构 + 文本**:选项的**首个元素子节点**恰是标签文本(原判据只锁类名与文本,
151+
// 新判据连「标签在行首、hint 在其后」这层结构一起锁 ⇒ 强度不降)。
152+
expect(item.firstElementChild?.textContent, e.key).toBe(e.label);
134153
}
135154
expect(screen.queryByTestId("command-page:not-a-page")).toBeNull(); // 阴性样本
136155
});
137156

138-
it("⑤ 静态纪律:不 import 原语层 · 层级走 zIndex() 标尺且无裸数字", () => {
139-
expect(/ui\/primitives/.test(TSX), "面板 import 了原语层(会把整层 CSS 拉进首屏)").toBe(false);
157+
it("⑤ 静态纪律:只经 barrel 导入原语 · 不留第二套遮罩/ESC/焦点(ADR-033 §7)· 无裸层级", () => {
158+
// 批 3 的「不 import `ui/primitives`」是**非目标 2 的临时守卫**(那条禁令的前提已被批 4 解除:
159+
// 迁移原语正是本批目标)⇒ 它按新架构换成**更强**的形态:入口唯一(barrel)+ 单实现(不留第二套
160+
// 弹层机制)+ 自带 CSS 层清零。四条原断言里「无内联 svg」与「无裸数字」两条逐字保留。
161+
// ⚠️ 本文件不得出现那个标签的**字面量**(连注释也不行):`ui/icons/no-inline-svg.test.ts` 是
162+
// **全文扫描**的棘轮,注释里出现即被判为「新增内联 svg 的文件」(`EmptyState.tsx` 边界③ 同坑)。
163+
expect(/from\s+"\.\.\/ui\/primitives"/.test(CODE), "面板没走 barrel(ADR-033 §1)").toBe(true);
164+
expect(CODE.match(/from\s+"\.\.\/ui\/primitives\/[A-Za-z]+"/), "面板深导入原语(深导入不带 motion.css)").toBeNull();
140165
expect(TSX.includes("<" + "svg"), "面板里出现内联 svg(图标一律走 ui/icons)").toBe(false);
141-
expect(/zIndex\(/.test(TSX), "层级没有走六档标尺").toBe(true);
142-
expect(/zIndex:\s*\d/.test(TSX), "层级写了裸数字").toBe(false);
166+
expect(/zIndex:\s*\d/.test(CODE), "层级写了裸数字").toBe(false);
167+
// 单实现(ADR-033 §7):portal / 事件监听 / 弹层语义 / 遮罩 / 焦点陷阱都只有 `Modal` 一份
168+
expect(CODE.match(/createPortal/), "面板自建 portal").toBeNull();
169+
expect(CODE.match(/addEventListener/), "面板自建事件监听(ESC 与焦点都归 `Modal`)").toBeNull();
170+
expect(CODE.match(/aria-modal/), "面板自建 `aria-modal`(第二套弹层语义)").toBeNull();
171+
expect(CODE.match(/position\s*:\s*["']fixed["']/), "面板自建遮罩(position: fixed)").toBeNull();
172+
expect(CODE.match(/inset\s*:\s*0/), "面板自建遮罩(inset: 0)").toBeNull();
173+
expect(CODE.match(/useFocusTrap|FOCUSABLE_SELECTOR/), "面板自建焦点陷阱").toBeNull();
143174
});
144175

145-
it("⑤ CSS 里没有动效声明(transition/animation/@keyframes 属批 6)", () => {
146-
const banned = [/transition\s*:/, /animation\s*:/, /@keyframes/];
147-
// 仪器自检:同一组正则对一段**确定含动效**的 CSS 必须命中(否则下面的「0 命中」是假绿)
148-
expect(banned.some((re) => re.test(".x { transition: opacity 200ms; }"))).toBe(true);
149-
const hits = banned.filter((re) => re.test(CSS)).map(String);
150-
expect(hits, `批 3 的面板 CSS 不得含动效(属批 6):${hits.join(" / ")}`).toEqual([]);
176+
it("⑤ 自带 CSS 整份删除(计划 V1):文件不在盘上,迁移面也不 import 任何 CSS / 不留残类", () => {
177+
// 判据对象随迁移**搬走**(原判据扫的是那个文件的文本;文件按 V1 删除 ⇒ 判据升级为「它必须不在」)。
178+
expect(existsSync(join(HERE, "CommandPalette.css")), "CommandPalette.css 又回来了").toBe(false);
179+
expect(CODE.match(/\bimport\s+["'][^"']+\.css["']/), "迁移面又 import 了 CSS").toBeNull();
180+
expect(CODE.match(/ed-cmdk/), "`.ed-cmdk*` 残类(样式已无落点)").toBeNull();
181+
// 仪器自证:同一条「存在性」读法对**确定存在**的文件必须为 true(否则上面那条 false 可能只是路径写错)
182+
expect(existsSync(join(HERE, "CommandPalette.tsx")), "文件存在性读法失效").toBe(true);
151183
});
152184
});
153185

0 commit comments

Comments
 (0)