@dsh-workbench/shared 0.3.4 → 0.3.6
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.
- package/dist/api/blocker-table.d.ts +13 -0
- package/dist/api/blocker-table.js +13 -0
- package/dist/api/blocker.d.ts +6 -0
- package/dist/api/context-ref.d.ts +31 -0
- package/dist/api/context-ref.js +28 -0
- package/dist/api/dsh-client.d.ts +99 -0
- package/dist/api/dsh.d.ts +55 -3
- package/dist/api/facts.d.ts +42 -0
- package/dist/api/facts.js +33 -0
- package/dist/api/host-write.d.ts +18 -0
- package/dist/api/host-write.js +12 -0
- package/dist/api/long-task.d.ts +66 -1
- package/dist/api/long-task.js +59 -0
- package/dist/api/unit-board.d.ts +25 -0
- package/dist/api/unit-board.js +16 -0
- package/dist/api/unit-declaration.d.ts +36 -0
- package/dist/api/unit-declaration.js +24 -0
- package/dist/api/unit.d.ts +32 -0
- package/dist/api/unit.js +5 -0
- package/dist/api/workbench-client.d.ts +25 -0
- package/dist/api/workbench-client.js +13 -0
- package/dist/api/workbench-contract.d.ts +12 -0
- package/dist/components/Activity.d.ts +33 -0
- package/dist/components/Activity.js +24 -0
- package/dist/components/ApprovalDetail.d.ts +12 -0
- package/dist/components/ApprovalDetail.js +9 -0
- package/dist/components/ApprovalPlan.d.ts +14 -0
- package/dist/components/ApprovalPlan.js +8 -0
- package/dist/components/Card.d.ts +13 -0
- package/dist/components/Card.js +10 -0
- package/dist/components/CardAnchor.d.ts +14 -0
- package/dist/components/CardAnchor.js +11 -0
- package/dist/components/CardBoundary.d.ts +20 -0
- package/dist/components/CardBoundary.js +14 -0
- package/dist/components/CardHead.d.ts +13 -0
- package/dist/components/CardHead.js +10 -0
- package/dist/components/ChoiceGroup.d.ts +24 -0
- package/dist/components/ChoiceGroup.js +12 -0
- package/dist/components/Field.d.ts +10 -0
- package/dist/components/Field.js +7 -0
- package/dist/components/Form.css +6 -0
- package/dist/components/Form.d.ts +20 -0
- package/dist/components/Form.js +58 -0
- package/dist/components/FormActions.d.ts +14 -0
- package/dist/components/FormActions.js +5 -0
- package/dist/components/FormNodes.d.ts +103 -0
- package/dist/components/FormNodes.js +153 -0
- package/dist/components/FormRows.css +31 -0
- package/dist/components/FormRows.d.ts +20 -0
- package/dist/components/FormRows.js +14 -0
- package/dist/components/FormTypes.d.ts +59 -0
- package/dist/components/FormTypes.js +1 -0
- package/dist/components/Link.d.ts +12 -0
- package/dist/components/Link.js +9 -0
- package/dist/components/LongTaskRow.css +52 -0
- package/dist/components/LongTaskRow.d.ts +35 -0
- package/dist/components/LongTaskRow.js +45 -4
- package/dist/components/Note.d.ts +11 -0
- package/dist/components/Note.js +8 -0
- package/dist/components/ResourceStates.d.ts +25 -0
- package/dist/components/ResourceStates.js +22 -0
- package/dist/components/Rows.d.ts +17 -0
- package/dist/components/Rows.js +11 -0
- package/dist/components/SaveBar.d.ts +12 -0
- package/dist/components/SaveBar.js +9 -0
- package/dist/components/SelectField.d.ts +26 -0
- package/dist/components/SelectField.js +22 -0
- package/dist/components/SelectFieldNodes.d.ts +24 -0
- package/dist/components/SelectFieldNodes.js +24 -0
- package/dist/components/SettingsSection.d.ts +11 -0
- package/dist/components/SettingsSection.js +8 -0
- package/dist/components/Spinner.d.ts +9 -0
- package/dist/components/Spinner.js +6 -0
- package/dist/components/Stack.d.ts +8 -0
- package/dist/components/Stack.js +5 -0
- package/dist/components/StateBlock.d.ts +13 -0
- package/dist/components/StateBlock.js +10 -0
- package/dist/components/TextField.d.ts +8 -0
- package/dist/components/TextField.js +5 -0
- package/dist/components/board-module.d.ts +30 -0
- package/dist/components/icons.d.ts +38 -0
- package/dist/components/icons.js +35 -0
- package/dist/config/constants.d.ts +42 -0
- package/dist/config/constants.js +42 -0
- package/dist/config/storage.d.ts +4 -0
- package/dist/config/storage.js +4 -0
- package/dist/host/long-task.d.ts +7 -0
- package/dist/host/long-task.js +31 -5
- package/dist/host/tool-approval.d.ts +1 -0
- package/dist/host/unit-face.d.ts +6 -1
- package/dist/i18n/en.d.ts +1 -0
- package/dist/i18n/en.js +10 -8
- package/dist/i18n/index.d.ts +13 -9
- package/dist/i18n/index.js +1 -0
- package/dist/i18n/merge.d.ts +5 -0
- package/dist/i18n/merge.js +2 -0
- package/dist/i18n/zh.d.ts +11 -8
- package/dist/i18n/zh.js +10 -8
- package/dist/index.d.ts +6 -2
- package/dist/index.js +6 -2
- package/dist/lib/blocker-action.d.ts +22 -0
- package/dist/lib/blocker-action.js +13 -0
- package/dist/lib/board-fact.d.ts +27 -0
- package/dist/lib/board-fact.js +18 -0
- package/dist/lib/busy.d.ts +10 -0
- package/dist/lib/busy.js +7 -0
- package/dist/lib/diy-debug.d.ts +42 -2
- package/dist/lib/diy-debug.js +19 -1
- package/dist/lib/diy-record.d.ts +41 -0
- package/dist/lib/diy-record.js +17 -0
- package/dist/lib/form-problem.d.ts +10 -0
- package/dist/lib/form-problem.js +10 -0
- package/dist/lib/panel-state.d.ts +60 -0
- package/dist/lib/panel-state.js +30 -0
- package/dist/lib/react.d.ts +39 -0
- package/dist/lib/react.js +36 -0
- package/dist/lib/text.d.ts +22 -0
- package/dist/lib/text.js +22 -0
- package/dist/lib/ui.d.ts +50 -0
- package/dist/lib/ui.js +41 -0
- package/package.json +1 -1
- package/src/api/__tests__/long-task.test.ts +41 -1
- package/src/api/blocker-table.ts +13 -0
- package/src/api/blocker.ts +6 -0
- package/src/api/context-ref.ts +31 -0
- package/src/api/dsh-client.ts +99 -0
- package/src/api/dsh.ts +56 -1
- package/src/api/facts.ts +42 -0
- package/src/api/host-write.ts +18 -0
- package/src/api/long-task.ts +79 -1
- package/src/api/unit-board.ts +25 -0
- package/src/api/unit-declaration.ts +36 -0
- package/src/api/unit.ts +32 -0
- package/src/api/workbench-client.ts +25 -0
- package/src/api/workbench-contract.ts +12 -0
- package/src/components/Activity.tsx +33 -0
- package/src/components/ApprovalDetail.tsx +12 -0
- package/src/components/ApprovalPlan.tsx +14 -0
- package/src/components/Card.tsx +13 -0
- package/src/components/CardAnchor.tsx +14 -0
- package/src/components/CardBoundary.tsx +20 -0
- package/src/components/CardHead.tsx +13 -0
- package/src/components/ChoiceGroup.tsx +24 -0
- package/src/components/Field.tsx +10 -0
- package/src/components/Form.css +6 -0
- package/src/components/Form.test.tsx +475 -0
- package/src/components/Form.tsx +78 -0
- package/src/components/FormActions.tsx +14 -0
- package/src/components/FormNodes.tsx +193 -0
- package/src/components/FormRows.css +31 -0
- package/src/components/FormRows.test.tsx +71 -1
- package/src/components/FormRows.tsx +20 -0
- package/src/components/FormTypes.ts +65 -0
- package/src/components/FormWiring.test.tsx +88 -0
- package/src/components/Link.tsx +12 -0
- package/src/components/LongTaskRow.css +52 -0
- package/src/components/LongTaskRow.tsx +76 -2
- package/src/components/Note.tsx +11 -0
- package/src/components/ResourceStates.tsx +25 -0
- package/src/components/Rows.tsx +17 -0
- package/src/components/SaveBar.tsx +12 -0
- package/src/components/SelectField.tsx +26 -0
- package/src/components/SelectFieldNodes.tsx +24 -0
- package/src/components/SettingsSection.tsx +11 -0
- package/src/components/Spinner.tsx +9 -0
- package/src/components/Stack.tsx +8 -0
- package/src/components/StateBlock.tsx +13 -0
- package/src/components/TextField.tsx +8 -0
- package/src/components/__tests__/long-task-row.test.ts +115 -0
- package/src/components/board-module.ts +30 -0
- package/src/components/icons.tsx +38 -0
- package/src/config/constants.ts +43 -0
- package/src/config/storage.ts +4 -0
- package/src/host/__tests__/long-task.test.ts +264 -0
- package/src/host/long-task.ts +43 -7
- package/src/host/tool-approval.ts +1 -0
- package/src/host/unit-face.ts +7 -1
- package/src/i18n/en.ts +11 -8
- package/src/i18n/index.ts +3 -0
- package/src/i18n/merge.ts +5 -0
- package/src/i18n/zh.ts +11 -8
- package/src/index.ts +6 -2
- package/src/lib/blocker-action.ts +22 -0
- package/src/lib/board-fact.ts +27 -0
- package/src/lib/busy.ts +10 -0
- package/src/lib/diy-debug.test.ts +2 -1
- package/src/lib/diy-debug.ts +44 -3
- package/src/lib/diy-record.ts +41 -0
- package/src/lib/form-problem.ts +10 -0
- package/src/lib/panel-state.ts +60 -0
- package/src/lib/react.ts +39 -0
- package/src/lib/text.ts +22 -0
- package/src/lib/ui.ts +50 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { h, useEffect, useRef, useState } from '../lib/react.js';
|
|
2
|
+
import type { FormProps } from './FormTypes.js';
|
|
3
|
+
import { FormActions } from './FormActions.js';
|
|
4
|
+
import { FormRows } from './FormRows.js';
|
|
5
|
+
import { Note } from './Note.js';
|
|
6
|
+
import {
|
|
7
|
+
formActionsOf,
|
|
8
|
+
formProblemsOf,
|
|
9
|
+
formRowsOf,
|
|
10
|
+
formSubmitOf,
|
|
11
|
+
reportDuplicateKeys,
|
|
12
|
+
visibleFieldsOf,
|
|
13
|
+
} from './FormNodes.js';
|
|
14
|
+
import { revealFirstProblem } from '../lib/form-problem.js';
|
|
15
|
+
import './Form.css';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 整张表单:`fields` 进,行、错误、操作区、(被挡住的)提交都由它安排 —— 它自己一个值都不存,
|
|
19
|
+
* 值仍归调用点的 model(`value` 只读给校验看)。
|
|
20
|
+
* 还没提交过时一格错都不显示;第一次提交之后每次渲染重算,改好了当场消。
|
|
21
|
+
* 提交那一拍永远先算一遍:有错就不调 `onSubmit`,并把第一个有错的 `FormRow` 带进视口、聚焦它里面的控件。
|
|
22
|
+
* 回车与点提交钮走的是同一条路:真 `<form>` 的隐式提交在**只有一个文本框**时也会 fire,所以 `busy` / `disabled` 时回车同样不提交。
|
|
23
|
+
* 调用点只管数据与语义:行怎么排、错误摆哪、什么时候校验、按钮在哪,这里没有口子。
|
|
24
|
+
* @param props.fields - 每格的键 / 标签 / 值 / 校验 / 控件;`visible: false` 的那格当它不存在(不渲染、不校验、不进 `values`)。
|
|
25
|
+
* @param props.onSubmit - 校验全过的那一拍,回的是从 `fields` 现取的键值表;被挡住时不会被调。
|
|
26
|
+
* @param props.submit - 提交钮文案;`busyLabel` 是提交中换的那句,不给就沿用 `label`。
|
|
27
|
+
* @param props.cancel - 取消钮;给了就排在提交钮左边,`busy` 时一起禁用。
|
|
28
|
+
* @param props.extra - 其余动作(default / danger),排在最左,`busy` 时一起禁用。
|
|
29
|
+
* @param props.busy - 提交中(默认 false):整表控件的绑定 `disabled` 为真,所有动作一起禁用,回车也不提交。
|
|
30
|
+
* @param props.disabled - 只再禁提交钮(如没改动可存),各格控件与其余动作不受影响,回车也不提交。
|
|
31
|
+
* @param props.problem - 整表级那句错(提交失败),落在行与操作区之间;不给就不画。
|
|
32
|
+
* @returns 一个 `<form>`(`noValidate`:报错形状归 `FormRow`,不走浏览器的气泡)。
|
|
33
|
+
*/
|
|
34
|
+
export function Form(props: FormProps) {
|
|
35
|
+
const { fields, onSubmit, submit, cancel, extra, busy = false, disabled = false, problem } = props;
|
|
36
|
+
const [done, setDone] = useState(false);
|
|
37
|
+
const [reveal, setReveal] = useState(0);
|
|
38
|
+
const rootRef = useRef<HTMLFormElement | null>(null);
|
|
39
|
+
const shown = visibleFieldsOf(fields);
|
|
40
|
+
const problems = formProblemsOf(shown, done);
|
|
41
|
+
|
|
42
|
+
reportDuplicateKeys(shown);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (reveal === 0) return;
|
|
46
|
+
const root = rootRef.current;
|
|
47
|
+
if (root !== null) revealFirstProblem(root);
|
|
48
|
+
}, [reveal]);
|
|
49
|
+
|
|
50
|
+
const submitForm = (): void => {
|
|
51
|
+
if (busy || disabled) return;
|
|
52
|
+
const outcome = formSubmitOf(visibleFieldsOf(fields));
|
|
53
|
+
setDone(true);
|
|
54
|
+
if (outcome.blocked) {
|
|
55
|
+
setReveal((count) => count + 1);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
onSubmit(outcome.values);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<form
|
|
63
|
+
className="wb-form"
|
|
64
|
+
ref={rootRef}
|
|
65
|
+
noValidate
|
|
66
|
+
onSubmit={(event) => {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
submitForm();
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
<FormRows>{formRowsOf({ fields: shown, problems, showProblem: done, busy })}</FormRows>
|
|
72
|
+
{problem === undefined ? null : h(Note, { tone: 'problem', role: 'alert' }, problem)}
|
|
73
|
+
<FormActions
|
|
74
|
+
actions={formActionsOf({ extra, cancel, submit, busy, disabled, onSubmitClick: submitForm })}
|
|
75
|
+
/>
|
|
76
|
+
</form>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -4,8 +4,14 @@ import { Button } from '@deepseek-ai/dsh-client-ui-primitives';
|
|
|
4
4
|
import { Spinner } from './Spinner.js';
|
|
5
5
|
import './FormActions.css';
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* 一颗动作按钮的语气:`default`、`danger`(破坏性,描边危险色,永远不当填充主操作)、`primary`(填充,一个区只许一颗)。
|
|
9
|
+
*/
|
|
7
10
|
export type FormActionKind = 'default' | 'danger' | 'primary';
|
|
8
11
|
|
|
12
|
+
/**
|
|
13
|
+
* 操作区里的一颗动作:键、文案、语气与回调;`busy` 时自带转圈并禁用。
|
|
14
|
+
*/
|
|
9
15
|
export interface FormAction {
|
|
10
16
|
readonly key: string;
|
|
11
17
|
readonly label: ReactNode;
|
|
@@ -15,6 +21,9 @@ export interface FormAction {
|
|
|
15
21
|
readonly busy?: boolean | undefined;
|
|
16
22
|
}
|
|
17
23
|
|
|
24
|
+
/**
|
|
25
|
+
* `FormActions` 的契约:一组动作。
|
|
26
|
+
*/
|
|
18
27
|
export interface FormActionsProps {
|
|
19
28
|
readonly actions: readonly FormAction[];
|
|
20
29
|
}
|
|
@@ -47,6 +56,11 @@ function variantOf(kind: FormActionKind): 'ghost' | 'outline' | 'primary' {
|
|
|
47
56
|
return 'ghost';
|
|
48
57
|
}
|
|
49
58
|
|
|
59
|
+
/**
|
|
60
|
+
* 操作区:把一组动作按"primary 永远在右、danger 紧挨它左侧"排好,窄了自动折行。
|
|
61
|
+
* @param props.actions - 动作清单;调用点给的顺序只在同语气内部生效,空数组时整块不渲染。
|
|
62
|
+
* @returns 一排按钮,或 `null`(没有动作时)。
|
|
63
|
+
*/
|
|
50
64
|
export function FormActions({ actions }: FormActionsProps) {
|
|
51
65
|
if (actions.length === 0) return null;
|
|
52
66
|
reportExtraPrimaries(actions);
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { ReactNode } from '../lib/react.js';
|
|
2
|
+
import { h } from '../lib/react.js';
|
|
3
|
+
import type { FormAction } from './FormActions.js';
|
|
4
|
+
import type { FormField, FormValue } from './FormTypes.js';
|
|
5
|
+
import { FormRow } from './FormRows.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 只剩要显示的那些格子 —— `visible: false` 的当它不存在:不渲染、不校验、不进 `values`,也不参与找第一处错误。
|
|
9
|
+
* @param fields - 表单的字段清单。
|
|
10
|
+
* @returns 保序的新数组,`visible` 不给或为 `true` 的都在;全不可见时给空数组。
|
|
11
|
+
*/
|
|
12
|
+
export function visibleFieldsOf(fields: readonly FormField[]): readonly FormField[] {
|
|
13
|
+
return fields.filter((field) => field.visible !== false);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 找出装配错的键:**假值**的键(空串 / `undefined` / `null`,运行期真漏给 `key` 就是这条)每次出现都算一处,
|
|
18
|
+
* 以及同一张表单里出现两次的键 —— 这两样都会让 `id` 撞车、`values` 少一格。
|
|
19
|
+
* @param fields - 表单的字段清单(通常是已经过滤过可见性的那份)。
|
|
20
|
+
* @returns 出问题的键的清单、按首次出现顺序去重;缺失的键一律记成空串(报的时候印成 `(空 key)`),空表给空数组。
|
|
21
|
+
*/
|
|
22
|
+
export function duplicateKeysOf(fields: readonly FormField[]): readonly string[] {
|
|
23
|
+
const seen = new Set<string>();
|
|
24
|
+
const duplicated = new Set<string>();
|
|
25
|
+
for (const field of fields) {
|
|
26
|
+
const key = field.key;
|
|
27
|
+
if (!key) {
|
|
28
|
+
duplicated.add('');
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (seen.has(key)) duplicated.add(key);
|
|
32
|
+
seen.add(key);
|
|
33
|
+
}
|
|
34
|
+
return [...duplicated];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 装配错了要当场诊断:`key` 缺失或重复时给 `console.error` —— 但照渲不误,不 throw。
|
|
39
|
+
* 形式与理由同 `FormActions` 里那颗多出来的 `primary`:装配的小错不该让 `CardBoundary` 把用户的整张卡变空白。
|
|
40
|
+
* @param fields - 表单的字段清单(通常是已经过滤过可见性的那份)。
|
|
41
|
+
*/
|
|
42
|
+
export function reportDuplicateKeys(fields: readonly FormField[]): void {
|
|
43
|
+
const duplicated = duplicateKeysOf(fields);
|
|
44
|
+
if (duplicated.length === 0) return;
|
|
45
|
+
const named = duplicated.map((key) => (key === '' ? '(空 key)' : key));
|
|
46
|
+
console.error(
|
|
47
|
+
`[dsh-workbench] Form 里有重复或缺失的字段 key:${named.join('、')} —— ` +
|
|
48
|
+
'同一张表单里 key 必须唯一且非空(它决定 id 与 values 的键),否则 id 会撞车、values 会少一格',
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 把每格的 `value` 收成一张键值表交给 `onSubmit` —— `Form` 自己一个值都不存,这张表就是从 `fields` 现取的快照。
|
|
54
|
+
* @param fields - 表单的字段清单;取的就是它们的 `value`,`value` 是数组的那格(多选)原样带出、不复制。
|
|
55
|
+
* @returns 键为字段 `key`、值为该格 `value` 的表;`fields` 为空时给空表,不是 `undefined`。
|
|
56
|
+
*/
|
|
57
|
+
export function formValuesOf(fields: readonly FormField[]): Readonly<Record<string, FormValue>> {
|
|
58
|
+
const values: Record<string, FormValue> = {};
|
|
59
|
+
for (const field of fields) values[field.key] = field.value;
|
|
60
|
+
return values;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 算每格现在该不该报错:还没提交过(`show` 为 false)时一格都不报 —— 不边打字边骂人。
|
|
65
|
+
* `validate` 返回**真值**才算错,假值(`undefined` / `null` / `false` / 空串)一律当"这格没错" ——
|
|
66
|
+
* 所以 `v.trim() === '' && '必填'` 这种用 `&&` 算文案的写法是对的。
|
|
67
|
+
* @param fields - 表单的字段清单(通常是已经过滤过可见性的那份)。
|
|
68
|
+
* @param show - 提交过没有;false 时全给 `undefined`,true 时逐格调 `field.validate` 并按真值收。
|
|
69
|
+
* @returns 键为 `key`、值为那句文案或 `undefined` 的表;没给 `validate` 的格子也是 `undefined`。
|
|
70
|
+
*/
|
|
71
|
+
export function formProblemsOf(
|
|
72
|
+
fields: readonly FormField[],
|
|
73
|
+
show: boolean,
|
|
74
|
+
): Readonly<Record<string, ReactNode | undefined>> {
|
|
75
|
+
const values = formValuesOf(fields);
|
|
76
|
+
const problems: Record<string, ReactNode | undefined> = {};
|
|
77
|
+
for (const field of fields) {
|
|
78
|
+
const found = show ? field.validate?.(field.value, values) : undefined;
|
|
79
|
+
problems[field.key] = found ? found : undefined;
|
|
80
|
+
}
|
|
81
|
+
return problems;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 按字段顺序取第一处错误 —— 提交被挡住时要带进视口的就是这一格。
|
|
86
|
+
* 判的是**真值**(与 `formProblemsOf` 同一口径):假值那种"错误文案"渲染出来本来就是空的,不算错。
|
|
87
|
+
* @param fields - 表单的字段清单,顺序就是「第一处」的判据。
|
|
88
|
+
* @param problems - `formProblemsOf` 那张表,也可以是调用点自己拼的(同样按真值判)。
|
|
89
|
+
* @returns 第一格有错的字段;一格都没错时给 `undefined`。
|
|
90
|
+
*/
|
|
91
|
+
export function formFirstProblemOf(
|
|
92
|
+
fields: readonly FormField[],
|
|
93
|
+
problems: Readonly<Record<string, ReactNode | undefined>>,
|
|
94
|
+
): FormField | undefined {
|
|
95
|
+
return fields.find((field) => Boolean(problems[field.key]));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 提交那一拍的结果:有错就挡住(并点出第一处错在哪一格),没错就把键值表交出去。
|
|
100
|
+
*/
|
|
101
|
+
export type FormSubmitOutcome =
|
|
102
|
+
| { readonly blocked: true; readonly first: FormField }
|
|
103
|
+
| { readonly blocked: false; readonly values: Readonly<Record<string, FormValue>> };
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 算提交那一拍该干什么 —— `Form` 的提交钮点下去、以及 `<form>` 的 Enter 提交,走的都是它。
|
|
107
|
+
* 错的判据只有一份(`formProblemsOf` + `formFirstProblemOf`),这里不重算第二遍。
|
|
108
|
+
* @param fields - 要提交的那些格子(通常是调用点先 `visibleFieldsOf` 过滤过的,不可见那格不进 `values` 也不算错)。
|
|
109
|
+
* @returns `blocked: true` 时带上是按字段顺序数第一格有错的字段(拿它去 `revealFirstProblem`);
|
|
110
|
+
* `blocked: false` 时带上从 `fields` 现取的键值表。空表一定给 `blocked: false` 与空 values。
|
|
111
|
+
*/
|
|
112
|
+
export function formSubmitOf(fields: readonly FormField[]): FormSubmitOutcome {
|
|
113
|
+
const problems = formProblemsOf(fields, true);
|
|
114
|
+
const first = formFirstProblemOf(fields, problems);
|
|
115
|
+
if (first !== undefined) return { blocked: true, first };
|
|
116
|
+
return { blocked: false, values: formValuesOf(fields) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 组表单的操作区:调用点给的其余动作在最左,取消在中间,提交永远是最右那颗 `primary`。
|
|
121
|
+
* @param args.extra - 其余动作(default / danger),保持调用点给的顺序;不给就是没有。
|
|
122
|
+
* @param args.cancel - 取消那格的 `label` 与 `onClick`;不给就少一颗钮。
|
|
123
|
+
* @param args.submit - 提交钮的 `label` 与 `busyLabel`。
|
|
124
|
+
* @param args.busy - 提交中:提交钮带转圈并换 `busyLabel`(不给就沿用 `label`),`extra` 与取消一起禁用。
|
|
125
|
+
* @param args.disabled - 只再禁提交钮(如没改动可存),其余动作不受影响。
|
|
126
|
+
* @param args.onSubmitClick - 提交钮点下去做什么,通常是把这一拍交给 `Form` 的提交处理。
|
|
127
|
+
* @returns 给 `FormActions` 的动作清单;没给 `extra` 也没给 `cancel` 时只剩提交那一颗。
|
|
128
|
+
*/
|
|
129
|
+
export function formActionsOf(args: {
|
|
130
|
+
readonly extra: readonly FormAction[] | undefined;
|
|
131
|
+
readonly cancel: { readonly label: ReactNode; readonly onClick: () => void } | undefined;
|
|
132
|
+
readonly submit: { readonly label: ReactNode; readonly busyLabel?: ReactNode | undefined };
|
|
133
|
+
readonly busy: boolean;
|
|
134
|
+
readonly disabled: boolean;
|
|
135
|
+
readonly onSubmitClick: () => void;
|
|
136
|
+
}): readonly FormAction[] {
|
|
137
|
+
const submit: FormAction = {
|
|
138
|
+
key: 'submit',
|
|
139
|
+
label: args.busy ? (args.submit.busyLabel ?? args.submit.label) : args.submit.label,
|
|
140
|
+
kind: 'primary',
|
|
141
|
+
busy: args.busy,
|
|
142
|
+
disabled: args.busy || args.disabled,
|
|
143
|
+
onClick: args.onSubmitClick,
|
|
144
|
+
};
|
|
145
|
+
const cancel: readonly FormAction[] =
|
|
146
|
+
args.cancel === undefined
|
|
147
|
+
? []
|
|
148
|
+
: [
|
|
149
|
+
{
|
|
150
|
+
key: 'cancel',
|
|
151
|
+
label: args.cancel.label,
|
|
152
|
+
kind: 'default',
|
|
153
|
+
disabled: args.busy,
|
|
154
|
+
onClick: args.cancel.onClick,
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
const extra = (args.extra ?? []).map(
|
|
158
|
+
(action): FormAction => (args.busy ? { ...action, disabled: true } : action),
|
|
159
|
+
);
|
|
160
|
+
return [...extra, ...cancel, submit];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 把整张表单的行画出来 —— 每格一个 `FormRow`,且**直接**是 `FormRows` 的孩子。
|
|
165
|
+
* 中间不许再包一层:`FormRow` 一旦不是 `.wb-formRows` 的网格项,`FormRows.css` 里那条 `subgrid` 就不匹配,
|
|
166
|
+
* 每行各自算列宽,控件左沿就参差了。
|
|
167
|
+
* @param args.fields - 要画的格子(通常是已经过滤过可见性的那份),顺序就是行的顺序。
|
|
168
|
+
* @param args.problems - `formProblemsOf` 那张表,决定哪一格显示错误文案(按**真值**判,假值不当错)。
|
|
169
|
+
* @param args.showProblem - 这一拍该不该显示错误(还没提交过时为 false,一格都不显示)。
|
|
170
|
+
* @param args.busy - 提交中:每格的绑定 `disabled` 为真。
|
|
171
|
+
* @returns `FormRow` 组成的数组;没有可画的格子时给空数组。
|
|
172
|
+
*/
|
|
173
|
+
export function formRowsOf(args: {
|
|
174
|
+
readonly fields: readonly FormField[];
|
|
175
|
+
readonly problems: Readonly<Record<string, ReactNode | undefined>>;
|
|
176
|
+
readonly showProblem: boolean;
|
|
177
|
+
readonly busy: boolean;
|
|
178
|
+
}): readonly ReactNode[] {
|
|
179
|
+
return args.fields.map((field) =>
|
|
180
|
+
h(FormRow, {
|
|
181
|
+
key: field.key,
|
|
182
|
+
label: field.label,
|
|
183
|
+
hint: field.hint,
|
|
184
|
+
problem: args.showProblem ? args.problems[field.key] : undefined,
|
|
185
|
+
layout: field.layout,
|
|
186
|
+
}, field.control({
|
|
187
|
+
id: `wb-form-${field.key}`,
|
|
188
|
+
name: field.key,
|
|
189
|
+
invalid: args.showProblem && Boolean(args.problems[field.key]),
|
|
190
|
+
disabled: args.busy || field.disabled === true,
|
|
191
|
+
})),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
gap: var(--wb-len-6);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
.wb-formRows > .wb-formRow[data-layout='stacked'] {
|
|
28
|
+
grid-template-columns: minmax(0, 1fr);
|
|
29
|
+
}
|
|
30
|
+
|
|
27
31
|
.wb-formLabel {
|
|
28
32
|
min-width: 0;
|
|
29
33
|
font-size: var(--dsw-font-xs-13-font-size);
|
|
@@ -36,12 +40,39 @@
|
|
|
36
40
|
white-space: normal;
|
|
37
41
|
}
|
|
38
42
|
|
|
43
|
+
.wb-formRow[data-layout='stacked'] .wb-formHint,
|
|
44
|
+
.wb-formRow[data-layout='stacked'] .wb-formProblem {
|
|
45
|
+
grid-column: 1 / -1;
|
|
46
|
+
}
|
|
47
|
+
|
|
39
48
|
.wb-formControl {
|
|
40
49
|
display: flex;
|
|
41
50
|
align-items: center;
|
|
42
51
|
min-width: 0;
|
|
43
52
|
}
|
|
44
53
|
|
|
54
|
+
.wb-formControl
|
|
55
|
+
> :where(
|
|
56
|
+
.wb-selectAnchor,
|
|
57
|
+
.wb-choiceMenu,
|
|
58
|
+
input:not(
|
|
59
|
+
[type='checkbox'],
|
|
60
|
+
[type='radio'],
|
|
61
|
+
[type='button'],
|
|
62
|
+
[type='submit'],
|
|
63
|
+
[type='reset'],
|
|
64
|
+
[type='image'],
|
|
65
|
+
[type='color'],
|
|
66
|
+
[type='range']
|
|
67
|
+
),
|
|
68
|
+
select,
|
|
69
|
+
textarea
|
|
70
|
+
) {
|
|
71
|
+
box-sizing: border-box;
|
|
72
|
+
width: 100%;
|
|
73
|
+
min-width: 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
45
76
|
.wb-formHint {
|
|
46
77
|
grid-column: 2;
|
|
47
78
|
font-size: var(--dsw-font-xxs-12-font-size);
|
|
@@ -84,11 +84,37 @@ describe('FormRow', () => {
|
|
|
84
84
|
|
|
85
85
|
const FORM_CSS = readFileSync(new URL('./FormRows.css', import.meta.url), 'utf8');
|
|
86
86
|
|
|
87
|
+
function normalize(selector: string): string {
|
|
88
|
+
return selector
|
|
89
|
+
.replace(/\s+/g, ' ')
|
|
90
|
+
.replace(/\(\s+/g, '(')
|
|
91
|
+
.replace(/\s+\)/g, ')')
|
|
92
|
+
.trim();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function selectorParts(head: string): string[] {
|
|
96
|
+
const parts: string[] = [];
|
|
97
|
+
let depth = 0;
|
|
98
|
+
let current = '';
|
|
99
|
+
for (const char of head) {
|
|
100
|
+
if (char === '(') depth += 1;
|
|
101
|
+
if (char === ')') depth -= 1;
|
|
102
|
+
if (char === ',' && depth === 0) {
|
|
103
|
+
parts.push(normalize(current));
|
|
104
|
+
current = '';
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
current += char;
|
|
108
|
+
}
|
|
109
|
+
parts.push(normalize(current));
|
|
110
|
+
return parts;
|
|
111
|
+
}
|
|
112
|
+
|
|
87
113
|
function declarations(selector: string): string[] {
|
|
88
114
|
for (const block of FORM_CSS.split('}')) {
|
|
89
115
|
const [head, ...rest] = block.split('{');
|
|
90
116
|
if (rest.length === 0 || head === undefined) continue;
|
|
91
|
-
if (head.
|
|
117
|
+
if (!selectorParts(head).includes(normalize(selector))) continue;
|
|
92
118
|
return rest
|
|
93
119
|
.join('{')
|
|
94
120
|
.split(';')
|
|
@@ -132,7 +158,51 @@ describe('标签列:同一个 FormRows 共用一列(ADR-0052 §11)', () =>
|
|
|
132
158
|
);
|
|
133
159
|
});
|
|
134
160
|
|
|
161
|
+
it('★ stacked 行在 FormRows 里也压得过 subgrid 与那条回退(同权重、靠后那条赢过的老坑)', () => {
|
|
162
|
+
expect(declarations(".wb-formRows > .wb-formRow[data-layout='stacked']")).toContain(
|
|
163
|
+
'grid-template-columns: minmax(0, 1fr)',
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('★ stacked 行里 hint / problem 通栏(它们的 grid-column: 2 会造出隐式第二列)', () => {
|
|
168
|
+
expect(declarations(".wb-formRow[data-layout='stacked'] .wb-formHint")).toContain(
|
|
169
|
+
'grid-column: 1 / -1',
|
|
170
|
+
);
|
|
171
|
+
expect(declarations(".wb-formRow[data-layout='stacked'] .wb-formProblem")).toContain(
|
|
172
|
+
'grid-column: 1 / -1',
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
135
176
|
it('★ 不是 FormRow 的子项(分组标题那种)通栏,别被塞进标签那一列', () => {
|
|
136
177
|
expect(declarations('.wb-formRows > :not(.wb-formRow)')).toContain('grid-column: 1 / -1');
|
|
137
178
|
});
|
|
138
179
|
});
|
|
180
|
+
|
|
181
|
+
const EXCLUDED_INPUT_TYPES = [
|
|
182
|
+
'checkbox',
|
|
183
|
+
'radio',
|
|
184
|
+
'button',
|
|
185
|
+
'submit',
|
|
186
|
+
'reset',
|
|
187
|
+
'image',
|
|
188
|
+
'color',
|
|
189
|
+
'range',
|
|
190
|
+
];
|
|
191
|
+
const CONTROL_FILL_SELECTOR = `.wb-formControl > :where(.wb-selectAnchor, .wb-choiceMenu, input:not(${EXCLUDED_INPUT_TYPES.map((one) => `[type='${one}']`).join(', ')}), select, textarea)`;
|
|
192
|
+
|
|
193
|
+
describe('控件槽那一格:字段控件铺满,宽度不归调用点写(ADR-0052 §13)', () => {
|
|
194
|
+
it('★ 那一格里的字段控件吃满控件槽:border-box + width 100% + min-width 0', () => {
|
|
195
|
+
const fill = declarations(CONTROL_FILL_SELECTOR);
|
|
196
|
+
|
|
197
|
+
expect(fill).toContain('box-sizing: border-box');
|
|
198
|
+
expect(fill).toContain('width: 100%');
|
|
199
|
+
expect(fill).toContain('min-width: 0');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('★ 开关与按钮那一族不在名单里(拉宽一颗 switch、一颗 <input type=submit> 就是另一回事)', () => {
|
|
203
|
+
for (const type of EXCLUDED_INPUT_TYPES) {
|
|
204
|
+
expect(CONTROL_FILL_SELECTOR).toContain(`[type='${type}']`);
|
|
205
|
+
}
|
|
206
|
+
expect(FORM_CSS).not.toMatch(/\.wb-formControl\s*>\s*\*/);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -2,14 +2,25 @@ import type { ReactNode } from '../lib/react.js';
|
|
|
2
2
|
import { h } from '../lib/react.js';
|
|
3
3
|
import './FormRows.css';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* `FormRows` 的契约:一列 `FormRow` 的容器。
|
|
7
|
+
*/
|
|
5
8
|
export interface FormRowsProps {
|
|
6
9
|
readonly children?: ReactNode | undefined;
|
|
7
10
|
}
|
|
8
11
|
|
|
12
|
+
/**
|
|
13
|
+
* 表单行的容器:同一份 `FormRows` 共用一个标签列(列宽由最长的标签定),行距也归它。
|
|
14
|
+
* @param props.children - 一串 `FormRow`;非行的孩子(如分组标题)自动通栏。
|
|
15
|
+
* @returns 竖排的 div。
|
|
16
|
+
*/
|
|
9
17
|
export function FormRows({ children }: FormRowsProps) {
|
|
10
18
|
return <div className="wb-formRows">{children}</div>;
|
|
11
19
|
}
|
|
12
20
|
|
|
21
|
+
/**
|
|
22
|
+
* `FormRow` 的契约:标签、提示、错误与控件的排布。
|
|
23
|
+
*/
|
|
13
24
|
export interface FormRowProps {
|
|
14
25
|
readonly label: ReactNode;
|
|
15
26
|
readonly hint?: ReactNode | undefined;
|
|
@@ -18,6 +29,15 @@ export interface FormRowProps {
|
|
|
18
29
|
readonly children?: ReactNode | undefined;
|
|
19
30
|
}
|
|
20
31
|
|
|
32
|
+
/**
|
|
33
|
+
* 表单一行:左边标签、右边控件,提示与错误占控件下面那一格。
|
|
34
|
+
* @param props.label - 这一行的标签;进了这一行,控件自己就别再给 `label`,否则会多一个标签。
|
|
35
|
+
* @param props.hint - 控件下的说明;给了 `problem` 就不画它 —— 同一格不叠两段话。
|
|
36
|
+
* @param props.problem - 校验失败那句话;给了就顶掉 `hint` 并给整行加上出错标记。
|
|
37
|
+
* @param props.layout - `stacked` 才是标签在上、控件在下(默认 `inline`:标签在左)。
|
|
38
|
+
* @param props.children - 控件本体。
|
|
39
|
+
* @returns 一个 `<label>`。
|
|
40
|
+
*/
|
|
21
41
|
export function FormRow({ label, hint, problem, layout = 'inline', children }: FormRowProps) {
|
|
22
42
|
return (
|
|
23
43
|
<label
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ReactNode } from '../lib/react.js';
|
|
2
|
+
import type { FormAction } from './FormActions.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 一格控件的值:单值(`TextField` / `SelectField` / `ChoiceGroup` 单选),或一组值(`ChoiceGroup` 多选)。
|
|
6
|
+
*/
|
|
7
|
+
export type FormValue = string | readonly string[];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `Form` 递给控件那一格的绑定:`id` / `name` / `invalid` / `disabled` 由 `Form` 定,控件只负责接上。
|
|
11
|
+
*/
|
|
12
|
+
export interface FormFieldBinding {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly invalid: boolean;
|
|
16
|
+
readonly disabled: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 一格字段的配置:键、标签、值、怎么算错(`validate`)、控件本身 —— 排布、时机、阻断都不写在这里,那些归 `Form`。
|
|
21
|
+
* `validate` 是纯函数、同步、只读 `values`:返回**真值**才算这一格错,假值(`undefined` / `null` / `false` / 空串)
|
|
22
|
+
* 一律当"没错"—— 所以 `(value) => value.trim() === '' && '必填'` 这种用 `&&` 算文案的写法是对的,
|
|
23
|
+
* 它的返回值就是错误行那句话。
|
|
24
|
+
*/
|
|
25
|
+
export interface FormField {
|
|
26
|
+
readonly key: string;
|
|
27
|
+
readonly label: ReactNode;
|
|
28
|
+
readonly hint?: ReactNode | undefined;
|
|
29
|
+
readonly layout?: 'inline' | 'stacked' | undefined;
|
|
30
|
+
readonly value: FormValue;
|
|
31
|
+
readonly visible?: boolean | undefined;
|
|
32
|
+
readonly disabled?: boolean | undefined;
|
|
33
|
+
readonly validate?: (value: FormValue, values: Readonly<Record<string, FormValue>>) => ReactNode | undefined;
|
|
34
|
+
readonly control: (binding: FormFieldBinding) => ReactNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 提交钮:文案归调用点,位置、禁用与忙态归 `Form`。
|
|
39
|
+
*/
|
|
40
|
+
export interface FormSubmit {
|
|
41
|
+
readonly label: ReactNode;
|
|
42
|
+
readonly busyLabel?: ReactNode | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 取消钮:文案与点下去做什么,`Form` 把它排在提交钮左边。
|
|
47
|
+
*/
|
|
48
|
+
export interface FormCancel {
|
|
49
|
+
readonly label: ReactNode;
|
|
50
|
+
readonly onClick: () => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `Form` 的契约:fields 配置进,排布、校验时机、提交阻断、操作区全归组件。
|
|
55
|
+
*/
|
|
56
|
+
export interface FormProps {
|
|
57
|
+
readonly fields: readonly FormField[];
|
|
58
|
+
readonly onSubmit: (values: Readonly<Record<string, FormValue>>) => void;
|
|
59
|
+
readonly submit: FormSubmit;
|
|
60
|
+
readonly cancel?: FormCancel | undefined;
|
|
61
|
+
readonly extra?: readonly FormAction[] | undefined;
|
|
62
|
+
readonly busy?: boolean | undefined;
|
|
63
|
+
readonly disabled?: boolean | undefined;
|
|
64
|
+
readonly problem?: ReactNode | undefined;
|
|
65
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { FormField, FormProps, FormValue } from './FormTypes.js';
|
|
3
|
+
import { Form } from './Form.js';
|
|
4
|
+
|
|
5
|
+
function field(key: string, value: FormValue, validate?: FormField['validate']): FormField {
|
|
6
|
+
return {
|
|
7
|
+
key,
|
|
8
|
+
label: key,
|
|
9
|
+
value,
|
|
10
|
+
control: (binding) => `${key}:${binding.id}:${String(binding.invalid)}:${String(binding.disabled)}`,
|
|
11
|
+
...(validate === undefined ? {} : { validate }),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
vi.mock('../lib/react.js', async () => {
|
|
16
|
+
const actual = await vi.importActual<Record<string, unknown>>('../lib/react.js');
|
|
17
|
+
return {
|
|
18
|
+
...actual,
|
|
19
|
+
useState: (initial: unknown) => [typeof initial === 'function' ? (initial as () => unknown)() : initial, () => {}],
|
|
20
|
+
useRef: (initial: unknown) => ({ current: initial ?? null }),
|
|
21
|
+
useEffect: () => {},
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('Form 的接线(hooks 换成假的,在 node 里当函数调)', () => {
|
|
26
|
+
function formEvent(): { preventDefault: () => void; prevented: () => boolean } {
|
|
27
|
+
let prevented = false;
|
|
28
|
+
return {
|
|
29
|
+
preventDefault: () => {
|
|
30
|
+
prevented = true;
|
|
31
|
+
},
|
|
32
|
+
prevented: () => prevented,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function submitHandlerOf(props: FormProps): (event: { preventDefault: () => void }) => void {
|
|
37
|
+
const node = Form(props) as unknown as { props: { onSubmit: (event: { preventDefault: () => void }) => void } };
|
|
38
|
+
return node.props.onSubmit;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function propsOfForm(onSubmit: (values: Readonly<Record<string, FormValue>>) => void, over: Partial<FormProps> = {}): FormProps {
|
|
42
|
+
return { fields: [field('env', 'prod')], onSubmit, submit: { label: '保存' }, ...over };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
it('★ 没错那一拍:preventDefault(不用浏览器默认提交)并调 onSubmit(values) 一次', () => {
|
|
46
|
+
const seen: (Readonly<Record<string, FormValue>>)[] = [];
|
|
47
|
+
const event = formEvent();
|
|
48
|
+
|
|
49
|
+
submitHandlerOf(propsOfForm((values) => void seen.push(values)))(event);
|
|
50
|
+
|
|
51
|
+
expect(event.prevented()).toBe(true);
|
|
52
|
+
expect(seen).toEqual([{ env: 'prod' }]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('★ 有错那一拍:不调 onSubmit', () => {
|
|
56
|
+
const onSubmit = vi.fn();
|
|
57
|
+
const fields = [field('project', '', () => '这个项目不存在')];
|
|
58
|
+
|
|
59
|
+
submitHandlerOf(propsOfForm(onSubmit, { fields }))({ preventDefault: () => {} });
|
|
60
|
+
|
|
61
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('★ busy 时 Enter / 点提交都不提交(隐式提交也走这道闸)', () => {
|
|
65
|
+
const onSubmit = vi.fn();
|
|
66
|
+
|
|
67
|
+
submitHandlerOf(propsOfForm(onSubmit, { busy: true }))({ preventDefault: () => {} });
|
|
68
|
+
|
|
69
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('★ disabled 时也不提交(没改动可存那颗钮禁着,回车同样进不来)', () => {
|
|
73
|
+
const onSubmit = vi.fn();
|
|
74
|
+
|
|
75
|
+
submitHandlerOf(propsOfForm(onSubmit, { disabled: true }))({ preventDefault: () => {} });
|
|
76
|
+
|
|
77
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('★ 不可见那格不进 values(过滤发生在提交那一拍)', () => {
|
|
81
|
+
const seen: (Readonly<Record<string, FormValue>>)[] = [];
|
|
82
|
+
const fields = [field('env', 'prod'), { ...field('secret', 'shh'), visible: false }];
|
|
83
|
+
|
|
84
|
+
submitHandlerOf(propsOfForm((values) => void seen.push(values), { fields }))({ preventDefault: () => {} });
|
|
85
|
+
|
|
86
|
+
expect(seen).toEqual([{ env: 'prod' }]);
|
|
87
|
+
});
|
|
88
|
+
});
|
package/src/components/Link.tsx
CHANGED
|
@@ -3,6 +3,9 @@ import { h } from '../lib/react.js';
|
|
|
3
3
|
import { IconRightUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives';
|
|
4
4
|
import './Link.css';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* `Link` 的契约:目标地址、文案与外链开关。
|
|
8
|
+
*/
|
|
6
9
|
export interface LinkProps {
|
|
7
10
|
readonly href: string;
|
|
8
11
|
readonly children?: ReactNode | undefined;
|
|
@@ -11,6 +14,15 @@ export interface LinkProps {
|
|
|
11
14
|
readonly className?: string | undefined;
|
|
12
15
|
}
|
|
13
16
|
|
|
17
|
+
/**
|
|
18
|
+
* 唯一的外链写法:自带新窗口、`rel="noopener noreferrer"`、正文色与行尾那颗小箭头。
|
|
19
|
+
* @param props.href - 目标地址,必给。
|
|
20
|
+
* @param props.children - 链接文字(别自己再带 `→`)。
|
|
21
|
+
* @param props.title - 原生 title 提示;不给就不写这个属性。
|
|
22
|
+
* @param props.external - 默认 true:新窗口打开并画那颗 ↗;站内跳转给 false,箭头也一起不画。
|
|
23
|
+
* @param props.className - 追加在基础类名之后,不是替换。
|
|
24
|
+
* @returns 一个 `<a>`。
|
|
25
|
+
*/
|
|
14
26
|
export function Link({ href, children, title, external = true, className }: LinkProps) {
|
|
15
27
|
return (
|
|
16
28
|
<a
|