Skip to content

Commit 0e5e78a

Browse files
committed
feat(ui): L1 原语 EmptyState(含主行动按钮)
1 parent 0c324be commit 0e5e78a

4 files changed

Lines changed: 487 additions & 0 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* EmptyState.css —— 空态的**唯一形态出口**(批 0-D Task 10;规格 §5.1 收敛账本的 `EmptyState` 行)。
3+
*
4+
* Why(为什么存在):现状**没有空态组件**,等价物是散落在 40 行 / 28 文件的「暂无…」灰字
5+
* (recon §2.1:`暂无|还没有|尚无|空空|没有任何`),5 套空态并存 —— 每个面板各自决定
6+
* 「离中心多远、灰到什么程度、有没有出路」,改一次要动 28 个文件。本文件是那一处的落点。
7+
*
8+
* 边界:
9+
* ① 颜色 / 间距 / 圆角一律 `var(--ed-*)`,**本文件零颜色字面量**(色值只在 `ui/tokens.css`
10+
* 与生成器里;`var()` 的兜底值也不许是色值);
11+
* ② 不得写 `z-index`(层级走 `ui/zIndex.ts` 六档标尺,规格 §4.2①);
12+
* ③ **不得定义任何 `--ed-*` 变量**:时长/缓动的真源是 `motion.css`(批 6 一整块接管),
13+
* 本文件只消费 —— 入场时长写 `var(--ed-dur-micro, 120ms)`,**不新造时长**(计划 Task 10 契约);
14+
* ④ 一律**单属性 ≤200ms 的 CSS transition / 一次性 animation**(§8.2 判据)。空态**不做循环动画**
15+
* (§8.6.1 第 1 条:环境层永远不抢注意力;「闲置时的生命感」由 `Loading`/`Probe` 承载);
16+
* ⑤ reduced-motion 由 `motion.css` 的**唯一**媒体查询块统一覆盖(`.ed-empty` 已在名单里),
17+
* 本文件**不重复写媒体查询**(重复会让「一处改对所有地方」失效);
18+
* ⑥ 盒模型口径由**基类自带**(控制方 2026-09-11 裁决:本仓无全局 CSS reset,`Surface`/`Button`
19+
* 同款口径 —— padded + 边框 + `width: 100%` 在 content-box 下会溢出)。
20+
*/
21+
22+
.ed-empty {
23+
display: flex;
24+
flex-direction: column;
25+
align-items: center;
26+
justify-content: center;
27+
gap: var(--ed-space-8, 8px);
28+
/* 空态自己的呼吸(与 spacing 标尺同值但语义不同,同 `Button` 的内边距口径) */
29+
padding: var(--ed-space-24, 24px) var(--ed-space-16, 16px);
30+
text-align: center;
31+
box-sizing: border-box;
32+
}
33+
34+
/* `compact`:给**列表行 / 侧栏内**的小空态(28 个迁移靶子里多数是这种),只收空气与间距,
35+
不动排版 —— 字阶与墨度的权威在 `Text`,空态不越过它去改字号。 */
36+
.ed-empty--compact {
37+
gap: var(--ed-space-4, 4px);
38+
padding: var(--ed-space-8, 8px);
39+
}
40+
41+
/* 图标:弱化墨度(装饰性顿点,视觉焦点让给标题与主行动按钮);`flex: none` 防被压扁。
42+
颜色走 `currentColor`(图标层 `stroke="currentColor"`)⇒ 批 6 想换墨度只需改这一行。 */
43+
.ed-empty__icon {
44+
flex: none;
45+
color: var(--ed-ink-3);
46+
}
47+
48+
/* 标题与描述都渲染成 `<p>`(语义标签,见 `EmptyState.tsx` 边界③):UA 对 `<p>` 自带
49+
`margin: 1em 0`,而本仓没有全局 CSS reset ⇒ 不减掉就会与容器的 flex `gap` 叠加成双倍留白。 */
50+
.ed-empty__title {
51+
margin: 0;
52+
}
53+
54+
.ed-empty__description {
55+
margin: 0;
56+
/* 约 36 个半角字符(中文约 18 字):空态说明是引导语,行宽过大时居中排版会失去可读性。
57+
字面量而非 token —— 它是**行宽**(排版度量),不在 spacing 标尺的语义里。 */
58+
max-width: 36ch;
59+
}
60+
61+
/* 次要内容槽(次要行动 / 快捷键提示):自己是一个可换行的居中行,不与主行动按钮挤在一行。 */
62+
.ed-empty__secondary {
63+
display: flex;
64+
flex-wrap: wrap;
65+
justify-content: center;
66+
gap: var(--ed-space-8, 8px);
67+
}
68+
69+
/* ★ 入场接缝(批 6 的唯一挂点):一次性透明度入场,**不做位移**(环境层不抢注意力)。
70+
`both` 是给批 6 留的:编排层若要加 stagger 延迟,元素在延迟期保持 `from` 的「先隐后现」,
71+
而不是先闪一下再动。`from` 只在动画期间生效 ⇒ 动画被取消/未加载时元素就是自然态(opacity 1),
72+
不存在「永久隐身」的失效模式。批 6 换 GSAP 时:改本规则或直接接管 `.ed-empty-enter` 元素,
73+
`.ed-empty` 本身保持不动(它是批 4 迁移的稳定锚点)。 */
74+
.ed-empty-enter {
75+
animation: ed-empty-in var(--ed-dur-micro, 120ms) var(--ed-ease, cubic-bezier(0.2, 0, 0, 1)) both;
76+
}
77+
78+
@keyframes ed-empty-in {
79+
from { opacity: 0; }
80+
to { opacity: 1; }
81+
}
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context EmptyState.test.tsx — L1 原语 `EmptyState` 的接口契约(批 0-D Task 10)。
4+
*
5+
* Why:本仓此前**没有空态组件** —— 等价物是散落在 **40 行 / 28 文件**的「暂无…」灰字
6+
* (recon §2.1:`暂无|还没有|尚无|空空|没有任何`),5 套空态并存、**首启路径无主行动按钮**
7+
* (规格 §5.1),且**无容器、无 `data-*` 锚点 ⇒ 无法挂入场动画**(recon §8.3)。⇒ 本文件钉的是
8+
* 「类名集合 + 三段槽位 + 主行动按钮走 `Button` + 入场接缝存在」,这四样是批 4 迁移 28 个文件
9+
* 与批 6 挂编排层动效的共同契约。
10+
*
11+
* 副作用:只读磁盘(同目录 `EmptyState.css` / `EmptyState.tsx` / `motion.css` 的文本),不修改任何文件。
12+
* 边界:① jsdom **不做样式级联** ⇒ `@keyframes` / `animation` 的效果无法行为级验证,只能守
13+
* **CSS 文本**(同 `Button.test.tsx` 思路),**不假装验证了视觉**;可行为级判定的是
14+
* 「渲染出真实 `<button>` + 类名 + 点击语义 + `aria-hidden`」这一侧。
15+
* ② 本仓未装 `jest-dom` / `user-event`(硬约束:不新增依赖)⇒ 断言用原生 DOM API。
16+
* ③ 走 `./index` 导入面(同 Text/Surface/Button 先例),顺带证明导出面可用。
17+
* ④ **内联 svg 探测串必须拼接**:批 0-B 的棘轮守卫对 `.tsx` 做**全文件文本扫描**
18+
* (`includes("<" + "svg")` 即命中)⇒ 本文件里若原样写出该字面量,会把自己变成「新增内联
19+
* svg 的文件」而让守卫红(同条纪律的另一面:jsdom 指令串也是全文件扫描,故只在首行出现)。
20+
*/
21+
import { readFileSync } from "node:fs";
22+
import { dirname, join } from "node:path";
23+
import { fileURLToPath } from "node:url";
24+
import { fireEvent, render } from "@testing-library/react";
25+
import { describe, expect, it, vi } from "vitest";
26+
import { EmptyState } from "./index";
27+
import type { EmptyStateAction } from "./index";
28+
29+
const HERE = dirname(fileURLToPath(import.meta.url));
30+
/** 判据前先剥注释(同 `style-seams.test.ts`):注释里提到 `infinite` / 颜色名不该让守卫误报 */
31+
const stripComments = (s: string): string => s.replace(/\/\*[\s\S]*?\*\//g, "");
32+
const readText = (file: string): string => readFileSync(join(HERE, file), "utf8").replace(/\r\n/g, "\n");
33+
34+
const EMPTY_CSS = stripComments(readText("EmptyState.css"));
35+
const EMPTY_TSX = readText("EmptyState.tsx");
36+
const MOTION_CSS = stripComments(readText("motion.css"));
37+
38+
/** 取一段**配对花括号**的块;缺块直接抛(比 `!` 更能定位「CSS 少写了哪条」)。
39+
必须是配对版而非「到第一个 `}`」:`@keyframes` 里嵌着 `from`/`to` 两个子块。 */
40+
function block(selector: string): string {
41+
const start = EMPTY_CSS.indexOf(selector);
42+
if (start < 0) throw new Error(`EmptyState.css 缺少:${selector}`);
43+
const open = EMPTY_CSS.indexOf("{", start);
44+
if (open < 0) throw new Error(`EmptyState.css 的规则未闭合:${selector}`);
45+
let depth = 0;
46+
for (let i = open; i < EMPTY_CSS.length; i += 1) {
47+
if (EMPTY_CSS[i] === "{") depth += 1;
48+
else if (EMPTY_CSS[i] === "}") {
49+
depth -= 1;
50+
if (depth === 0) return EMPTY_CSS.slice(start, i + 1);
51+
}
52+
}
53+
throw new Error(`EmptyState.css 的块未闭合:${selector}`);
54+
}
55+
56+
/** 契约名单:与 `EmptyState.css` 的规则一一对应(改名单必须同时改 CSS 与实现) */
57+
const CLASSES: readonly string[] = [
58+
"ed-empty",
59+
"ed-empty--compact",
60+
"ed-empty__icon",
61+
"ed-empty__title",
62+
"ed-empty__description",
63+
"ed-empty__secondary",
64+
"ed-empty-enter",
65+
];
66+
67+
const root = (container: HTMLElement): HTMLElement => {
68+
const el = container.firstElementChild;
69+
if (!el) throw new Error("EmptyState 没有渲染出任何元素");
70+
return el as HTMLElement;
71+
};
72+
const classesOf = (container: HTMLElement): string[] => root(container).className.split(/\s+/);
73+
const buttonOf = (container: HTMLElement): HTMLButtonElement | null => container.querySelector("button");
74+
75+
describe("EmptyState 默认契约(无 action / icon / description)", () => {
76+
it("默认 = div + `ed-empty ed-empty-enter`,类名与顺序逐字固定", () => {
77+
const { container } = render(<EmptyState title="还没有笔记" />);
78+
expect(root(container).tagName).toBe("DIV");
79+
expect(root(container).className).toBe("ed-empty ed-empty-enter");
80+
expect(root(container).textContent).toBe("还没有笔记");
81+
});
82+
83+
it("标题落在 `__title` 槽且走 `Text`(墨度与字阶由 Text 的类承载,空态自己不写排版)", () => {
84+
const el = root(render(<EmptyState title="还没有笔记" />).container).querySelector(".ed-empty__title");
85+
expect(el).not.toBeNull();
86+
expect(el?.className).toBe("ed-text ed-text--s3 ed-text--ink-1 ed-text--font-ui ed-empty__title");
87+
expect(el?.tagName).toBe("P");
88+
});
89+
90+
it("可选槽位缺省时**不产生**空壳(无 icon/description/secondary 元素,也不留空 div)", () => {
91+
const { container } = render(<EmptyState title="空空如也" />);
92+
expect(container.querySelector(".ed-empty__icon")).toBeNull();
93+
expect(container.querySelector(".ed-empty__description")).toBeNull();
94+
expect(container.querySelector(".ed-empty__secondary")).toBeNull();
95+
expect(root(container).children).toHaveLength(1);
96+
});
97+
98+
it("无 action 时不渲染任何 button(现状 28 文件的形态:只有灰字、没有出路)", () => {
99+
const { container } = render(<EmptyState title="暂无会话" description="去「课堂助手」开始实时捕获" />);
100+
expect(buttonOf(container)).toBeNull();
101+
});
102+
});
103+
104+
describe("EmptyState 描述走 Text(弱化墨度,ReactNode 原样渲染)", () => {
105+
it("description 落在 `__description` 槽:第 4 档字阶 + ink-3 墨度", () => {
106+
const el = root(render(<EmptyState title="t" description="d" />).container).querySelector(
107+
".ed-empty__description",
108+
);
109+
expect(el?.className).toBe("ed-text ed-text--s4 ed-text--ink-3 ed-text--font-ui ed-empty__description");
110+
expect(el?.tagName).toBe("P");
111+
});
112+
113+
it("description 是 ReactNode:嵌套元素不改写(引导语里的强调与快捷键提示都要能进来)", () => {
114+
const { container } = render(
115+
<EmptyState title="还没有对话" description={<>点 <strong>+</strong> 开始</>} />,
116+
);
117+
expect(container.querySelector(".ed-empty__description")?.textContent).toBe("点 + 开始");
118+
});
119+
});
120+
121+
describe("EmptyState 图标走图标层(不内联 svg)", () => {
122+
it("icon 渲染出图标层的 svg 元素:装饰性 aria-hidden + 尺寸 24 + `__icon` 槽", () => {
123+
const svg = root(render(<EmptyState title="t" icon="goals" />).container).querySelector("svg");
124+
expect(svg).not.toBeNull();
125+
expect(svg?.getAttribute("aria-hidden")).toBe("true");
126+
expect(svg?.getAttribute("role")).toBeNull();
127+
expect(svg?.getAttribute("width")).toBe("24");
128+
expect(svg?.getAttribute("height")).toBe("24");
129+
expect(svg?.getAttribute("class")).toBe("ed-empty__icon");
130+
expect(svg?.querySelectorAll("path, circle, rect").length).toBeGreaterThan(0);
131+
});
132+
133+
it("实现从图标层 import(`../icons`),且源码里没有内联 svg 字面量", () => {
134+
expect(EMPTY_TSX).toContain('from "../icons"');
135+
expect(EMPTY_TSX.includes("<" + "svg")).toBe(false);
136+
});
137+
138+
it("不 import 本层 barrel `./index`(组内互引用走相对文件路径,避免循环依赖)", () => {
139+
expect(EMPTY_TSX).not.toContain('from "./index"');
140+
expect(EMPTY_TSX).toContain('from "./Text"');
141+
expect(EMPTY_TSX).toContain('from "./Button"');
142+
});
143+
});
144+
145+
describe("EmptyState 主行动按钮(规格 §5.1:首启路径的落点)", () => {
146+
const ACTION: EmptyStateAction = { label: "新建第一篇笔记", onClick: () => undefined };
147+
148+
it("render 出**真实** `<button type=\"button\">`,且走 Button 的类(四态自动继承)", () => {
149+
const { container } = render(<EmptyState title="还没有笔记" action={ACTION} />);
150+
const btn = buttonOf(container);
151+
expect(btn?.tagName).toBe("BUTTON");
152+
expect(btn?.getAttribute("type")).toBe("button");
153+
expect(btn?.textContent).toBe("新建第一篇笔记");
154+
expect(btn?.className.split(/\s+/)).toEqual(
155+
expect.arrayContaining(["ed-btn", "ed-btn--primary", "ed-btn--md"]),
156+
);
157+
});
158+
159+
it("点击调用 action.onClick(恰好一次)", () => {
160+
const onClick = vi.fn();
161+
const { container } = render(<EmptyState title="t" action={{ label: "开始", onClick }} />);
162+
fireEvent.click(buttonOf(container) as HTMLButtonElement);
163+
expect(onClick).toHaveBeenCalledTimes(1);
164+
});
165+
166+
it("variant 透传给 Button(工具型空态可退到 ghost);缺省是 primary(空态的唯一出路)", () => {
167+
const ghost = buttonOf(
168+
render(<EmptyState title="t" action={{ ...ACTION, variant: "ghost" }} />).container,
169+
) as HTMLButtonElement;
170+
expect(ghost.className).toContain("ed-btn--ghost");
171+
expect(ghost.className).not.toContain("ed-btn--primary");
172+
expect(buttonOf(render(<EmptyState title="t" action={ACTION} />).container)?.className).toContain(
173+
"ed-btn--primary",
174+
);
175+
});
176+
177+
it("disabled 透传:原生 disabled 属性 + 点击不派发 onClick(不可逆动作进行中的空态)", () => {
178+
const onClick = vi.fn();
179+
const btn = buttonOf(
180+
render(<EmptyState title="t" action={{ label: "开始", onClick, disabled: true }} />).container,
181+
) as HTMLButtonElement;
182+
expect(btn.hasAttribute("disabled")).toBe(true);
183+
fireEvent.click(btn);
184+
expect(onClick).not.toHaveBeenCalled();
185+
});
186+
});
187+
188+
describe("EmptyState compact 与透传", () => {
189+
it("compact 缺省 false;开启后类集合精确为 `ed-empty ed-empty--compact ed-empty-enter`", () => {
190+
expect(classesOf(render(<EmptyState title="t" />).container)).not.toContain("ed-empty--compact");
191+
const { container } = render(<EmptyState title="t" compact />);
192+
expect(root(container).className).toBe("ed-empty ed-empty--compact ed-empty-enter");
193+
});
194+
195+
it("secondary ReactNode 落在 `__secondary` 槽(次要行动/说明与主行动按钮分开)", () => {
196+
const { container } = render(<EmptyState title="t" secondary={<span>按 ⌘K 搜索</span>} />);
197+
const slot = container.querySelector(".ed-empty__secondary");
198+
expect(slot?.textContent).toBe("按 ⌘K 搜索");
199+
});
200+
201+
it("testId 落到 data-testid;不传时不产生该属性(不留 `data-testid=\"undefined\"`)", () => {
202+
expect(root(render(<EmptyState title="t" testId="empty-notes" />).container).getAttribute("data-testid")).toBe(
203+
"empty-notes",
204+
);
205+
expect(root(render(<EmptyState title="t" />).container).hasAttribute("data-testid")).toBe(false);
206+
});
207+
208+
it("结构不变量:三段槽位各至多一个(不产生两档叠加态)", () => {
209+
const { container } = render(
210+
<EmptyState title="t" description="d" icon="notes" action={{ label: "a", onClick: () => undefined }} />,
211+
);
212+
for (const cls of ["ed-empty__icon", "ed-empty__title", "ed-empty__description"]) {
213+
expect(container.querySelectorAll(`.${cls}`), cls).toHaveLength(1);
214+
}
215+
});
216+
});
217+
218+
describe("EmptyState 反例守门(批 6 要一处改对所有地方)", () => {
219+
it("视觉**只走类**:根元素与按钮都不落内联 style", () => {
220+
const { container } = render(
221+
<EmptyState title="t" description="d" icon="notes" compact action={{ label: "a", onClick: () => undefined }} />,
222+
);
223+
expect(root(container).getAttribute("style")).toBeNull();
224+
expect(buttonOf(container)?.getAttribute("style")).toBeNull();
225+
});
226+
227+
it("渲染结果不含任何颜色字面量(墨度全部来自 --ed-* 变量)", () => {
228+
const { container } = render(<EmptyState title="t" icon="goals" action={{ label: "a", onClick: () => undefined }} />);
229+
expect(container.innerHTML).not.toMatch(/#[0-9a-fA-F]{3,8}/);
230+
expect(container.innerHTML).not.toMatch(/\b(?:rgb|hsl)a?\(/);
231+
});
232+
233+
it("CSS 零颜色字面量、无 z-index、不写媒体查询(reduced-motion 只由 motion.css 的唯一块覆盖)", () => {
234+
expect(EMPTY_CSS).not.toMatch(/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|hsl)a?\(/);
235+
expect(EMPTY_CSS).not.toMatch(/z-index/);
236+
expect(EMPTY_CSS).not.toContain("@media");
237+
expect(MOTION_CSS.slice(MOTION_CSS.indexOf("@media (prefers-reduced-motion"))).toContain(".ed-empty");
238+
});
239+
240+
it("盒模型口径由基类自带且只声明一次(本仓无全局 CSS reset,空态不声明就没人声明)", () => {
241+
expect(block(".ed-empty {")).toContain("box-sizing: border-box");
242+
expect(EMPTY_CSS.match(/box-sizing\s*:/g)).toHaveLength(1);
243+
});
244+
});
245+
246+
describe("EmptyState 入场接缝(批 6 换编排层动效的唯一挂点)", () => {
247+
it("`.ed-empty-enter` 提供一次性的透明度入场,时长取 `--ed-dur-micro`(不新造时长)", () => {
248+
const enter = block(".ed-empty-enter {");
249+
expect(enter).toContain("animation:");
250+
expect(enter).toContain("ed-empty-in");
251+
expect(enter).toContain("var(--ed-dur-micro, 120ms)");
252+
// `both`:批 6 给编排层加 stagger 延迟时,元素在延迟期仍是「先隐后现」而不是闪一下
253+
expect(enter).toContain("both");
254+
});
255+
256+
it("`@keyframes ed-empty-in` 只做透明度 0 → 1(不做位移,环境层不抢注意力)", () => {
257+
const kf = block("@keyframes ed-empty-in");
258+
expect(kf).toMatch(/from\s*\{\s*opacity:\s*0;\s*\}/);
259+
expect(kf).toMatch(/to\s*\{\s*opacity:\s*1;\s*\}/);
260+
expect(EMPTY_CSS).not.toMatch(/translate/);
261+
});
262+
263+
it("空态自身**不做循环动画**(§8.6.1 第 1 条:环境层永远不抢注意力;循环留给 Loading/Probe)", () => {
264+
expect(EMPTY_CSS).not.toContain("infinite");
265+
});
266+
267+
it("本文件不定义任何 `--ed-*` 变量(时长/缓动的真源在 motion.css,批 6 一整块接管)", () => {
268+
expect(EMPTY_CSS.match(/--ed-[a-z0-9-]+\s*:/g)).toBeNull();
269+
});
270+
271+
it("实现产出的每个类在 CSS 里都有规则(类名拼错 = 静默无样式,编译期全绿)", () => {
272+
for (const cls of CLASSES) {
273+
expect(EMPTY_CSS, `EmptyState.css 缺少 .${cls} 的规则`).toContain(`.${cls} {`);
274+
}
275+
});
276+
});

0 commit comments

Comments
 (0)