Skip to content

Commit ef24081

Browse files
committed
fix(ui): ConfirmDialog 退场期禁用交互并补断言
1 parent 83710e2 commit ef24081

2 files changed

Lines changed: 66 additions & 12 deletions

File tree

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

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
/**
33
* @ai-context ConfirmDialog.test.tsx —— 危险确认的**语义契约**测试(批 0-D Task 8;规格 §5.3)。
44
*
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 条)。
5+
* Why jsdom:弹层经 `Modal` 走 portal 且带焦点陷阱 ⇒ 要真容器与 `document.activeElement`(同理,
6+
* 样式判据只能读文本:`vitest.config.ts` 的 `css` 为 false ⇒ 测试环境不加载样式表,范式 C)。
7+
* 本文件钉住五件事:① §5.3「框内必须列明级联影响与保留项」;② **危险语义用色**:确认按钮保持中性、
8+
* `--ed-stamp` 绝不出现在任何 `background*` 声明里(裁决③ · 与 Task 14 第 4 条守卫**同向**);③ **复用
9+
* Modal 内核**(扫源码作证据:含 `from "./Modal"`、不含 Portal 与层级标尺符号,且本原语 CSS 无层级无定位);
10+
* ④ 可中断 / 可反向 / 重复触发」的确定行为(§8.6.1 第 3 条);⑤ 退场相位(`open=false` 仍在淡出的 160ms)
11+
* 内整个对话框失活 —— 点击即时、退场异步 ⇒ 第二次点击会落到按钮上(评审 I-1,见该 describe 的用例)。
1112
*
12-
* 样式判据读文本:`vitest.config.ts` 的 `css` 为 false(测试环境不加载样式表)⇒ 范式 C(同 Modal.test.tsx)。
1313
* 副作用:无(只挂 React 树 + 读三个文本文件);假计时器只用在推进 presence 计时的那个 describe。
1414
* 边界:不用 jest-dom(本仓未装)—— 一律 `getAttribute` / `textContent` / `document.activeElement`。
1515
*/
@@ -36,10 +36,9 @@ const IMPACTS: readonly ConfirmImpact[] = [
3636
{ text: "笔记 7 篇保留", keep: true },
3737
];
3838

39-
/** 宿主 props:派生自原语契约(每个用例只关心其中几项 ⇒ `Partial` + 必需的 `open`) */
39+
/** 宿主 props:派生自原语契约(`Partial` + 必需的 `open`);受控形态,`open` 由测试 rerender 驱动 */
4040
type HostProps = Partial<ConfirmDialogProps> & { open: boolean };
4141

42-
/** 宿主:受控形态(与真实调用点一致,`open` 由测试 rerender 驱动) */
4342
function Host({ open, title = "删除「高数」?", onConfirm = noop, onCancel = noop, ...rest }: HostProps) {
4443
return <ConfirmDialog open={open} title={title} onConfirm={onConfirm} onCancel={onCancel} testId="cd" {...rest} />;
4544
}
@@ -271,4 +270,28 @@ describe("⑦ 进出场时机(presence 由 Modal 承载)", () => {
271270
expect(onCancel).not.toHaveBeenCalled();
272271
expect(onConfirm).not.toHaveBeenCalled();
273272
});
273+
274+
it("反例守门:退场相位(open=false 且仍挂载)内点确认 / 取消 / 遮罩 **一个回调都不产生**", () => {
275+
const onConfirm = vi.fn();
276+
const onCancel = vi.fn();
277+
const { rerender } = render(<Host open onConfirm={onConfirm} onCancel={onCancel} />);
278+
tick(0);
279+
rerender(<Host open={false} onConfirm={onConfirm} onCancel={onCancel} />);
280+
expect(panel().getAttribute("data-phase")).toBe("exit");
281+
fireEvent.click(confirmBtn()); // 批 4 的误触面:点了删除、弹层在淡出、手一抖又点到确认
282+
fireEvent.click(cancelBtn());
283+
fireEvent.mouseDown(screen.getByTestId("cd-overlay"));
284+
expect([onConfirm.mock.calls.length, onCancel.mock.calls.length], "退场期不得再接受任何交互").toEqual([0, 0]);
285+
});
286+
287+
it("对照组:**打开态**下三条路径各仍触发一次(防「把功能一起关掉」)", () => {
288+
const onConfirm = vi.fn();
289+
const onCancel = vi.fn();
290+
render(<Host open onConfirm={onConfirm} onCancel={onCancel} />);
291+
fireEvent.click(confirmBtn());
292+
fireEvent.click(cancelBtn());
293+
fireEvent.mouseDown(screen.getByTestId("cd-overlay"));
294+
// confirm 1 次;cancel 2 次 = 取消钮 1 + 遮罩 1
295+
expect([onConfirm.mock.calls.length, onCancel.mock.calls.length], "confirm/cancel 次数").toEqual([1, 2]);
296+
});
274297
});

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

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@
2929
* ③ **退出路径永不产生确认**:ESC / 点遮罩一律走 `onCancel`(`Modal` 的关闭意图)——「破坏性动作
3030
* 不得因误点遮罩而被确认」;本原语不区分退出路径,也不因 `busy` 改变退出语义(确定行为)。
3131
* ④ 不迁移任何现有 `confirm` 调用点(那 23 处是批 4 的迁移面;本批只交付被迁移的靶子)。
32+
* ⑤ **退场相位(`open=false` 而面板仍在淡出的 160ms)内整个对话框失活**(评审 I-1 的修复):
33+
* 点击是**即时**的,而退场是**异步**的 —— 那 160ms 里面板还挂在屏上,用户「手一抖」的第二次点击
34+
* 会真的落到按钮上(批 4 迁移后正是删除 / 级联删除的**误触面**)。两条落点:
35+
* ① 两颗按钮 `busy={busy || !open}`(`Button` 的 `busy` 已实测拦下 click,且不设原生 `disabled`
36+
* ⇒ 焦点与 Tab 序在退场期保持稳定,不会因卸载前的属性抖动打断读屏);
37+
* ② `Modal` 的关闭意图(遮罩 / 头部「关闭」钮)经 `handleCloseIntent` **按 `open` 门控** ——
38+
* 这条 `busy` 到不了(那是 `Modal` 自己的处理器),故在本文件补一道守卫;ESC 无需处理
39+
* (`Modal` 的 ESC 监听本就以 `open` 为门控,实测退场期不响应)。
40+
* ⚠️ **系统级修法建议(登记给 T7 的 `Modal`,本任务不改它的文件)**:在
41+
* `Modal.css` 给 `[data-phase="exit"]` 的面板与遮罩加 `pointer-events: none` —— 那能一次性
42+
* 覆盖**所有** Modal 消费者(含批 4 迁移的 28 个手写弹层),而本原语这两行只是**文件内缓解**。
3243
*/
3344
import { useCallback, useEffect, useRef, useState } from "react";
3445
import type { ReactElement, ReactNode } from "react";
@@ -105,6 +116,19 @@ export function ConfirmDialog({
105116
setCancelSlot(node);
106117
}, []);
107118

119+
/**
120+
* 退场相位内按钮**必须失活**(见 @ai-context 边界⑤):复用 `Button` 的 `busy`(它拦 click、不设原生
121+
* `disabled`)而不是 `disabled` —— 退场只有 160ms,`disabled` 会把焦点踢出 Tab 序再被卸载,读屏会读成
122+
* 「按钮被禁用」这种无意义的状态抖动。
123+
*/
124+
const buttonsInert = busy || !open;
125+
126+
/** `Modal` 的关闭意图守卫(遮罩 / 头部「关闭」钮):`open=false` 的那一瞬起就不再接受退出意图 */
127+
const handleCloseIntent = useCallback((): void => {
128+
if (!open) return;
129+
onCancel();
130+
}, [open, onCancel]);
131+
108132
useEffect(() => {
109133
if (!open) {
110134
redirectedRef.current = false; // 关闭即复位:下一次打开重新定向
@@ -123,18 +147,25 @@ export function ConfirmDialog({
123147
const footer = (
124148
<>
125149
<span ref={attachCancelSlot}>
126-
<Button variant="secondary" onClick={onCancel} busy={busy} testId={testId ? `${testId}-cancel` : undefined}>
150+
<Button variant="secondary" onClick={onCancel} busy={buttonsInert} testId={testId ? `${testId}-cancel` : undefined}>
127151
{cancelLabel}
128152
</Button>
129153
</span>
130-
<Button variant="secondary" onClick={onConfirm} busy={busy} testId={testId ? `${testId}-confirm` : undefined}>
154+
<Button variant="secondary" onClick={onConfirm} busy={buttonsInert} testId={testId ? `${testId}-confirm` : undefined}>
131155
{confirmLabel}
132156
</Button>
133157
</>
134158
);
135159

136160
return (
137-
<Modal open={open} onClose={onCancel} title={title} size={CONFIRM_SIZE} testId={testId} footer={footer}>
161+
<Modal
162+
open={open}
163+
onClose={handleCloseIntent}
164+
title={title}
165+
size={CONFIRM_SIZE}
166+
testId={testId}
167+
footer={footer}
168+
>
138169
<div className="ed-confirm">
139170
<span className="ed-confirm-seal" aria-hidden="true">
140171
{SEAL_TEXT}

0 commit comments

Comments
 (0)