@morlay/dsh-client-ui-primitives 0.0.2-alpha.7 → 0.0.2-alpha.9

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 (35) hide show
  1. package/README.md +275 -13
  2. package/dist/client.cjs +4959 -1057
  3. package/dist/client.d.cts +1366 -16
  4. package/dist/client.d.mts +1365 -16
  5. package/dist/rolldown-runtime-EAsCxrDo.mjs +14 -0
  6. package/locale/en.json +1 -1
  7. package/locale/zh.json +1 -1
  8. package/package.json +28 -11
  9. package/src/client/index.ts +41 -2
  10. package/src/client/schema-form/Editor.tsx +777 -0
  11. package/src/client/schema-form/SchemaForm.tsx +77 -0
  12. package/src/client/schema-form/controller.ts +457 -0
  13. package/src/client/schema-form/draft.ts +487 -0
  14. package/src/client/schema-form/fields.tsx +100 -0
  15. package/src/client/schema-form/hints.ts +286 -0
  16. package/src/client/schema-form/index.ts +188 -0
  17. package/src/client/schema-form/labels.ts +30 -0
  18. package/src/client/schema-form/lines.ts +334 -0
  19. package/src/client/schema-form/locales.ts +128 -0
  20. package/src/client/schema-form/rows.ts +153 -0
  21. package/src/client/schema-form/schema-node.ts +812 -0
  22. package/src/client/schema-form/slot-contract.ts +156 -0
  23. package/src/client/schema-form/styles.ts +310 -0
  24. package/src/client/schema-form/testing/fake-describe.ts +45 -0
  25. package/src/client/schema-form/testing/fake-scope.ts +97 -0
  26. package/src/client/schema-form/value.tsx +146 -0
  27. package/src/client/settings-form/SettingsForm.styles.ts +58 -0
  28. package/src/client/settings-form/SettingsForm.tsx +72 -0
  29. package/src/client/settings-form/fields.styles.ts +158 -0
  30. package/src/client/settings-form/fields.tsx +194 -0
  31. package/src/client/settings-form/form-model.ts +421 -0
  32. package/src/client/settings-form/index.ts +19 -0
  33. package/src/client/styling/focus.ts +9 -0
  34. package/src/client/theme.generated.ts +802 -728
  35. package/cordis.patch.yml +0 -3
@@ -0,0 +1,146 @@
1
+ /**
2
+ * 行内值的呈现与编辑文本规则,以及字段槽注册方复用的默认值组件。
3
+ *
4
+ * 值本身仍走草稿:这个文件只决定"怎么显示"(语法色 token、下拉触发的呈现)与"怎么编辑"(一行输入框里的文本
5
+ * 形状——字符串把换行写成 `\n`,与一行的输入框相配)。
6
+ */
7
+
8
+ import { useState, type ReactNode } from "react";
9
+ import { IconChevronDownOutlineRegular, Menu } from "@deepseek-ai/dsh-client-ui-primitives";
10
+ import type { SelectOption } from "./hints.ts";
11
+ import type { SchemaFieldOwnerProps } from "./slot-contract.ts";
12
+ import { ICON_SIZE, LineValue, ValueTrigger } from "./styles.ts";
13
+
14
+ /** 编辑态文本:字符串就是原文(换行照旧),其余按 JSON 写。 */
15
+ export function valueText(value: unknown): string {
16
+ if (value === undefined || value === null) return "";
17
+ if (typeof value === "string") return value;
18
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
19
+ return JSON.stringify(value);
20
+ }
21
+
22
+ /** 这个值要在多行输入里编辑吗(字符串带换行)。 */
23
+ export function isMultiline(value: unknown): boolean {
24
+ return typeof value === "string" && /[\r\n]/.test(value);
25
+ }
26
+
27
+ /** 显示态文本:字符串带引号;多行与特殊字符都按 JSON 的写法收在一行里。 */
28
+ export function tokenText(value: unknown): string {
29
+ if (value === undefined || value === null) return "null";
30
+ if (typeof value === "string") return JSON.stringify(value);
31
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
32
+ return JSON.stringify(value);
33
+ }
34
+
35
+ /** 值的语法色分组。 */
36
+ export function tokenTone(value: unknown): "string" | "number" | "boolean" | "empty" {
37
+ if (value === undefined || value === null) return "empty";
38
+ if (typeof value === "number") return "number";
39
+ if (typeof value === "boolean") return "boolean";
40
+ return "string";
41
+ }
42
+
43
+ /**
44
+ * 行内值的默认呈现(显示态 token)。
45
+ * @param props.owner - 字段上下文(值、是否 secret、只读原因)。
46
+ * @returns 一行里的值节点。
47
+ */
48
+ export function InlineValue({ owner }: { owner: SchemaFieldOwnerProps }): ReactNode {
49
+ if (owner.options !== undefined) return <OptionSelect owner={owner} options={owner.options} />;
50
+ if (owner.node.meta.secret) {
51
+ // secret 不回显值:截断时不给全文,只给说明。
52
+ return (
53
+ <LineValue data-tone="empty" title={owner.hint}>
54
+ {owner.secretConfigured ? "••••••" : ""}
55
+ </LineValue>
56
+ );
57
+ }
58
+ return (
59
+ <LineValue
60
+ data-tone={owner.node.readOnly === null ? tokenTone(owner.value) : "empty"}
61
+ // 值长了会截断:整份内容挂在 title 上,hover 就能看全。
62
+ title={tokenText(owner.value)}
63
+ >
64
+ {tokenText(owner.value)}
65
+ </LineValue>
66
+ );
67
+ }
68
+
69
+ /**
70
+ * 选择器:**值本身**就是下拉的触发(不再另画一个 `select` 与它并列),点开是官方菜单。
71
+ *
72
+ * 当前值不在候选里时(例如目录里已经没有那个服务商)照原样显示,选中才换成新值。
73
+ */
74
+ function OptionSelect({
75
+ owner,
76
+ options,
77
+ }: {
78
+ owner: SchemaFieldOwnerProps;
79
+ options: readonly SelectOption[];
80
+ }): ReactNode {
81
+ const [open, setOpen] = useState(false);
82
+ const selected = options.findIndex((option) => sameValue(option.value, owner.value));
83
+ const current = selected < 0 ? undefined : options[selected];
84
+ const label =
85
+ current?.label ??
86
+ (owner.value === undefined
87
+ ? owner.t("unconfigured")
88
+ : tokenText(current?.value ?? owner.value));
89
+ return (
90
+ <Menu
91
+ open={open}
92
+ dense
93
+ compact
94
+ // 浮层挂到 body:行容器与表单壳的裁剪/层级都挡不住它。
95
+ portal
96
+ align="start"
97
+ items={options.map((option, index) => ({
98
+ id: String(index),
99
+ label: option.label ?? tokenText(option.value),
100
+ }))}
101
+ selectedId={selected < 0 ? undefined : String(selected)}
102
+ onSelect={(id) => {
103
+ setOpen(false);
104
+ const option = options[Number(id)];
105
+ if (option !== undefined) owner.onChange(option.value);
106
+ }}
107
+ onClose={() => {
108
+ setOpen(false);
109
+ }}
110
+ anchor={
111
+ <ValueTrigger
112
+ type="button"
113
+ disabled={owner.disabled}
114
+ aria-label={owner.label}
115
+ onClick={() => {
116
+ setOpen(true);
117
+ }}
118
+ >
119
+ <LineValue
120
+ data-tone={current === undefined ? tokenTone(owner.value) : tokenTone(current.value)}
121
+ >
122
+ {label}
123
+ </LineValue>
124
+ <IconChevronDownOutlineRegular size={ICON_SIZE} />
125
+ </ValueTrigger>
126
+ }
127
+ />
128
+ );
129
+ }
130
+
131
+ /** 值是否就是这一项(菜单的高亮判据)。 */
132
+ function sameValue(left: unknown, right: unknown): boolean {
133
+ if (Object.is(left, right)) return true;
134
+ if (typeof left !== "object" || typeof right !== "object" || left === null || right === null)
135
+ return false;
136
+ return JSON.stringify(left) === JSON.stringify(right);
137
+ }
138
+
139
+ /**
140
+ * 字段槽注册方复用的默认值:只改文案时拿它包一层即可(`owner` 上覆盖 `label`/`hint`)。
141
+ *
142
+ * 注意行式视图里键名来自配置键本身、注释来自 `hint`,所以 `label` 只在业务自写控件时才需要。
143
+ */
144
+ export function SchemaFieldDefault({ owner }: { owner: SchemaFieldOwnerProps }): ReactNode {
145
+ return <InlineValue owner={owner} />;
146
+ }
@@ -0,0 +1,58 @@
1
+ // 样式搬自 vendor/deepseek-harness/packages/client/ui-primitives/src/settings-form/SettingsForm.module.css,
2
+ // 换成包内 css-in-js(CSSProps + dsw);上游那份是 CSS Modules,本包不做预编译。
3
+
4
+ import type { CSSProps } from "../styling/css.ts";
5
+ import { focusRing } from "../styling/focus.ts";
6
+ import { dsw } from "../theme.ts";
7
+
8
+ // 上游引用的 --dsw-alias-label-error 不在官方主题定义集内(token 树里没有这个变量),
9
+ // 照抄引用而不是换一个近义 token——官方主题哪天定义它,这里自动跟着生效。
10
+ const failedColor = "var(--dsw-alias-label-error)";
11
+
12
+ export const styles = {
13
+ form: {
14
+ display: "flex",
15
+ flexDirection: "column",
16
+ },
17
+ // 上游 readOnly 与 unavailable 同一条规则:两处用同一个样式对象。
18
+ notice: {
19
+ margin: "0 0 12px",
20
+ fontSize: "12px",
21
+ lineHeight: 1.5,
22
+ color: dsw.alias.label.tertiary,
23
+ },
24
+ footer: {
25
+ display: "flex",
26
+ alignItems: "center",
27
+ gap: "8px",
28
+ paddingTop: "16px",
29
+ },
30
+ failed: {
31
+ flex: 1,
32
+ minWidth: 0,
33
+ margin: 0,
34
+ fontSize: "12px",
35
+ lineHeight: 1.5,
36
+ color: failedColor,
37
+ },
38
+ save: {
39
+ appearance: "none",
40
+ border: "1px solid transparent",
41
+ borderRadius: "8px",
42
+ padding: "5px 14px",
43
+ font: "inherit",
44
+ fontSize: "13px",
45
+ lineHeight: 1.5,
46
+ cursor: "pointer",
47
+ background: dsw.alias.label.primary,
48
+ color: dsw.alias.bg.layer["3"],
49
+ "&:disabled": {
50
+ opacity: 0.4,
51
+ cursor: "default",
52
+ },
53
+ "&:focus-visible": {
54
+ outline: focusRing,
55
+ outlineOffset: "1px",
56
+ },
57
+ },
58
+ } satisfies Record<string, CSSProps>;
@@ -0,0 +1,72 @@
1
+ // 搬自 vendor/deepseek-harness/packages/client/ui-primitives/src/settings-form/SettingsForm.tsx
2
+ // (上游 `@deepseek-ai/dsh-client-ui-primitives`):行为同形,样式从 CSS Modules 换成包内 css-in-js。
3
+
4
+ import { useEffect, useRef, type ReactNode } from "react";
5
+ import { styled } from "../styling/styled.tsx";
6
+ import type { SettingsFormShell } from "./form-model.ts";
7
+ import { styles } from "./SettingsForm.styles.ts";
8
+
9
+ const Form = styled("div")(styles.form);
10
+ const Notice = styled("p")(styles.notice);
11
+ const Footer = styled("div")(styles.footer);
12
+ const Failed = styled("p")(styles.failed);
13
+ const Save = styled("button")(styles.save);
14
+
15
+ /** The copy the form frame renders, from the owning plugin's dictionary. */
16
+ export interface SettingsFormLabels {
17
+ /** Shown in place of the controls while the namespace is not served. */
18
+ unavailable: string;
19
+ /** Shown over the controls while the document is read-only. */
20
+ readOnly: string;
21
+ /** Shown beside the save after a save the Host did not accept. */
22
+ saveFailed: string;
23
+ /** The save control. */
24
+ save: string;
25
+ /** The save control while a save is crossing the wire. */
26
+ saving: string;
27
+ }
28
+
29
+ /** Form chrome shared by every settings page. */
30
+ export interface SettingsFormProps {
31
+ /** The frame's copy. */
32
+ labels: SettingsFormLabels;
33
+ /** The form state: availability, writability, and what a save would do. */
34
+ state: SettingsFormShell;
35
+ /** Write every staged edit. */
36
+ onSave: () => void;
37
+ /** Drop every staged edit; the form calls it when it leaves the page. */
38
+ onDiscard: () => void;
39
+ /** The plugin's controls. */
40
+ children: ReactNode;
41
+ }
42
+
43
+ /**
44
+ * Render one plugin's settings form.
45
+ * @param props - the form's copy and state, its controls, and the save and discard actions.
46
+ * @returns the form, or the unavailable line while the namespace is not served.
47
+ */
48
+ export function SettingsForm(props: SettingsFormProps) {
49
+ const { state, labels } = props;
50
+ const discard = useRef(props.onDiscard);
51
+ discard.current = props.onDiscard;
52
+ useEffect(
53
+ () => () => {
54
+ discard.current();
55
+ },
56
+ [],
57
+ );
58
+ if (!state.available) return <Notice role="status">{labels.unavailable}</Notice>;
59
+ const blocked = !state.dirty || state.invalid || state.saving;
60
+ return (
61
+ <Form>
62
+ {!state.writable ? <Notice role="status">{labels.readOnly}</Notice> : null}
63
+ {props.children}
64
+ <Footer>
65
+ {state.failed ? <Failed role="status">{labels.saveFailed}</Failed> : null}
66
+ <Save type="button" disabled={blocked} onClick={props.onSave}>
67
+ {state.saving ? labels.saving : labels.save}
68
+ </Save>
69
+ </Footer>
70
+ </Form>
71
+ );
72
+ }
@@ -0,0 +1,158 @@
1
+ // 样式搬自 vendor/deepseek-harness/packages/client/ui-primitives/src/settings-form/fields.module.css,
2
+ // 换成包内 css-in-js(CSSProps + dsw);上游那份是 CSS Modules,本包不做预编译。
3
+
4
+ import type { CSSProps } from "../styling/css.ts";
5
+ import { focusRing } from "../styling/focus.ts";
6
+ import { dsw } from "../theme.ts";
7
+
8
+ // 上游引用的 --dsw-alias-bg-layer-4 不在官方主题定义集内(token 树里没有这个变量),
9
+ // 照抄引用而不是换一个近义 token——官方主题哪天定义它,这里自动跟着生效。
10
+ const helpButtonHoverBackground = "var(--dsw-alias-bg-layer-4)";
11
+
12
+ export const styles = {
13
+ field: {
14
+ display: "flex",
15
+ flexDirection: "column",
16
+ gap: "6px",
17
+ padding: "12px 0",
18
+ "& + &": {
19
+ borderTop: `0.5px solid ${String(dsw.alias.border.l2)}`,
20
+ },
21
+ },
22
+ head: {
23
+ display: "flex",
24
+ alignItems: "center",
25
+ gap: "8px",
26
+ },
27
+ label: {
28
+ flex: 1,
29
+ minWidth: 0,
30
+ fontSize: "13px",
31
+ fontWeight: 500,
32
+ lineHeight: 1.5,
33
+ color: dsw.alias.label.primary,
34
+ },
35
+ // 标签进 labelGroup 后由外层承担伸缩,自己不再抢空间(上游的 `.labelGroup > .label`)。
36
+ groupLabel: {
37
+ flex: "0 1 auto",
38
+ },
39
+ labelGroup: {
40
+ display: "flex",
41
+ alignItems: "center",
42
+ gap: "4px",
43
+ flex: 1,
44
+ minWidth: 0,
45
+ },
46
+ helpButton: {
47
+ display: "inline-flex",
48
+ alignItems: "center",
49
+ justifyContent: "center",
50
+ flex: "none",
51
+ width: "24px",
52
+ height: "24px",
53
+ padding: 0,
54
+ border: 0,
55
+ borderRadius: "6px",
56
+ background: "none",
57
+ color: dsw.alias.label.tertiary,
58
+ cursor: "pointer",
59
+ "&:hover": {
60
+ background: helpButtonHoverBackground,
61
+ color: dsw.alias.label.secondary,
62
+ },
63
+ "&[aria-expanded='true']": {
64
+ background: helpButtonHoverBackground,
65
+ color: dsw.alias.label.secondary,
66
+ },
67
+ "&:focus-visible": {
68
+ outline: focusRing,
69
+ outlineOffset: "1px",
70
+ },
71
+ },
72
+ help: {
73
+ padding: "10px 0 0",
74
+ fontSize: "12px",
75
+ lineHeight: 1.6,
76
+ color: dsw.alias.label.secondary,
77
+ "& > p": {
78
+ margin: 0,
79
+ },
80
+ "& > p + p": {
81
+ marginTop: "8px",
82
+ },
83
+ },
84
+ badges: {
85
+ display: "inline-flex",
86
+ alignItems: "center",
87
+ gap: "8px",
88
+ },
89
+ // 上游用 Tag(tone="neutral" | "quiet"):这里按变体拆两个 styled,形态与颜色照抄 Tag.module.css。
90
+ badge: {
91
+ display: "inline-flex",
92
+ alignItems: "center",
93
+ borderRadius: "999px",
94
+ cornerShape: "round",
95
+ padding: "1px 8px",
96
+ fontSize: "11px",
97
+ lineHeight: "17px",
98
+ fontWeight: 500,
99
+ whiteSpace: "nowrap",
100
+ },
101
+ badgeNeutral: {
102
+ background: dsw.alias.bg.module.platform,
103
+ color: dsw.alias.label.secondary,
104
+ },
105
+ badgeQuiet: {
106
+ color: dsw.alias.label.tertiary,
107
+ },
108
+ reset: {
109
+ border: "none",
110
+ background: "none",
111
+ padding: 0,
112
+ font: "inherit",
113
+ fontSize: "12px",
114
+ lineHeight: 1.5,
115
+ color: dsw.alias.label.secondary,
116
+ cursor: "pointer",
117
+ "&:hover:not(:disabled)": {
118
+ color: dsw.alias.label.primary,
119
+ },
120
+ "&:disabled": {
121
+ cursor: "default",
122
+ },
123
+ },
124
+ input: {
125
+ height: "34px",
126
+ padding: "0 12px",
127
+ border: `0.5px solid ${String(dsw.alias.border.l4)}`,
128
+ borderRadius: "8px",
129
+ background: dsw.alias.bg.layer["3"],
130
+ font: "inherit",
131
+ fontSize: "13px",
132
+ lineHeight: 1.5,
133
+ color: dsw.alias.label.primary,
134
+ "&:focus-visible": {
135
+ outline: "none",
136
+ borderColor: dsw.alias.state.business.primary,
137
+ },
138
+ "&:disabled": {
139
+ color: dsw.alias.label.tertiary,
140
+ cursor: "default",
141
+ },
142
+ "&[aria-invalid='true']": {
143
+ borderColor: dsw.alias.state.error.primary,
144
+ },
145
+ },
146
+ invalid: {
147
+ margin: 0,
148
+ fontSize: "12px",
149
+ lineHeight: 1.5,
150
+ color: dsw.alias.state.error.primary,
151
+ },
152
+ hint: {
153
+ margin: 0,
154
+ fontSize: "12px",
155
+ lineHeight: 1.5,
156
+ color: dsw.alias.label.tertiary,
157
+ },
158
+ } satisfies Record<string, CSSProps>;
@@ -0,0 +1,194 @@
1
+ // 搬自 vendor/deepseek-harness/packages/client/ui-primitives/src/settings-form/fields.tsx
2
+ // (上游 `@deepseek-ai/dsh-client-ui-primitives`):结构与行为同形,样式从 CSS Modules 换成包内 css-in-js。
3
+ // 上游用的 Tag 与 IconInfoOutlineRegular 没有一起搬——本包不依赖上游 primitives,用等价的本地徽标与图标。
4
+
5
+ import { useState, type ReactNode } from "react";
6
+ import { styled } from "../styling/styled.tsx";
7
+ import { styles } from "./fields.styles.ts";
8
+
9
+ const Field = styled("div")(styles.field);
10
+ const Head = styled("div")(styles.head);
11
+ const LabelGroup = styled("div")(styles.labelGroup);
12
+ const GroupLabel = styled("label")(styles.label, styles.groupLabel);
13
+ const HeadLabel = styled("label")(styles.label);
14
+ const HelpButton = styled("button")(styles.helpButton);
15
+ const Help = styled("div")(styles.help);
16
+ const Badges = styled("span")(styles.badges);
17
+ const NeutralBadge = styled("span")(styles.badge, styles.badgeNeutral);
18
+ const QuietBadge = styled("span")(styles.badge, styles.badgeQuiet);
19
+ const Reset = styled("button")(styles.reset);
20
+ const Input = styled("input")(styles.input);
21
+ const Invalid = styled("p")(styles.invalid);
22
+ const Hint = styled("p")(styles.hint);
23
+
24
+ /** 上游 icons 里的 IconInfoOutlineRegular(fill-only 几何,整份图标表不搬)。 */
25
+ function InfoGlyph({ size }: { size: number }) {
26
+ return (
27
+ <svg
28
+ width={size}
29
+ height={size}
30
+ viewBox="0 0 14 14"
31
+ fill="none"
32
+ xmlns="http://www.w3.org/2000/svg"
33
+ aria-hidden="true"
34
+ >
35
+ <path
36
+ d="M12.5757 7.00012C12.5757 3.92085 10.0794 1.42463 7.00012 1.42456C3.9208 1.42456 1.42456 3.9208 1.42456 7.00012C1.42463 10.0794 3.92085 12.5757 7.00012 12.5757C10.0793 12.5756 12.5756 10.0793 12.5757 7.00012ZM13.8002 7.00012C13.8001 10.7559 10.7559 13.8001 7.00012 13.8002C3.2443 13.8002 0.199291 10.7559 0.199219 7.00012C0.199219 3.24426 3.24426 0.199219 7.00012 0.199219C10.7559 0.199291 13.8002 3.2443 13.8002 7.00012Z"
37
+ fill="currentColor"
38
+ />
39
+ <path d="M7.6127 3.18921V4.55986H6.38735V3.18921H7.6127Z" fill="currentColor" />
40
+ <path d="M7.6127 5.68921V10.8109H6.38735V5.68921H7.6127Z" fill="currentColor" />
41
+ </svg>
42
+ );
43
+ }
44
+
45
+ /** What every settings field control needs regardless of its value type. */
46
+ export interface SettingsFieldProps {
47
+ /** Stable id associating the label with its control. */
48
+ id: string;
49
+ /** Visible label. */
50
+ label: string;
51
+ /** One-line explanation rendered under the control. */
52
+ hint: string;
53
+ /** Draft text this control renders. */
54
+ text: string;
55
+ /** True when saving would leave a user-layer entry for this field. */
56
+ overridden: boolean;
57
+ /** True when the draft is not a value this field accepts. */
58
+ invalid: boolean;
59
+ /** Copy for the overridden badge. */
60
+ overriddenLabel: string;
61
+ /** Copy for the reset control. */
62
+ resetLabel: string;
63
+ /** Copy shown in place of the hint while the draft is invalid. */
64
+ invalidLabel: string;
65
+ /** Disables every control (read-only document, or an unavailable namespace). */
66
+ disabled: boolean;
67
+ /** Stage draft text. */
68
+ onEdit: (text: string) => void;
69
+ /** Stage a clear so the field re-inherits the composition layer. */
70
+ onReset: () => void;
71
+ }
72
+
73
+ /**
74
+ * A staged value field. `numeric` only hints the keypad: which drafts a field
75
+ * accepts is decided by its spec, so the control never silently rewrites what
76
+ * the user typed.
77
+ * @param props - the field's copy, its staged text, and the edit actions.
78
+ * @returns the labelled control.
79
+ */
80
+ export function SettingsValueField(
81
+ props: Omit<SettingsFieldProps, "hint"> & {
82
+ /** Optional explanation shown below the input. */
83
+ hint?: string;
84
+ /** Rules disclosed by the information button beside the label. */
85
+ help?: { label: string; content: ReactNode };
86
+ /** Hints a numeric keypad without narrowing what the control accepts. */
87
+ numeric?: boolean;
88
+ /** Placeholder shown while the draft is empty. */
89
+ placeholder?: string;
90
+ },
91
+ ) {
92
+ const [helpOpen, setHelpOpen] = useState(false);
93
+ const helpId = `${props.id}-help`;
94
+ const messageId = `${props.id}-message`;
95
+ const hasMessage = props.invalid || Boolean(props.hint);
96
+ const description = [hasMessage ? messageId : "", helpOpen ? helpId : ""]
97
+ .filter(Boolean)
98
+ .join(" ");
99
+ return (
100
+ <Field>
101
+ <Head>
102
+ <LabelGroup>
103
+ <GroupLabel htmlFor={props.id}>{props.label}</GroupLabel>
104
+ {props.help !== undefined ? (
105
+ <HelpButton
106
+ type="button"
107
+ aria-label={props.help.label}
108
+ aria-expanded={helpOpen}
109
+ aria-controls={helpId}
110
+ onClick={() => {
111
+ setHelpOpen(!helpOpen);
112
+ }}
113
+ >
114
+ <InfoGlyph size={12} />
115
+ </HelpButton>
116
+ ) : null}
117
+ </LabelGroup>
118
+ {props.overridden ? (
119
+ <Badges>
120
+ <NeutralBadge>{props.overriddenLabel}</NeutralBadge>
121
+ <Reset type="button" disabled={props.disabled} onClick={props.onReset}>
122
+ {props.resetLabel}
123
+ </Reset>
124
+ </Badges>
125
+ ) : null}
126
+ </Head>
127
+ <Input
128
+ id={props.id}
129
+ type="text"
130
+ {...(props.numeric === true ? { inputMode: "numeric" as const } : {})}
131
+ {...(props.invalid ? { "aria-invalid": true } : {})}
132
+ aria-describedby={description || undefined}
133
+ value={props.text}
134
+ placeholder={props.placeholder ?? ""}
135
+ disabled={props.disabled}
136
+ onChange={(event) => {
137
+ props.onEdit(event.target.value);
138
+ }}
139
+ />
140
+ {hasMessage ? (
141
+ props.invalid ? (
142
+ <Invalid id={messageId}>{props.invalidLabel}</Invalid>
143
+ ) : (
144
+ <Hint id={messageId}>{props.hint}</Hint>
145
+ )
146
+ ) : null}
147
+ {props.help !== undefined && helpOpen ? (
148
+ <Help id={helpId} role="region" aria-label={props.help.label}>
149
+ {props.help.content}
150
+ </Help>
151
+ ) : null}
152
+ </Field>
153
+ );
154
+ }
155
+
156
+ /**
157
+ * A write-only credential control. The value never rides a response, so the
158
+ * control reports only whether one is configured and starts blank; a blank
159
+ * draft writes nothing, which keeps the stored key rather than clearing it.
160
+ * 控件要求浏览器不要把已存的登录密码自动填进来(上游 11c0511271)。
161
+ * @param props - the field's copy, its staged text, and the configured state.
162
+ * @returns the labelled control.
163
+ */
164
+ export function SettingsSecretField(
165
+ props: Pick<SettingsFieldProps, "id" | "label" | "hint" | "text" | "disabled" | "onEdit"> & {
166
+ /** Whether the Host reports a configured credential for this reference. */
167
+ configured: boolean;
168
+ /** Copy describing the configured state. */
169
+ stateLabel: string;
170
+ },
171
+ ) {
172
+ const Badge = props.configured ? NeutralBadge : QuietBadge;
173
+ return (
174
+ <Field>
175
+ <Head>
176
+ <HeadLabel htmlFor={props.id}>{props.label}</HeadLabel>
177
+ <Badges>
178
+ <Badge>{props.stateLabel}</Badge>
179
+ </Badges>
180
+ </Head>
181
+ <Input
182
+ id={props.id}
183
+ type="password"
184
+ autoComplete="new-password"
185
+ value={props.text}
186
+ disabled={props.disabled}
187
+ onChange={(event) => {
188
+ props.onEdit(event.target.value);
189
+ }}
190
+ />
191
+ <Hint>{props.hint}</Hint>
192
+ </Field>
193
+ );
194
+ }