Skip to content

Commit 54b9b93

Browse files
committed
feat(primitives): add placement and align slots
1 parent a9c63c0 commit 54b9b93

10 files changed

Lines changed: 412 additions & 13 deletions
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context EmptyState.align.test.tsx —— `EmptyState` 的**对齐档**契约(批 4 Task 3;B6)。
4+
*
5+
* Why(这条判据为什么存在):`EmptyStateProps` 逐字**不加** `className` / `style`(`EmptyState.tsx`
6+
* 边界①:空态是「一处的形态」),而现存 **44 处**空态普遍带内联排版 —— 其中 `textAlign` /
7+
* `alignItems` 是**基类形态(居中)表达不了**的那部分(实测:44 处里 `alignItems: flex-start`
8+
* 2 处、`textAlign: center` 2 处;见 T3 报告 §实测)。B6 因此只授权开**一个受控的排版档位/布局槽**
9+
* (`align`),**不许**放开裸 `className`(那会把「一处改对所有地方」重新打散)。
10+
*
11+
* 四条判据(对计划 Task 3 Step 1 的 ①–④ 逐条):
12+
* ① 默认 `center` 不产生修饰类 —— **精确类名断言**(先例 `EmptyState.test.tsx:79` 就是 `toBe`:
13+
* 默认档多一个字都会红);
14+
* ② `align="start"` ⇒ 类名精确为 `ed-empty ed-empty--start ed-empty-enter`;
15+
* ③ 根元素在两种档位下都**没有** `style` 属性 —— 防"用行内 style 绕过类语义"(ADR-033 §4);
16+
* ④ 图标 / 标题 / 说明 / 动作四个槽在两种对齐下都渲染(对齐档不改变槽位结构)。
17+
*
18+
* ② 另判 `EmptyState.css` 的修饰类**内容**(`align-items` + `text-align`)并带反例样本:
19+
* `style-contract.test.ts` 的全枚举只判「规则存在」,一条空的 `{}` 也能过 —— 那正是"新档位忘了写
20+
* 排版"的静默失效面。
21+
*
22+
* 副作用:只挂 React 树 + 只读同目录 `EmptyState.css`。边界:本仓未装 jest-dom ⇒ 原生 DOM API。
23+
*/
24+
import { readFileSync } from "node:fs";
25+
import { dirname, join } from "node:path";
26+
import { fileURLToPath } from "node:url";
27+
import { render } from "@testing-library/react";
28+
import { describe, expect, it } from "vitest";
29+
import { EmptyState } from "./EmptyState";
30+
import type { EmptyStateAlign } from "./EmptyState";
31+
32+
const HERE = dirname(fileURLToPath(import.meta.url));
33+
const read = (file: string): string => readFileSync(join(HERE, file), "utf8").replace(/\r\n/g, "\n");
34+
const stripComments = (s: string): string => s.replace(/\/\*[\s\S]*?\*\//g, "");
35+
36+
const CSS = stripComments(read("EmptyState.css"));
37+
/** 取 `.ed-empty--start` 的规则体(`选择器 {` 到第一个 `}`)—— 防「写在别的规则里也算过」 */
38+
const START_BODY = (() => {
39+
const at = CSS.indexOf(".ed-empty--start");
40+
return at < 0 ? "" : CSS.slice(at, CSS.indexOf("}", at));
41+
})();
42+
43+
const ACTION = { label: "新建第一篇笔记", onClick: (): void => {} } as const;
44+
45+
function renderAt(align?: EmptyStateAlign): HTMLElement {
46+
const { container } = render(
47+
<EmptyState
48+
title="还没有笔记"
49+
description="去「课堂助手」开始实时捕获"
50+
icon="notes"
51+
action={ACTION}
52+
align={align}
53+
/>,
54+
);
55+
const el = container.firstElementChild;
56+
if (!el) throw new Error("EmptyState 没有渲染出任何元素");
57+
return el as HTMLElement;
58+
}
59+
60+
describe("① 默认档 center = 基类形态(不加修饰类)", () => {
61+
it("不传 align ⇒ `ed-empty ed-empty-enter`,且不含 `--start`", () => {
62+
const root = renderAt();
63+
expect(root.className).toBe("ed-empty ed-empty-enter");
64+
expect(root.className).not.toContain("ed-empty--start");
65+
});
66+
});
67+
68+
describe("② align=\"start\" ⇒ 修饰类 + CSS 内容(带反例样本)", () => {
69+
it("类名精确为 `ed-empty ed-empty--start ed-empty-enter`(compact 之后、enter 之前)", () => {
70+
expect(renderAt("start").className).toBe("ed-empty ed-empty--start ed-empty-enter");
71+
});
72+
73+
it("`.ed-empty--start` 的规则体给出两项对齐;反例样本证明这两条各自能红", () => {
74+
expect(START_BODY, "EmptyState.css 缺 `.ed-empty--start` 规则").not.toBe("");
75+
expect(START_BODY, "缺横向对齐(align-items)").toContain("align-items: flex-start");
76+
expect(START_BODY, "缺文本对齐(text-align)").toContain("text-align: start");
77+
// 反例守门:一条只写了 align-items 的规则必须**不满足**上面第二条(否则该断言是永真)
78+
const mutated = ".ed-empty--start {\n align-items: flex-start;\n}";
79+
expect(mutated).toContain("align-items: flex-start");
80+
expect(mutated).not.toContain("text-align: start");
81+
});
82+
});
83+
84+
describe("③ 视觉只走类:根元素两种档位下都没有 style 属性(ADR-033 §4)", () => {
85+
it("`getAttribute(\"style\")` 恒为 null(默认档与 start 档)", () => {
86+
expect(renderAt().getAttribute("style")).toBeNull();
87+
expect(renderAt("start").getAttribute("style")).toBeNull();
88+
});
89+
});
90+
91+
describe("④ 四个槽位在两种对齐下都渲染(对齐档不改结构)", () => {
92+
it("icon / title / description / action 的锚点在 center 与 start 下都各出现一次", () => {
93+
for (const align of ["center", "start"] as const) {
94+
const root = renderAt(align);
95+
expect(root.querySelectorAll(".ed-empty__icon"), `${align}:图标槽`).toHaveLength(1);
96+
expect(root.querySelectorAll(".ed-empty__title"), `${align}:标题槽`).toHaveLength(1);
97+
expect(root.querySelectorAll(".ed-empty__description"), `${align}:说明槽`).toHaveLength(1);
98+
expect(root.querySelectorAll("button"), `${align}:主行动`).toHaveLength(1);
99+
expect(root.querySelector(".ed-empty__title")?.textContent).toBe("还没有笔记");
100+
}
101+
});
102+
});

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@
3838
padding: var(--ed-space-8, 8px);
3939
}
4040

41+
/* 对齐档 `start`(批 4 Task 3 · B6:44 处共用 ⇒ **只开这一个**受控排版档位,不放开裸 `className`):
42+
落在列表行 / 侧栏里的空态要与同级列表项左对齐,而不是把自己居中成一块告示牌。
43+
为什么两项都要:`align-items` 管 flex 子项(图标 / 标题 / 按钮)的横向落点,
44+
`text-align` 管多行文本自身的对齐 —— 只写一项时另一项仍是基类的 `center`(判据
45+
`EmptyState.align.test.tsx` ② 对两项各有一条断言,且带"只写一项"的反例样本)。 */
46+
.ed-empty--start {
47+
align-items: flex-start;
48+
text-align: start;
49+
}
50+
4151
/* 图标:弱化墨度(装饰性顿点,视觉焦点让给标题与主行动按钮);`flex: none` 防被压扁。
4252
颜色走 `currentColor`(图标层 `stroke="currentColor"`)⇒ 批 6 想换墨度只需改这一行。 */
4353
.ed-empty__icon {

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

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,11 @@
3232
* 组件本身不读 store、不发请求、不写磁盘、**不持有任何状态**(无 `useState`/`useEffect`)。
3333
*
3434
* 边界:
35-
* ① **契约就是 `EmptyStateProps` 这 7 个字段**(计划 Task 10 的 `Produces`):**不加** `className` /
36-
* `style` —— 空态是「一处的形态」,调用点若要微调排版就等于把批 6 的「一处改对所有地方」重新打散。
35+
* ① **契约就是 `EmptyStateProps` 这几个字段**(计划 Task 10 的 `Produces` 是 7 个;批 4 Task 3
36+
* 按 B6 加了第 8 个 `align`):**仍然不加** `className` / `style` —— 空态是「一处的形态」,
37+
* 调用点若要微调排版就等于把批 6 的「一处改对所有地方」重新打散。批 4 唯一开的口子是
38+
* **两档受控对齐**(`EmptyStateAlign`,见其 `@ai-context`:44 处共用 ⇒ 只开一个档位,
39+
* `fontSize`/墨度仍归 `Text`、空气仍归 `compact`)。
3740
* ② `action.disabled` 走 `Button` 的**原生 `disabled`**(移出 Tab 序、不派发 click)——语义是
3841
* 「这个出路暂时不可用」;「点了正在处理」那种不可用态用 `Button` 的 `busy`,属调用点自己的按钮。
3942
* ③ `description` 渲染成 `<p>` ⇒ **ReactNode 应当是行内内容**(`<p>` 不接受块级子元素);
@@ -59,6 +62,16 @@ import "./EmptyState.css";
5962
*/
6063
const ICON_SIZE = 24;
6164

65+
/**
66+
* `align` → **附加类**(`null` = 不加,即基类形态)。写成 `Record<…>` 而非内联三元:
67+
* **新增一档对齐而忘了给类会成为编译错误**(同 `Toast.tsx` 的 `PLACEMENT_CLASS` 契约完整性锚)。
68+
* 默认档不产生 `--center` 类:`EmptyState.test.tsx:79` 的精确类名断言要求默认形态逐字不变。
69+
*/
70+
const ALIGN_CLASS: Readonly<Record<EmptyStateAlign, string | null>> = {
71+
center: null,
72+
start: "ed-empty--start",
73+
};
74+
6275
/** 主行动按钮:空态的**唯一出路**(规格 §5.1「首启路径无主行动按钮」的落点) */
6376
export interface EmptyStateAction {
6477
/** 按钮文案(如「新建第一篇笔记」) */
@@ -71,6 +84,29 @@ export interface EmptyStateAction {
7184
readonly disabled?: boolean;
7285
}
7386

87+
/**
88+
* 两档**对齐**(受控排版档位,不是任意 `style`)—— 批 4 Task 3 按 B6 新增,`@ai-context`:
89+
*
90+
* Why 需要它:本原语的边界① 逐字**不加** `className` / `style`(空态是「一处的形态」,调用点微调
91+
* 排版等于把「一处改对所有地方」重新打散)。而现存 **44 处**空态(33 文件,规格 §5.1 逐字相等)
92+
* 普遍带内联排版:其中 `fontSize` / 墨度是 `Text` 字阶的权威、空气是 `compact` 的权威,
93+
* **唯独"整块左对齐"(`align-items: flex-start` / `text-align: start`)在基类(居中)里表达不了**。
94+
* 44 ≥ 3 ⇒ 按 B6「同一缺口 ≥3 个调用点共用 ⇒ 改原语」,**只开这一个**受控排版档位。
95+
*
96+
* 谁在用:`center`(默认)= 基类形态(居中的空态,今日 5 套空态的主流形态);`start` = 落在**列表行 /
97+
* 侧栏**里的空态(与同级列表项左对齐,不把自己居中成一块告示牌)。批 4 T13 的迁移任务逐处选档。
98+
*
99+
* 边界(逐条):
100+
* ① **只许这两档**,且**不放开**裸 `className` / `style`(边界① 逐字保留)—— 仍然表达不了的排版
101+
* (如某个 `fontSize: 11`)由 `Text` 字阶 / `compact` / 外层 `Surface` 组合吸收;
102+
* 若确有 ≥3 处共用的第三种形态 ⇒ **登记 + 报控制方按 B6 判**,不许就地加口子。
103+
* ② 实现**走类**(`.ed-empty--start`),默认档**不产生**修饰类 —— 精确类名断言
104+
* (`EmptyState.test.tsx:79` 既有的 `toBe`)要求默认档与今日形态逐字相同。
105+
* ③ 判据在 `EmptyState.align.test.tsx`(类名精确值 · 无 `style` 属性 · 四槽位在两种档位下都在),
106+
* 取值联合 ↔ CSS 类的全枚举锚在 `style-contract.test.ts`(`EmptyStateAlign` 一行)。
107+
*/
108+
export type EmptyStateAlign = "center" | "start";
109+
74110
export interface EmptyStateProps {
75111
/** 标题(**必填**):一句话说明「这里为什么是空的」。它同时是空态的主信息,不靠图标传达 */
76112
title: string;
@@ -84,6 +120,10 @@ export interface EmptyStateProps {
84120
secondary?: ReactNode;
85121
/** 紧凑形态(默认 `false`):给列表行 / 侧栏内的空态收空气,不改字阶 */
86122
compact?: boolean;
123+
/** **对齐档**,默认 `"center"`(居中,基类形态);`"start"` = 整块左对齐(`.ed-empty--start`),
124+
* 给落在列表行 / 侧栏里的空态用。**取值为闭集、且不放开 `className`/`style`** —— 为什么需要、
125+
* 边界与"还表达不了什么"见 `EmptyStateAlign` 的 `@ai-context`。实现走类 ⇒ 根元素无 `style`。 */
126+
align?: EmptyStateAlign;
87127
/** 落到 `data-testid`;不传时不产生该属性 */
88128
testId?: string;
89129
}
@@ -101,9 +141,12 @@ export function EmptyState({
101141
action,
102142
secondary,
103143
compact = false,
144+
align = "center",
104145
testId,
105146
}: EmptyStateProps): ReactElement {
106-
const cls = ["ed-empty", compact ? "ed-empty--compact" : null, "ed-empty-enter"].filter(Boolean).join(" ");
147+
const cls = ["ed-empty", compact ? "ed-empty--compact" : null, ALIGN_CLASS[align], "ed-empty-enter"]
148+
.filter(Boolean)
149+
.join(" ");
107150

108151
return (
109152
<div className={cls} data-testid={testId}>

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@
5050
.ed-toast--ok { border-left-color: var(--ed-ok); }
5151
.ed-toast--err { border-left-color: var(--ed-stamp); }
5252

53+
/* 位置档 ②(批 4 Task 3 · B6 特殊条款):贴在**应用导航条**下方。
54+
为什么是类而不是行内 `top`:`--ed-nav-h` 是壳层 token(`ui/tokens.css`),L1 原语读不到它,
55+
而调用点用行内 style 覆盖类语义被 ADR-033 §4 逐字禁止 ⇒ 具名档位 `ToastProps.placement="belowNav"`。
56+
兜底 56px 与 `TopBar.css` 的 `var(--ed-nav-h, 56px)` 同值(两处兜底漂移不会有任何报错)。
57+
⚠️ `bottom: auto` 是**必要配套**,不是冗余:基类声明了 `bottom`,固定定位元素同时给出 top 与
58+
bottom(高度 auto)会被**上下拉伸**成整屏高 —— 判据在 `Toast.placement.test.tsx` ④。 */
59+
.ed-toast--below-nav {
60+
top: calc(var(--ed-nav-h, 56px) + 8px);
61+
bottom: auto;
62+
}
63+
5364
.ed-toast[data-phase="enter"] { opacity: 0; transform: translateY(8px); } /* 位移 8px = 规格 §8.4 上限 */
5465
.ed-toast[data-phase="entered"] { opacity: 1; transform: translateY(0); }
5566
.ed-toast[data-phase="exit"] {
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context Toast.placement.test.tsx —— `Toast` 的**位置档**契约(批 4 Task 3;B6 特殊条款)。
4+
*
5+
* Why(这条判据为什么存在):`App.tsx:509` 的 AI toast 需要 `top: "calc(var(--ed-nav-h) + 8px)"`,
6+
* 而 `--ed-nav-h` 是**壳层 token**(`ui/tokens.css:73`),L1 原语**结构上读不到**它 ⇒ 迁移时唯一
7+
* 「改调用点」的走法是行内 `style` 覆盖类语义,而 ADR-033 §4 **逐字禁止**。B6 因此给了特殊条款:
8+
* 允许改原语,**只许加一个具名、有文档的 prop**(`placement`),且实现**走类**不走行内 `style`。
9+
*
10+
* 五条判据(对计划 Task 3 Step 1 的 ①–⑤ 逐条):
11+
* ① 默认档(`viewport`)**不产生**修饰类 —— 精确类名断言:默认档多一个类会立刻红
12+
* (先例 `EmptyState.test.tsx:79` 的 `toBe` 形态);
13+
* ② `placement="belowNav"` ⇒ 类名含 `ed-toast--below-nav`;
14+
* ③ **容器上没有行内 `top`**(两种档位都判)—— 防"用行内 style 绕过类语义"(ADR-033 §4);
15+
* ④ `Toast.css` 里该修饰类的 `top` **消费 `var(--ed-nav-h…)`**,且 `bottom: auto` 一并落位
16+
* —— **读文件判,不是读 DOM**(jsdom 不排版,DOM 侧永远看不到"其实没生效");
17+
* ⑤ 三档 `kind` × 两档 `placement` 的**笛卡尔积**类名逐字固定。
18+
*
19+
* 副作用:只挂 React 树 + 只读同目录 `Toast.css`(不写文件、不发请求)。
20+
* 边界:本仓未装 jest-dom ⇒ 一律 `getAttribute` / `className` / `style.top`;
21+
* ④ 判的是**文本**(`var()` 的真值解析在浏览器里,jsdom 不做)—— 故 ④ 额外带一个**反例样本**,
22+
* 证明该正则抓得到"写死 64px"的回归(否则它只是一条永真的字符串包含)。
23+
*/
24+
import { readFileSync } from "node:fs";
25+
import { dirname, join } from "node:path";
26+
import { fileURLToPath } from "node:url";
27+
import { cleanup, render, screen } from "@testing-library/react";
28+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
29+
import { Toast } from "./Toast";
30+
import type { ToastKind, ToastPlacement } from "./Toast";
31+
32+
const HERE = dirname(fileURLToPath(import.meta.url));
33+
const read = (file: string): string => readFileSync(join(HERE, file), "utf8").replace(/\r\n/g, "\n");
34+
const stripComments = (s: string): string => s.replace(/\/\*[\s\S]*?\*\//g, "");
35+
36+
/** 取 `.ed-toast--below-nav` 规则体(`选择器 {` 到第一个 `}`)—— 防「写在别的规则里也算过」 */
37+
const CSS = stripComments(read("Toast.css"));
38+
const BELOW_NAV_BODY = (() => {
39+
const at = CSS.indexOf(".ed-toast--below-nav");
40+
return at < 0 ? "" : CSS.slice(at, CSS.indexOf("}", at));
41+
})();
42+
43+
/** 位置档的 `top` 判据本体(抽成常量:反例样本要用**同一条**正则,否则证明不了它有区分度) */
44+
const TOKEN_TOP = /top:\s*calc\(\s*var\(--ed-nav-h/;
45+
46+
const noop = (): void => {};
47+
48+
function Host({ kind, placement }: { kind?: ToastKind; placement?: ToastPlacement }) {
49+
return (
50+
<Toast open message="已保存" kind={kind} placement={placement} onDismiss={noop} testId="toast" />
51+
);
52+
}
53+
54+
const toast = (): HTMLElement => screen.getByTestId("toast");
55+
const cls = (el: Element): string => el.getAttribute("class") ?? "";
56+
57+
beforeEach(() => {
58+
vi.useFakeTimers();
59+
});
60+
61+
afterEach(() => {
62+
cleanup();
63+
vi.useRealTimers();
64+
});
65+
66+
describe("① 默认档不产生修饰类(精确类名断言)", () => {
67+
it("不传 placement ⇒ `ed-toast ed-toast--info`,且不含 below-nav", () => {
68+
render(<Host />);
69+
expect(cls(toast())).toBe("ed-toast ed-toast--info");
70+
expect(cls(toast())).not.toContain("below-nav");
71+
});
72+
});
73+
74+
describe("② belowNav 档 ⇒ 修饰类", () => {
75+
it("placement=\"belowNav\" ⇒ 类名含 `ed-toast--below-nav`(基类与 kind 档仍在)", () => {
76+
render(<Host placement="belowNav" />);
77+
expect(cls(toast())).toContain("ed-toast--below-nav");
78+
expect(cls(toast())).toContain("ed-toast ed-toast--info");
79+
});
80+
});
81+
82+
describe("③ 不许用行内 style 绕过(ADR-033 §4)", () => {
83+
it("两种档位的容器都没有行内 `top`(jsdom 的 style.top 为空串)", () => {
84+
for (const placement of ["viewport", "belowNav"] as const) {
85+
render(<Host placement={placement} />);
86+
expect(toast().style.top, `${placement} 档写了行内 top ⇒ 位置语义脱离类`).toBe("");
87+
expect(toast().getAttribute("style") ?? "").not.toContain("top");
88+
cleanup();
89+
}
90+
});
91+
});
92+
93+
describe("④ 位置档的 top 消费壳层 token(读文件判;带反例样本)", () => {
94+
it("`.ed-toast--below-nav` 的 top 取 `var(--ed-nav-h…)`,且 bottom 归 auto(防固定定位被拉伸)", () => {
95+
expect(BELOW_NAV_BODY, "Toast.css 缺 `.ed-toast--below-nav` 规则").not.toBe("");
96+
expect(BELOW_NAV_BODY, "位置档的 top 必须消费壳层 token `--ed-nav-h`,不许写死数值").toMatch(TOKEN_TOP);
97+
expect(
98+
BELOW_NAV_BODY,
99+
"基类有 `bottom` ⇒ 本档不写 `bottom: auto` 时固定定位元素会被上下拉伸(观感全错)",
100+
).toContain("bottom: auto");
101+
});
102+
103+
it("判据有区分度(反例守门):写死 `top: 64px` 的样本必须被同一条正则判否", () => {
104+
const mutated = ".ed-toast--below-nav {\n top: 64px;\n bottom: auto;\n}";
105+
expect(mutated).not.toMatch(TOKEN_TOP);
106+
expect(mutated).toContain("bottom: auto"); // 反例只在 top 上变异 ⇒ 证明 ④ 的两条各自能红
107+
});
108+
});
109+
110+
describe("⑤ 三档 kind × 两档 placement 的笛卡尔积", () => {
111+
it("6 种组合的类名逐字固定(默认档不附加类,belowNav 只在末位多一个)", () => {
112+
for (const kind of ["info", "ok", "err"] as const) {
113+
for (const placement of ["viewport", "belowNav"] as const) {
114+
render(<Host kind={kind} placement={placement} />);
115+
const expected = ["ed-toast", `ed-toast--${kind}`, placement === "belowNav" ? "ed-toast--below-nav" : ""]
116+
.filter(Boolean)
117+
.join(" ");
118+
expect(cls(toast())).toBe(expected);
119+
cleanup();
120+
}
121+
}
122+
});
123+
});

0 commit comments

Comments
 (0)