Skip to content

Commit 9755d47

Browse files
committed
feat(ui): Surface 加受控槽与只出边框档
1 parent 2c3aa60 commit 9755d47

5 files changed

Lines changed: 87 additions & 13 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@
3838
.ed-surface--surface { background: var(--ed-bg-surface); } /* 卡片 / 列 / 阅读面(= 基类,显式写出让四档对称) */
3939
.ed-surface--raised { background: var(--ed-bg-raised); box-shadow: var(--ed-shadow-1); } /* 弹层 / 菜单 / 浮窗 */
4040

41+
/* 第五档底 `none`(批 7 T7 的「只出边框」档):**明确声明不出底** —— `transparent` 就是
42+
`background` 的初始值,与迁移前「style 里根本不写 background」逐字同义。
43+
Why 需要它:基类必出 `--ed-bg-surface`,而仓里有一批「整圈边框 + 无底色」的透明容器
44+
(批 4 B11 的 `no-background` 残留,批 7 T7 实测 21 处)—— 没有本档就只能留在行内 style 里。
45+
⚠️ 本档**不是**第五种底色,也不能反过来当「继承父层底」用:它就是不出底。
46+
规则写在四档之后 ⇒ 同特异性下后者胜,基类的 `background` 被本档覆盖。 */
47+
.ed-surface--none { background: transparent; }
48+
4149
/* 五档圆角:前四档的 token 真源是 `SCALE_TOKENS.radiusScale`(3/5/8/10),类名带 `r-` 前缀以免与
4250
同名的 level 档(如 `--surface`)撞在一起。
4351

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

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { Surface } from "./index";
2323
import type { SurfaceLevel, SurfaceRadius, SurfaceTag } from "./index";
2424

2525
/** 契约名单:与 `Surface.css` 的规则一一对应(改名单必须同时改 CSS 与测试) */
26-
const LEVELS: readonly SurfaceLevel[] = ["sunken", "canvas", "surface", "raised"];
26+
const LEVELS: readonly SurfaceLevel[] = ["sunken", "canvas", "surface", "raised", "none"];
2727
const RADIUS: readonly SurfaceRadius[] = ["stamp", "control", "panel", "overlay", "pill"];
2828
const TAGS: ReadonlyArray<readonly [SurfaceTag, string]> = [
2929
["div", "DIV"], ["section", "SECTION"], ["article", "ARTICLE"], ["aside", "ASIDE"], ["li", "LI"],
@@ -61,7 +61,7 @@ describe("Surface 默认契约", () => {
6161
});
6262

6363
describe("Surface 四档底 / 五档圆角映射到类", () => {
64-
it("4 档 level 各有对应类(sunken 输入槽 · canvas 纸 · surface 卡面 · raised 弹层)", () => {
64+
it("5 档 level 各有对应类(sunken 输入槽 · canvas 纸 · surface 卡面 · raised 弹层 · none 只出边框)", () => {
6565
for (const level of LEVELS) {
6666
const cls = classesOf(render(<Surface level={level}>x</Surface>).container);
6767
expect(cls, `档位 ${level}`).toContain(`ed-surface--${level}`);
@@ -154,6 +154,35 @@ describe("Surface 语义与透传", () => {
154154
});
155155
});
156156

157+
describe("Surface 受控槽(批 7 T7:`html` / `domId` —— 只开属性级的两个口,**不开 `...rest`**)", () => {
158+
it("`html` 走 `dangerouslySetInnerHTML`:宿主的 innerHTML 含该串,且**不**渲染 children", () => {
159+
const { container } = render(<Surface html={'<p class="x">已渲染串</p>'} />);
160+
expect(root(container).innerHTML).toBe('<p class="x">已渲染串</p>');
161+
expect(root(container).querySelector("p")?.className).toBe("x");
162+
});
163+
164+
it("`html` 与面本身的正交性:类名/圆角档照旧,`html` 不吞掉面的契约", () => {
165+
const cls = classesOf(render(<Surface level="none" radius="panel" html="<b>k</b>" />).container);
166+
expect(cls).toContain("ed-surface--none");
167+
expect(cls).toContain("ed-surface--r-panel");
168+
});
169+
170+
it("`domId` 落到宿主的 `id` ⇒ `document.getElementById` 找得到(锚点跳转的落点)", () => {
171+
render(<Surface domId="anchor-x">x</Surface>);
172+
expect(document.getElementById("anchor-x")?.textContent).toBe("x");
173+
});
174+
175+
it("`domId` 不给时不产生 `id` 属性(不留 `id=\"undefined\"`)", () => {
176+
expect(root(render(<Surface>x</Surface>).container).hasAttribute("id")).toBe(false);
177+
});
178+
179+
it("反例守门:`...rest` 没开 —— 未知属性既不被解构也不被渲染(编译期那一半由 tsc 探针 V2 守)", () => {
180+
const stray = { "data-foo": "1" };
181+
const { container } = render(<Surface {...stray}>x</Surface>);
182+
expect(root(container).hasAttribute("data-foo")).toBe(false);
183+
});
184+
});
185+
157186
describe("Surface 反例守门(批 6 要一处改对所有地方)", () => {
158187
it("底 / 边框 / 圆角 / 阴影 / 内边距 / 位移**只走类**,绝不落内联 style", () => {
159188
const el = root(

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

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,11 @@
2727
import type { CSSProperties, ReactElement, ReactNode } from "react";
2828
import "./Surface.css";
2929

30-
/** 四档底,与 `COLOR_TOKENS` 的 `bg-*` 四个颜色 token 一一对应(值只在 `ui/tokens.css`) */
31-
export type SurfaceLevel = "sunken" | "canvas" | "surface" | "raised";
30+
/** 底档:四档与 `COLOR_TOKENS` 的 `bg-*` 四个颜色 token 一一对应(值只在 `ui/tokens.css`);
31+
* 第五档 `none` 是**批 7 T7 的「只出边框」档** —— 它不是第五种底色,而是**明确声明不出底**
32+
* (`background: transparent` = 迁移前「不写 background」的逐字同义),供「整圈边框 + 无底色」的
33+
* 透明容器落进原语(批 4 B11 的 `no-background` 残留:`Surface` 基类必出底色)。 */
34+
export type SurfaceLevel = "sunken" | "canvas" | "surface" | "raised" | "none";
3235

3336
/** 五档圆角:四档与 `SCALE_TOKENS.radiusScale` 同序(3 / 5 / 8 / 10px),第五档 `pill` 是批 4 T17 加的**药丸**形态
3437
* (`radiusScale` 里还没有它 ⇒ `Surface.css` 的该档走 `var(--ed-radius-pill, 999px)` 兜底;补 token 见彼处注释) */
@@ -37,8 +40,9 @@ export type SurfaceRadius = "stamp" | "control" | "panel" | "overlay" | "pill";
3740
/** 可渲染的语义标签(不含 `button` / `a` —— 可交互元素属 `Button` 与调用点) */
3841
export type SurfaceTag = "div" | "section" | "article" | "aside" | "li";
3942

40-
export interface SurfaceProps {
41-
/** 底色档位,默认 `surface`(卡片 / 列 / 阅读面) */
43+
/** 面的**自有属性**(不含内容面 —— 内容的二选一由 `SurfaceProps` 的联合表达) */
44+
interface SurfaceOwnProps {
45+
/** 底色档位,默认 `surface`(卡片 / 列 / 阅读面);`none` = **只出边框、不出底色**(批 7 T7) */
4246
level?: SurfaceLevel;
4347
/** 圆角档位,默认 `panel`(8px) */
4448
radius?: SurfaceRadius;
@@ -56,9 +60,30 @@ export interface SurfaceProps {
5660
style?: CSSProperties;
5761
/** 落到 `data-testid`;不传时不产生该属性 */
5862
testId?: string;
59-
children: ReactNode;
63+
/**
64+
* **受控槽**:宿主元素的 `id`(供锚点跳转,如 `SessionScreenCards` 的 `ocr-<会话>-<ms>`)。
65+
* Why 用受控槽而不是 `...rest`:`Surface` 是「面」的唯一出口,DOM 属性面一旦放开就再也收不回
66+
* (ADR-033 §4 + 批 7 §C9.4 的裁决)—— 只把**已点名的**两个属性开口。
67+
*/
68+
domId?: string;
6069
}
6170

71+
/**
72+
* `Surface` 的属性。**内容面是联合类型**:`children`(常规)与 `html`(受控槽,走
73+
* `dangerouslySetInnerHTML`)**互斥** —— 两者都给 = TS2353/TS2322,不是运行期才炸。
74+
*/
75+
export type SurfaceProps =
76+
| (SurfaceOwnProps & {
77+
readonly children: ReactNode;
78+
/** 受控槽:把**已渲染的 HTML 串**交给 `dangerouslySetInnerHTML`(与 `children` 互斥) */
79+
readonly html?: never;
80+
})
81+
| (SurfaceOwnProps & {
82+
readonly children?: never;
83+
/** 受控槽:把**已渲染的 HTML 串**交给 `dangerouslySetInnerHTML`(与 `children` 互斥) */
84+
readonly html: string;
85+
});
86+
6287
/**
6388
* 渲染一个「面」。
6489
*
@@ -75,6 +100,8 @@ export function Surface({
75100
className,
76101
style,
77102
testId,
103+
domId,
104+
html,
78105
children,
79106
}: SurfaceProps): ReactElement {
80107
const cls = [
@@ -90,8 +117,16 @@ export function Surface({
90117
.join(" ");
91118

92119
const Tag = as;
120+
// 两个内容分支**分开写**:`children` 与 `dangerouslySetInnerHTML` 同时出现会被 React 抛错,
121+
// 而联合类型已在编译期禁止「两者都给」;分开写让「运行期只可能走一支」一眼可见。
122+
if (html !== undefined) {
123+
return (
124+
<Tag className={cls} style={style} data-testid={testId} id={domId}
125+
dangerouslySetInnerHTML={{ __html: html }} />
126+
);
127+
}
93128
return (
94-
<Tag className={cls} style={style} data-testid={testId}>
129+
<Tag className={cls} style={style} data-testid={testId} id={domId}>
95130
{children}
96131
</Tag>
97132
);

‎app/src/ui/primitives/style-contract.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ const LEVEL_CLASS: Record<SurfaceLevel, string> = {
104104
canvas: "ed-surface--canvas",
105105
surface: "ed-surface--surface",
106106
raised: "ed-surface--raised",
107+
// 批 7 T7:「只出边框」档(透明边框容器的落点)⇒ 底档 4 → 5
108+
none: "ed-surface--none",
107109
};
108110
const RADIUS_CLASS: Record<SurfaceRadius, string> = {
109111
stamp: "ed-surface--r-stamp",
@@ -163,7 +165,7 @@ const CONTRACTS: readonly UnionContract[] = [
163165
{ union: "TextTone", css: "Text.css", members: TONE_CLASS, expected: 9 },
164166
{ union: "TextSize", css: "Text.css", members: SIZE_CLASS, expected: 6 },
165167
{ union: "TextFont", css: "Text.css", members: FONT_CLASS, expected: 3 },
166-
{ union: "SurfaceLevel", css: "Surface.css", members: LEVEL_CLASS, expected: 4 },
168+
{ union: "SurfaceLevel", css: "Surface.css", members: LEVEL_CLASS, expected: 5 },
167169
{ union: "SurfaceRadius", css: "Surface.css", members: RADIUS_CLASS, expected: 5 },
168170
{ union: "ButtonVariant", css: "Button.css", members: VARIANT_CLASS, expected: 3 },
169171
{ union: "ButtonSize", css: "Button.css", members: BTN_SIZE_CLASS, expected: 3 },

‎app/src/ui/primitives/style-seams.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,11 @@ describe("Text 类名 ↔ CSS 规则一致(类名拼错 = 静默无样式)",
8888
});
8989
});
9090

91-
/** `Surface.tsx` 产出的 13 个类名(与 `Surface.test.tsx` 的名单同源) */
91+
/** `Surface.tsx` 产出的 14 个类名(与 `Surface.test.tsx` 的名单同源) */
9292
const SURFACE_CLASSES: readonly string[] = [
9393
"ed-surface",
94-
...["sunken", "canvas", "surface", "raised"].map((l) => `ed-surface--${l}`),
95-
// 批 4 T17:`pill` 是 B17 第 2 条加的药丸档(切片内 `borderRadius: 999` 实测 3 处 ⇒ ≥3)⇒ 12 → 13
94+
// 批 7 T7:底档第五档 `none`(只出边框)⇒ 13 → 14;批 4 T17:`pill` 药丸档(切片内 `borderRadius: 999` 实测 3 处 ⇒ ≥3)⇒ 12 → 13
95+
...["sunken", "canvas", "surface", "raised", "none"].map((l) => `ed-surface--${l}`),
9696
...["stamp", "control", "panel", "overlay", "pill"].map((r) => `ed-surface--r-${r}`),
9797
"ed-surface--bordered",
9898
"ed-surface--interactive",
@@ -118,7 +118,7 @@ describe("Surface 盒子契约(控制方 2026-09-11 裁决 · 类名 ↔ CSS
118118

119119
it("Surface.tsx 产出的每个类在 Surface.css 里都有规则;且零颜色字面量、无 z-index", () => {
120120
const clean = stripComments(SURFACE_CSS);
121-
expect(SURFACE_CLASSES).toHaveLength(13);
121+
expect(SURFACE_CLASSES).toHaveLength(14);
122122
for (const cls of SURFACE_CLASSES) {
123123
expect(clean, `Surface.css 缺少 .${cls} 的规则`).toContain(`.${cls} {`);
124124
}

0 commit comments

Comments
 (0)