Skip to content

Commit 248e6f7

Browse files
committed
feat(ui): L1 原语 Modal(role=dialog + 焦点陷阱 + 进出场)
1 parent 35abe6f commit 248e6f7

8 files changed

Lines changed: 963 additions & 0 deletions

File tree

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Modal.css —— 弹层的唯一外观出口(批 0-D Task 7;规格 §5.2 / §8.4)。
3+
*
4+
* Why(为什么存在):全仓 28 个文件各自手写 `position:fixed; inset:0` 的遮罩、25 行/22 文件各写
5+
* 一份深色遮罩 `rgba(...)`(recon §10)。本文件把那套外观收成一处:遮罩基色、透明度、圆角、阴影、
6+
* 底色、描边一律经 token 消费。
7+
*
8+
* ⚠️ 本文件不得出现任何颜色字面量(色值只许出现在 `ui/tokens.css` 与 token 生成器里),
9+
* 也不得写 `z-index`(层级是 TS 标尺 `ui/zIndex.ts` 的职责,由 Modal.tsx 内联到遮罩上)。
10+
*
11+
* ⚠️ **等时长不变量**:`usePresence` 以**首个** `transitionend` 收尾 —— 同一个元素上的
12+
* `opacity` 与 `transform` 必须等时长,否则短的那个一到就把弹层摘掉,长的那半永远看不到。
13+
* 故面板基类的两条过渡都用 `--ed-dur-overlay-in`,出场统一在退出态里改成
14+
* `--ed-dur-overlay-out`(**出场 160 < 进场 200,出场比进场快** —— 规格 §8.4)。
15+
* 每个 `var()` 都带同值兜底字面量:批 6 的动效 token 真源落地后删掉 motion.css 的变量块即生效。
16+
*/
17+
.ed-modal-overlay {
18+
position: fixed;
19+
inset: 0;
20+
display: flex;
21+
align-items: center;
22+
justify-content: center;
23+
/* 遮罩 = --ed-overlay 按 --ed-overlay-alpha 压暗(不写 rgba 字面量:颜色只在 token 里) */
24+
background: color-mix(in srgb, var(--ed-overlay) calc(var(--ed-overlay-alpha, 0.34) * 100%), transparent);
25+
transition: opacity var(--ed-dur-overlay-in, 200ms) var(--ed-ease, cubic-bezier(0.2, 0, 0, 1));
26+
}
27+
.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); }
29+
30+
.ed-modal {
31+
position: relative;
32+
display: flex;
33+
flex-direction: column;
34+
max-height: 85vh;
35+
max-width: calc(100vw - 48px);
36+
box-sizing: border-box;
37+
background: var(--ed-bg-raised);
38+
border: 1px solid var(--ed-border);
39+
border-radius: var(--ed-radius-overlay, 10px);
40+
box-shadow: var(--ed-shadow-2);
41+
/* 面板的 opacity 与 transform 必须【等时长】(见文件头的等时长不变量) */
42+
transition: opacity var(--ed-dur-overlay-in, 200ms) var(--ed-ease, cubic-bezier(0.2, 0, 0, 1)),
43+
transform var(--ed-dur-overlay-in, 200ms) var(--ed-ease, cubic-bezier(0.2, 0, 0, 1));
44+
}
45+
.ed-modal[data-phase="enter"] { opacity: 0; transform: translateY(8px); } /* 位移 8px = 规格 §8.4 上限 */
46+
.ed-modal[data-phase="entered"] { opacity: 1; transform: translateY(0); }
47+
.ed-modal[data-phase="exit"] {
48+
opacity: 0;
49+
transform: translateY(4px); /* 出场位移只留一半:出场更短更快,4px 是它的配套量 */
50+
transition-duration: var(--ed-dur-overlay-out, 160ms);
51+
}
52+
.ed-modal:focus-visible { outline: 2px solid var(--ed-ink-1); outline-offset: 2px; }
53+
54+
.ed-modal--s { width: 380px; }
55+
.ed-modal--m { width: 520px; }
56+
.ed-modal--l { width: 720px; }
57+
58+
.ed-modal-head {
59+
display: flex;
60+
align-items: center;
61+
justify-content: space-between;
62+
gap: var(--ed-space-8, 8px);
63+
padding: var(--ed-space-12, 12px) var(--ed-space-16, 16px);
64+
border-bottom: 1px solid var(--ed-border);
65+
}
66+
.ed-modal-body { padding: var(--ed-space-16, 16px); overflow: auto; }
67+
.ed-modal-foot {
68+
display: flex;
69+
justify-content: flex-end;
70+
gap: var(--ed-space-8, 8px);
71+
padding: var(--ed-space-12, 12px) var(--ed-space-16, 16px);
72+
border-top: 1px solid var(--ed-border);
73+
}
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context Modal.test.tsx —— 弹层唯一实现的契约测试(批 0-D Task 7;本批缺口最大的一类)。
4+
*
5+
* Why jsdom:Modal 走 `createPortal`(需要真容器)+ 焦点陷阱(需要 `document.activeElement`)。
6+
* 本文件钉住**三条全仓 0 命中的契约**(`createPortal` / `role="dialog"` / `aria-modal="true"`——
7+
* 改造前实测 0/0/0,recon §10)+ ESC 的「最内层唯一响应」+ 焦点归还。时序判据读 CSS 文本而非
8+
* `getComputedStyle`:`vitest.config.ts` 的 `css` 默认 false ⇒ 测试环境不加载样式表,只能按范式 C
9+
* 直接读实现文件(同 `tokens.drift.test.ts`);不用 jest-dom(本仓未装),一律原生属性断言。
10+
*
11+
* 副作用:无(只挂 React 树 + 读 1 个 CSS 文件);假计时器只用在需要推进 presence 计时的 describe。
12+
* ⚠️ `usePresence` 的 `enter → entered` 要等**下一个宏任务**(`ENTER_TICK_MS = 0`,为的是让出一次
13+
* 绘制机会)⇒ jsdom + 假计时器下必须 `vi.advanceTimersByTime(0)`,否则 `phase` 永远停在 `enter`。
14+
*/
15+
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
16+
import { readFileSync } from "node:fs";
17+
import { dirname, join } from "node:path";
18+
import { fileURLToPath } from "node:url";
19+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
20+
import { Z_TIER } from "../zIndex";
21+
import { Modal } from "./Modal";
22+
23+
const HERE = dirname(fileURLToPath(import.meta.url));
24+
const readCss = (name: string): string => readFileSync(join(HERE, name), "utf8");
25+
const noop = (): void => undefined;
26+
27+
interface HostProps {
28+
open: boolean;
29+
onClose?: () => void;
30+
closeOnEsc?: boolean;
31+
closeOnOverlay?: boolean;
32+
tier?: "modal" | "modalNested";
33+
footer?: boolean;
34+
}
35+
36+
/** 宿主:一个触发按钮 + 一个受控 Modal(`open` 由测试 rerender 驱动) */
37+
function Host({ open, onClose = noop, closeOnEsc = true, closeOnOverlay = true, tier = "modal", footer = false }: HostProps) {
38+
return (
39+
<div data-testid="host">
40+
<button data-testid="trigger">触发</button>
41+
<Modal
42+
open={open}
43+
onClose={onClose}
44+
title="标题文本"
45+
size="m"
46+
tier={tier}
47+
closeOnEsc={closeOnEsc}
48+
closeOnOverlay={closeOnOverlay}
49+
testId="m"
50+
footer={footer ? <button data-testid="ok">确定</button> : undefined}
51+
>
52+
正文内容
53+
</Modal>
54+
</div>
55+
);
56+
}
57+
58+
const panel = (): HTMLElement => screen.getByRole("dialog");
59+
const panelOrNull = (): Element | null => document.body.querySelector('[role="dialog"]');
60+
61+
afterEach(() => {
62+
cleanup();
63+
vi.useRealTimers();
64+
});
65+
66+
describe("三条全仓 0→1 的契约", () => {
67+
it("createPortal:弹层离开原 React 容器,直挂 document.body(否则祖先的 overflow/z-index 会吃掉它)", () => {
68+
const { container } = render(<Host open />);
69+
expect(container.querySelector('[role="dialog"]')).toBeNull();
70+
expect(screen.getByTestId("m-overlay").parentElement).toBe(document.body);
71+
});
72+
73+
it('role="dialog" + aria-modal="true" 成对出现(改造前全仓 0 命中,recon §10)', () => {
74+
render(<Host open />);
75+
expect(panel().tagName).toBe("DIV");
76+
expect(panel().getAttribute("aria-modal")).toBe("true");
77+
});
78+
79+
it("aria-labelledby 指向的节点文本 === title(不硬编码 useId 的值)", () => {
80+
render(<Host open />);
81+
const id = panel().getAttribute("aria-labelledby");
82+
expect(id, "缺 aria-labelledby ⇒ 读屏只会念「对话框」").toBeTruthy();
83+
expect(document.getElementById(id as string)?.textContent).toBe("标题文本");
84+
});
85+
86+
it("不 open 时不渲染:queryByTestId 为 null 且 body 里没有 portal 节点", () => {
87+
render(<Host open={false} />);
88+
expect(screen.queryByTestId("m-overlay")).toBeNull();
89+
expect(panelOrNull()).toBeNull();
90+
});
91+
});
92+
93+
describe("关闭路径", () => {
94+
it("ESC → onClose(监听挂在 document、冒泡相)", () => {
95+
const onClose = vi.fn();
96+
render(<Host open onClose={onClose} />);
97+
fireEvent.keyDown(document, { key: "Escape" });
98+
expect(onClose).toHaveBeenCalledTimes(1);
99+
});
100+
101+
it("closeOnEsc=false ⇒ 不关闭;长按 ESC(repeat)也不重复触发", () => {
102+
const off = vi.fn();
103+
const { unmount } = render(<Host open onClose={off} closeOnEsc={false} />);
104+
fireEvent.keyDown(document, { key: "Escape" });
105+
expect(off).not.toHaveBeenCalled();
106+
unmount();
107+
108+
const onClose = vi.fn();
109+
render(<Host open onClose={onClose} />);
110+
fireEvent.keyDown(document, { key: "Escape", repeat: true });
111+
expect(onClose, "自动重复的 keydown 不得连关多层").not.toHaveBeenCalled();
112+
fireEvent.keyDown(document, { key: "Escape" });
113+
expect(onClose).toHaveBeenCalledTimes(1);
114+
});
115+
116+
it("遮罩 mouseDown → onClose;面板内 mouseDown 不触发(防「面板内按下、遮罩上松开」误关)", () => {
117+
const onClose = vi.fn();
118+
render(<Host open onClose={onClose} />);
119+
fireEvent.mouseDown(panel());
120+
expect(onClose).not.toHaveBeenCalled();
121+
fireEvent.mouseDown(screen.getByTestId("m-overlay"));
122+
expect(onClose).toHaveBeenCalledTimes(1);
123+
});
124+
125+
it("closeOnOverlay=false ⇒ 点遮罩不关闭;关闭按钮 → onClose", () => {
126+
const onClose = vi.fn();
127+
render(<Host open onClose={onClose} closeOnOverlay={false} />);
128+
fireEvent.mouseDown(screen.getByTestId("m-overlay"));
129+
expect(onClose).not.toHaveBeenCalled();
130+
fireEvent.click(screen.getByTestId("m-close"));
131+
expect(onClose).toHaveBeenCalledTimes(1);
132+
});
133+
134+
it("最上层消费:拦住仍挂在 window 上的旧 ESC 监听(仓内 19 处手写弹层全在 window)", () => {
135+
const legacy = vi.fn();
136+
const onClose = vi.fn();
137+
window.addEventListener("keydown", legacy);
138+
render(<Host open onClose={onClose} />);
139+
fireEvent.keyDown(document, { key: "Tab" }); // 前提:jsdom 的事件确实从 document 冒到 window
140+
expect(legacy, "前提不成立则本用例空转(Tab 不被 Modal 消费)").toHaveBeenCalledTimes(1);
141+
legacy.mockClear();
142+
fireEvent.keyDown(document, { key: "Escape" });
143+
expect(onClose).toHaveBeenCalledTimes(1);
144+
expect(legacy, "弹层必须消费掉这一次 ESC,不能同时关掉下层的旧面板").not.toHaveBeenCalled();
145+
window.removeEventListener("keydown", legacy);
146+
});
147+
});
148+
149+
describe("进出场([data-phase] 三态 + 卸载时机)", () => {
150+
beforeEach(() => {
151+
vi.useFakeTimers();
152+
});
153+
154+
const tick = (ms: number): void => {
155+
act(() => {
156+
vi.advanceTimersByTime(ms);
157+
});
158+
};
159+
160+
it("打开:先 'enter'(CSS 起点),下一宏任务才转 'entered' 触发 transition", () => {
161+
render(<Host open />);
162+
expect(panel().getAttribute("data-phase")).toBe("enter");
163+
expect(screen.getByTestId("m-overlay").getAttribute("data-phase")).toBe("enter");
164+
tick(0);
165+
expect(panel().getAttribute("data-phase")).toBe("entered");
166+
});
167+
168+
it("关闭:先 'exit' 且仍挂载;兜底窗口(160+80)到点才卸载 ——「关不掉的弹层」防线", () => {
169+
const { rerender } = render(<Host open />);
170+
tick(0);
171+
rerender(<Host open={false} />);
172+
expect(panel().getAttribute("data-phase")).toBe("exit");
173+
tick(239);
174+
expect(panelOrNull(), "兜底窗口未到不得卸载").not.toBeNull();
175+
tick(1);
176+
expect(panelOrNull()).toBeNull();
177+
});
178+
179+
it("面板收到本节点 transitionend ⇒ 立即卸载(不等兜底)", () => {
180+
const { rerender } = render(<Host open />);
181+
tick(0);
182+
rerender(<Host open={false} />);
183+
const el = panel();
184+
fireEvent.transitionEnd(el, { target: el, currentTarget: el });
185+
expect(panelOrNull()).toBeNull();
186+
});
187+
188+
it("可中断/可反向:出场期间 open 回 true ⇒ 不卸载且直回 'entered'(不重播进场)", () => {
189+
const { rerender } = render(<Host open />);
190+
tick(0);
191+
rerender(<Host open={false} />);
192+
rerender(<Host open />);
193+
expect(panel().getAttribute("data-phase")).toBe("entered");
194+
tick(10_000);
195+
expect(panelOrNull(), "退场兜底计时器必须已被接管").not.toBeNull();
196+
});
197+
});
198+
199+
describe("焦点(无障碍优先于动效,§8.6.1 第 4 条)", () => {
200+
it("打开时焦点进入面板(首个可聚焦元素 = 关闭按钮)", () => {
201+
render(<Host open />);
202+
expect(document.activeElement).toBe(screen.getByTestId("m-close"));
203+
expect(panel().contains(document.activeElement)).toBe(true);
204+
});
205+
206+
it("关闭时焦点归还触发元素(§5.2 能力②)", () => {
207+
const { rerender } = render(<Host open={false} />);
208+
screen.getByTestId("trigger").focus();
209+
rerender(<Host open />);
210+
expect(document.activeElement).toBe(screen.getByTestId("m-close"));
211+
rerender(<Host open={false} />);
212+
expect(document.activeElement).toBe(screen.getByTestId("trigger"));
213+
});
214+
215+
it("Tab 在面板内循环:末元素回卷首元素、Shift+Tab 反向回卷(jsdom 无原生 Tab 导航)", () => {
216+
render(<Host open footer />);
217+
const close = screen.getByTestId("m-close");
218+
const ok = screen.getByTestId("ok");
219+
expect(document.activeElement).toBe(close);
220+
fireEvent.keyDown(document, { key: "Tab" });
221+
expect(document.activeElement).toBe(ok);
222+
fireEvent.keyDown(document, { key: "Tab" });
223+
expect(document.activeElement).toBe(close);
224+
fireEvent.keyDown(document, { key: "Tab", shiftKey: true });
225+
expect(document.activeElement).toBe(ok);
226+
});
227+
});
228+
229+
describe("z-index(出生即用标尺,规格 §4.2①)", () => {
230+
it("tier 决定遮罩的 zIndex(modal / modalNested),不得写裸数字", () => {
231+
const first = render(<Host open tier="modal" />);
232+
expect(screen.getByTestId("m-overlay").style.zIndex).toBe(String(Z_TIER.modal));
233+
first.unmount();
234+
render(<Host open tier="modalNested" />);
235+
expect(screen.getByTestId("m-overlay").style.zIndex).toBe(String(Z_TIER.modalNested));
236+
});
237+
});
238+
239+
describe("ESC 优先级:最内层唯一响应(与仓内「菜单优先」退出链同语义)", () => {
240+
/** 两层在**同一次提交**里挂载(最苛刻的情形:effect 自底向上跑,入栈序会把外层当栈顶) */
241+
function Nested({ inner, onOuter, onInner }: { inner: boolean; onOuter: () => void; onInner: () => void }) {
242+
return (
243+
<Modal open onClose={onOuter} title="外层" testId="outer" tier="modal">
244+
<Modal open={inner} onClose={onInner} title="内层" testId="inner" tier="modalNested">
245+
内层正文
246+
</Modal>
247+
</Modal>
248+
);
249+
}
250+
251+
it("两层同开时一次 ESC 只关内层;内层关上后 ESC 才轮到外层", () => {
252+
const onOuter = vi.fn();
253+
const onInner = vi.fn();
254+
const { rerender } = render(<Nested inner onOuter={onOuter} onInner={onInner} />);
255+
fireEvent.keyDown(document, { key: "Escape" });
256+
expect(onInner).toHaveBeenCalledTimes(1);
257+
expect(onOuter, "一次 ESC 只许关一层(仓内退出链语义)").not.toHaveBeenCalled();
258+
rerender(<Nested inner={false} onOuter={onOuter} onInner={onInner} />);
259+
fireEvent.keyDown(document, { key: "Escape" });
260+
expect(onOuter, "内层出栈后外层必须重新成为唯一响应者").toHaveBeenCalledTimes(1);
261+
expect(onInner, "已关闭的内层不得被再次触发").toHaveBeenCalledTimes(1);
262+
});
263+
});
264+
265+
describe("进出场时序:出场比进场快(规格 §8.4「弹层 200/160」)", () => {
266+
/** 取一条 CSS 规则原文(到第一个 `}` 为止;transition 值里没有花括号) */
267+
const ruleText = (selector: string): string => {
268+
const css = readCss("Modal.css");
269+
const start = css.indexOf(selector);
270+
expect(start, `Modal.css 缺少规则「${selector}」`).toBeGreaterThanOrEqual(0);
271+
return css.slice(start, css.indexOf("}", start) + 1);
272+
};
273+
274+
it("遮罩与面板:进场用 in(200)、出场用 out(160),兜底字面量与变量同名同值", () => {
275+
expect(ruleText(".ed-modal-overlay {")).toContain("var(--ed-dur-overlay-in, 200ms)");
276+
expect(ruleText('.ed-modal-overlay[data-phase="exit"]')).toContain(
277+
"transition-duration: var(--ed-dur-overlay-out, 160ms)",
278+
);
279+
expect(ruleText('.ed-modal[data-phase="exit"]')).toContain("transition-duration: var(--ed-dur-overlay-out, 160ms)");
280+
});
281+
282+
it("fallback 字面量:出场 160 < 进场 200(「出场比进场快」的机器判据)", () => {
283+
const css = readCss("Modal.css");
284+
const inMs = Number(/var\(--ed-dur-overlay-in,\s*(\d+)ms\)/.exec(css)?.[1] ?? "0");
285+
const outMs = Number(/var\(--ed-dur-overlay-out,\s*(\d+)ms\)/.exec(css)?.[1] ?? "0");
286+
expect(inMs).toBe(200);
287+
expect(outMs).toBe(160);
288+
expect(outMs, "出场必须比进场快").toBeLessThan(inMs);
289+
});
290+
291+
it("面板的 opacity 与 transform 等时长(presence 以首个 transitionend 收尾,不等长会提前摘掉)", () => {
292+
const base = ruleText(".ed-modal {");
293+
expect(base.match(/var\(--ed-dur-overlay-in, 200ms\)/g) ?? []).toHaveLength(2);
294+
expect(base, "退出时长只许出现在 [data-phase='exit'] 规则里").not.toContain("--ed-dur-overlay-out");
295+
});
296+
});

0 commit comments

Comments
 (0)