@dsh-workbench/shared 0.2.12 → 0.2.14

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 (53) hide show
  1. package/dist/components/ChoiceGroup.css +53 -3
  2. package/dist/components/ChoiceGroup.d.ts +5 -0
  3. package/dist/components/ChoiceGroup.js +15 -5
  4. package/dist/components/FormActions.css +13 -0
  5. package/dist/components/FormActions.d.ts +15 -0
  6. package/dist/components/FormActions.js +38 -0
  7. package/dist/components/FormRows.css +69 -0
  8. package/dist/components/FormRows.d.ts +14 -0
  9. package/dist/components/FormRows.js +11 -0
  10. package/dist/components/SaveBar.d.ts +0 -1
  11. package/dist/components/SaveBar.js +13 -5
  12. package/dist/components/SelectField.css +12 -4
  13. package/dist/components/SelectField.d.ts +1 -0
  14. package/dist/components/SelectField.js +9 -7
  15. package/dist/components/Spinner.css +6 -0
  16. package/dist/components/Spinner.d.ts +6 -0
  17. package/dist/components/Spinner.js +7 -0
  18. package/dist/components/TextField.css +2 -2
  19. package/dist/host.d.ts +3 -3
  20. package/dist/host.js +3 -3
  21. package/dist/i18n/en.js +9 -0
  22. package/dist/i18n/index.d.ts +10 -1
  23. package/dist/i18n/zh.d.ts +9 -0
  24. package/dist/i18n/zh.js +9 -0
  25. package/dist/index.d.ts +8 -4
  26. package/dist/index.js +8 -4
  27. package/dist/lib/form-problem.d.ts +2 -0
  28. package/dist/lib/form-problem.js +20 -0
  29. package/package.json +2 -1
  30. package/src/components/ChoiceGroup.css +53 -3
  31. package/src/components/ChoiceGroup.test.tsx +88 -0
  32. package/src/components/ChoiceGroup.tsx +70 -28
  33. package/src/components/FormActions.css +13 -0
  34. package/src/components/FormActions.test.tsx +157 -0
  35. package/src/components/FormActions.tsx +75 -0
  36. package/src/components/FormRows.css +69 -0
  37. package/src/components/FormRows.test.tsx +138 -0
  38. package/src/components/FormRows.tsx +39 -0
  39. package/src/components/SaveBar.tsx +14 -18
  40. package/src/components/SelectField.css +12 -4
  41. package/src/components/SelectField.test.tsx +20 -2
  42. package/src/components/SelectField.tsx +11 -3
  43. package/src/components/Spinner.css +6 -0
  44. package/src/components/Spinner.test.tsx +35 -0
  45. package/src/components/Spinner.tsx +19 -0
  46. package/src/components/TextField.css +2 -2
  47. package/src/host.ts +3 -3
  48. package/src/i18n/en.ts +9 -0
  49. package/src/i18n/zh.ts +9 -0
  50. package/src/index.ts +8 -4
  51. package/src/lib/form-problem.test.ts +76 -0
  52. package/src/lib/form-problem.ts +22 -0
  53. package/src/components/SaveBar.css +0 -7
@@ -0,0 +1,157 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { FormActions, type FormAction, type FormActionKind } from './FormActions.js';
4
+ import { SaveBar } from './SaveBar.js';
5
+
6
+ function action(key: string, kind?: FormActionKind): FormAction {
7
+ return { key, label: key, ...(kind === undefined ? {} : { kind }), onClick: () => {} };
8
+ }
9
+
10
+ function buttonsOf(node: unknown): { props: Record<string, unknown> }[] {
11
+ return (node as { props: { children: unknown[] } }).props.children as { props: Record<string, unknown> }[];
12
+ }
13
+
14
+ function labelsOf(node: unknown): unknown[] {
15
+ return buttonsOf(node).map((one) => one.props['children']);
16
+ }
17
+
18
+ describe('FormActions', () => {
19
+ it('★ 顺序由组件决定:给乱序数组,primary 仍在末尾、danger 紧挨它左侧、其余保持调用点顺序', () => {
20
+ const node = FormActions({
21
+ actions: [action('save', 'primary'), action('retry'), action('remove', 'danger'), action('edit')],
22
+ });
23
+
24
+ expect(labelsOf(node)).toEqual(['retry', 'edit', 'remove', 'save']);
25
+ expect(buttonsOf(node).map((one) => one.props['variant'])).toEqual([
26
+ 'ghost',
27
+ 'ghost',
28
+ 'outline',
29
+ 'primary',
30
+ ]);
31
+ });
32
+
33
+ it('没有 primary 时 danger 仍是最后一个(固定槽位,不随调用点顺序漂)', () => {
34
+ const node = FormActions({ actions: [action('remove', 'danger'), action('reset')] });
35
+
36
+ expect(labelsOf(node)).toEqual(['reset', 'remove']);
37
+ });
38
+
39
+ it('★ 两颗 primary 给可诊断信号(点名 key),但不吞、不挪、不丢', () => {
40
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
41
+ try {
42
+ const node = FormActions({ actions: [action('keep', 'primary'), action('also', 'primary')] });
43
+
44
+ expect(spy).toHaveBeenCalledTimes(1);
45
+ const message = String(spy.mock.calls[0]?.[0]);
46
+ expect(message).toContain('[dsh-workbench]');
47
+ expect(message).toContain('keep');
48
+ expect(message).toContain('also');
49
+ expect(labelsOf(node)).toEqual(['keep', 'also']);
50
+ } finally {
51
+ spy.mockRestore();
52
+ }
53
+ });
54
+
55
+ it('一颗 primary 不报', () => {
56
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
57
+ try {
58
+ FormActions({ actions: [action('save', 'primary'), action('cancel')] });
59
+ expect(spy).not.toHaveBeenCalled();
60
+ } finally {
61
+ spy.mockRestore();
62
+ }
63
+ });
64
+
65
+ it('★ 空数组不渲空壳', () => {
66
+ expect(FormActions({ actions: [] })).toBeNull();
67
+ });
68
+
69
+ it('danger 是 outline + 危险类,绝不是 primary 填充', () => {
70
+ const node = FormActions({ actions: [action('remove', 'danger')] });
71
+ const only = buttonsOf(node)[0];
72
+
73
+ expect(only?.props['variant']).toBe('outline');
74
+ expect(only?.props['className']).toBe('wb-formActionDanger');
75
+ });
76
+
77
+ it('busy 那颗禁用并带 Spinner;disabled 单独算', () => {
78
+ const node = FormActions({
79
+ actions: [
80
+ { key: 'save', label: '保存', kind: 'primary', onClick: () => {}, busy: true },
81
+ { key: 'cancel', label: '取消', onClick: () => {}, disabled: true },
82
+ ],
83
+ });
84
+ const [cancel, save] = buttonsOf(node);
85
+
86
+ expect(save?.props['disabled']).toBe(true);
87
+ expect(save?.props['icon']).toBeTruthy();
88
+ expect(cancel?.props['disabled']).toBe(true);
89
+ expect(cancel?.props['icon']).toBeUndefined();
90
+ });
91
+ });
92
+
93
+ const CSS = readFileSync(new URL('./FormActions.css', import.meta.url), 'utf8');
94
+
95
+ function declarations(selector: string): string[] {
96
+ for (const block of CSS.split('}')) {
97
+ const [head, ...rest] = block.split('{');
98
+ if (rest.length === 0 || head === undefined) continue;
99
+ if (head.trim() !== selector) continue;
100
+ return rest
101
+ .join('{')
102
+ .split(';')
103
+ .map((one) => one.replace(/\s+/g, ' ').trim())
104
+ .filter((one) => one !== '');
105
+ }
106
+ throw new Error(`FormActions.css 里没有这条规则:${selector}`);
107
+ }
108
+
109
+ describe('操作区的版式(ADR-0054 §2 规则四)', () => {
110
+ it('★ 窄容器换行 + 整组右对齐 + 唯一一档间距', () => {
111
+ const rule = declarations('.wb-formActions');
112
+
113
+ expect(rule).toContain('flex-wrap: wrap');
114
+ expect(rule).toContain('justify-content: flex-end');
115
+ expect(rule).toContain('gap: var(--wb-len-8)');
116
+ });
117
+
118
+ it('★ 危险色走现成令牌(文字 / 描边 / 悬停)', () => {
119
+ const rule = declarations('.wb-formActionDanger');
120
+
121
+ expect(rule).toContain('color: var(--dsw-alias-state-error-primary)');
122
+ expect(rule.some((one) => one.startsWith('border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary)'))).toBe(
123
+ true,
124
+ );
125
+ expect(rule.some((one) => one.startsWith('--dsw-alias-interactive-bg-hover:'))).toBe(true);
126
+ });
127
+ });
128
+
129
+ describe('SaveBar 自吃狗粮(ADR-0054 §10)', () => {
130
+ it('两个动作喂给 FormActions:取消是 default、保存是 primary(顺序不再由它摆)', () => {
131
+ const node = SaveBar({
132
+ busy: false,
133
+ labels: { cancel: '取消', save: '保存', saving: '保存中…' },
134
+ onCancel: () => {},
135
+ onSave: () => {},
136
+ }) as unknown as { props: { actions: readonly FormAction[] } };
137
+
138
+ expect(node.props.actions.map((one) => [one.key, one.kind])).toEqual([
139
+ ['cancel', 'default'],
140
+ ['save', 'primary'],
141
+ ]);
142
+ });
143
+
144
+ it('busy 时保存那颗显示 saving 文案、禁用且带忙碌标记', () => {
145
+ const node = SaveBar({
146
+ busy: true,
147
+ labels: { cancel: '取消', save: '保存', saving: '保存中…' },
148
+ onCancel: () => {},
149
+ onSave: () => {},
150
+ }) as unknown as { props: { actions: readonly FormAction[] } };
151
+ const save = node.props.actions[1];
152
+
153
+ expect(save?.label).toBe('保存中…');
154
+ expect(save?.disabled).toBe(true);
155
+ expect(save?.busy).toBe(true);
156
+ });
157
+ });
@@ -0,0 +1,75 @@
1
+ import type { ReactNode } from '../lib/react.js';
2
+ import { h } from '../lib/react.js';
3
+ import { Button } from '@deepseek-ai/dsh-client-ui-primitives';
4
+ import { Spinner } from './Spinner.js';
5
+ import './FormActions.css';
6
+
7
+ export type FormActionKind = 'default' | 'danger' | 'primary';
8
+
9
+ export interface FormAction {
10
+ readonly key: string;
11
+ readonly label: ReactNode;
12
+ readonly kind?: FormActionKind | undefined;
13
+ readonly onClick: () => void;
14
+ readonly disabled?: boolean | undefined;
15
+ readonly busy?: boolean | undefined;
16
+ }
17
+
18
+ export interface FormActionsProps {
19
+ readonly actions: readonly FormAction[];
20
+ }
21
+
22
+ const RANK: Record<FormActionKind, number> = { default: 0, danger: 1, primary: 2 };
23
+
24
+ function kindOf(action: FormAction): FormActionKind {
25
+ return action.kind ?? 'default';
26
+ }
27
+
28
+ function ordered(actions: readonly FormAction[]): readonly FormAction[] {
29
+ return actions
30
+ .map((action, index) => ({ action, index, rank: RANK[kindOf(action)] }))
31
+ .sort((left, right) => left.rank - right.rank || left.index - right.index)
32
+ .map((one) => one.action);
33
+ }
34
+
35
+ function reportExtraPrimaries(actions: readonly FormAction[]): void {
36
+ const keys = actions.filter((one) => kindOf(one) === 'primary').map((one) => one.key);
37
+ if (keys.length < 2) return;
38
+ console.error(
39
+ `[dsh-workbench] FormActions 一个区里出现了 ${keys.length} 颗 primary(${keys.join('、')})—— ` +
40
+ '一个区只许一颗主操作(ADR-0054 §2 规则二):多余的这颗该改成 default',
41
+ );
42
+ }
43
+
44
+ function variantOf(kind: FormActionKind): 'ghost' | 'outline' | 'primary' {
45
+ if (kind === 'primary') return 'primary';
46
+ if (kind === 'danger') return 'outline';
47
+ return 'ghost';
48
+ }
49
+
50
+ export function FormActions({ actions }: FormActionsProps) {
51
+ if (actions.length === 0) return null;
52
+ reportExtraPrimaries(actions);
53
+ return (
54
+ <div className="wb-formActions">
55
+ {ordered(actions).map((action) => {
56
+ const kind = kindOf(action);
57
+ const busy = action.busy === true;
58
+ return (
59
+ <Button
60
+ key={action.key}
61
+ type="button"
62
+ size="sm"
63
+ variant={variantOf(kind)}
64
+ className={kind === 'danger' ? 'wb-formActionDanger' : undefined}
65
+ disabled={busy || action.disabled === true}
66
+ icon={busy ? <Spinner size={12} /> : undefined}
67
+ onClick={action.onClick}
68
+ >
69
+ {action.label}
70
+ </Button>
71
+ );
72
+ })}
73
+ </div>
74
+ );
75
+ }
@@ -0,0 +1,69 @@
1
+ .wb-formRows {
2
+ display: grid;
3
+ grid-template-columns: minmax(72px, max-content) minmax(0, 1fr);
4
+ column-gap: var(--wb-len-8);
5
+ row-gap: var(--wb-len-12);
6
+ }
7
+
8
+ .wb-formRows > :not(.wb-formRow) {
9
+ grid-column: 1 / -1;
10
+ }
11
+
12
+ .wb-formRow {
13
+ display: grid;
14
+ grid-column: 1 / -1;
15
+ grid-template-columns: minmax(72px, max-content) minmax(0, 1fr);
16
+ align-items: center;
17
+ column-gap: var(--wb-len-8);
18
+ row-gap: var(--wb-len-8);
19
+ }
20
+
21
+ .wb-formRows > .wb-formRow {
22
+ grid-template-columns: subgrid;
23
+ }
24
+
25
+ .wb-formRow[data-layout='stacked'] {
26
+ grid-template-columns: minmax(0, 1fr);
27
+ align-items: stretch;
28
+ gap: var(--wb-len-6);
29
+ }
30
+
31
+ .wb-formLabel {
32
+ min-width: 0;
33
+ font-size: var(--dsw-font-xs-13-font-size);
34
+ color: var(--dsw-alias-label-secondary);
35
+ overflow-wrap: anywhere;
36
+ white-space: nowrap;
37
+ }
38
+
39
+ .wb-formRow[data-layout='stacked'] .wb-formLabel {
40
+ white-space: normal;
41
+ }
42
+
43
+ .wb-formControl {
44
+ display: flex;
45
+ align-items: center;
46
+ min-width: 0;
47
+ }
48
+
49
+ .wb-formHint {
50
+ grid-column: 2;
51
+ font-size: var(--dsw-font-xxs-12-font-size);
52
+ line-height: var(--wb-line-18);
53
+ color: var(--dsw-alias-label-tertiary);
54
+ }
55
+
56
+ .wb-formProblem {
57
+ grid-column: 2;
58
+ font-size: var(--dsw-font-xxs-12-font-size);
59
+ line-height: var(--wb-line-18);
60
+ color: var(--dsw-alias-state-error-primary);
61
+ }
62
+
63
+ .wb-formRow:has(.wb-formProblem) .wb-formLabel {
64
+ color: var(--dsw-alias-state-error-primary);
65
+ }
66
+
67
+ .wb-formRow:has(:disabled) .wb-formLabel {
68
+ opacity: 0.4;
69
+ }
@@ -0,0 +1,138 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { FormRow, FormRows } from './FormRows.js';
4
+
5
+ function texts(node: unknown): string[] {
6
+ if (node === null || node === undefined || typeof node === 'boolean') return [];
7
+ if (typeof node === 'string') return [node];
8
+ if (typeof node === 'number') return [String(node)];
9
+ if (Array.isArray(node)) return node.flatMap((one) => texts(one));
10
+ const element = node as { props?: { children?: unknown } };
11
+ return texts(element.props?.children);
12
+ }
13
+
14
+ function childrenOf(node: unknown): unknown[] {
15
+ return (node as { props: { children: unknown } }).props.children as unknown[];
16
+ }
17
+
18
+ function noteNodeOf(node: unknown, className: string): unknown {
19
+ return childrenOf(node).find(
20
+ (one) => (one as { props?: Record<string, unknown> } | null)?.props?.['className'] === className,
21
+ );
22
+ }
23
+
24
+ describe('FormRow', () => {
25
+ it('标签、控件、提示按顺序出现(提示排在控件下面那一格)', () => {
26
+ expect(texts(FormRow({ label: '项目', hint: '选错了也能改', children: '下拉' }))).toEqual([
27
+ '项目',
28
+ '下拉',
29
+ '选错了也能改',
30
+ ]);
31
+ });
32
+
33
+ it('默认是「标签在左」的两列;stacked 才是上下堆叠', () => {
34
+ const inline = FormRow({ label: '项目', children: '下拉' }) as unknown as { props: Record<string, unknown> };
35
+ const stacked = FormRow({ label: '项目', layout: 'stacked', children: '下拉' }) as unknown as {
36
+ props: Record<string, unknown>;
37
+ };
38
+
39
+ expect(inline.props['data-layout']).toBe('inline');
40
+ expect(stacked.props['data-layout']).toBe('stacked');
41
+ expect(inline.props['className']).toBe('wb-formRow');
42
+ });
43
+
44
+ it('没给提示就不多一个空节点(否则每行都空一截)', () => {
45
+ const node = FormRow({ label: '项目', children: '下拉' });
46
+
47
+ expect(texts(node)).toEqual(['项目', '下拉']);
48
+ expect(childrenOf(node).filter((one) => one !== null)).toHaveLength(2);
49
+ });
50
+
51
+ it('有 problem 就顶掉 hint(同一格,二选一),错误行挂 data-wb-problem 给聚焦那件找', () => {
52
+ const node = FormRow({
53
+ label: '项目',
54
+ hint: '只列非生产',
55
+ problem: '这个项目不存在',
56
+ children: '下拉',
57
+ }) as unknown as { props: Record<string, unknown> };
58
+
59
+ expect(texts(node)).toEqual(['项目', '下拉', '这个项目不存在']);
60
+ expect((noteNodeOf(node, 'wb-formProblem') as { props: Record<string, unknown> }).props['className']).toBe(
61
+ 'wb-formProblem',
62
+ );
63
+ expect(node.props['data-wb-problem']).toBe('');
64
+ });
65
+
66
+ it('没有 problem 就不多挂标记,hint 照常', () => {
67
+ const node = FormRow({ label: '项目', hint: '只列非生产', children: '下拉' }) as unknown as {
68
+ props: Record<string, unknown>;
69
+ };
70
+
71
+ expect(node.props['data-wb-problem']).toBeUndefined();
72
+ expect(noteNodeOf(node, 'wb-formHint')).toBeDefined();
73
+ expect(noteNodeOf(node, 'wb-formProblem')).toBeUndefined();
74
+ expect(texts(node)).toEqual(['项目', '下拉', '只列非生产']);
75
+ });
76
+
77
+ it('FormRows 只是一列容器,不替调用点决定格子', () => {
78
+ const node = FormRows({ children: '两行' }) as unknown as { props: Record<string, unknown> };
79
+
80
+ expect(node.props['className']).toBe('wb-formRows');
81
+ expect(texts(node)).toEqual(['两行']);
82
+ });
83
+ });
84
+
85
+ const FORM_CSS = readFileSync(new URL('./FormRows.css', import.meta.url), 'utf8');
86
+
87
+ function declarations(selector: string): string[] {
88
+ for (const block of FORM_CSS.split('}')) {
89
+ const [head, ...rest] = block.split('{');
90
+ if (rest.length === 0 || head === undefined) continue;
91
+ if (head.trim() !== selector) continue;
92
+ return rest
93
+ .join('{')
94
+ .split(';')
95
+ .map((one) => one.replace(/\s+/g, ' ').trim())
96
+ .filter((one) => one !== '');
97
+ }
98
+ throw new Error(`FormRows.css 里没有这条规则:${selector}`);
99
+ }
100
+
101
+ describe('标签列:同一个 FormRows 共用一列(ADR-0052 §11)', () => {
102
+ it('★ FormRows 自己是一个 grid:标签列 minmax(72px, max-content),行距还是那一档', () => {
103
+ const rows = declarations('.wb-formRows');
104
+
105
+ expect(rows).toContain('display: grid');
106
+ expect(rows).toContain('grid-template-columns: minmax(72px, max-content) minmax(0, 1fr)');
107
+ expect(rows).toContain('row-gap: var(--wb-len-12)');
108
+ });
109
+
110
+ it('★ 行先留回退两列,再由 subgrid 继承父网格那两列(老浏览器丢掉 subgrid 仍有回退)', () => {
111
+ expect(declarations('.wb-formRow')).toContain(
112
+ 'grid-template-columns: minmax(72px, max-content) minmax(0, 1fr)',
113
+ );
114
+ expect(declarations('.wb-formRows > .wb-formRow')).toContain('grid-template-columns: subgrid');
115
+ });
116
+
117
+ it('★ 行自己的列距与 FormRows 那一档同值(不同值就是控件起点参差)', () => {
118
+ const rowGap = declarations('.wb-formRow').find((one) => one.startsWith('column-gap:'));
119
+ const rowsGap = declarations('.wb-formRows').find((one) => one.startsWith('column-gap:'));
120
+
121
+ expect(rowGap).toBe('column-gap: var(--wb-len-8)');
122
+ expect(rowGap).toBe(rowsGap);
123
+ });
124
+
125
+ it('★ 标签不折行;stacked 还是单列、标签照常换行', () => {
126
+ expect(declarations('.wb-formLabel')).toContain('white-space: nowrap');
127
+ expect(declarations(".wb-formRow[data-layout='stacked'] .wb-formLabel")).toContain(
128
+ 'white-space: normal',
129
+ );
130
+ expect(declarations(".wb-formRow[data-layout='stacked']")).toContain(
131
+ 'grid-template-columns: minmax(0, 1fr)',
132
+ );
133
+ });
134
+
135
+ it('★ 不是 FormRow 的子项(分组标题那种)通栏,别被塞进标签那一列', () => {
136
+ expect(declarations('.wb-formRows > :not(.wb-formRow)')).toContain('grid-column: 1 / -1');
137
+ });
138
+ });
@@ -0,0 +1,39 @@
1
+ import type { ReactNode } from '../lib/react.js';
2
+ import { h } from '../lib/react.js';
3
+ import './FormRows.css';
4
+
5
+ export interface FormRowsProps {
6
+ readonly children?: ReactNode | undefined;
7
+ }
8
+
9
+ export function FormRows({ children }: FormRowsProps) {
10
+ return <div className="wb-formRows">{children}</div>;
11
+ }
12
+
13
+ export interface FormRowProps {
14
+ readonly label: ReactNode;
15
+ readonly hint?: ReactNode | undefined;
16
+ readonly problem?: ReactNode | undefined;
17
+ readonly layout?: 'inline' | 'stacked' | undefined;
18
+ readonly children?: ReactNode | undefined;
19
+ }
20
+
21
+ export function FormRow({ label, hint, problem, layout = 'inline', children }: FormRowProps) {
22
+ return (
23
+ <label
24
+ className="wb-formRow"
25
+ data-layout={layout}
26
+ data-wb-problem={problem === undefined ? undefined : ''}
27
+ >
28
+ <span className="wb-formLabel">{label}</span>
29
+ <span className="wb-formControl">{children}</span>
30
+ {problem === undefined ? (
31
+ hint === undefined ? null : (
32
+ <span className="wb-formHint">{hint}</span>
33
+ )
34
+ ) : (
35
+ <span className="wb-formProblem">{problem}</span>
36
+ )}
37
+ </label>
38
+ );
39
+ }
@@ -1,6 +1,6 @@
1
- import { Button } from '@deepseek-ai/dsh-client-ui-primitives';
2
1
  import { h } from '../lib/react.js';
3
- import './SaveBar.css';
2
+ import type { FormAction } from './FormActions.js';
3
+ import { FormActions } from './FormActions.js';
4
4
 
5
5
  export interface SaveBarProps {
6
6
  readonly busy: boolean;
@@ -15,20 +15,16 @@ export interface SaveBarProps {
15
15
  }
16
16
 
17
17
  export function SaveBar({ busy, disabled, labels, onCancel, onSave }: SaveBarProps) {
18
- return (
19
- <div className="wb-formActions">
20
- <Button type="button" size="sm" variant="ghost" onClick={onCancel} disabled={busy}>
21
- {labels.cancel}
22
- </Button>
23
- <Button
24
- type="button"
25
- size="sm"
26
- variant="primary"
27
- onClick={onSave}
28
- disabled={busy || disabled === true}
29
- >
30
- {busy ? labels.saving : labels.save}
31
- </Button>
32
- </div>
33
- );
18
+ const actions: readonly FormAction[] = [
19
+ { key: 'cancel', label: labels.cancel, kind: 'default', onClick: onCancel, disabled: busy },
20
+ {
21
+ key: 'save',
22
+ label: busy ? labels.saving : labels.save,
23
+ kind: 'primary',
24
+ onClick: onSave,
25
+ disabled: busy || disabled === true,
26
+ busy,
27
+ },
28
+ ];
29
+ return <FormActions actions={actions} />;
34
30
  }
@@ -4,8 +4,8 @@
4
4
  align-items: center;
5
5
  gap: var(--wb-len-8);
6
6
  width: 100%;
7
- min-height: var(--wb-len-32);
8
- padding: var(--wb-len-6) var(--wb-len-12);
7
+ min-height: var(--wb-len-28);
8
+ padding: var(--wb-len-4) var(--wb-len-10);
9
9
  border: 0.5px solid var(--dsw-alias-border-l4);
10
10
  border-radius: var(--dsw-radius-md);
11
11
  background: var(--dsw-alias-bg-layer-3);
@@ -21,6 +21,14 @@
21
21
  box-shadow: inset 0 0 0 0.5px var(--dsw-alias-state-business-primary);
22
22
  }
23
23
 
24
+ .wb-selectTrigger[aria-invalid='true'] {
25
+ border-color: var(--dsw-alias-state-error-primary);
26
+ }
27
+
28
+ .wb-selectTrigger[aria-invalid='true']:focus-visible {
29
+ box-shadow: inset 0 0 0 0.5px var(--dsw-alias-state-error-primary);
30
+ }
31
+
24
32
  .wb-selectTrigger:disabled {
25
33
  cursor: not-allowed;
26
34
  opacity: 0.4;
@@ -100,8 +108,8 @@
100
108
  align-items: center;
101
109
  gap: var(--wb-len-8);
102
110
  width: 100%;
103
- min-height: var(--wb-len-32);
104
- padding: var(--wb-len-6) var(--wb-len-12);
111
+ min-height: var(--wb-len-28);
112
+ padding: var(--wb-len-4) var(--wb-len-10);
105
113
  border: 0.5px solid var(--dsw-alias-border-l4);
106
114
  border-radius: var(--dsw-radius-md);
107
115
  background: var(--dsw-alias-bg-layer-3);
@@ -11,7 +11,8 @@ vi.mock('../lib/react.js', () => ({
11
11
 
12
12
  vi.mock('./SelectField.css', () => ({ default: {} }));
13
13
 
14
- import { Menu, StateDot, TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives';
14
+ import { Menu, TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives';
15
+ import { Spinner } from './Spinner.js';
15
16
  import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives';
16
17
  import { h } from '../lib/react.js';
17
18
  import { Field } from './Field.js';
@@ -169,7 +170,7 @@ describe('SelectField:工作区那套下拉的公共形状', () => {
169
170
  expect(busy?.props['aria-busy']).toBe('true');
170
171
  expect(busy?.props['disabled']).toBe(true);
171
172
  expect(elementsOf(busy?.children).some((one) => one.type === TextShimmer)).toBe(true);
172
- expect(elementsOf(busy?.children).some((one) => one.type === StateDot)).toBe(true);
173
+ expect(elementsOf(busy?.children).some((one) => one.type === Spinner)).toBe(true);
173
174
 
174
175
  const fallback = render({
175
176
  options: [],
@@ -178,6 +179,23 @@ describe('SelectField:工作区那套下拉的公共形状', () => {
178
179
  expect(inputOf(fallback)?.props['value']).toBe('手填的');
179
180
  });
180
181
 
182
+ it('invalid:触发钮与退化成手填那格都挂 aria-invalid,没给就不挂', () => {
183
+ const trigger = elementsOf(menuOf(render({ invalid: true })).props['anchor']).find(
184
+ (one) => one.type === 'button',
185
+ );
186
+ expect(trigger?.props['aria-invalid']).toBe(true);
187
+
188
+ const ok = elementsOf(menuOf(render({})).props['anchor']).find((one) => one.type === 'button');
189
+ expect(ok?.props['aria-invalid']).toBeUndefined();
190
+
191
+ const fallback = render({
192
+ options: [],
193
+ invalid: true,
194
+ fallback: { value: '手填的', onChange: () => {} },
195
+ });
196
+ expect(inputOf(fallback)?.props['invalid']).toBe(true);
197
+ });
198
+
181
199
  it('label / hint 走 Field,footer 那条动作独立成一行', () => {
182
200
  const root = render({ label: '工作区', hint: '三选一' });
183
201
  expect(elementsOf(root).some((one) => one.type === Field)).toBe(true);