Skip to content

Commit afd3543

Browse files
committed
feat(ui): add ViewSwitcher segmented control
1 parent d48fca5 commit afd3543

5 files changed

Lines changed: 458 additions & 0 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* ViewSwitcher.css —— 视图切换器(分段控件)的**类名与状态契约**(批 5 Task 5;C14① + R-2)。
3+
*
4+
* ★ Why 复用 `ed-btn` 基座命名空间(R-2 的三条理由,与 `ViewSwitcher.tsx` 文件头逐字同源):
5+
* ① 段控件本质是**成组的按钮** —— 元素就是 `<button>`,四态(hover / active / focus-visible /
6+
* disabled)与焦点环、按压微陷全部由 `.ed-btn` 免费提供,重写一份 = 两份必然漂移;
7+
* ② `motion-coverage.test.ts:113-123` 的「未登记基类」规则**逐字排除**「含 `--`」与
8+
* 「`<基类>-…`」⇒ 本文件的 `.ed-btn-group` / `.ed-btn--segment` 不触发它;`.ed-btn` 已在
9+
* `motion.css` 的 reduced-motion 名单里,而段类与基类**同在一个元素上** ⇒ 已被同一条规则
10+
* 覆盖(规格 §11-5 逐字:「修饰类(`--`)与子元素类不进名单 …… 它们与基类同在一个元素上、
11+
* 已被同一条规则覆盖」)⇒ 不动 `BASE_CLASSES`、不动 `motion.css`、不动 `style-seams.test.ts:190`
12+
* 的 12 个基类计数 ⇒ **本批对既有断言的改动数保持 0**(附 B.6 逐字成立);
13+
* ③ **诚实代价**:段控件的类名因此落在**按钮基座命名空间**里 —— 这是一次**语义折中**(一个
14+
* 「视图切换器」在类名上读起来像「一组按钮」),换来的是不改两处既有守卫。登记给 T18 的
15+
* 「诚实代价」段(控制方 R-2 裁决 ③ 逐字要求)。
16+
*
17+
* ★ 状态承载(为什么是属性选择器而不是状态类):选中态写在 `[aria-pressed="true"]` 上 ——
18+
* 语义(辅助技术读到的)与样式(眼睛看到的)**同源**,不可能漂移;先例 = `Button.css` 的
19+
* `.ed-btn[aria-disabled="true"]`(那里同样用属性而非 `--disabled` 修饰类)。
20+
* 代价:段元素的 `className` 在选中/未选中下**逐字相同**(`ed-btn ed-btn--segment`)——
21+
* 因此本批的类名枚举只有两项(见 `style-contract.test.ts` 的 `VIEW_SWITCHER_CLASSES`)。
22+
*
23+
* ★ 响应层接缝(规格 §8.6.1 第 3 条;C14① 逐字要求覆盖):三个属性各自**单属性、无时序、
24+
* `var(--ed-dur-micro, 120ms)`** —— 下一个输入直接接管当前过渡,不排队。批 6 的三档强度 /
25+
* 双基调 / GSAP 不在本文件(B9:本批交付的是结构与内容,手感在批 6)。
26+
*
27+
* 边界(与 `Button.css` 同口径):
28+
* ① 零颜色字面量(`style-seams.test.ts` 守卫①扫**全部** `primitives/*.css`)· 不写 `z-index`;
29+
* ② **不写 `@media`** —— reduced-motion 由 `motion.css` 的**唯一**块统一覆盖;重复写会让
30+
* 「一处改对所有地方」失效,也是本文件**不得新增未被覆盖动效类**的落点;
31+
* ③ 无 `@keyframes`、无 `animation`(段控件是响应层,不是环境层);
32+
* ④ 无任何 `translate*()` —— 按压微陷由 `.ed-btn:active` 提供,本文件不做位移(≤8px 上限自然成立)。
33+
*/
34+
35+
.ed-btn-group {
36+
display: inline-flex;
37+
gap: 2px;
38+
padding: 2px;
39+
border-radius: var(--ed-radius-control, 5px);
40+
background: var(--ed-bg-sunken);
41+
}
42+
43+
.ed-btn--segment {
44+
border: 1px solid transparent;
45+
background: transparent;
46+
color: var(--ed-ink-3);
47+
transition: background var(--ed-dur-micro, 120ms), color var(--ed-dur-micro, 120ms), border-color var(--ed-dur-micro, 120ms);
48+
}
49+
50+
.ed-btn--segment[aria-pressed="true"] {
51+
background: var(--ed-bg-raised);
52+
color: var(--ed-ink-1);
53+
border-color: var(--ed-border);
54+
}
55+
56+
.ed-btn--segment:hover:not([aria-pressed="true"]):not(:disabled) {
57+
background: var(--ed-bg-canvas);
58+
}
59+
60+
.ed-btn--segment:disabled {
61+
opacity: .6;
62+
cursor: default;
63+
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context ViewSwitcher.test.tsx —— `ViewSwitcher` 的**行为级契约**(批 5 Task 5;C14① + C15)。
4+
*
5+
* Why 每条判据都在这里(对计划 Step 4 的 W1–W7 逐条兑现,编号一一对应):
6+
* W1 选中态**恰好一段**且等于受控值(受控值不是"看着像",是可数出来的);
7+
* W2 点击 ⇒ 回调**恰 1 次**且实参 = 该段 key;点**当前**段 ⇒ **0 次**(C4 的阻断面:不许白跑 flushSave);
8+
* W3 方向键/Home/End **只移焦点**、不改值(键盘用户不该在"路过"时被切走视图);
9+
* W4 状态**只经类与 `aria-pressed`**,根/段上**没有** `style` 属性(ADR-033 §4:行内 style 不得覆盖类语义);
10+
* W5 `disabled` ⇒ 点击**不**回调且段带原生 `disabled`(C4 的异步守卫期间由宿主门控);
11+
* W6 容器的可访问名 + 每段的可访问名 + roving tabindex(§8.6.1 第 4 条:无障碍优先于「活」);
12+
* W7 图标走 `ui/icons` 注册表(渲染出 `svg` 且**装饰性**;类型层由 `tsc` 承担,见报告 V2);
13+
* W8(C14① 的 §8.6.1 第 3 条)响应层接缝 `--ed-dur-micro` 120ms,且**没有新增未被覆盖的动效类**。
14+
*
15+
* 副作用:只挂 React 树 + 只读同目录 `ViewSwitcher.css` / `motion.css`。边界:本仓未装 jest-dom ⇒ 原生 DOM API;
16+
* jsdom **不是真浏览器** —— 真实观感/焦点环像素/真实粘性与动效一律登记为「未验证」(见 T5 报告 §未验证)。
17+
*/
18+
import { readFileSync } from "node:fs";
19+
import { dirname, join } from "node:path";
20+
import { fileURLToPath } from "node:url";
21+
import { cleanup, fireEvent, render } from "@testing-library/react";
22+
import { afterEach, describe, expect, it, vi } from "vitest";
23+
import { ViewSwitcher } from "./ViewSwitcher";
24+
import type { ViewSwitcherOption } from "./ViewSwitcher";
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+
const VS_CSS = stripComments(read("ViewSwitcher.css"));
30+
const MOTION_CSS = stripComments(read("motion.css"));
31+
32+
/** 取某选择器的规则体(选择器 → 第一个 `}`)—— 防「写在别的规则里也算过」 */
33+
function bodyOf(css: string, sel: string): string {
34+
const at = css.indexOf(sel);
35+
return at < 0 ? "" : css.slice(at, css.indexOf("}", at));
36+
}
37+
38+
const OPTIONS: readonly ViewSwitcherOption[] = [
39+
{ key: "raw", label: "原文", icon: "sessions" },
40+
{ key: "tritrack", label: "三轨对齐", icon: "image" },
41+
{ key: "proof", label: "印样" },
42+
];
43+
const ARIA = "会话视图";
44+
45+
/** 渲染一次,返回分段数组 + 回调 spy(`cleanup` 由 afterEach 统一做) */
46+
function setup(value: string, disabled = false): { segs: HTMLButtonElement[]; onChange: ReturnType<typeof vi.fn> } {
47+
const onChange = vi.fn();
48+
const { container } = render(
49+
<ViewSwitcher options={OPTIONS} value={value} onChange={onChange} ariaLabel={ARIA} disabled={disabled} />,
50+
);
51+
const root = container.firstElementChild;
52+
if (!root) throw new Error("ViewSwitcher 没有渲染出任何元素");
53+
return { segs: [...root.querySelectorAll("button")] as HTMLButtonElement[], onChange };
54+
}
55+
afterEach(cleanup);
56+
57+
describe("W1 受控选中态:`aria-pressed=\"true\"` 恰一段,且等于 `value`", () => {
58+
it("三个受控值各渲染 3 段;`true` 恰一个且逐字落在 value 那段上", () => {
59+
for (const value of ["raw", "tritrack", "proof"]) {
60+
const { segs } = setup(value);
61+
expect(segs, `${value}:分段数`).toHaveLength(OPTIONS.length);
62+
const pressed = segs.filter((s) => s.getAttribute("aria-pressed") === "true");
63+
expect(pressed, `${value}:aria-pressed="true" 必须恰一个`).toHaveLength(1);
64+
expect(pressed[0].textContent, `${value}:选中的不是 value 那段`).toBe(OPTIONS.find((o) => o.key === value)?.label);
65+
// 其余必须是显式的 `"false"`(不是缺属性)—— 缺属性时辅助技术读到"未定义",CSS 也失去排除依据
66+
for (const s of segs.filter((x) => x !== pressed[0])) expect(s.getAttribute("aria-pressed")).toBe("false");
67+
cleanup();
68+
}
69+
});
70+
});
71+
72+
describe("W2 回调纪律:只在 key 真的变化时回调一次", () => {
73+
it("点第 k 段 ⇒ `onChange` 恰 1 次、实参 = 第 k 个 key", () => {
74+
const { segs, onChange } = setup("raw");
75+
fireEvent.click(segs[1]);
76+
expect(onChange).toHaveBeenCalledTimes(1);
77+
expect(onChange).toHaveBeenCalledWith("tritrack");
78+
fireEvent.click(segs[2]);
79+
expect(onChange).toHaveBeenCalledTimes(2);
80+
expect(onChange).toHaveBeenLastCalledWith("proof");
81+
});
82+
83+
it("点**当前**段 ⇒ 0 次(否则「切到当前视图」会白跑一次 `flushSave`,C4)", () => {
84+
const { segs, onChange } = setup("raw");
85+
fireEvent.click(segs[0]);
86+
expect(onChange).not.toHaveBeenCalled();
87+
});
88+
});
89+
90+
describe("W3 键盘:方向键 / Home / End 只移焦点,不改值(roving tabindex)", () => {
91+
it("ArrowRight 到下一段 · Home 到首段 · End 到末段,全程 `onChange` 0 次", () => {
92+
const { segs, onChange } = setup("raw");
93+
expect(fireEvent.keyDown(segs[0], { key: "ArrowRight" }), "处理器没拦下 ArrowRight(未 preventDefault)").toBe(false);
94+
expect(document.activeElement).toBe(segs[1]);
95+
fireEvent.keyDown(segs[1], { key: "End" });
96+
expect(document.activeElement).toBe(segs[2]);
97+
fireEvent.keyDown(segs[2], { key: "Home" });
98+
expect(document.activeElement).toBe(segs[0]);
99+
fireEvent.keyDown(segs[0], { key: "ArrowLeft" });
100+
expect(document.activeElement, "ArrowLeft 应环绕到末段").toBe(segs[2]);
101+
expect(onChange, "焦点移动不许触发回调(键盘路过 ≠ 切视图)").not.toHaveBeenCalled();
102+
});
103+
104+
it("反例守门:无关键不被吞掉(`preventDefault` 只对本组件处理的键生效)", () => {
105+
const { segs } = setup("raw");
106+
expect(fireEvent.keyDown(segs[0], { key: "a" }), "无关键被 preventDefault = 吞键").toBe(true);
107+
});
108+
});
109+
110+
describe("W4 状态只经类与 aria-pressed:根/段上不存在 `style` 属性(ADR-033 §4)", () => {
111+
it("每段 `className` 逐字为 `ed-btn ed-btn--segment`(选中/未选中**同一份** —— 状态不在类里)", () => {
112+
const { segs } = setup("tritrack");
113+
for (const s of segs) expect(s.className).toBe("ed-btn ed-btn--segment");
114+
});
115+
116+
it("根元素与每一段的 `getAttribute(\"style\")` 恒为 null(含阳性对照:探针能看见 style 属性)", () => {
117+
const onChange = vi.fn();
118+
const { container } = render(
119+
<ViewSwitcher options={OPTIONS} value="raw" onChange={onChange} ariaLabel={ARIA} disabled />,
120+
);
121+
const root = container.firstElementChild as HTMLElement;
122+
expect(root.getAttribute("style"), "根元素有行内 style").toBeNull();
123+
for (const s of root.querySelectorAll("button")) expect(s.getAttribute("style"), `段「${s.textContent}」有行内 style`).toBeNull();
124+
// 阳性对照(防真空):同一个探针喂一个**已知带 style** 的节点必须非 null,否则上面两条是"测空气"
125+
const probe = document.createElement("button");
126+
probe.setAttribute("style", "background: red");
127+
expect(probe.getAttribute("style"), "探针看不见已知的 style 属性 ⇒ 上面的 null 断言不承重").not.toBeNull();
128+
});
129+
});
130+
131+
describe("W5 disabled:点击不回调且段带原生 disabled(C4 的异步守卫期)", () => {
132+
it("disabled 时点击 0 次回调、每段 `disabled === true`;同一 spy 在启用态下**确实能**收到回调", () => {
133+
const off = setup("raw", true);
134+
fireEvent.click(off.segs[1]);
135+
fireEvent.click(off.segs[2]);
136+
expect(off.onChange, "disabled 期间回调了 ⇒ 异步守卫形同虚设").not.toHaveBeenCalled();
137+
for (const s of off.segs) {
138+
expect(s.disabled, "段必须带原生 disabled(移出 Tab 序 + 不派发 click)").toBe(true);
139+
expect(s.getAttribute("disabled")).not.toBeNull();
140+
}
141+
cleanup();
142+
// 阳性对照:证明上面的 spy 是通的(不是"spy 根本没接上"造成的假绿)
143+
const on = setup("raw");
144+
fireEvent.click(on.segs[1]);
145+
expect(on.onChange).toHaveBeenCalledTimes(1);
146+
});
147+
});
148+
149+
describe("W6 无障碍:容器名 + 段可访问名 + roving tabindex", () => {
150+
it("容器 `role=\"group\"` 且可访问名 = `ariaLabel`;每段可访问名 = `label`", () => {
151+
const onChange = vi.fn();
152+
const { container } = render(
153+
<ViewSwitcher options={OPTIONS} value="raw" onChange={onChange} ariaLabel={ARIA} />,
154+
);
155+
const root = container.firstElementChild as HTMLElement;
156+
expect(root.getAttribute("role")).toBe("group");
157+
expect(root.getAttribute("aria-label")).toBe(ARIA);
158+
for (const [i, s] of [...root.querySelectorAll("button")].entries()) expect(accName(s)).toBe(OPTIONS[i].label);
159+
});
160+
161+
it("可访问名探针自证:`aria-label` 优先 · 纯文本可用 · **只有装饰性 svg 时报空**", () => {
162+
const el = document.createElement("button");
163+
el.innerHTML = "<svg aria-hidden=\"true\"></svg>";
164+
expect(accName(el), "装饰性 svg 被算进名字 ⇒ 下面的名字断言不承重").toBe("");
165+
el.textContent = "原文";
166+
expect(accName(el)).toBe("原文");
167+
el.setAttribute("aria-label", "显式名");
168+
expect(accName(el)).toBe("显式名");
169+
});
170+
171+
it("roving tabindex:选中段 `0`、其余 `-1`;值对不上任何 key 时仍恰有一段可 Tab(不把键盘用户挡在外面)", () => {
172+
const { segs } = setup("tritrack");
173+
expect(segs.map((s) => s.tabIndex)).toEqual([-1, 0, -1]);
174+
cleanup();
175+
const stray = setup("已删除的视图 key");
176+
expect(stray.segs.map((s) => s.tabIndex), "脏值时全组 -1 = 整组 Tab 不到").toEqual([0, -1, -1]);
177+
expect(stray.segs.filter((s) => s.getAttribute("aria-pressed") === "true"), "脏值不许假报选中").toHaveLength(0);
178+
});
179+
});
180+
181+
/** 可访问名的**简化口径**(本仓未装 jest-dom / 计算式 a11y 库):`aria-label` 优先,否则文本子节点 */
182+
function accName(el: Element): string {
183+
const label = el.getAttribute("aria-label");
184+
if (label !== null && label.trim() !== "") return label.trim();
185+
return (el.textContent ?? "").replace(/\s+/g, " ").trim();
186+
}
187+
188+
describe("W7 图标走 `ui/icons`:渲染 `svg` 且为装饰性(不是 emoji、不污染可访问名)", () => {
189+
it("带 icon 的段内恰 1 个 `svg` 且 `aria-hidden=\"true\"`;段文本仍**逐字**等于 label", () => {
190+
const { segs } = setup("raw");
191+
expect(segs[0].querySelectorAll("svg")).toHaveLength(1);
192+
expect(segs[0].querySelector("svg")?.getAttribute("aria-hidden")).toBe("true");
193+
expect(segs[0].textContent).toBe("原文");
194+
// 负对照:不带 icon 的段必须 0 个 svg(否则上面的"恰 1 个"是个与入参无关的常数)
195+
expect(segs[2].querySelectorAll("svg"), "无 icon 的段渲染了 svg").toHaveLength(0);
196+
expect(segs[2].textContent).toBe("印样");
197+
});
198+
});
199+
200+
describe("W8 响应层接缝(§8.6.1 第 3 条):`--ed-dur-micro` 120ms + 不新增未被覆盖的动效类", () => {
201+
it("`.ed-btn--segment` 的三属性过渡都取 `var(--ed-dur-micro, 120ms)`,真源定值 = 120ms", () => {
202+
const body = bodyOf(VS_CSS, ".ed-btn--segment {");
203+
expect(body, "ViewSwitcher.css 缺 `.ed-btn--segment` 规则").not.toBe("");
204+
expect(body.match(/var\(--ed-dur-micro, 120ms\)/g) ?? [], "接缝不是三属性单值 120ms").toHaveLength(3);
205+
expect(body, "过渡缺 `transition:` 声明").toContain("transition:");
206+
expect(MOTION_CSS, "`--ed-dur-micro` 的真源定值不是 120ms").toContain("--ed-dur-micro: 120ms;");
207+
// 反例守门:同一条取段器喂一条**没有**接缝的合成规则必须取不到 ⇒ 上面三条不承重时会露馅
208+
expect(bodyOf(".ed-btn--segment { color: var(--ed-ink-3); }", ".ed-btn--segment {")).not.toContain("transition:");
209+
});
210+
211+
it("reduced-motion 由既有机制覆盖:段元素带 `ed-btn`(在 motion.css 名单里),且本文件无 `@media`/`@keyframes`", () => {
212+
const { segs } = setup("raw");
213+
for (const s of segs) expect(s.className.split(/\s+/), "段元素掉了基类 ⇒ motion.css 的规则打不到它").toContain("ed-btn");
214+
const block = MOTION_CSS.slice(MOTION_CSS.indexOf("@media (prefers-reduced-motion"));
215+
expect(block, "motion.css 的名单不含 `.ed-btn`(段类与基类同元素的前提被破坏)").toMatch(/\.ed-btn(?![\w-])/);
216+
expect(VS_CSS, "本文件写了 @media(reduced-motion 必须只有 motion.css 一处)").not.toContain("@media");
217+
expect(VS_CSS, "本文件写了动画落点(段控件是响应层,不是环境层)").not.toMatch(/(?:^|[;\s])(?:-(?:webkit|moz|ms|o)-)?animation(?:-name)?\s*:/);
218+
});
219+
});

0 commit comments

Comments
 (0)