Skip to content

Commit a9c63c0

Browse files
committed
feat(primitives): lock body scroll in modal
1 parent 6effbda commit a9c63c0

4 files changed

Lines changed: 381 additions & 1 deletion

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context ConfirmDialog.tier.test.tsx —— `ConfirmDialogProps.tier` 的**透传**契约(批 4 T2;B6 缺口 B)。
4+
*
5+
* Why:ADR-033 后果④逐字「`ConfirmDialogProps` 无 `tier` ⇒ 弹层内再弹拿不到 `modalNested`(登记为批 4
6+
* 观察项)」—— 实测**≥3 处结构性嵌套**(`InterviewDialog → GoalPlanApprovalDialog` ·
7+
* `NoteAiDialog → RefineLaunchDialog/RefineWorkbench` · `RouteInfoPopover → GroupDeleteConfirm/
8+
* ModelCardCreateDialog`)⇒ 按 B6 阈值「同一缺口 ≥3 个调用点共用 ⇒ 改原语」加**透传**。
9+
* 「改调用点」在这里**结构上不可表达**:消费者拿不到 `modalNested`(那是 `Modal` 的档位,
10+
* 且 ADR-033 §4 逐字禁止用行内 `style` 覆盖类语义)。本任务**不新增档位**、不动 `ModalTier` 联合。
11+
*
12+
* Why 三条 + 一条同源守卫:
13+
* ① 不传 `tier` ⇒ 仍是 `modal`(300):**默认值不许漂移**(迁移的 8 处顶层确认不传它);
14+
* ② 传 `tier="modalNested"` ⇒ 400:档位来自 `Z_TIER` 标尺,不是裸数字;
15+
* ③ **真场景**:`ConfirmDialog` 落在另一个 `Modal` 的子树里 ⇒ 必须能拿到 400(这正是观察项的原话);
16+
* ④ 同源守卫:`ConfirmDialog.tsx` 里 `ModalTier` 只解析一次(局部别名不许漂移)。
17+
*
18+
* 副作用:无(只挂 React 树 + 读 1 个文本文件)。
19+
* 边界:不用 jest-dom(本仓未装);`zIndex` 断言读内联 `style`(`Modal` 写的就是内联标尺值)。
20+
*/
21+
import { cleanup, render } 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 } from "vitest";
26+
import { Z_TIER } from "../zIndex";
27+
import { ConfirmDialog } from "./ConfirmDialog";
28+
import type { ConfirmDialogProps } from "./ConfirmDialog";
29+
import { Modal } from "./Modal";
30+
31+
const HERE = dirname(fileURLToPath(import.meta.url));
32+
/** 判据要数的关键字(写成常量:口径锚与正文共用同一份,避免两处正则各自漂移) */
33+
const STRIP_PROBE = /ModalTier/g;
34+
/** 归一 EOL 后剥块注释(本仓无 `.gitattributes` 且 `core.autocrlf=true`) */
35+
const CONFIRM_SRC = readFileSync(join(HERE, "ConfirmDialog.tsx"), "utf8")
36+
.replace(/\r\n/g, "\n")
37+
.replace(/\/\*[\s\S]*?\*\//g, "");
38+
39+
const noop = (): void => undefined;
40+
const overlay = (): HTMLElement => document.body.querySelector('[data-testid="cd-overlay"]') as HTMLElement;
41+
const tierOf = (): string => overlay().style.zIndex;
42+
const numericTier = (): number => Number(tierOf());
43+
44+
afterEach(cleanup);
45+
46+
/**
47+
* 只渲染一个受控确认框(`tier` 按需给)。
48+
* ⚠️ `open` 必须**先解构出来**再展开 `rest`:写成 `open={props.open} … {...props}` 会命中
49+
* TS2783(同一 prop 指定两次、后者覆盖前者)—— 而 **vitest 走 esbuild 只剥类型、不做类型检查**
50+
* ⇒ 那种写法在测试里全绿、只有 `tsc --noEmit` 会红(本批 T1 踩过一次)。
51+
*/
52+
function Plain({ open, ...rest }: Partial<ConfirmDialogProps> & { open: boolean }) {
53+
return <ConfirmDialog open={open} title="删除「高数」?" onConfirm={noop} onCancel={noop} testId="cd" {...rest} />;
54+
}
55+
56+
/** 真场景:确认框渲染在**另一个弹层**的子树里(弹层内再弹) */
57+
function InsideModal({ tier }: { tier?: ConfirmDialogProps["tier"] }) {
58+
return (
59+
<Modal open onClose={noop} title="外层弹层" testId="outer" tier="modal">
60+
<ConfirmDialog
61+
open
62+
tier={tier}
63+
title="删除「高数」?"
64+
onConfirm={noop}
65+
onCancel={noop}
66+
testId="cd"
67+
/>
68+
</Modal>
69+
);
70+
}
71+
72+
describe("① 默认档位不漂移(迁移的 8 处顶层确认不传 `tier`)", () => {
73+
it("不传 tier ⇒ `modal`(300),且不是裸数字", () => {
74+
render(<Plain open />);
75+
expect(numericTier()).toBe(Z_TIER.modal);
76+
expect(String(Z_TIER.modal), "档位必须来自 zIndex 标尺").toBe("300");
77+
});
78+
79+
it("tier=undefined 与完全不传同值(可选字段的语义)", () => {
80+
const { unmount } = render(<Plain open tier={undefined} />);
81+
const implicit = numericTier();
82+
unmount();
83+
render(<Plain open />);
84+
expect(numericTier()).toBe(implicit);
85+
});
86+
});
87+
88+
describe("② 显式 modalNested ⇒ 400(ADR-033 后果④的档位)", () => {
89+
it("tier='modalNested' ⇒ `Z_TIER.modalNested`(400) 而**不是** 300", () => {
90+
render(<Plain open tier="modalNested" />);
91+
expect(numericTier()).toBe(Z_TIER.modalNested);
92+
expect(numericTier(), "两层必须真的分得开").not.toBe(Z_TIER.modal);
93+
});
94+
});
95+
96+
describe("③ 真场景:确认框落在另一个弹层里 ⇒ 拿到 400", () => {
97+
it("嵌套时内层确认框 zIndex === modalNested;外层仍是 modal(各归各的档)", () => {
98+
render(<InsideModal tier="modalNested" />);
99+
const outer = document.body.querySelector('[data-testid="outer-overlay"]') as HTMLElement;
100+
expect(Number(overlay().style.zIndex)).toBe(Z_TIER.modalNested);
101+
expect(Number(outer.style.zIndex)).toBe(Z_TIER.modal);
102+
expect(numericTier(), "内层必须压在外层上").toBeGreaterThan(Number(outer.style.zIndex));
103+
});
104+
105+
it("对照组:同一个嵌套宿主**不传** tier ⇒ 内层仍是 300(等于外层 ⇒ 叠放错,这是迁移必须显式传参的原因)", () => {
106+
render(<InsideModal />);
107+
const outer = document.body.querySelector('[data-testid="outer-overlay"]') as HTMLElement;
108+
expect(numericTier()).toBe(Z_TIER.modal);
109+
expect(numericTier()).toBe(Number(outer.style.zIndex));
110+
});
111+
});
112+
113+
describe("④ 同源守卫:`tier` 的声明与透传必须成对(只有一处不许漂移)", () => {
114+
it("`ModalTier` 恰好 2 处(import 一行 + `tier?:` 一行);`<Modal` 上有 `tier=` 透传", () => {
115+
const hits = (CONFIRM_SRC.match(STRIP_PROBE) ?? []).length;
116+
expect(hits, `ModalTier 出现 ${hits} 次(应为 2:import 一行 + 声明一行;多出 = 有人在本地重声明这个联合,` +
117+
`那会让它悄悄脱离 ui/zIndex 标尺)`).toBe(2);
118+
expect(CONFIRM_SRC, "声明必须是可选字段(不许变成必填 —— 8 处顶层确认不传它)").toMatch(/tier\?\s*:\s*ModalTier/);
119+
expect(CONFIRM_SRC, "tier 必须真的接到 Modal 上(否则 prop 是死字段)").toMatch(/<Modal[\s\S]{0,400}?tier=\{tier\}/);
120+
expect(CONFIRM_SRC, "默认值写在原语里(`= \"modal\"`),单测钉住实际档位").toContain('tier = "modal"');
121+
});
122+
123+
it("口径锚:剥注释这一步真的有效(合成样本 —— 注释里的类型名不算命中)", () => {
124+
const synthetic = "/* 透传 `ModalTier`(注释里提到不算) */\ntier?: ModalTier;";
125+
expect(synthetic.match(STRIP_PROBE) ?? [], "锚本身必须含犯规样本,否则它在测空气").toHaveLength(2);
126+
expect(
127+
synthetic.replace(/\/\*[\s\S]*?\*\//g, "").match(STRIP_PROBE) ?? [],
128+
"剥注释失效 ⇒ 本文件的关键字计数会把文件头注释也算进去",
129+
).toHaveLength(1);
130+
});
131+
});

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
4545
import type { ReactElement, ReactNode } from "react";
4646
import { Button } from "./Button";
4747
import { Modal } from "./Modal";
48+
import type { ModalTier } from "./Modal";
4849
import { Text } from "./Text";
4950
import "./ConfirmDialog.css";
5051

@@ -80,6 +81,13 @@ export interface ConfirmDialogProps {
8081
cancelLabel?: string;
8182
/** 动作进行中:两个按钮都不可点(`aria-disabled` + 降级),但不卸载、不改变退出语义 */
8283
busy?: boolean;
84+
/**
85+
* 层级,透传给 `Modal` 的 `tier`(默认 `"modal"` = 300)。
86+
* **何时该传 `modalNested`(400)**:本确认框**被另一个弹层承载**时(如
87+
* `InterviewDialog → GoalPlanApprovalDialog` 那类「弹层内再弹」)—— 不传就与外层同档 300,
88+
* 叠放顺序退化成 DOM 序、外层遮罩会压住它。ADR-033 后果④的观察项,批 4 T2 结清。
89+
*/
90+
tier?: ModalTier;
8391
/** 用户确认(危险动作;调用点应同步置 `busy` 以挡住重复触发) */
8492
onConfirm: () => void;
8593
/** 用户取消 / ESC / 点遮罩 —— **所有退出路径都归到这里** */
@@ -102,6 +110,7 @@ export function ConfirmDialog({
102110
confirmLabel = "确认",
103111
cancelLabel = "取消",
104112
busy = false,
113+
tier = "modal",
105114
onConfirm,
106115
onCancel,
107116
testId,
@@ -163,6 +172,7 @@ export function ConfirmDialog({
163172
onClose={handleCloseIntent}
164173
title={title}
165174
size={CONFIRM_SIZE}
175+
tier={tier}
166176
testId={testId}
167177
footer={footer}
168178
>
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context Modal.scroll-lock.test.tsx —— `Modal` 的 **body 滚动锁**行为契约(批 4 T2;B6 缺口 A)。
4+
*
5+
* Why 单列一个文件:`Modal.test.tsx` 296/300、`Modal.exit.test.tsx` 91,两者都在 300 硬红线附近;
6+
* 而滚动锁是**行为**(不是 prop、不是类),判据形态与它们都不同。
7+
*
8+
* Why 这五条(B6 裁决「20 个调用点共用 ⇒ 改原语」的可失败判据):
9+
* ① 开弹层 ⇒ `body` 被锁(`overflow: hidden`);
10+
* ② 锁随 `presence.mounted` 而不是 `open` —— `open=false` 之后还有 160ms 退场(面板仍在屏上),
11+
* 提前解锁会让背景在弹层还在时就能滚(与 §5.2 第 2 条「退场相位禁指针事件」同源);
12+
* ③ **嵌套**:弹层内再弹(`tier="modalNested"`)时关内层**不得**解锁(否则背景跳一下);
13+
* ④ 恢复的是**原值快照**而不是空串 —— 宿主页自己设过的 `overflow` 被抹掉是"看不见的破坏";
14+
* ⑤ 无 `transitionend` 的兜底路径(reduced-motion / jsdom 不派发事件)到点也必须恢复。
15+
*
16+
* 副作用:无(只挂 React 树);假计时器只用在第 ⑤ 条。
17+
* 边界:不用 jest-dom(本仓未装),一律原生 `style` 断言;`document.body.style.overflow` 是
18+
* **既有的 app 级共享状态** ⇒ 每个用例前后都复位,防用例间串味。
19+
*/
20+
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
21+
import { afterEach, describe, expect, it, vi } from "vitest";
22+
import { Modal } from "./Modal";
23+
import type { ReactElement } from "react";
24+
25+
const noop = (): void => undefined;
26+
/** 与 `Modal.tsx` 的 `EXIT_MS` / `usePresence` 的 `timeoutSlackMs` 同源:兜底窗口 = 160 + 80 */
27+
const EXIT_FALLBACK_MS = 240;
28+
29+
const overflow = (): string => document.body.style.overflow;
30+
const panel = (): HTMLElement => screen.getByRole("dialog");
31+
/** 「当前有没有面板」的判据 —— 多条用例用它当**前提断言**(防判据在空转) */
32+
const panelOrNull = (): Element | null => document.body.querySelector('[role="dialog"]');
33+
34+
/**
35+
* 让弹层真正走到 `entered`:`usePresence` 的 `enter → entered` 要等**下一个宏任务**
36+
* (`ENTER_TICK_MS = 0`,为的是让出一次绘制机会)—— 没走到 `entered` 时关闭是**直接卸载**
37+
* (`usePresence` 边界④:一次绘制都没发生过就不排退场窗口),退场相位根本不会出现。
38+
*/
39+
const tick = (ms: number): void => {
40+
act(() => {
41+
vi.advanceTimersByTime(ms);
42+
});
43+
};
44+
45+
/** 卸载整个树(含 portal)并把共享的 `body` 样式复位 */
46+
afterEach(() => {
47+
cleanup();
48+
vi.useRealTimers();
49+
document.body.style.overflow = "";
50+
});
51+
52+
function Host({ open, testId = "m" }: { open: boolean; testId?: string }) {
53+
return (
54+
<Modal open={open} onClose={noop} title="标题文本" testId={testId}>
55+
正文内容
56+
</Modal>
57+
);
58+
}
59+
60+
/** 两层弹层:外层由 `outer` 驱动、内层由 `inner` 驱动(`tier` 各按真实层级的语义给) */
61+
function Nested({ outer, inner }: { outer: boolean; inner: boolean }) {
62+
return (
63+
<Modal open={outer} onClose={noop} title="外层" testId="outer" tier="modal">
64+
<Modal open={inner} onClose={noop} title="内层" testId="inner" tier="modalNested">
65+
内层正文
66+
</Modal>
67+
</Modal>
68+
);
69+
}
70+
71+
/** 关掉一层:rerender 到 `open=false` 再派发该面板自己的 `transitionend`(= 退场真结束) */
72+
function closeAndSettle(rerender: (ui: ReactElement) => void, ui: ReactElement, testId = "m"): void {
73+
rerender(ui);
74+
const el = screen.getByTestId(testId);
75+
fireEvent.transitionEnd(el, { target: el, currentTarget: el });
76+
}
77+
78+
describe("① 开弹层 ⇒ body 被锁(B6:20 个调用点共用,故锁在 Modal 里)", () => {
79+
it("未打开时不动 body;open=true 且已挂载 ⇒ overflow:'hidden'", () => {
80+
const { rerender } = render(<Host open={false} />);
81+
expect(overflow(), "没打开弹层时不得碰 body(原语不许有隐藏副作用)").toBe("");
82+
83+
rerender(<Host open />);
84+
expect(screen.getByTestId("m-overlay").parentElement).toBe(document.body); // 前提:确实挂上了 portal
85+
expect(overflow()).toBe("hidden");
86+
});
87+
});
88+
89+
describe("② 锁随 presence.mounted,不随 open(退场窗口内仍锁)", () => {
90+
it("open=false 且退场未结束(未派发 transitionend)⇒ 仍锁;退场结束 ⇒ 恢复", () => {
91+
vi.useFakeTimers();
92+
const { rerender } = render(<Host open />);
93+
tick(0); // enter → entered(见 tick 的注释)
94+
expect(overflow()).toBe("hidden");
95+
96+
rerender(<Host open={false} />);
97+
expect(panel().getAttribute("data-phase"), "前提:此时正处于退场相位").toBe("exit");
98+
expect(overflow(), "面板还在屏上就解锁 ⇒ 背景在弹层未消失时就能滚").toBe("hidden");
99+
100+
const el = panel();
101+
fireEvent.transitionEnd(el, { target: el, currentTarget: el });
102+
expect(panelOrNull(), "前提:退场确实结束了").toBeNull();
103+
expect(overflow()).toBe("");
104+
});
105+
});
106+
107+
describe("③ 嵌套:关内层不解锁,关外层才恢复(引用计数)", () => {
108+
it("两层同开 ⇒ 关内层仍锁;关外层 ⇒ 恢复", () => {
109+
vi.useFakeTimers();
110+
const { rerender } = render(<Nested outer={false} inner={false} />);
111+
tick(0);
112+
rerender(<Nested outer inner />);
113+
tick(0);
114+
expect(overflow()).toBe("hidden");
115+
116+
// 关内层:它自己的 transitionend 到达 ⇒ 内层被卸载,但外层还在屏上
117+
closeAndSettle(rerender, <Nested outer inner={false} />, "inner");
118+
expect(screen.queryByTestId("inner"), "前提:内层确实已卸载").toBeNull();
119+
expect(screen.getByTestId("outer"), "前提:外层仍在屏上").not.toBeNull();
120+
expect(overflow(), "内层关闭时外层还在 ⇒ 解锁会让背景跳一下").toBe("hidden");
121+
122+
closeAndSettle(rerender, <Nested outer={false} inner={false} />, "outer");
123+
expect(panelOrNull(), "前提:外层退场结束").toBeNull();
124+
expect(overflow(), "最后一层关闭必须恢复").toBe("");
125+
});
126+
});
127+
128+
describe("④ 恢复的是原值快照,不是空串", () => {
129+
it("开工前 body 已有 overflow:'auto' ⇒ 关完后仍是 'auto'", () => {
130+
vi.useFakeTimers();
131+
document.body.style.overflow = "auto";
132+
const { rerender } = render(<Host open />);
133+
tick(0);
134+
expect(overflow(), "锁期间必须是 hidden").toBe("hidden");
135+
136+
closeAndSettle(rerender, <Host open={false} />);
137+
expect(overflow(), "恢复成空串 = 抹掉宿主页设过的值(看不见的破坏)").toBe("auto");
138+
});
139+
});
140+
141+
describe("⑤ 无 transitionend 的兜底路径(reduced-motion / jsdom 不派发)", () => {
142+
it("推进 EXIT_MS + slack + 1 ms ⇒ 恢复(不派发任何事件)", () => {
143+
vi.useFakeTimers();
144+
const { rerender } = render(<Host open />);
145+
tick(0);
146+
expect(overflow()).toBe("hidden");
147+
148+
rerender(<Host open={false} />);
149+
tick(EXIT_FALLBACK_MS - 1);
150+
expect(panelOrNull(), "前提:兜底窗口未到,面板仍在").not.toBeNull();
151+
expect(overflow(), "面板仍在时不得提前解锁").toBe("hidden");
152+
153+
tick(1);
154+
expect(panelOrNull(), "前提:兜底窗口到点后确实卸载了").toBeNull();
155+
expect(overflow()).toBe("");
156+
});
157+
});
158+
159+
describe("⑥ 原值快照属于**第一个**持锁者(弹层交接时不许被改写成 'hidden')", () => {
160+
/**
161+
* 交接场景:A 正在退场(`open=false` 但未收到 `transitionend`)而 B 已打开。
162+
* A 与 B **必须是两个独立实例** —— 同一个 `Modal` 只改 `open`/`testId` 是"同一个持锁者",
163+
* 测不出快照归属(第一版正是这样写的,被"`a` 找不到"当场证伪)。
164+
*/
165+
function Handoff({ a, b }: { a: boolean; b: boolean }) {
166+
return (
167+
<>
168+
<Modal open={a} onClose={noop} title="弹层 A" testId="a">
169+
A
170+
</Modal>
171+
<Modal open={b} onClose={noop} title="弹层 B" testId="b">
172+
B
173+
</Modal>
174+
</>
175+
);
176+
}
177+
178+
it("A 退场未结束时 B 接手 ⇒ 全程锁住,且最终恢复成最初的原值 'auto'", () => {
179+
vi.useFakeTimers();
180+
document.body.style.overflow = "auto";
181+
const { rerender } = render(<Handoff a b={false} />);
182+
tick(0);
183+
184+
// A 开始退场(未收到 transitionend)⇒ 仍持锁;B 随即打开 ⇒ 两个持锁者并存
185+
rerender(<Handoff a={false} b={false} />);
186+
expect(screen.getByTestId("a").getAttribute("data-phase")).toBe("exit");
187+
rerender(<Handoff a={false} b />);
188+
tick(0); // B 是**新挂载**的实例:它也要走一次 enter → entered(见 tick 的注释)
189+
expect(overflow(), "交接期间不得出现「背景能滚」的窗口").toBe("hidden");
190+
191+
// A 的退场结束 ⇒ 释放一次;B 仍持锁 ⇒ 仍 hidden
192+
const panelA = screen.getByTestId("a");
193+
fireEvent.transitionEnd(panelA, { target: panelA, currentTarget: panelA });
194+
expect(screen.queryByTestId("a"), "前提:A 已卸载").toBeNull();
195+
expect(screen.getByTestId("b"), "前提:B 仍在屏上").not.toBeNull();
196+
expect(overflow(), "B 仍在 ⇒ 不许解锁").toBe("hidden");
197+
198+
// B 也退场 ⇒ 恢复的必须是**最初**的原值(若第二个持锁者改写了快照,这里会是 'hidden')
199+
closeAndSettle(rerender, <Handoff a={false} b={false} />, "b");
200+
expect(overflow(), "快照被后来者改写 ⇒ 宿主页的 'auto' 永久丢失").toBe("auto");
201+
});
202+
});

0 commit comments

Comments
 (0)