@dsh-workbench/shared 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/dist/api/context-ref.d.ts +0 -12
  2. package/dist/api/context-ref.js +0 -40
  3. package/dist/api/facts.d.ts +0 -6
  4. package/dist/api/facts.js +0 -8
  5. package/dist/api/long-task.d.ts +33 -4
  6. package/dist/api/long-task.js +34 -2
  7. package/dist/components/ConfirmBar.css +26 -0
  8. package/dist/components/ConfirmBar.d.ts +36 -0
  9. package/dist/components/ConfirmBar.js +26 -0
  10. package/dist/components/Form.d.ts +7 -5
  11. package/dist/components/Form.js +5 -3
  12. package/dist/components/FormNodes.d.ts +17 -13
  13. package/dist/components/FormNodes.js +13 -5
  14. package/dist/components/FormRows.css +31 -0
  15. package/dist/components/FormTypes.d.ts +39 -14
  16. package/dist/components/LongTaskRow.css +52 -0
  17. package/dist/components/LongTaskRow.d.ts +18 -2
  18. package/dist/components/LongTaskRow.js +33 -5
  19. package/dist/components/MockStrip.css +11 -1
  20. package/dist/components/StateDot.d.ts +5 -0
  21. package/dist/components/StateDot.js +10 -0
  22. package/dist/config/constants.d.ts +2 -0
  23. package/dist/config/constants.js +2 -0
  24. package/dist/host/__tests__/fakes.d.ts +41 -0
  25. package/dist/host/__tests__/fakes.js +76 -0
  26. package/dist/host/__tests__/preflight-fixtures.d.ts +19 -0
  27. package/dist/host/__tests__/preflight-fixtures.js +47 -0
  28. package/dist/host/board-fact.d.ts +8 -0
  29. package/dist/host/board-fact.js +16 -0
  30. package/dist/host/browser-read.js +2 -1
  31. package/dist/host/context-inject.d.ts +24 -0
  32. package/dist/host/context-inject.js +74 -0
  33. package/dist/host/context-ref.d.ts +13 -0
  34. package/dist/host/context-ref.js +41 -0
  35. package/dist/host/diy-debug.d.ts +80 -0
  36. package/dist/host/diy-debug.js +88 -0
  37. package/dist/host/diy-record.d.ts +135 -0
  38. package/dist/host/diy-record.js +162 -0
  39. package/dist/host/events.d.ts +9 -0
  40. package/dist/host/events.js +70 -0
  41. package/dist/host/facts.d.ts +6 -0
  42. package/dist/host/facts.js +9 -0
  43. package/dist/host/git.js +1 -1
  44. package/dist/host/http.d.ts +2 -2
  45. package/dist/host/http.js +3 -3
  46. package/dist/host/long-task.d.ts +12 -0
  47. package/dist/host/long-task.js +34 -5
  48. package/dist/host/mount-units.d.ts +52 -0
  49. package/dist/host/mount-units.js +186 -0
  50. package/dist/host/opencli-runtime.js +1 -1
  51. package/dist/host/opencli-window.js +1 -1
  52. package/dist/host/opencli.js +2 -1
  53. package/dist/host/panel-state.d.ts +120 -0
  54. package/dist/host/panel-state.js +176 -0
  55. package/dist/host/plugin-source.js +1 -1
  56. package/dist/host/preflight.d.ts +58 -0
  57. package/dist/host/preflight.js +233 -0
  58. package/dist/host/probe-cache.d.ts +6 -0
  59. package/dist/host/probe-cache.js +19 -0
  60. package/dist/host/probe-libs.d.ts +45 -0
  61. package/dist/host/probe-libs.js +61 -0
  62. package/dist/host/probe-units.d.ts +17 -0
  63. package/dist/host/probe-units.js +102 -0
  64. package/dist/host/probe-verdict.d.ts +7 -0
  65. package/dist/host/probe-verdict.js +143 -0
  66. package/dist/host/shell-text.d.ts +13 -0
  67. package/dist/host/shell-text.js +20 -0
  68. package/dist/host/tool-approval.d.ts +1 -0
  69. package/dist/host/tool-scope.d.ts +32 -0
  70. package/dist/host/tool-scope.js +84 -0
  71. package/dist/host/unit-declaration.d.ts +65 -0
  72. package/dist/host/unit-declaration.js +213 -0
  73. package/dist/host/unit-face.d.ts +1 -0
  74. package/dist/host/unit-readiness.d.ts +7 -0
  75. package/dist/host/unit-readiness.js +5 -0
  76. package/dist/host/unit-registry.d.ts +9 -0
  77. package/dist/host/unit-registry.js +33 -0
  78. package/dist/host/version-range.d.ts +2 -0
  79. package/dist/host/version-range.js +73 -0
  80. package/dist/host.d.ts +7 -4
  81. package/dist/host.js +7 -4
  82. package/dist/i18n/en.js +6 -0
  83. package/dist/i18n/index.d.ts +7 -1
  84. package/dist/i18n/zh.d.ts +6 -0
  85. package/dist/i18n/zh.js +6 -0
  86. package/dist/index.d.ts +1 -4
  87. package/dist/index.js +1 -4
  88. package/dist/lib/board-fact.d.ts +0 -7
  89. package/dist/lib/board-fact.js +0 -15
  90. package/dist/lib/diy-record.d.ts +38 -1
  91. package/dist/lib/diy-record.js +66 -0
  92. package/dist/lib/text.d.ts +0 -13
  93. package/dist/lib/text.js +0 -20
  94. package/dist/testing/declaration.js +1 -1
  95. package/package.json +1 -1
  96. package/src/api/__tests__/facts.test.ts +0 -13
  97. package/src/api/__tests__/long-task.test.ts +41 -1
  98. package/src/api/context-ref.ts +0 -39
  99. package/src/api/facts.ts +0 -9
  100. package/src/api/long-task.ts +46 -4
  101. package/src/components/ConfirmBar.css +26 -0
  102. package/src/components/ConfirmBar.test.tsx +130 -0
  103. package/src/components/ConfirmBar.tsx +63 -0
  104. package/src/components/Form.test.tsx +15 -0
  105. package/src/components/Form.tsx +7 -5
  106. package/src/components/FormNodes.tsx +50 -26
  107. package/src/components/FormRows.css +31 -0
  108. package/src/components/FormRows.test.tsx +71 -1
  109. package/src/components/FormTypes.test.ts +31 -0
  110. package/src/components/FormTypes.ts +46 -15
  111. package/src/components/FormWiring.test.tsx +1 -0
  112. package/src/components/LongTaskRow.css +52 -0
  113. package/src/components/LongTaskRow.tsx +59 -4
  114. package/src/components/__tests__/long-task-row.test.ts +115 -0
  115. package/src/config/constants.ts +3 -0
  116. package/src/host/__tests__/board-fact.test.ts +47 -0
  117. package/src/{lib → host/__tests__}/diy-debug.test.ts +1 -1
  118. package/src/{lib → host/__tests__}/diy-record.test.ts +137 -1
  119. package/src/host/__tests__/facts.test.ts +13 -0
  120. package/src/host/__tests__/long-task.test.ts +306 -0
  121. package/src/{lib → host/__tests__}/panel-state.test.ts +1 -1
  122. package/src/host/__tests__/preflight-route.test.ts +33 -0
  123. package/src/host/__tests__/shell-text.test.ts +44 -0
  124. package/src/host/board-fact.ts +14 -0
  125. package/src/host/browser-read.ts +2 -1
  126. package/src/host/context-ref.ts +40 -0
  127. package/src/{lib → host}/diy-record.ts +95 -1
  128. package/src/host/facts.ts +10 -0
  129. package/src/host/git.ts +1 -1
  130. package/src/host/http.ts +4 -4
  131. package/src/host/long-task.ts +52 -7
  132. package/src/host/opencli-runtime.ts +1 -1
  133. package/src/host/opencli-window.ts +1 -1
  134. package/src/host/opencli.ts +2 -1
  135. package/src/host/plugin-source.ts +1 -1
  136. package/src/host/shell-text.ts +21 -0
  137. package/src/host/tool-approval.ts +1 -0
  138. package/src/{api → host}/unit-declaration.ts +1 -1
  139. package/src/host/unit-face.ts +1 -0
  140. package/src/host.ts +7 -4
  141. package/src/i18n/en.ts +7 -0
  142. package/src/i18n/zh.ts +6 -0
  143. package/src/index.ts +1 -4
  144. package/src/lib/board-fact.test.ts +1 -33
  145. package/src/lib/board-fact.ts +0 -13
  146. package/src/lib/text.test.ts +1 -43
  147. package/src/lib/text.ts +0 -22
  148. package/src/testing/declaration.ts +1 -1
  149. package/dist/lib/lint-probe.d.ts +0 -1
  150. package/dist/lib/lint-probe.js +0 -6
  151. /package/src/{api → host}/__tests__/unit-declaration.test.ts +0 -0
  152. /package/src/{lib → host}/diy-debug.ts +0 -0
  153. /package/src/{lib → host}/panel-state.ts +0 -0
package/dist/lib/text.js CHANGED
@@ -1,23 +1,3 @@
1
- /**
2
- * 把一个值裹成 shell 里的单引号字面量(值里的单引号一并转义),保证元字符按字面量传递。
3
- * @param value - 任意值;不是字符串的先 `String()`。
4
- * @returns 可安全拼进 `/bin/sh -c` 的一段文本 —— 含空格 / `$()` / `;` / 通配符的值都不会被当成语法。
5
- */
6
- export function quoteShellArg(value) {
7
- return `'${String(value).replace(/'/g, `'\\''`)}'`;
8
- }
9
- /**
10
- * 取一段文本的末尾若干行(如命令输出),顺带去掉首尾空白。
11
- * @param text - 原文;`null` / `undefined` 当空串。
12
- * @param lines - 最多留几行,默认 6;0 给全篇、负数从开头砍掉那么多行(都是 `slice` 语义),调用方传正数。
13
- * @returns 末尾若干行、去首尾空行后的文本;原文行数不够就全给,空输入给空串。
14
- */
15
- export function tailLines(text, lines = 6) {
16
- const all = String(text ?? '')
17
- .trim()
18
- .split('\n');
19
- return all.slice(-lines).join('\n');
20
- }
21
1
  function candidateStarts(raw) {
22
2
  const starts = [];
23
3
  for (let index = 0; index < raw.length; index += 1) {
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { readDeclaration } from '../api/unit-declaration.js';
3
+ import { readDeclaration } from '../host/unit-declaration.js';
4
4
  export function declarationOfPackage(pkg, packageJson) {
5
5
  const at = typeof packageJson === 'string' ? packageJson : fileURLToPath(packageJson);
6
6
  let parsed;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-workbench/shared",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "工作台与模块共享的纯代码与契约类型:类型、纯逻辑、哑组件、平台解析。宿主侧的东西从 /host 进(不影响浏览器产物)。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,10 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
2
2
  import {
3
3
  createFactChannel,
4
4
  factOfRoute,
5
- isFactScope,
6
5
  parseFactFrame,
7
- scopePrefixOf,
8
- unitFactScope,
9
6
  type FactFrame,
10
7
  type FactTransport,
11
8
  } from '../facts.js';
@@ -262,13 +259,3 @@ describe('factOfRoute', () => {
262
259
  expect(factOfRoute('capability')).toBe('capability');
263
260
  });
264
261
  });
265
-
266
- describe('unitFactScope', () => {
267
- it('命名空间 = 模块 id + `*`("这个模块的数据全作废"只写一个字符串)', () => {
268
- expect(unitFactScope('cmdb')).toBe('cmdb*');
269
- expect(isFactScope('cmdb*')).toBe(true);
270
- expect(isFactScope('*')).toBe(false);
271
- expect(isFactScope('cmdb/summary')).toBe(false);
272
- expect(scopePrefixOf('cmdb*')).toBe('cmdb');
273
- });
274
- });
@@ -1,5 +1,14 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { longTaskDot, longTaskPollMs, longTaskRunsOf, runsRouteOf } from '../long-task.js';
2
+ import {
3
+ longTaskDot,
4
+ longTaskPollMs,
5
+ longTaskRunsOf,
6
+ longTaskStopPress,
7
+ longTaskStopStage,
8
+ LONG_TASK_STOP_ARM_MS,
9
+ runsRouteOf,
10
+ stopRouteOf,
11
+ } from '../long-task.js';
3
12
 
4
13
  describe('runsRouteOf', () => {
5
14
  it('★ 事实名 = 路由名(三处同一个来源,改一处不可能漏另一处)', () => {
@@ -8,6 +17,14 @@ describe('runsRouteOf', () => {
8
17
  });
9
18
  });
10
19
 
20
+ describe('stopRouteOf(停止那条写路由)', () => {
21
+ it('★ 就是读路由 + `/stop`(读口与写口同一个单元名,不另造一套命名)', () => {
22
+ expect(stopRouteOf('cmdb')).toBe('cmdb/runs/stop');
23
+ expect(stopRouteOf('issues')).toBe('issues/runs/stop');
24
+ expect(stopRouteOf('cmdb')).toBe(`${runsRouteOf('cmdb')}/stop`);
25
+ });
26
+ });
27
+
11
28
  describe('longTaskDot', () => {
12
29
  it('★ 两个"还在等"的状态都转圈:等编排与等你登录是同一个信号', () => {
13
30
  expect(longTaskDot('running')).toBe('ongoing');
@@ -15,6 +32,29 @@ describe('longTaskDot', () => {
15
32
  expect(longTaskDot('ok')).toBe('done');
16
33
  expect(longTaskDot('failed')).toBe('error');
17
34
  });
35
+
36
+ it('★ 用户按停的那一趟是灰点:不是成功、不是出错,落进 idle', () => {
37
+ expect(longTaskDot('stopped')).toBe('idle');
38
+ });
39
+ });
40
+
41
+ describe('longTaskStopStage / longTaskStopPress(两按:第一次上膛,第二次才真发)', () => {
42
+ it('★ 三档:没上膛 / 上了膛 / 请求在飞;在飞压过一切', () => {
43
+ expect(longTaskStopStage({ armed: false, busy: false })).toBe('idle');
44
+ expect(longTaskStopStage({ armed: true, busy: false })).toBe('armed');
45
+ expect(longTaskStopStage({ armed: false, busy: true })).toBe('busy');
46
+ expect(longTaskStopStage({ armed: true, busy: true })).toBe('busy');
47
+ });
48
+
49
+ it('★ 按一下该干什么:idle → arm、armed → fire、busy → ignore(在飞时按不动)', () => {
50
+ expect(longTaskStopPress('idle')).toBe('arm');
51
+ expect(longTaskStopPress('armed')).toBe('fire');
52
+ expect(longTaskStopPress('busy')).toBe('ignore');
53
+ });
54
+
55
+ it('★ 上膛时限照 dsh:3 秒自己落膛', () => {
56
+ expect(LONG_TASK_STOP_ARM_MS).toBe(3000);
57
+ });
18
58
  });
19
59
 
20
60
  describe('longTaskRunsOf(这一读交回的"当前那些趟")', () => {
@@ -11,10 +11,6 @@ export interface ContextRef {
11
11
  readonly parts: readonly string[];
12
12
  }
13
13
 
14
- const MARKER = new RegExp(`@${CONTEXT_MARKER_NAME}:([a-z][a-z0-9-]*:[A-Za-z0-9._~%#/-]+)`, 'g');
15
-
16
- const TRAILING_PUNCTUATION = /[.,;:!?]+$/;
17
-
18
14
  /**
19
15
  * 把引用拼回标记体里的那一段文本(不含 @ 与标记名)。
20
16
  * @param ref 要拼的引用。
@@ -24,21 +20,6 @@ export function encodeRef(ref: ContextRef): string {
24
20
  return `${ref.kind}:${ref.parts.join('/')}`;
25
21
  }
26
22
 
27
- /**
28
- * 把 kind:part/part 文本读回引用。
29
- * @param text 待解析文本。
30
- * @returns 没冒号、冒号在首尾、种类不是小写字母开头、或冒号后为空时给 undefined;不抛异常。
31
- */
32
- export function decodeRef(text: string): ContextRef | undefined {
33
- const at = text.indexOf(':');
34
- if (at <= 0 || at === text.length - 1) return undefined;
35
- const kind = text.slice(0, at);
36
- if (!/^[a-z][a-z0-9-]*$/.test(kind)) return undefined;
37
- const parts = text.slice(at + 1).split('/').filter((part) => part !== '');
38
- if (parts.length === 0) return undefined;
39
- return { kind, parts };
40
- }
41
-
42
23
  /**
43
24
  * 拼出 @ 后面那半截(标记名加已编码文本),比较与拼接都用它。
44
25
  * @param encoded encodeRef 的结果。
@@ -56,23 +37,3 @@ export function markerBody(encoded: string): string {
56
37
  export function buildMarker(ref: ContextRef): string {
57
38
  return `@${markerBody(encodeRef(ref))}`;
58
39
  }
59
-
60
- /**
61
- * 从一段文本里按出现顺序捞出所有标记引用,同一个引用只留第一次。
62
- * @param text 任意文本;null / undefined 当空串。
63
- * @returns 解析不出来的标记会被丢掉,所以结果只含合法引用,可能为空数组。
64
- */
65
- export function parseMarkers(text: string): ContextRef[] {
66
- const seen = new Set<string>();
67
- const refs: ContextRef[] = [];
68
- for (const match of String(text ?? '').matchAll(MARKER)) {
69
- const raw = match[1].replace(TRAILING_PUNCTUATION, '');
70
- const ref = decodeRef(raw);
71
- if (ref === undefined) continue;
72
- const key = encodeRef(ref);
73
- if (seen.has(key)) continue;
74
- seen.add(key);
75
- refs.push(ref);
76
- }
77
- return refs;
78
- }
package/src/api/facts.ts CHANGED
@@ -12,15 +12,6 @@ export interface FactFrame {
12
12
  */
13
13
  export const FACT_SCOPE_SUFFIX = '*';
14
14
 
15
- /**
16
- * 由单元 id 拼出它那一族事实的范围名。
17
- * @param unit 单元 id,不带后缀。
18
- * @returns <unit>* —— 永远是范围名,不是某一条事实。
19
- */
20
- export function unitFactScope(unit: string): string {
21
- return `${unit}${FACT_SCOPE_SUFFIX}`;
22
- }
23
-
24
15
  /**
25
16
  * 判一个名字是不是范围查法,不是问这条事实存不存在。
26
17
  * @param name 事实名或范围名。
@@ -1,7 +1,7 @@
1
1
  /**
2
- * 一条长任务的四种态;needs-login 与 running 一样算还没结束。
2
+ * 一条长任务的五种态;needs-login 与 running 一样算还没结束,stopped 是用户自己按停的。
3
3
  */
4
- export type LongTaskStatus = 'running' | 'needs-login' | 'ok' | 'failed';
4
+ export type LongTaskStatus = 'running' | 'needs-login' | 'stopped' | 'ok' | 'failed';
5
5
 
6
6
  /**
7
7
  * 长任务步骤流里的一行:kind 定样式,登录那一步带 loginUrl 给用户去点。
@@ -21,6 +21,15 @@ export function runsRouteOf(unit: string): string {
21
21
  return `${unit}/runs`;
22
22
  }
23
23
 
24
+ /**
25
+ * 停止那条写路由(走 actions 通道的资源名):`<单元>/runs/stop`。
26
+ * @param unit 单元 id。
27
+ * @returns <unit>/runs/stop。
28
+ */
29
+ export function stopRouteOf(unit: string): string {
30
+ return `${unit}/runs/stop`;
31
+ }
32
+
24
33
  /**
25
34
  * 长任务读口回的载荷:一串 run,形状由模块自己定,外壳只透传。
26
35
  */
@@ -63,8 +72,8 @@ export function longTaskRunsOf<T>(state: {
63
72
 
64
73
  /**
65
74
  * 把长任务态折成状态点的那一档。
66
- * @param status 长任务的四种态。
67
- * @returns ongoing / done / error / idle;needs-login 也算进行中。
75
+ * @param status 长任务的五种态。
76
+ * @returns ongoing / done / error / idle;needs-login 也算进行中;`stopped` 落进 idle —— 灰点:不是成功、不是出错,是用户自己按停的。
68
77
  */
69
78
  export function longTaskDot(status: LongTaskStatus): 'ongoing' | 'done' | 'error' | 'idle' {
70
79
  if (status === 'ok') return 'done';
@@ -72,3 +81,36 @@ export function longTaskDot(status: LongTaskStatus): 'ongoing' | 'done' | 'error
72
81
  if (status === 'running' || status === 'needs-login') return 'ongoing';
73
82
  return 'idle';
74
83
  }
84
+
85
+ /**
86
+ * 停止钮的三档:没上膛 / 上了膛 / 请求在飞。
87
+ */
88
+ export type LongTaskStopStage = 'idle' | 'armed' | 'busy';
89
+
90
+ /**
91
+ * 算停止钮现在在哪一档。
92
+ * @param state armed = 已经按过第一下、在等第二下;busy = 停止请求已经在飞。
93
+ * @returns busy 优先(在飞时按不动),其次 armed,否则 idle。
94
+ */
95
+ export function longTaskStopStage(state: {
96
+ readonly armed: boolean;
97
+ readonly busy: boolean;
98
+ }): LongTaskStopStage {
99
+ if (state.busy) return 'busy';
100
+ return state.armed ? 'armed' : 'idle';
101
+ }
102
+
103
+ /**
104
+ * 按一下该干什么:第一次只上膛('arm'),上了膛再按才真发('fire'),在飞时按不动('ignore')。
105
+ * @param stage 现在这一档(先过 `longTaskStopStage`)。
106
+ * @returns 'arm' / 'fire' / 'ignore',调用点照它上膛、落膛加发请求、或什么都不做。
107
+ */
108
+ export function longTaskStopPress(stage: LongTaskStopStage): 'arm' | 'fire' | 'ignore' {
109
+ if (stage === 'busy') return 'ignore';
110
+ return stage === 'armed' ? 'fire' : 'arm';
111
+ }
112
+
113
+ /**
114
+ * 上膛后多久自己落膛(毫秒)。3 秒 = dsh 那行现在的取值。
115
+ */
116
+ export const LONG_TASK_STOP_ARM_MS = 3000;
@@ -0,0 +1,26 @@
1
+ .wb-confirmBar {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--wb-len-8);
5
+ width: 100%;
6
+ box-sizing: border-box;
7
+ padding: var(--wb-len-10) var(--wb-len-12);
8
+ border: 0.5px solid var(--dsw-alias-border-l3);
9
+ border-radius: var(--dsw-radius-md);
10
+ background: var(--dsw-alias-bg-layer-1);
11
+ }
12
+ .wb-confirmBarTitle {
13
+ margin: 0;
14
+ font-size: var(--dsw-font-xxs-12-font-size);
15
+ font-weight: 600;
16
+ line-height: var(--wb-line-18);
17
+ color: var(--dsw-alias-label-primary);
18
+ overflow-wrap: anywhere;
19
+ }
20
+ .wb-confirmBarHint {
21
+ margin: 0;
22
+ font-size: var(--dsw-font-xxxs-11-font-size);
23
+ line-height: var(--wb-line-16);
24
+ color: var(--dsw-alias-label-secondary);
25
+ overflow-wrap: anywhere;
26
+ }
@@ -0,0 +1,130 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { ConfirmBar, type ConfirmRow } from './ConfirmBar.js';
4
+ import { FormActions, type FormAction } from './FormActions.js';
5
+ import { Note } from './Note.js';
6
+ import { Rows } from './Rows.js';
7
+
8
+ interface AnyElement {
9
+ readonly type: unknown;
10
+ readonly props: Record<string, unknown>;
11
+ readonly key?: unknown;
12
+ }
13
+
14
+ function action(key: string, kind?: FormAction['kind']): FormAction {
15
+ return { key, label: key, kind, onClick: () => {} };
16
+ }
17
+
18
+ function row(key: string): ConfirmRow {
19
+ return { key, label: `名字-${key}`, value: `值-${key}` };
20
+ }
21
+
22
+ function bar(extra: Record<string, unknown> = {}): AnyElement {
23
+ return ConfirmBar({
24
+ title: '发布到 prod?',
25
+ actions: [action('publish', 'primary')],
26
+ ...extra,
27
+ }) as unknown as AnyElement;
28
+ }
29
+
30
+ function childrenOf(node: AnyElement): AnyElement[] {
31
+ return ((node.props['children'] as unknown[]) ?? []).filter(
32
+ (one): one is AnyElement => typeof one === 'object' && one !== null,
33
+ );
34
+ }
35
+
36
+ describe('确认条(契约类 wb-confirmBar)', () => {
37
+ it('★ 是一个 role="group" 的条,读屏名默认取字符串标题', () => {
38
+ const root = bar();
39
+
40
+ expect(root.props['role']).toBe('group');
41
+ expect(root.props['aria-label']).toBe('发布到 prod?');
42
+ expect(childrenOf(root)[0]?.props['className']).toBe('wb-confirmBarTitle');
43
+ });
44
+
45
+ it('显式给了 label 就用它;标题不是字符串又没给 label 就不设读屏名', () => {
46
+ expect(bar({ label: '发布确认' }).props['aria-label']).toBe('发布确认');
47
+ expect(bar({ title: 42 }).props['aria-label']).toBeUndefined();
48
+ });
49
+
50
+ it('busy 只喂 aria-busy,不改动作那一块', () => {
51
+ const root = bar({ busy: true, actions: [action('publish', 'primary'), action('cancel')] });
52
+
53
+ expect(root.props['aria-busy']).toBe(true);
54
+ expect(childrenOf(root)[1]?.props['actions']).toHaveLength(2);
55
+ });
56
+
57
+ it('★ 三件都借现成的:键值走 Rows、失败那句走 Note(tone=problem, role=alert)、动作走 FormActions', () => {
58
+ const actions = [action('publish', 'primary'), action('cancel')];
59
+ const root = bar({ rows: [row('env')], problem: '源上没有这个包', actions });
60
+ const children = childrenOf(root);
61
+
62
+ expect(children[0]?.props['className']).toBe('wb-confirmBarTitle');
63
+ expect(children[1]?.type).toBe(Rows);
64
+ expect(children[2]?.type).toBe(Note);
65
+ expect(children[2]?.props['tone']).toBe('problem');
66
+ expect(children[2]?.props['role']).toBe('alert');
67
+ expect(children[3]?.type).toBe(FormActions);
68
+ expect(children[3]?.props['actions']).toBe(actions);
69
+ });
70
+
71
+ it('★ 没给的那几块不占位:只要标题与动作区', () => {
72
+ expect(childrenOf(bar())).toHaveLength(2);
73
+ expect(childrenOf(bar({ rows: [] }))).toHaveLength(2);
74
+ expect(childrenOf(bar({ hint: '这一趟会重启一次' }))).toHaveLength(3);
75
+ });
76
+
77
+ it('★ 键值那几格走 Row 的 label / value 两格(key 交给 React 认列表),不是自己画两栏', () => {
78
+ const root = bar({ rows: [row('env'), row('branch')] });
79
+ const rowNodes = childrenOf(childrenOf(root)[1] as AnyElement);
80
+
81
+ expect(rowNodes.map((one) => one.props['label'])).toEqual(['名字-env', '名字-branch']);
82
+ expect(rowNodes.map((one) => one.props['value'])).toEqual(['值-env', '值-branch']);
83
+ expect(rowNodes.map((one) => one.key)).toEqual(['env', 'branch']);
84
+ });
85
+ });
86
+
87
+ const CSS = readFileSync(new URL('./ConfirmBar.css', import.meta.url), 'utf8');
88
+
89
+ function declarations(selector: string): string[] {
90
+ for (const block of CSS.split('}')) {
91
+ const [head, ...rest] = block.split('{');
92
+ if (rest.length === 0 || head === undefined) continue;
93
+ if (head.trim() !== selector) continue;
94
+ return rest
95
+ .join('{')
96
+ .split(';')
97
+ .map((one) => one.replace(/\s+/g, ' ').trim())
98
+ .filter((one) => one !== '');
99
+ }
100
+ throw new Error(`ConfirmBar.css 里没有这条规则:${selector}`);
101
+ }
102
+
103
+ describe('确认条的版式(ADR-0073 §2.2)', () => {
104
+ it('★ 占满自己那一格(有内边距与描边 ⇒ 配 border-box),别在横向父容器里退回内容宽', () => {
105
+ const rule = declarations('.wb-confirmBar');
106
+
107
+ expect(rule).toContain('width: 100%');
108
+ expect(rule).toContain('box-sizing: border-box');
109
+ });
110
+
111
+ it('★ 一档间距、描边与圆角都走令牌', () => {
112
+ const rule = declarations('.wb-confirmBar');
113
+
114
+ expect(rule).toContain('gap: var(--wb-len-8)');
115
+ expect(rule).toContain('padding: var(--wb-len-10) var(--wb-len-12)');
116
+ expect(rule).toContain('border: 0.5px solid var(--dsw-alias-border-l3)');
117
+ expect(rule).toContain('border-radius: var(--dsw-radius-md)');
118
+ });
119
+
120
+ it('★ 标题与说明的大小走令牌,标题比说明重一档', () => {
121
+ const title = declarations('.wb-confirmBarTitle');
122
+ const hint = declarations('.wb-confirmBarHint');
123
+
124
+ expect(title).toContain('font-size: var(--dsw-font-xxs-12-font-size)');
125
+ expect(title).toContain('line-height: var(--wb-line-18)');
126
+ expect(title).toContain('font-weight: 600');
127
+ expect(hint).toContain('font-size: var(--dsw-font-xxxs-11-font-size)');
128
+ expect(hint).toContain('line-height: var(--wb-line-16)');
129
+ });
130
+ });
@@ -0,0 +1,63 @@
1
+ import type { ReactNode } from '../lib/react.js';
2
+ import { h } from '../lib/react.js';
3
+ import { FormActions, type FormAction } from './FormActions.js';
4
+ import { Note } from './Note.js';
5
+ import { Row, Rows } from './Rows.js';
6
+ import './ConfirmBar.css';
7
+
8
+ /**
9
+ * `ConfirmBar` 的一格键值:`label` 在左、`value` 在右(走 `Rows` 的标签列)。
10
+ */
11
+ export interface ConfirmRow {
12
+ readonly key: string;
13
+ readonly label: ReactNode;
14
+ readonly value: ReactNode;
15
+ }
16
+
17
+ /**
18
+ * `ConfirmBar` 的契约:标题、说明、键值、失败那句与动作。
19
+ */
20
+ export interface ConfirmBarProps {
21
+ readonly title: ReactNode;
22
+ readonly hint?: ReactNode | undefined;
23
+ readonly rows?: readonly ConfirmRow[] | undefined;
24
+ readonly actions: readonly FormAction[];
25
+ readonly problem?: ReactNode | undefined;
26
+ readonly busy?: boolean | undefined;
27
+ readonly label?: string | undefined;
28
+ }
29
+
30
+ /**
31
+ * 「先问一句再动手」那一条:标题 + 说明 + 键值 + 失败那句 + 动作区,形状一次收口。
32
+ * **要不要问**归调用点(ADR-0054 §2 规则三),这一件只回答"问的时候长什么样"。
33
+ * @param props.title - 那一句问话("发布到 prod?")。
34
+ * @param props.hint - 标题下面那句说明或正文;不给不占位。
35
+ * @param props.rows - 键值两格(键值列宽与分隔线归 `Rows` / `Row`);不给或空数组不渲染。值那一格可以放控件。
36
+ * @param props.actions - 动作清单(归 `FormActions`:一个区一颗 `primary`、永远在右、窄了折行)。
37
+ * @param props.problem - 失败那句(走 `Note tone="problem"`,读屏是 `alert`);不给不占位。
38
+ * @param props.busy - 整条在忙(只喂 `aria-busy`;按钮自己的转圈与禁用归 `actions` 里那一颗)。
39
+ * @param props.label - 读屏的组名;不给时 `title` 是字符串就拿它顶上,两者都没有就不设。
40
+ * @returns 一个 `role="group"` 的条;`actions` 空数组时最后那一块不渲染。
41
+ */
42
+ export function ConfirmBar({ title, hint, rows, actions, problem, busy, label }: ConfirmBarProps) {
43
+ const name = label ?? (typeof title === 'string' ? title : undefined);
44
+ return (
45
+ <div className="wb-confirmBar" role="group" aria-label={name} aria-busy={busy}>
46
+ <p className="wb-confirmBarTitle">{title}</p>
47
+ {hint === undefined ? null : <p className="wb-confirmBarHint">{hint}</p>}
48
+ {rows === undefined || rows.length === 0 ? null : (
49
+ <Rows>
50
+ {rows.map((row) => (
51
+ <Row key={row.key} label={row.label} value={row.value} />
52
+ ))}
53
+ </Rows>
54
+ )}
55
+ {problem === undefined ? null : (
56
+ <Note tone="problem" role="alert">
57
+ {problem}
58
+ </Note>
59
+ )}
60
+ <FormActions actions={actions} />
61
+ </div>
62
+ );
63
+ }
@@ -20,6 +20,7 @@ function field(key: string, value: FormValue, validate?: FormField['validate']):
20
20
  key,
21
21
  label: key,
22
22
  value,
23
+ onChange: () => {},
23
24
  control: (binding) => `${key}:${binding.id}:${String(binding.invalid)}:${String(binding.disabled)}`,
24
25
  ...(validate === undefined ? {} : { validate }),
25
26
  };
@@ -402,6 +403,20 @@ describe('formRowsOf', () => {
402
403
  expect(propsOf(rows[0])['children']).toBe('env:wb-form-env:true:false');
403
404
  });
404
405
 
406
+ it('★ 这一格的 value / onChange 从字段配置原样进绑定(值只写一处)', () => {
407
+ const onChange = (): void => {};
408
+ const probe: FormField = {
409
+ key: 'env',
410
+ label: 'env',
411
+ value: 'prod',
412
+ onChange,
413
+ control: (binding) => `${String(binding.value)}:${String(binding.onChange === onChange)}`,
414
+ };
415
+ const rows = formRowsOf({ fields: [probe], problems: {}, showProblem: false, busy: false });
416
+
417
+ expect(propsOf(rows[0])['children']).toBe('prod:true');
418
+ });
419
+
405
420
  it('★ 还没提交过(showProblem: false):文案不画、invalid 也不给(done 之前一律 false)', () => {
406
421
  const rows = formRowsOf({ fields: [field('env', 'prod')], problems, showProblem: false, busy: false });
407
422
 
@@ -1,5 +1,5 @@
1
1
  import { h, useEffect, useRef, useState } from '../lib/react.js';
2
- import type { FormProps } from './FormTypes.js';
2
+ import type { FormProps, FormValues } from './FormTypes.js';
3
3
  import { FormActions } from './FormActions.js';
4
4
  import { FormRows } from './FormRows.js';
5
5
  import { Note } from './Note.js';
@@ -16,13 +16,15 @@ import './Form.css';
16
16
 
17
17
  /**
18
18
  * 整张表单:`fields` 进,行、错误、操作区、(被挡住的)提交都由它安排 —— 它自己一个值都不存,
19
- * 值仍归调用点的 model(`value` 只读给校验看)。
19
+ * 值仍归调用点(`value` 与 `onChange` 写在字段配置的同一格里,`Form` 只是把两者原样递给那一格的 `control`)。
20
+ * 写了 `Form<Values>` 就按 `Values` 把每一格的键与值类型钉住(`Values` 里会 `visible: false` 的键写成可选键);
21
+ * 不写就是默认档,键值都不钉。
20
22
  * 还没提交过时一格错都不显示;第一次提交之后每次渲染重算,改好了当场消。
21
23
  * 提交那一拍永远先算一遍:有错就不调 `onSubmit`,并把第一个有错的 `FormRow` 带进视口、聚焦它里面的控件。
22
24
  * 回车与点提交钮走的是同一条路:真 `<form>` 的隐式提交在**只有一个文本框**时也会 fire,所以 `busy` / `disabled` 时回车同样不提交。
23
25
  * 调用点只管数据与语义:行怎么排、错误摆哪、什么时候校验、按钮在哪,这里没有口子。
24
- * @param props.fields - 每格的键 / 标签 / 值 / 校验 / 控件;`visible: false` 的那格当它不存在(不渲染、不校验、不进 `values`)。
25
- * @param props.onSubmit - 校验全过的那一拍,回的是从 `fields` 现取的键值表;被挡住时不会被调。
26
+ * @param props.fields - 每格的键 / 标签 / 值 / 值怎么改(`onChange`)/ 校验 / 控件;`visible: false` 的那格当它不存在(不渲染、不校验、不进 `values`)。
27
+ * @param props.onSubmit - 校验全过的那一拍,回的是从 `fields` 现取的键值表(按 `visibleFieldsOf` 过滤过,不可见那格不在里面);被挡住时不会被调。
26
28
  * @param props.submit - 提交钮文案;`busyLabel` 是提交中换的那句,不给就沿用 `label`。
27
29
  * @param props.cancel - 取消钮;给了就排在提交钮左边,`busy` 时一起禁用。
28
30
  * @param props.extra - 其余动作(default / danger),排在最左,`busy` 时一起禁用。
@@ -31,7 +33,7 @@ import './Form.css';
31
33
  * @param props.problem - 整表级那句错(提交失败),落在行与操作区之间;不给就不画。
32
34
  * @returns 一个 `<form>`(`noValidate`:报错形状归 `FormRow`,不走浏览器的气泡)。
33
35
  */
34
- export function Form(props: FormProps) {
36
+ export function Form<Values extends object = FormValues>(props: FormProps<Values>) {
35
37
  const { fields, onSubmit, submit, cancel, extra, busy = false, disabled = false, problem } = props;
36
38
  const [done, setDone] = useState(false);
37
39
  const [reveal, setReveal] = useState(0);