Skip to content

Commit 35abe6f

Browse files
committed
feat(ui): L1 原语 ConfirmDialog(级联影响清单)
1 parent 30ee2fa commit 35abe6f

3 files changed

Lines changed: 493 additions & 0 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* ConfirmDialog.css —— 危险确认的外观出口(批 0-D Task 8;规格 §5.3 / §4.1)。
3+
*
4+
* Why(为什么存在):规格 §5.3 要求高危不可逆动作的确认框**框内列明级联影响与保留项**,而现状 23 处
5+
* 确认调用(recon §3.2/§10)全是 `window.confirm` / `confirm()` 那种一行字的命令式弹窗 ——
6+
* 连「保留项」这个概念都无处安放。本文件给那套清单与印章标记一个落点。
7+
*
8+
* ⚠️ 本文件不得出现颜色字面量(色值只许在 ui/tokens.css 与 token 生成器里)。
9+
* ⚠️ **容器与层级不是本文件的职责**:遮罩、居中、进出场全在 `Modal`(本原语只消费它的 footer 槽),
10+
* 故本文件既不写层级也不写定位 —— 自己写一套就等于第二套弹层实现。
11+
* ⚠️ **危险色绝不作底色**(规格 §4.1「绝不用于按钮」;控制方裁决③):`--ed-stamp` 在本文件里
12+
* 只作**文字色与描边**(印章标记),危险信号的承载物是「印章 + 清单」而不是红底按钮。
13+
* 守卫见 `ConfirmDialog.test.tsx` 与 Task 14 Step 1 第 4 条(同向:background 声明出现该 token 即红)。
14+
*/
15+
16+
.ed-confirm {
17+
display: grid;
18+
grid-template-columns: auto 1fr;
19+
gap: var(--ed-space-8, 8px) var(--ed-space-12, 12px);
20+
align-items: start;
21+
}
22+
23+
/* 印章标记:装饰性(`aria-hidden`),全站唯一的非中性色只用在这里,且只作文字与描边 */
24+
.ed-confirm-seal {
25+
display: inline-block;
26+
padding: 0 var(--ed-space-4, 4px);
27+
border: 1px solid var(--ed-stamp);
28+
border-radius: var(--ed-radius-stamp, 3px);
29+
color: var(--ed-stamp);
30+
font-family: var(--ed-font-ui, "Inter", "Segoe UI Variable", "Microsoft YaHei UI", system-ui, sans-serif);
31+
font-size: var(--ed-type-6-size, 11.5px);
32+
line-height: var(--ed-type-6-line, 16px);
33+
font-weight: var(--ed-type-6-weight, 500);
34+
}
35+
36+
/* 级联影响清单:跨两列(行序 = 印章 + 说明 → 清单) */
37+
.ed-confirm-impacts {
38+
grid-column: 1 / -1;
39+
display: flex;
40+
flex-direction: column;
41+
gap: var(--ed-space-4, 4px);
42+
margin: 0;
43+
padding: 0;
44+
list-style: none;
45+
}
46+
.ed-confirm-impacts > li {
47+
display: flex;
48+
gap: var(--ed-space-8, 8px);
49+
}
50+
.ed-confirm-impacts > li::before {
51+
content: "·";
52+
color: var(--ed-ink-3);
53+
}
54+
/* 保留项:正向语气 —— 前缀换「›」并用 --ed-ok(规格 §5.3 的「笔记 7 篇保留」要一眼可辨) */
55+
.ed-confirm-impacts > li.ed-confirm-keep::before {
56+
content: "›";
57+
color: var(--ed-ok);
58+
}
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context ConfirmDialog.test.tsx —— 危险确认的**语义契约**测试(批 0-D Task 8;规格 §5.3)。
4+
*
5+
* Why jsdom:弹层经 `Modal` 走 portal 且带焦点陷阱 ⇒ 要真容器与 `document.activeElement`。
6+
* 本文件钉住四件事:① §5.3 的硬要求「框内必须列明级联影响与保留项」;② **危险语义用色**
7+
* (控制方 2026-09-11 裁决③ · §4.1「绝不用于按钮」)—— 确认按钮保持中性,`--ed-stamp` 绝不出现在
8+
* 任何 `background*` 声明里(与 Task 14 Step 1 第 4 条的批次守卫**同向**);③ **复用 Modal 内核**:
9+
* 直接扫 `ConfirmDialog.tsx` 的源码作机器证据(含 `from "./Modal"`、不含 Portal 与层级标尺的符号),
10+
* 并断言本原语 CSS 无层级、无定位;④ 「可中断 / 可反向 / 重复触发」的确定行为(§8.6.1 第 3 条)。
11+
*
12+
* 样式判据读文本:`vitest.config.ts` 的 `css` 为 false(测试环境不加载样式表)⇒ 范式 C(同 Modal.test.tsx)。
13+
* 副作用:无(只挂 React 树 + 读三个文本文件);假计时器只用在推进 presence 计时的那个 describe。
14+
* 边界:不用 jest-dom(本仓未装)—— 一律 `getAttribute` / `textContent` / `document.activeElement`。
15+
*/
16+
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
17+
import { readFileSync } from "node:fs";
18+
import { dirname, join } from "node:path";
19+
import { fileURLToPath } from "node:url";
20+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
21+
import { ConfirmDialog } from "./ConfirmDialog";
22+
import type { ConfirmDialogProps, ConfirmImpact } from "./ConfirmDialog";
23+
24+
const HERE = dirname(fileURLToPath(import.meta.url));
25+
/** 归一 EOL(本仓无 `.gitattributes` 且 `core.autocrlf=true`,逐字节断言会在别人机器上假阳性) */
26+
const readText = (n: string): string => readFileSync(join(HERE, n), "utf8").replace(/\r\n/g, "\n");
27+
const CONFIRM_TSX = readText("ConfirmDialog.tsx");
28+
const CONFIRM_CSS = readText("ConfirmDialog.css");
29+
const BUTTON_CSS = readText("Button.css");
30+
const noop = (): void => {};
31+
32+
/** 规格 §5.3 的范例:级联删除项 + 保留项(保留项必须与删除项一眼可分) */
33+
const IMPACTS: readonly ConfirmImpact[] = [
34+
{ text: "将删除 1 个组" },
35+
{ text: "3 条排序记录" },
36+
{ text: "笔记 7 篇保留", keep: true },
37+
];
38+
39+
/** 宿主 props:派生自原语契约(每个用例只关心其中几项 ⇒ `Partial` + 必需的 `open`) */
40+
type HostProps = Partial<ConfirmDialogProps> & { open: boolean };
41+
42+
/** 宿主:受控形态(与真实调用点一致,`open` 由测试 rerender 驱动) */
43+
function Host({ open, title = "删除「高数」?", onConfirm = noop, onCancel = noop, ...rest }: HostProps) {
44+
return <ConfirmDialog open={open} title={title} onConfirm={onConfirm} onCancel={onCancel} testId="cd" {...rest} />;
45+
}
46+
47+
const panel = (): HTMLElement => screen.getByRole("dialog");
48+
const panelOrNull = (): Element | null => document.body.querySelector('[role="dialog"]');
49+
const confirmBtn = (): HTMLElement => screen.getByTestId("cd-confirm");
50+
const cancelBtn = (): HTMLElement => screen.getByTestId("cd-cancel");
51+
const sealOrNull = (): Element | null => document.body.querySelector(".ed-confirm-seal");
52+
const cls = (el: Element): string => el.getAttribute("class") ?? "";
53+
54+
afterEach(() => {
55+
cleanup();
56+
vi.useRealTimers();
57+
});
58+
59+
describe("① 复用 Modal 内核:本原语不自建第二套 Portal / 层叠 / 键盘", () => {
60+
it("源码证据:import Modal;不出现 createPortal / zIndex", () => {
61+
expect(CONFIRM_TSX, "必须消费 Modal(弹层唯一实现)").toContain('from "./Modal"');
62+
expect(CONFIRM_TSX, "自建 portal = 第二套弹层实现").not.toContain("createPortal");
63+
expect(CONFIRM_TSX, "自建层级 = 绕过 z-index 标尺").not.toContain("zIndex");
64+
});
65+
66+
it("样式证据:ConfirmDialog.css 无 z-index、无 position(自建遮罩的两个特征)", () => {
67+
expect(CONFIRM_CSS).not.toMatch(/z-index/);
68+
expect(CONFIRM_CSS).not.toMatch(/position\s*:/);
69+
});
70+
71+
it("open=false 不渲染;open=true 时 role=dialog + aria-modal + aria-labelledby==title", () => {
72+
const { rerender } = render(<Host open={false} />);
73+
expect(panelOrNull()).toBeNull();
74+
rerender(<Host open />);
75+
expect(panel().getAttribute("aria-modal")).toBe("true");
76+
const id = panel().getAttribute("aria-labelledby");
77+
expect(document.getElementById(id as string)?.textContent).toBe("删除「高数」?");
78+
});
79+
});
80+
81+
describe("② 级联影响清单(§5.3:高危不可逆动作必须列明级联影响与保留项)", () => {
82+
it("impacts 逐条渲染且顺序不变;keep 项带 `ed-confirm-keep` 类与 `data-keep`", () => {
83+
render(<Host open impacts={IMPACTS} />);
84+
const items = screen.getAllByRole("listitem");
85+
expect(items.map((li) => li.textContent)).toEqual(["将删除 1 个组", "3 条排序记录", "笔记 7 篇保留"]);
86+
expect(items[0].getAttribute("class")).toBeNull();
87+
expect(items[0].getAttribute("data-keep")).toBeNull();
88+
expect(items[2].getAttribute("class")).toBe("ed-confirm-keep");
89+
expect(items[2].getAttribute("data-keep")).toBe("true");
90+
});
91+
92+
it("保留项前缀是正向标记(「›」+ --ed-ok),删除项是中性「·」", () => {
93+
expect(CONFIRM_CSS).toMatch(/li\.ed-confirm-keep::before\s*\{[^}]*content:\s*"›"/);
94+
expect(CONFIRM_CSS).toMatch(/li\.ed-confirm-keep::before\s*\{[^}]*color:\s*var\(--ed-ok\)/);
95+
});
96+
97+
it("不传 impacts / 传空数组 ⇒ 不渲染列表;message 传了才渲染", () => {
98+
const { rerender } = render(<Host open />);
99+
expect(screen.queryByRole("list")).toBeNull();
100+
expect(screen.queryByText("此操作不可撤销。")).toBeNull();
101+
rerender(<Host open impacts={[]} />);
102+
expect(screen.queryByRole("list")).toBeNull();
103+
rerender(<Host open message="此操作不可撤销。" />);
104+
expect(screen.getByText("此操作不可撤销。")).toBeTruthy();
105+
});
106+
});
107+
108+
describe("③ 危险语义用色(控制方裁决③ · 与 Task 14 第 4 条守卫同向)", () => {
109+
it("两个按钮都是 secondary(中性),确认按钮不得是 primary / danger", () => {
110+
render(<Host open />);
111+
for (const btn of [cancelBtn(), confirmBtn()]) {
112+
expect(cls(btn)).toContain("ed-btn--secondary");
113+
expect(cls(btn)).not.toContain("ed-btn--primary");
114+
expect(cls(btn)).not.toContain("danger");
115+
}
116+
});
117+
118+
it("`--ed-stamp` 绝不作底色:任何 background* 声明都不得含它", () => {
119+
const offenders = CONFIRM_CSS.split("\n").filter(
120+
(line) => /^\s*background[a-z-]*\s*:/.test(line) && line.includes("var(--ed-stamp)"),
121+
);
122+
expect(offenders, "该 token 出现在 background 声明里(§4.1「绝不用于按钮」)").toEqual([]);
123+
});
124+
125+
it("`--ed-stamp` 在本原语里只作文字色与描边(印章标记)", () => {
126+
const tail = CONFIRM_CSS.slice(CONFIRM_CSS.indexOf(".ed-confirm-seal {"));
127+
const rule = tail.slice(0, tail.indexOf("}"));
128+
expect(rule).toMatch(/color:\s*var\(--ed-stamp\)/);
129+
expect(rule).toMatch(/border:\s*1px solid var\(--ed-stamp\)/);
130+
});
131+
132+
it("Button.css 内 `--ed-stamp` 计数为 0(Task 14 同向:按钮层不得出现危险色)", () => {
133+
expect((BUTTON_CSS.match(/--ed-stamp/g) ?? []).length).toBe(0);
134+
});
135+
136+
it("印章标记存在、`aria-hidden`、有可见字符(装饰性;危险语义由清单的**文字**承载)", () => {
137+
render(<Host open />);
138+
const seal = sealOrNull();
139+
expect(seal, "缺印章标记 ⇒ 危险信号只剩按钮与文案").not.toBeNull();
140+
expect(seal?.getAttribute("aria-hidden")).toBe("true");
141+
expect((seal?.textContent ?? "").length).toBeGreaterThan(0);
142+
});
143+
});
144+
145+
describe("④ 回调契约(含文案默认值与 testId 透传)", () => {
146+
it("确认 → onConfirm 一次;取消 → onCancel 一次", () => {
147+
const onConfirm = vi.fn();
148+
const onCancel = vi.fn();
149+
render(<Host open onConfirm={onConfirm} onCancel={onCancel} />);
150+
fireEvent.click(confirmBtn());
151+
expect(onConfirm).toHaveBeenCalledTimes(1);
152+
expect(onCancel).not.toHaveBeenCalled();
153+
fireEvent.click(cancelBtn());
154+
expect(onCancel).toHaveBeenCalledTimes(1);
155+
expect(onConfirm).toHaveBeenCalledTimes(1);
156+
});
157+
158+
it("默认文案「确认」/「取消」;可逐项自定义", () => {
159+
const { rerender } = render(<Host open />);
160+
expect(confirmBtn().textContent).toBe("确认");
161+
expect(cancelBtn().textContent).toBe("取消");
162+
rerender(<Host open confirmLabel="删除" cancelLabel="再想想" />);
163+
expect(confirmBtn().textContent).toBe("删除");
164+
expect(cancelBtn().textContent).toBe("再想想");
165+
});
166+
});
167+
168+
describe("⑤ 退出路径永不产生确认(ESC / 遮罩都走 onCancel)", () => {
169+
it("ESC → onCancel;长按(repeat)不重复触发;onConfirm 始终 0", () => {
170+
const onConfirm = vi.fn();
171+
const onCancel = vi.fn();
172+
render(<Host open onConfirm={onConfirm} onCancel={onCancel} />);
173+
fireEvent.keyDown(document, { key: "Escape", repeat: true });
174+
expect(onCancel, "自动重复的 keydown 不得连关").not.toHaveBeenCalled();
175+
fireEvent.keyDown(document, { key: "Escape" });
176+
expect(onCancel).toHaveBeenCalledTimes(1);
177+
expect(onConfirm).not.toHaveBeenCalled();
178+
});
179+
180+
it("点遮罩 → onCancel;面板内 mousedown 不触发(防「面板内按下、遮罩上松开」误关)", () => {
181+
const onConfirm = vi.fn();
182+
const onCancel = vi.fn();
183+
render(<Host open onConfirm={onConfirm} onCancel={onCancel} />);
184+
fireEvent.mouseDown(panel());
185+
expect(onCancel).not.toHaveBeenCalled();
186+
fireEvent.mouseDown(screen.getByTestId("cd-overlay"));
187+
expect(onCancel).toHaveBeenCalledTimes(1);
188+
expect(onConfirm).not.toHaveBeenCalled();
189+
});
190+
});
191+
192+
describe("⑥ 可中断 / 可反向 / 重复触发(§8.6.1 第 3 条)", () => {
193+
it("打开后焦点落在「取消」而非「确认」(危险动作不能因一个回车就发生)", () => {
194+
render(<Host open impacts={IMPACTS} />);
195+
expect(document.activeElement, "焦点必须在面板内").toBe(cancelBtn());
196+
expect(document.activeElement).not.toBe(confirmBtn());
197+
});
198+
199+
it("Tab 循环仍由 Modal 的陷阱接管:焦点不逃出面板(取消 → 确认 → 回卷)", () => {
200+
render(<Host open />);
201+
expect(document.activeElement).toBe(cancelBtn());
202+
fireEvent.keyDown(document, { key: "Tab" });
203+
expect(document.activeElement, "footer 内顺序:危险在右").toBe(confirmBtn());
204+
fireEvent.keyDown(document, { key: "Tab" });
205+
expect(panel().contains(document.activeElement), "末元素必须回卷").toBe(true);
206+
});
207+
208+
it("busy=true:两个按钮 aria-disabled,各连点 3 次仍 0 回调(重复触发的第一道防线)", () => {
209+
const onConfirm = vi.fn();
210+
const onCancel = vi.fn();
211+
render(<Host open busy onConfirm={onConfirm} onCancel={onCancel} />);
212+
for (const btn of [confirmBtn(), cancelBtn()]) {
213+
expect(btn.getAttribute("aria-disabled")).toBe("true");
214+
for (let i = 0; i < 3; i += 1) fireEvent.click(btn);
215+
}
216+
expect(onConfirm).not.toHaveBeenCalled();
217+
expect(onCancel).not.toHaveBeenCalled();
218+
});
219+
220+
it("busy 不改变退出语义(确定行为):只有按钮被拦,ESC / 遮罩仍走 onCancel", () => {
221+
const onConfirm = vi.fn();
222+
const onCancel = vi.fn();
223+
render(<Host open busy onConfirm={onConfirm} onCancel={onCancel} />);
224+
fireEvent.keyDown(document, { key: "Escape" });
225+
fireEvent.mouseDown(screen.getByTestId("cd-overlay"));
226+
expect(onCancel).toHaveBeenCalledTimes(2);
227+
expect(onConfirm).not.toHaveBeenCalled();
228+
});
229+
230+
it("非 busy 时连点确认两次 ⇒ 恰好两次(**不做去抖**:防重复是父级 busy 的职责,见上条)", () => {
231+
const onConfirm = vi.fn();
232+
render(<Host open onConfirm={onConfirm} />);
233+
fireEvent.click(confirmBtn());
234+
fireEvent.click(confirmBtn());
235+
expect(onConfirm).toHaveBeenCalledTimes(2);
236+
});
237+
});
238+
239+
describe("⑦ 进出场时机(presence 由 Modal 承载)", () => {
240+
beforeEach(() => {
241+
vi.useFakeTimers();
242+
});
243+
244+
const tick = (ms: number): void => {
245+
act(() => {
246+
vi.advanceTimersByTime(ms);
247+
});
248+
};
249+
250+
it("关闭:先 exit 且仍挂载;面板收到 transitionend ⇒ 立即卸载", () => {
251+
const { rerender } = render(<Host open />);
252+
tick(0);
253+
rerender(<Host open={false} />);
254+
expect(panel().getAttribute("data-phase")).toBe("exit");
255+
const el = panel();
256+
fireEvent.transitionEnd(el, { target: el, currentTarget: el });
257+
expect(panelOrNull()).toBeNull();
258+
});
259+
260+
it("可反向:出场期间 open 回 true ⇒ 不卸载、回 entered,且不产生任何回调(动效不带动作语义)", () => {
261+
const onConfirm = vi.fn();
262+
const onCancel = vi.fn();
263+
const { rerender } = render(<Host open onConfirm={onConfirm} onCancel={onCancel} />);
264+
tick(0);
265+
rerender(<Host open={false} onConfirm={onConfirm} onCancel={onCancel} />);
266+
expect(panel().getAttribute("data-phase")).toBe("exit");
267+
rerender(<Host open onConfirm={onConfirm} onCancel={onCancel} />);
268+
expect(panel().getAttribute("data-phase")).toBe("entered");
269+
tick(10_000);
270+
expect(panelOrNull(), "退场兜底计时器必须已被接管(不排队)").not.toBeNull();
271+
expect(onCancel).not.toHaveBeenCalled();
272+
expect(onConfirm).not.toHaveBeenCalled();
273+
});
274+
});

0 commit comments

Comments
 (0)