Skip to content

Commit fa64fb5

Browse files
committed
feat(ui): L1 原语 Toast(进出场 180/140 且可打断)
1 parent 174b189 commit fa64fb5

6 files changed

Lines changed: 780 additions & 0 deletions

File tree

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Toast.css —— Toast 的唯一外观出口(批 0-D Task 9;规格 §5.3 / §8.4)。
3+
*
4+
* Why(为什么存在):仓内现有 4 套自绘 toast 各自内联写死定位、圆角、阴影与配色(recon §3.2),
5+
* 且**全部只有"进"、没有"出"**。本文件把外观收成一处,并给"出场"一个可被 CSS 驱动的落点:
6+
* `[data-phase]` 三态 —— 时机由 `usePresence` 给(Toast.tsx),样子在这里。
7+
*
8+
* ⚠️ 本文件不得出现颜色字面量(色值只许在 ui/tokens.css 与生成器里),也不得写 `z-index`
9+
* (层级是 TS 标尺 ui/zIndex.ts 的职责,由 Toast.tsx 内联到节点上)。
10+
* ⚠️ **不 animate height**(规格 §8.4 明文):本文件的 transition **只允许**出现在 `opacity` 与
11+
* `transform` 上 —— 高度 / 内边距 / 位置过渡会触发重排,也会让多条 toast 的堆叠逐帧抖动。
12+
* 机器判据在 `Toast.style.test.ts`(扫 transition 声明的属性名,附变异样本证明有区分度)。
13+
* ⚠️ **等时长不变量**(同 Modal.css):`usePresence` 以**首个** `transitionend` 收尾 ⇒ 同一元素上的
14+
* opacity 与 transform 必须等时长;进场 180ms、出场 140ms(**出场比进场快**,规格 §8.4)。
15+
*
16+
* 批 6 接缝:① 时长经 `var(--ed-dur-toast-in/out, …)` 消费 —— 批 6 的动效 token 真源落地后删掉
17+
* motion.css 的变量块即生效,无需改本文件;② 多条并存时的堆叠只需宿主按序给
18+
* `--ed-toast-stack-offset`(默认 0px),不动 DOM 结构、不做 height 过渡(本原语不排队,
19+
* 见 `Toast.tsx` 的「新消息接管」契约)。
20+
*/
21+
22+
.ed-toast {
23+
position: fixed;
24+
right: 18px;
25+
/* 18px 与现状 4 套 toast 的落点一致(recon §3.2)—— 批 4 迁移时位置不得移动;
26+
堆叠偏移留给批 6 的宿主按序赋值(见文件头接缝②),默认展开为 0px */
27+
bottom: calc(18px + var(--ed-toast-stack-offset, 0px));
28+
display: flex;
29+
align-items: center;
30+
gap: var(--ed-space-8, 8px);
31+
box-sizing: border-box;
32+
max-width: 420px;
33+
padding: var(--ed-space-8, 8px) var(--ed-space-12, 12px);
34+
background: var(--ed-bg-raised);
35+
border: 1px solid var(--ed-border);
36+
border-left: 3px solid var(--ed-border-strong); /* 三档 kind 各自覆盖左边框色(下一段) */
37+
border-radius: var(--ed-radius-panel, 8px);
38+
box-shadow: var(--ed-shadow-2);
39+
transition: opacity var(--ed-dur-toast-in, 180ms) var(--ed-ease, cubic-bezier(0.2, 0, 0, 1)),
40+
transform var(--ed-dur-toast-in, 180ms) var(--ed-ease, cubic-bezier(0.2, 0, 0, 1));
41+
}
42+
43+
/* 三档语义色**只作边框色**(文字色由 Toast.tsx 经 `Text` 的 tone 给,色值仍是 token);
44+
`--ed-stamp` 在本文件里绝不出现在任何 background 声明中(规格 §4.1「绝不用于按钮」) */
45+
.ed-toast--info { border-left-color: var(--ed-ink-3); }
46+
.ed-toast--ok { border-left-color: var(--ed-ok); }
47+
.ed-toast--err { border-left-color: var(--ed-stamp); }
48+
49+
.ed-toast[data-phase="enter"] { opacity: 0; transform: translateY(8px); } /* 位移 8px = 规格 §8.4 上限 */
50+
.ed-toast[data-phase="entered"] { opacity: 1; transform: translateY(0); }
51+
.ed-toast[data-phase="exit"] {
52+
opacity: 0;
53+
transform: translateY(4px); /* 出场位移只留一半:出场更短更快,4px 是它的配套量 */
54+
transition-duration: var(--ed-dur-toast-out, 140ms);
55+
}
56+
57+
/* 行动槽(撤销 / 重试):靠右排;视觉权威仍全在 `.ed-btn` 的四态里,本类只做定位 */
58+
.ed-toast-action {
59+
flex: 0 0 auto;
60+
margin-left: auto;
61+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context Toast.interrupt.test.tsx —— Toast 的**接管与关闭权归属**(批 0-D Task 9;规格 §8.6.1 第 3 条)。
4+
*
5+
* 语义边界:本文件管「一条 toast 的非常规路径」——
6+
* ① **出场中被新消息打断** ⇒ 取消出场、回到 `entered`(不重放进场)、旧退场不再卸载;
7+
* ② **不排队**:连续改 message 时至多一个计时器、DOM 里始终只有一条(§8.6.1 第 3 条
8+
* 「下一个输入要能接管当前动效,不排队」的机器判据);
9+
* ③ **关闭权归属**:父级 `open=false` 是父级已知情的决定 ⇒ 退场结束后**不得**回调 `onDismiss`;
10+
* ④ **多条并存**:彼此独立、不共用模块级状态(本原语不做队列/堆栈 —— 堆叠偏移是批 6 的 CSS 接缝)。
11+
* 「正常生命周期」(渲染/计时/卸载/reduced-motion)在同目录 `Toast.test.tsx`,两份都 ≤300 行;
12+
* 样式文本判据在 `Toast.style.test.ts`(node 环境)。
13+
*
14+
* 副作用:只挂 React 树(不写文件、不发请求、不读 store)。
15+
* 边界:本仓未装 jest-dom ⇒ 一律 `getAttribute` / `textContent` / `queryBy…`;假计时器下
16+
* `usePresence` 的 `enter → entered` 需要**推一次 0ms**(`settle()`),否则永远停在 `enter`。
17+
*/
18+
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
19+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
20+
import { Toast } from "./Toast";
21+
import type { ToastProps } from "./Toast";
22+
23+
const noop = (): void => {};
24+
25+
/** 宿主 props:派生自原语契约(每个用例只关心其中几项 ⇒ `Partial` + 必需的 `open`) */
26+
type HostProps = Partial<ToastProps> & { open: boolean };
27+
28+
/** 宿主:受控形态(`open` 由测试 rerender 驱动,与真实调用点一致) */
29+
function Host(props: HostProps) {
30+
return (
31+
<Toast
32+
{...props}
33+
message={props.message ?? "已保存"}
34+
onDismiss={props.onDismiss ?? noop}
35+
testId={props.testId ?? "toast"}
36+
/>
37+
);
38+
}
39+
40+
const toast = (): HTMLElement => screen.getByTestId("toast");
41+
const toastOrNull = (): Element | null => document.body.querySelector(".ed-toast");
42+
const phase = (): string | null => toastOrNull()?.getAttribute("data-phase") ?? null;
43+
const cls = (el: Element): string => el.getAttribute("class") ?? "";
44+
45+
/** 推进受控时钟(包 act:React 的状态更新必须 flush 后才可断言) */
46+
const tick = (ms: number): void => {
47+
act(() => {
48+
vi.advanceTimersByTime(ms);
49+
});
50+
};
51+
/** 走完 `usePresence` 的 enter → entered(0ms 宏任务) */
52+
const settle = (): void => tick(0);
53+
/** 派发一次「本节点的过渡结束」(usePresence 判 `target === currentTarget` 且 `phase === "exit"`) */
54+
const endTransition = (el: Element = toast()): void => {
55+
fireEvent.transitionEnd(el, { target: el, currentTarget: el });
56+
};
57+
58+
beforeEach(() => {
59+
vi.useFakeTimers();
60+
});
61+
62+
afterEach(() => {
63+
cleanup();
64+
vi.useRealTimers();
65+
});
66+
67+
describe("① 出场中被打断 ⇒ 接管(不排队、不新开一条)", () => {
68+
it("出场期间改 message ⇒ 回 entered、仍挂载、旧退场不再卸载(越过兜底窗口仍活着)", () => {
69+
const onDismiss = vi.fn();
70+
const { rerender } = render(<Host open message="第一条" onDismiss={onDismiss} />);
71+
settle();
72+
tick(3000); // 默认 3000 到点 ⇒ exit
73+
expect(phase()).toBe("exit");
74+
rerender(<Host open message="第二条" onDismiss={onDismiss} />);
75+
expect(phase(), "回 entered 而不是重新进场(不闪一下)").toBe("entered");
76+
tick(300); // 越过退场兜底窗口(140+80):旧退场若未被接管,此点已卸载
77+
expect(toastOrNull()).not.toBeNull();
78+
expect(onDismiss).not.toHaveBeenCalled();
79+
tick(2700); // t=6000 = 接管点 + 3000 ⇒ 只退场、仍不回调
80+
expect(phase()).toBe("exit");
81+
expect(onDismiss).not.toHaveBeenCalled();
82+
});
83+
84+
it("显示中改 message ⇒ 重新计时(旧排期到点不消失)", () => {
85+
const { rerender } = render(<Host open message="A" durationMs={3000} />);
86+
settle();
87+
tick(2000);
88+
rerender(<Host open message="B" durationMs={3000} />);
89+
tick(2000); // t=4000:旧排期(t=3000)若未重排,此刻应已 exit
90+
expect(phase()).toBe("entered");
91+
tick(1000); // t=5000 = 接管点 + 3000
92+
expect(phase()).toBe("exit");
93+
});
94+
95+
it("改 kind ⇒ 同一条被接管(类名与 aria-live 随之更新,不新开)", () => {
96+
const { rerender } = render(<Host open kind="info" message="A" />);
97+
settle();
98+
rerender(<Host open kind="err" message="A" />);
99+
expect(cls(toast())).toContain("ed-toast--err");
100+
expect(toast().getAttribute("aria-live")).toBe("assertive");
101+
expect(screen.getAllByRole("status")).toHaveLength(1);
102+
});
103+
104+
it("连续改 3 次 message:至多一个计时器、DOM 里始终只有一条 toast(不排队)", () => {
105+
const { rerender } = render(<Host open message="0" />);
106+
settle();
107+
expect(vi.getTimerCount(), "显示期只有自动消失这一个计时器").toBe(1);
108+
for (const m of ["1", "2", "3"]) {
109+
rerender(<Host open message={m} />);
110+
expect(toast().textContent).toBe(m);
111+
expect(vi.getTimerCount(), "接管 = 重排一个,不是排队两个").toBe(1);
112+
}
113+
expect(screen.getAllByRole("status")).toHaveLength(1);
114+
});
115+
});
116+
117+
describe("② 关闭权归属:父级 open=false 是父级已知情的决定 ⇒ 不回调", () => {
118+
it("直接走出场且不调 onDismiss;transitionend 后卸载", () => {
119+
const onDismiss = vi.fn();
120+
const { rerender } = render(<Host open onDismiss={onDismiss} />);
121+
settle();
122+
rerender(<Host open={false} onDismiss={onDismiss} />);
123+
expect(phase()).toBe("exit");
124+
expect(toastOrNull()).not.toBeNull();
125+
endTransition();
126+
expect(toastOrNull()).toBeNull();
127+
tick(10_000);
128+
expect(onDismiss, "父级主动关闭不该收到自己刚做的决定").not.toHaveBeenCalled();
129+
});
130+
131+
it("退场中反向 open=true ⇒ 不卸载、回 entered、零回调(可反向)", () => {
132+
const onDismiss = vi.fn();
133+
const { rerender } = render(<Host open onDismiss={onDismiss} />);
134+
settle();
135+
rerender(<Host open={false} onDismiss={onDismiss} />);
136+
expect(phase()).toBe("exit");
137+
rerender(<Host open onDismiss={onDismiss} />);
138+
expect(phase()).toBe("entered");
139+
tick(300); // 越过退场兜底窗口
140+
expect(toastOrNull()).not.toBeNull();
141+
expect(onDismiss).not.toHaveBeenCalled();
142+
});
143+
});
144+
145+
describe("③ 多条并存(堆叠的确定行为:彼此独立、不共用模块级状态)", () => {
146+
it("两条各自计时、各自回调:一条到点不影响另一条", () => {
147+
const a = vi.fn();
148+
const b = vi.fn();
149+
render(
150+
<>
151+
<Toast open message="A" durationMs={1000} onDismiss={a} testId="ta" />
152+
<Toast open message="B" durationMs={5000} onDismiss={b} testId="tb" />
153+
</>,
154+
);
155+
settle();
156+
expect(screen.getAllByRole("status")).toHaveLength(2);
157+
tick(1000);
158+
const first = screen.getByTestId("ta");
159+
expect(first.getAttribute("data-phase")).toBe("exit");
160+
expect(screen.getByTestId("tb").getAttribute("data-phase")).toBe("entered");
161+
endTransition(first);
162+
expect(screen.queryByTestId("ta")).toBeNull();
163+
expect(a).toHaveBeenCalledTimes(1);
164+
expect(b).not.toHaveBeenCalled();
165+
expect(screen.getByTestId("tb").textContent).toBe("B");
166+
});
167+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* @ai-context Toast.style.test.ts —— Toast 的**样式文本判据**(批 0-D Task 9;规格 §8.4 · §4.1)。
3+
*
4+
* Why node 环境(**不加** jsdom 头):本文件只读 `Toast.css` / `Toast.tsx` 的文本,不挂 DOM。
5+
* 与行为契约 `Toast.test.tsx` 按语义拆成两份,使两份都留在**新文件 ≤300 行**的红线内
6+
* (批 0-D 纪律:新文件超 300 不许登记豁免,只能拆)。
7+
*
8+
* 三条判据:
9+
* ① **不 animate height**(规格 §8.4 明文:「相变 chrome 用绝对定位交叉淡入,不 animate height」)
10+
* —— transition 只许出现在 `opacity` / `transform` 上;高度/内边距/位置过渡会触发重排,
11+
* 也会让多条 toast 的堆叠逐帧抖动。第二个 `it` 用**变异样本**证明该判据有区分度。
12+
* ② 进出场时长逐字取 `--ed-dur-toast-in/out`(180 / 140,**出场比进场快**)且各带同值兜底字面量
13+
* —— 批 6 的动效 token 真源落地后删 `motion.css` 的变量块即生效。
14+
* ③ 与其它原语同向的纪律:类名 ↔ CSS 规则一致 · 零颜色字面量 · 零 `z-index`(层级是 TS 标尺的
15+
* 职责)· `--ed-stamp` 不进任何 `background` 声明(规格 §4.1「绝不用于按钮」)。
16+
*
17+
* 副作用:只读磁盘(同目录两个文件),不修改任何文件。
18+
* 边界:判据前先**剥注释**(注释里会提到反例串);EOL 归一(本仓无 `.gitattributes` 且
19+
* `core.autocrlf=true`,逐字节断言会在别人机器上假阳性)。
20+
*/
21+
import { readFileSync } from "node:fs";
22+
import { dirname, join } from "node:path";
23+
import { fileURLToPath } from "node:url";
24+
import { describe, expect, it } from "vitest";
25+
26+
const HERE = dirname(fileURLToPath(import.meta.url));
27+
const read = (file: string): string => readFileSync(join(HERE, file), "utf8").replace(/\r\n/g, "\n");
28+
const stripComments = (s: string): string => s.replace(/\/\*[\s\S]*?\*\//g, "");
29+
30+
const CLEAN = stripComments(read("Toast.css"));
31+
const TSX = read("Toast.tsx");
32+
33+
/** 颜色字面量(与 `style-seams.test.ts` 同一口径:本层不得出现,颜色只经 `var(--ed-*)`) */
34+
const COLOR_LITERAL = /#[0-9a-fA-F]{3,8}\b|\b(?:rgb|hsl)a?\(/;
35+
36+
/** `Toast.tsx` 产出的类名契约(改名单必须同时改 CSS 与行为测试) */
37+
const CLASSES: readonly string[] = ["ed-toast", "ed-toast--info", "ed-toast--ok", "ed-toast--err", "ed-toast-action"];
38+
39+
/** 会触发重排的属性名(规格 §8.4 只允许 compositor 友好的 opacity / transform 进场) */
40+
const LAYOUT_PROPS = /height|padding|margin|\bwidth\b|\btop\b|\bbottom\b|\bleft\b|\bright\b|inset/;
41+
42+
/**
43+
* 判据本体(抽成函数:以便第二个 `it` 用**变异样本**证明它有区分度 —— 一条抓不到反例的守卫
44+
* 等于没有守卫)。
45+
*/
46+
function offendingTransitions(css: string): string[] {
47+
return [...css.matchAll(/transition(?:-property|-duration)?\s*:\s*([^;]+);/g)]
48+
.map((m) => m[1])
49+
.filter((value) => LAYOUT_PROPS.test(value));
50+
}
51+
52+
/** 取一条规则的规则体(`选择器 {` 到第一个 `}`)—— 防「写在别的规则里也算过」 */
53+
function ruleBody(selector: string): string {
54+
const at = CLEAN.indexOf(selector);
55+
expect(at, `Toast.css 缺少 \`${selector}\` 的规则`).toBeGreaterThanOrEqual(0);
56+
return CLEAN.slice(at, CLEAN.indexOf("}", at));
57+
}
58+
59+
describe("① 不 animate height(规格 §8.4 明文)", () => {
60+
it("Toast.css 的 transition 只动 opacity / transform(不触发重排)", () => {
61+
expect(offendingTransitions(CLEAN), "过渡里出现了会重排的属性(规格 §8.4:不 animate height)").toEqual([]);
62+
const values = [...CLEAN.matchAll(/transition(?:-property)?\s*:\s*([^;]+);/g)].map((m) => m[1]).join(" ");
63+
expect(values, "进出场必须走 opacity 与 transform").toContain("opacity");
64+
expect(values).toContain("transform");
65+
});
66+
67+
it("判据有区分度(反例守门):变异样本 `transition: max-height …` 必须被同一条判据抓到", () => {
68+
const mutated = ".ed-toast { transition: max-height var(--ed-dur-toast-in, 180ms); }";
69+
expect(offendingTransitions(mutated)).toHaveLength(1);
70+
});
71+
});
72+
73+
describe("② 进出场时长与三态(规格 §8.4:Toast 180/140,出场比进场快)", () => {
74+
it("基类进场取 --ed-dur-toast-in / 180ms;exit 态取 --ed-dur-toast-out / 140ms", () => {
75+
expect(ruleBody(".ed-toast {")).toContain("var(--ed-dur-toast-in, 180ms)");
76+
expect(ruleBody('.ed-toast[data-phase="exit"]')).toContain("transition-duration: var(--ed-dur-toast-out, 140ms)");
77+
});
78+
79+
it("[data-phase] 三态齐全:enter 起点透明、entered 终态、exit 出场", () => {
80+
expect(ruleBody('.ed-toast[data-phase="enter"]')).toContain("opacity: 0");
81+
expect(ruleBody('.ed-toast[data-phase="entered"]')).toContain("opacity: 1");
82+
expect(ruleBody('.ed-toast[data-phase="exit"]')).toContain("opacity: 0");
83+
});
84+
});
85+
86+
describe("③ 纪律判据(与批 0-D 其它原语同向)", () => {
87+
it("Toast.tsx 产出的每个类在 Toast.css 里都有规则(类名拼错 = 静默无样式)", () => {
88+
for (const cls of CLASSES) expect(CLEAN, `Toast.css 缺少 .${cls} 的规则`).toContain(`.${cls} {`);
89+
});
90+
91+
it("零颜色字面量、零 z-index(层级是 ui/zIndex.ts 标尺的职责)", () => {
92+
expect(CLEAN, "Toast.css 出现颜色字面量(色值只在 ui/tokens.css 与生成器里)").not.toMatch(COLOR_LITERAL);
93+
expect(CLEAN, "Toast.css 写了 z-index").not.toMatch(/z-index/);
94+
});
95+
96+
it("`--ed-stamp` 只作文字色与描边,绝不进任何 background 声明(规格 §4.1「绝不用于按钮」)", () => {
97+
const offenders = CLEAN.split("\n").filter(
98+
(line) => /^\s*background[a-z-]*\s*:/.test(line) && line.includes("var(--ed-stamp)"),
99+
);
100+
expect(offenders).toEqual([]);
101+
});
102+
103+
it("Toast.tsx 的层级经标尺取(裸数字由 zIndex.guard 棘轮守);且 import 了自己的 CSS", () => {
104+
expect(TSX).toContain('zIndex("toast")');
105+
expect(TSX).not.toMatch(/zIndex\s*:\s*\d/);
106+
expect(TSX, "不 import 自己的 CSS ⇒ 全部类静默无样式").toContain('import "./Toast.css";');
107+
});
108+
});

0 commit comments

Comments
 (0)