Skip to content

Commit 83710e2

Browse files
committed
fix(ui): Modal 退场期禁指针事件并更正 ESC 注释口径
1 parent 1a1ea2a commit 83710e2

4 files changed

Lines changed: 114 additions & 6 deletions

File tree

‎app/src/ui/primitives/Modal.css‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,14 @@
2525
transition: opacity var(--ed-dur-overlay-in, 200ms) var(--ed-ease, cubic-bezier(0.2, 0, 0, 1));
2626
}
2727
.ed-modal-overlay[data-phase="enter"] { opacity: 0; }
28-
.ed-modal-overlay[data-phase="exit"] { opacity: 0; transition-duration: var(--ed-dur-overlay-out, 160ms); }
28+
/* 退场窗口内**禁指针事件**:`open=false` 后弹层仍挂载 160ms(相位 = `[data-phase="exit"]`),
29+
那时再点"确认 / 删除"不该触发回调(T8 探针实测:退场中按钮仍可点 ⇒ 批 4 迁移 28 个手写弹层后
30+
这是删除类操作的误触面)。进场相位**不动**,也不使用 `!important`。 */
31+
.ed-modal-overlay[data-phase="exit"] {
32+
opacity: 0;
33+
pointer-events: none;
34+
transition-duration: var(--ed-dur-overlay-out, 160ms);
35+
}
2936

3037
.ed-modal {
3138
position: relative;
@@ -47,6 +54,7 @@
4754
.ed-modal[data-phase="exit"] {
4855
opacity: 0;
4956
transform: translateY(4px); /* 出场位移只留一半:出场更短更快,4px 是它的配套量 */
57+
pointer-events: none; /* 同遮罩:退场期间的按钮不得再被点到(见上面的注释) */
5058
transition-duration: var(--ed-dur-overlay-out, 160ms);
5159
}
5260
.ed-modal:focus-visible { outline: 2px solid var(--ed-ink-1); outline-offset: 2px; }
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context Modal.exit.test.tsx —— **退场窗口的可点性契约**(批 0-D Task 7 追加;T8 评审 I-1 的
4+
* 系统级修法)。
5+
*
6+
* Why:`open=false` 之后弹层仍会挂载 160ms(`[data-phase="exit"]`,`usePresence` 的出场窗口)。
7+
* T8 的探针实测:这个窗口里面板与遮罩**仍然可点** ⇒ 消费者(`ConfirmDialog`)的「确认 / 删除」
8+
* 在退场中会再触发一次回调;批 4 把 28 个手写弹层迁过来之后,这就是**删除类操作的误触面**。
9+
* 解法:退场相位统一 `pointer-events: none`(进场相位不动,也不用 `!important`)。
10+
*
11+
* Why 另开一个文件:`Modal.test.tsx` 已 296/300 行,而 ≤300 是硬红线(`line-limits.mjs` 扫 `app/src`)。
12+
*
13+
* Why 判据读 CSS 文本 + DOM 相位:`vitest.config.ts` 的 `css` 默认 false ⇒ 测试环境不加载样式表
14+
* (jsdom 也不做命中测试)⇒ `getComputedStyle` 永远读不到 `pointer-events`。故拆成两条互补断言:
15+
* ① 退场相位**真的**带 `data-phase="exit"` 且面板内容仍在 DOM(= 因果关系的前件);
16+
* ② `[data-phase="exit"]` 规则**真的**声明了 `pointer-events: none`(= 后件)。两者缺一,本契约即断。
17+
*
18+
* 副作用:无(只挂 React 树 + 读 1 个 CSS 文件);假计时器只在本文件的第一个用例里用。
19+
* 边界:不重述 `Modal.test.tsx` 已覆盖的进出场时序(`enter → entered`、兜底 240ms、可反向)。
20+
*/
21+
import { act, cleanup, render, screen } from "@testing-library/react";
22+
import { readFileSync } from "node:fs";
23+
import { dirname, join } from "node:path";
24+
import { fileURLToPath } from "node:url";
25+
import { afterEach, describe, expect, it, vi } from "vitest";
26+
import { Modal } from "./Modal";
27+
28+
const HERE = dirname(fileURLToPath(import.meta.url));
29+
const noop = (): void => undefined;
30+
/** 剥注释后再判据:文件头的注释里就写着"不使用 !important"(同 `style-seams.test.ts` 的口径) */
31+
const CSS = readFileSync(join(HERE, "Modal.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
32+
33+
/** 取一条 CSS 规则的原文(到第一个 `}` 为止;transition 值里没有花括号) */
34+
function rule(selector: string): string {
35+
const start = CSS.indexOf(selector);
36+
expect(start, `Modal.css 缺少规则「${selector}」`).toBeGreaterThanOrEqual(0);
37+
return CSS.slice(start, CSS.indexOf("}", start) + 1);
38+
}
39+
40+
function Host({ open }: { open: boolean }) {
41+
return (
42+
<Modal open={open} onClose={noop} title="标题文本" testId="m" footer={<button data-testid="ok">确认</button>}>
43+
正文内容
44+
</Modal>
45+
);
46+
}
47+
48+
afterEach(() => {
49+
cleanup();
50+
vi.useRealTimers();
51+
});
52+
53+
describe("退场窗口:相位前件(没有它,下面那组 CSS 断言就是空转)", () => {
54+
it("open=false 后 160ms 内面板与遮罩都带 data-phase='exit',且面板内容仍在 DOM 里", () => {
55+
vi.useFakeTimers();
56+
const { rerender } = render(<Host open />);
57+
act(() => {
58+
vi.advanceTimersByTime(0); // enter → entered 要等一个宏任务(见 usePresence 的 ENTER_TICK_MS)
59+
});
60+
expect(screen.getByRole("dialog").getAttribute("data-phase")).toBe("entered");
61+
rerender(<Host open={false} />);
62+
63+
expect(screen.getByRole("dialog").getAttribute("data-phase")).toBe("exit");
64+
expect(screen.getByTestId("m-overlay").getAttribute("data-phase"), "遮罩是另一条规则的锚点").toBe("exit");
65+
expect(screen.getByTestId("ok"), "按钮仍在 DOM 里 ⇒ 没禁指针事件时它真的可点").not.toBeNull();
66+
act(() => {
67+
vi.advanceTimersByTime(239); // 兜底窗口未到 ⇒ 仍在退场相位
68+
});
69+
expect(screen.getByRole("dialog").getAttribute("data-phase")).toBe("exit");
70+
});
71+
});
72+
73+
describe("退场相位禁指针事件(面板 + 遮罩各一条,进场相位不动)", () => {
74+
it("两条 exit 规则都声明 pointer-events: none", () => {
75+
expect(rule('.ed-modal-overlay[data-phase="exit"]')).toContain("pointer-events: none;");
76+
expect(rule('.ed-modal[data-phase="exit"]')).toContain("pointer-events: none;");
77+
});
78+
79+
it("反例守门:进场相位(enter / entered)不得出现 pointer-events(否则弹层一开始就点不动)", () => {
80+
expect(rule('.ed-modal-overlay[data-phase="enter"]')).not.toContain("pointer-events");
81+
expect(rule('.ed-modal[data-phase="enter"]')).not.toContain("pointer-events");
82+
expect(rule('.ed-modal[data-phase="entered"]')).not.toContain("pointer-events");
83+
expect(rule(".ed-modal-overlay {")).not.toContain("pointer-events");
84+
expect(rule(".ed-modal {")).not.toContain("pointer-events");
85+
});
86+
87+
it("Modal.css 里 pointer-events 恰好出现 2 次(只在两条 exit 规则里),且全文件无 !important", () => {
88+
expect(CSS.match(/pointer-events/g) ?? []).toHaveLength(2);
89+
expect(CSS).not.toContain("!important");
90+
});
91+
});

‎app/src/ui/primitives/Modal.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ describe("关闭路径", () => {
131131
expect(onClose).toHaveBeenCalledTimes(1);
132132
});
133133

134-
it("最上层消费:拦住仍挂在 window 上的旧 ESC 监听(仓内 19 处手写弹层全在 window)", () => {
134+
it("最上层消费:拦住 window 冒泡类的旧 ESC 监听(实测 15 处;capture 与元素级拦不住,见 Modal.tsx 边界④)", () => {
135135
const legacy = vi.fn();
136136
const onClose = vi.fn();
137137
window.addEventListener("keydown", legacy);

‎app/src/ui/primitives/Modal.tsx‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,19 @@
1818
* 入栈序:React 的 effect 是**自底向上**跑的(子 Modal 先入栈 ⇒ 入栈序会把外层当栈顶),而
1919
* portal 在 `document.body` 里的插入序**会随挂载时机反转**(同一次提交里内层反而更靠前 ——
2020
* 实测 `BODY_ORDER ["inner","outer"]`,晚挂载的内层才排在后面)⇒ 只有树深度稳定等于「最内层」。
21-
* ④ **ESC 的消费是排他的**:最内层 `stopPropagation()`(冒泡相里 `document` 先于 `window`)⇒
22-
* 一次 ESC 不会同时关掉弹层与它下面仍挂在 `window` 上的 19 处手写 ESC 监听(仓内退出链是
23-
* "菜单优先、只响应一层",`SessionListPanel.tsx:108-120`)。`closeOnEsc=false` 时**仍然消费**
24-
* (弹层是最上层,ESC 不该穿透到下层),只是不调用 `onClose`。
21+
* ④ **ESC 的消费是排他的,但只拦得住"冒泡类"**:最内层 `stopPropagation()` 挂在 `document`
22+
* **冒泡相**上(仓内退出链是"最上层优先、一次只响应一层",`SessionListPanel.tsx:108-120`)。
23+
* **拦得住**:`window` 上**冒泡相**的 15 处手写 ESC 监听(document 冒泡先于 window 冒泡)。
24+
* **拦不住(批 4 迁移时必须一并处理)**:
25+
* ① `SelectionActionMenu.tsx:73-78` 的 `window` **capture** 相 ESC 监听 —— capture 从 window
26+
* 开始,跑在 `document` 冒泡之前;
27+
* ② **4 处元素级 React `onKeyDown`**:`SessionDetailHeader.tsx:106` · `GroupSidebarRow.tsx:115` ·
28+
* `LinkEntityPicker.tsx:101` · `SessionListRow.tsx:188` —— React 19 把合成事件挂在根容器
29+
* (在 document 之内),故它们也早于本监听。
30+
* ⚠️ 口径更正:仓内 `addEventListener("keydown")` 共 19 个文件,其中 **16 个处理 Escape**
31+
* (15 冒泡 + 1 捕获),另 3 个(`App.tsx:203` · `useClassroomShortcuts.ts:37` ·
32+
* `useClassroomFloat.ts:73`)**不处理 Escape** —— 不要把它写成"19 处 ESC"。
33+
* `closeOnEsc=false` 时**仍然消费**(弹层是最上层,ESC 不该穿透到下层),只是不调用 `onClose`。
2534
* ⑤ **不做 body 滚动锁**:现状 20 个弹层也没有(属批 4 的观察项,已登记在报告的「未做」)。
2635
* ⑥ **不消费 `isImeComposing`**:计划 Task 7 Step 1 明确"本批只建不接"—— 需要 IME 守卫的是
2736
* 「Enter 提交」,那是调用点的动作(`Modal` 自己不定义提交)。

0 commit comments

Comments
 (0)