@dsh-workbench/shared 0.2.11 → 0.2.13

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 (40) 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/FormRows.css +53 -0
  5. package/dist/components/FormRows.d.ts +14 -0
  6. package/dist/components/FormRows.js +11 -0
  7. package/dist/components/MockStrip.css +5 -1
  8. package/dist/components/SelectField.css +12 -4
  9. package/dist/components/SelectField.d.ts +1 -0
  10. package/dist/components/SelectField.js +9 -7
  11. package/dist/components/Spinner.css +6 -0
  12. package/dist/components/Spinner.d.ts +6 -0
  13. package/dist/components/Spinner.js +7 -0
  14. package/dist/components/TextField.css +2 -2
  15. package/dist/host.d.ts +3 -3
  16. package/dist/host.js +3 -3
  17. package/dist/index.d.ts +7 -5
  18. package/dist/index.js +7 -5
  19. package/dist/lib/form-problem.d.ts +2 -0
  20. package/dist/lib/form-problem.js +20 -0
  21. package/package.json +2 -1
  22. package/src/components/ChoiceGroup.css +53 -3
  23. package/src/components/ChoiceGroup.test.tsx +88 -0
  24. package/src/components/ChoiceGroup.tsx +70 -28
  25. package/src/components/FormRows.css +53 -0
  26. package/src/components/FormRows.test.tsx +82 -0
  27. package/src/components/FormRows.tsx +39 -0
  28. package/src/components/SelectField.css +12 -4
  29. package/src/components/SelectField.test.tsx +20 -2
  30. package/src/components/SelectField.tsx +11 -3
  31. package/src/components/Spinner.css +6 -0
  32. package/src/components/Spinner.test.tsx +35 -0
  33. package/src/components/Spinner.tsx +19 -0
  34. package/src/components/TextField.css +2 -2
  35. package/src/host.ts +3 -3
  36. package/src/index.ts +7 -5
  37. package/src/lib/form-problem.test.ts +76 -0
  38. package/src/lib/form-problem.ts +22 -0
  39. package/src/components/MockStrip.css +0 -45
  40. package/src/components/MockStrip.tsx +0 -41
@@ -195,6 +195,94 @@ describe('ChoiceGroup:单选照旧,多选是新加的那条路', () => {
195
195
  expect(inputs.every((one) => one.props['disabled'] === true)).toBe(true);
196
196
  });
197
197
 
198
+ it('★ loading:不画选项,画一行 Spinner + loadingText', () => {
199
+ const element = ChoiceGroup({
200
+ label: '环境',
201
+ name: 'env',
202
+ value: '',
203
+ options: OPTIONS,
204
+ loading: true,
205
+ loadingText: '正在读环境…',
206
+ onChange: () => {},
207
+ });
208
+ const tree = treeOf(element);
209
+ const loading = tree.find((one) => one.props['className'] === 'wb-choiceLoading');
210
+
211
+ expect(inputsOf(element)).toHaveLength(0);
212
+ expect(tree.some((one) => one.props['className'] === 'wb-choiceOption')).toBe(false);
213
+ expect(
214
+ treeOf(loading?.props['children']).some(
215
+ (one) => one.props['className'] === 'wb-spinner',
216
+ ),
217
+ ).toBe(true);
218
+ expect(
219
+ treeOf(loading?.props['children']).some(
220
+ (one) => one.props['children'] === '正在读环境…',
221
+ ),
222
+ ).toBe(true);
223
+ });
224
+
225
+ it('★ 没得选且不 loading:画 emptyText 那句,不画选项', () => {
226
+ const element = ChoiceGroup({
227
+ label: '环境',
228
+ name: 'env',
229
+ value: '',
230
+ options: [],
231
+ emptyText: '这个工作区还没绑定环境',
232
+ onChange: () => {},
233
+ });
234
+
235
+ expect(inputsOf(element)).toHaveLength(0);
236
+ expect(
237
+ treeOf(element).find((one) => one.props['className'] === 'wb-choiceEmpty')
238
+ ?.props['children'],
239
+ ).toBe('这个工作区还没绑定环境');
240
+ });
241
+
242
+ it('★ variant 落在菜单上:ghost 写 data-variant=ghost,默认是 boxed', () => {
243
+ const menuOf = (variant?: 'boxed' | 'ghost') =>
244
+ treeOf(
245
+ ChoiceGroup({
246
+ label: '环境',
247
+ name: 'env',
248
+ value: 'tencent-test',
249
+ options: OPTIONS,
250
+ ...(variant === undefined ? {} : { variant }),
251
+ onChange: () => {},
252
+ }),
253
+ ).find((one) => one.props['className'] === 'wb-choiceMenu');
254
+
255
+ expect(menuOf('ghost')?.props['data-variant']).toBe('ghost');
256
+ expect(menuOf(undefined)?.props['data-variant']).toBe('boxed');
257
+ });
258
+
259
+ it('★ description 画在 label 下面第二行', () => {
260
+ const element = ChoiceGroup({
261
+ label: '环境',
262
+ name: 'env',
263
+ value: 'tencent-test',
264
+ options: [
265
+ { value: 'tencent-test', label: '腾讯云 · 测试', description: '走的是测试账号' },
266
+ { value: 'tencent-prod', label: '腾讯云 · 生产' },
267
+ ],
268
+ onChange: () => {},
269
+ });
270
+ const labels = treeOf(element).filter(
271
+ (one) => one.props['className'] === 'wb-choiceLabel',
272
+ );
273
+ const descriptions = treeOf(element).filter(
274
+ (one) => one.props['className'] === 'wb-choiceDescription',
275
+ );
276
+
277
+ expect(descriptions).toHaveLength(1);
278
+ expect(descriptions[0]?.props['children']).toBe('走的是测试账号');
279
+ expect(
280
+ treeOf(labels[0]?.props['children']).some(
281
+ (one) => one.props['className'] === 'wb-choiceDescription',
282
+ ),
283
+ ).toBe(true);
284
+ });
285
+
198
286
  it('Fragment 还是 React 自己的(别把 key 语义换掉)', () => {
199
287
  expect(Fragment).toBeDefined();
200
288
  });
@@ -1,11 +1,13 @@
1
1
  import type { ReactNode } from '../lib/react.js';
2
2
  import { Fragment, h } from '../lib/react.js';
3
3
  import { IconCheckOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives';
4
+ import { Spinner } from './Spinner.js';
4
5
  import './ChoiceGroup.css';
5
6
 
6
7
  export interface ChoiceGroupOption {
7
8
  readonly value: string;
8
9
  readonly label: ReactNode;
10
+ readonly description?: ReactNode | undefined;
9
11
  }
10
12
 
11
13
  interface ChoiceGroupCommon {
@@ -13,6 +15,10 @@ interface ChoiceGroupCommon {
13
15
  readonly name: string;
14
16
  readonly options: readonly ChoiceGroupOption[];
15
17
  readonly disabled?: boolean | undefined;
18
+ readonly variant?: 'boxed' | 'ghost' | undefined;
19
+ readonly loading?: boolean | undefined;
20
+ readonly loadingText?: ReactNode | undefined;
21
+ readonly emptyText?: ReactNode | undefined;
16
22
  }
17
23
 
18
24
  export interface ChoiceGroupSingleProps extends ChoiceGroupCommon {
@@ -40,42 +46,66 @@ function ChoiceMenu(input: {
40
46
  readonly options: readonly ChoiceGroupOption[];
41
47
  readonly disabled: boolean;
42
48
  readonly multi: boolean;
49
+ readonly variant: 'boxed' | 'ghost';
50
+ readonly loading: boolean;
51
+ readonly loadingText: ReactNode | undefined;
52
+ readonly emptyText: ReactNode | undefined;
43
53
  readonly rowOf: (value: string) => ChoiceRowState;
44
54
  }) {
55
+ const body = input.loading ? (
56
+ <span className="wb-choiceLoading" role="status">
57
+ <Spinner size={14} />
58
+ <span className="wb-choiceLoadingText">{input.loadingText ?? '…'}</span>
59
+ </span>
60
+ ) : input.options.length === 0 ? (
61
+ input.emptyText === undefined ? null : (
62
+ <span className="wb-choiceEmpty">{input.emptyText}</span>
63
+ )
64
+ ) : (
65
+ input.options.map((option) => {
66
+ const row = input.rowOf(option.value);
67
+ return (
68
+ <Fragment key={option.value}>
69
+ <label
70
+ className={`wb-choiceOption${row.checked ? ' wb-choiceOptionOn' : ''}`}
71
+ >
72
+ <input
73
+ className={input.multi ? 'wb-checkbox' : 'wb-radio'}
74
+ type={input.multi ? 'checkbox' : 'radio'}
75
+ name={input.name}
76
+ checked={row.checked}
77
+ disabled={input.disabled}
78
+ onChange={row.onSelect}
79
+ />
80
+ {input.multi ? (
81
+ <span
82
+ className="wb-checkboxBox"
83
+ aria-hidden="true"
84
+ >
85
+ {row.checked ? <IconCheckOutlineRegular size={12} /> : null}
86
+ </span>
87
+ ) : null}
88
+ <span className="wb-choiceLabel">
89
+ <span className="wb-choiceLabelText">{option.label}</span>
90
+ {option.description === undefined ? null : (
91
+ <span className="wb-choiceDescription">{option.description}</span>
92
+ )}
93
+ </span>
94
+ </label>
95
+ </Fragment>
96
+ );
97
+ })
98
+ );
99
+
45
100
  return (
46
101
  <div
47
102
  className="wb-choiceMenu"
103
+ data-variant={input.variant}
48
104
  role={input.multi ? 'group' : 'radiogroup'}
49
105
  aria-label={input.label}
106
+ aria-busy={input.loading ? true : undefined}
50
107
  >
51
- {input.options.map((option) => {
52
- const row = input.rowOf(option.value);
53
- return (
54
- <Fragment key={option.value}>
55
- <label
56
- className={`wb-choiceOption${row.checked ? ' wb-choiceOptionOn' : ''}`}
57
- >
58
- <input
59
- className={input.multi ? 'wb-checkbox' : 'wb-radio'}
60
- type={input.multi ? 'checkbox' : 'radio'}
61
- name={input.name}
62
- checked={row.checked}
63
- disabled={input.disabled}
64
- onChange={row.onSelect}
65
- />
66
- {input.multi ? (
67
- <span
68
- className="wb-checkboxBox"
69
- aria-hidden="true"
70
- >
71
- {row.checked ? <IconCheckOutlineRegular size={12} /> : null}
72
- </span>
73
- ) : null}
74
- <span className="wb-choiceLabel">{option.label}</span>
75
- </label>
76
- </Fragment>
77
- );
78
- })}
108
+ {body}
79
109
  </div>
80
110
  );
81
111
  }
@@ -86,6 +116,10 @@ export function ChoiceGroup(props: ChoiceGroupProps) {
86
116
  name: props.name,
87
117
  options: props.options,
88
118
  disabled: props.disabled === true,
119
+ variant: props.variant ?? 'boxed',
120
+ loading: props.loading === true,
121
+ loadingText: props.loadingText,
122
+ emptyText: props.emptyText,
89
123
  };
90
124
 
91
125
  if (props.multi === true) {
@@ -98,6 +132,10 @@ export function ChoiceGroup(props: ChoiceGroupProps) {
98
132
  options={common.options}
99
133
  disabled={common.disabled}
100
134
  multi={true}
135
+ variant={common.variant}
136
+ loading={common.loading}
137
+ loadingText={common.loadingText}
138
+ emptyText={common.emptyText}
101
139
  rowOf={(value) => ({
102
140
  checked: values.includes(value),
103
141
  onSelect: () => {
@@ -121,6 +159,10 @@ export function ChoiceGroup(props: ChoiceGroupProps) {
121
159
  options={common.options}
122
160
  disabled={common.disabled}
123
161
  multi={false}
162
+ variant={common.variant}
163
+ loading={common.loading}
164
+ loadingText={common.loadingText}
165
+ emptyText={common.emptyText}
124
166
  rowOf={(option) => ({
125
167
  checked: option === value,
126
168
  onSelect: () => {
@@ -0,0 +1,53 @@
1
+ .wb-formRows {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--wb-len-12);
5
+ }
6
+
7
+ .wb-formRow {
8
+ display: grid;
9
+ grid-template-columns: 72px minmax(0, 1fr);
10
+ align-items: center;
11
+ gap: var(--wb-len-8);
12
+ }
13
+
14
+ .wb-formRow[data-layout='stacked'] {
15
+ grid-template-columns: minmax(0, 1fr);
16
+ align-items: stretch;
17
+ gap: var(--wb-len-6);
18
+ }
19
+
20
+ .wb-formLabel {
21
+ min-width: 0;
22
+ font-size: var(--dsw-font-xs-13-font-size);
23
+ color: var(--dsw-alias-label-secondary);
24
+ overflow-wrap: anywhere;
25
+ }
26
+
27
+ .wb-formControl {
28
+ display: flex;
29
+ align-items: center;
30
+ min-width: 0;
31
+ }
32
+
33
+ .wb-formHint {
34
+ grid-column: 2;
35
+ font-size: var(--dsw-font-xxs-12-font-size);
36
+ line-height: var(--wb-line-18);
37
+ color: var(--dsw-alias-label-tertiary);
38
+ }
39
+
40
+ .wb-formProblem {
41
+ grid-column: 2;
42
+ font-size: var(--dsw-font-xxs-12-font-size);
43
+ line-height: var(--wb-line-18);
44
+ color: var(--dsw-alias-state-error-primary);
45
+ }
46
+
47
+ .wb-formRow:has(.wb-formProblem) .wb-formLabel {
48
+ color: var(--dsw-alias-state-error-primary);
49
+ }
50
+
51
+ .wb-formRow:has(:disabled) .wb-formLabel {
52
+ opacity: 0.4;
53
+ }
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { FormRow, FormRows } from './FormRows.js';
3
+
4
+ function texts(node: unknown): string[] {
5
+ if (node === null || node === undefined || typeof node === 'boolean') return [];
6
+ if (typeof node === 'string') return [node];
7
+ if (typeof node === 'number') return [String(node)];
8
+ if (Array.isArray(node)) return node.flatMap((one) => texts(one));
9
+ const element = node as { props?: { children?: unknown } };
10
+ return texts(element.props?.children);
11
+ }
12
+
13
+ function childrenOf(node: unknown): unknown[] {
14
+ return (node as { props: { children: unknown } }).props.children as unknown[];
15
+ }
16
+
17
+ function noteNodeOf(node: unknown, className: string): unknown {
18
+ return childrenOf(node).find(
19
+ (one) => (one as { props?: Record<string, unknown> } | null)?.props?.['className'] === className,
20
+ );
21
+ }
22
+
23
+ describe('FormRow', () => {
24
+ it('标签、控件、提示按顺序出现(提示排在控件下面那一格)', () => {
25
+ expect(texts(FormRow({ label: '项目', hint: '选错了也能改', children: '下拉' }))).toEqual([
26
+ '项目',
27
+ '下拉',
28
+ '选错了也能改',
29
+ ]);
30
+ });
31
+
32
+ it('默认是「标签在左」的两列;stacked 才是上下堆叠', () => {
33
+ const inline = FormRow({ label: '项目', children: '下拉' }) as unknown as { props: Record<string, unknown> };
34
+ const stacked = FormRow({ label: '项目', layout: 'stacked', children: '下拉' }) as unknown as {
35
+ props: Record<string, unknown>;
36
+ };
37
+
38
+ expect(inline.props['data-layout']).toBe('inline');
39
+ expect(stacked.props['data-layout']).toBe('stacked');
40
+ expect(inline.props['className']).toBe('wb-formRow');
41
+ });
42
+
43
+ it('没给提示就不多一个空节点(否则每行都空一截)', () => {
44
+ const node = FormRow({ label: '项目', children: '下拉' });
45
+
46
+ expect(texts(node)).toEqual(['项目', '下拉']);
47
+ expect(childrenOf(node).filter((one) => one !== null)).toHaveLength(2);
48
+ });
49
+
50
+ it('有 problem 就顶掉 hint(同一格,二选一),错误行挂 data-wb-problem 给聚焦那件找', () => {
51
+ const node = FormRow({
52
+ label: '项目',
53
+ hint: '只列非生产',
54
+ problem: '这个项目不存在',
55
+ children: '下拉',
56
+ }) as unknown as { props: Record<string, unknown> };
57
+
58
+ expect(texts(node)).toEqual(['项目', '下拉', '这个项目不存在']);
59
+ expect((noteNodeOf(node, 'wb-formProblem') as { props: Record<string, unknown> }).props['className']).toBe(
60
+ 'wb-formProblem',
61
+ );
62
+ expect(node.props['data-wb-problem']).toBe('');
63
+ });
64
+
65
+ it('没有 problem 就不多挂标记,hint 照常', () => {
66
+ const node = FormRow({ label: '项目', hint: '只列非生产', children: '下拉' }) as unknown as {
67
+ props: Record<string, unknown>;
68
+ };
69
+
70
+ expect(node.props['data-wb-problem']).toBeUndefined();
71
+ expect(noteNodeOf(node, 'wb-formHint')).toBeDefined();
72
+ expect(noteNodeOf(node, 'wb-formProblem')).toBeUndefined();
73
+ expect(texts(node)).toEqual(['项目', '下拉', '只列非生产']);
74
+ });
75
+
76
+ it('FormRows 只是一列容器,不替调用点决定格子', () => {
77
+ const node = FormRows({ children: '两行' }) as unknown as { props: Record<string, unknown> };
78
+
79
+ expect(node.props['className']).toBe('wb-formRows');
80
+ expect(texts(node)).toEqual(['两行']);
81
+ });
82
+ });
@@ -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
+ }
@@ -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);
@@ -5,11 +5,11 @@ import {
5
5
  IconChevronDownOutlineRegular,
6
6
  IconSearchOutlineRegular,
7
7
  Menu,
8
- StateDot,
9
8
  TextShimmer,
10
9
  } from '@deepseek-ai/dsh-client-ui-primitives';
11
10
  import { Field } from './Field.js';
12
11
  import { TextField } from './TextField.js';
12
+ import { Spinner } from './Spinner.js';
13
13
  import './SelectField.css';
14
14
 
15
15
  export interface SelectFieldOption {
@@ -48,6 +48,7 @@ export interface SelectFieldProps {
48
48
  readonly open?: boolean | undefined;
49
49
  readonly onOpenChange?: ((open: boolean) => void) | undefined;
50
50
  readonly disabled?: boolean | undefined;
51
+ readonly invalid?: boolean | undefined;
51
52
  readonly loading?: boolean | undefined;
52
53
  readonly loadingText?: string | undefined;
53
54
  readonly fallback?: SelectFieldFallback | undefined;
@@ -83,6 +84,7 @@ function triggerNode(args: {
83
84
  readonly id: string | undefined;
84
85
  readonly className: string;
85
86
  readonly disabled: boolean;
87
+ readonly invalid: boolean;
86
88
  readonly open: boolean;
87
89
  readonly ariaLabel: string | undefined;
88
90
  readonly title: string | undefined;
@@ -98,6 +100,7 @@ function triggerNode(args: {
98
100
  disabled={args.disabled}
99
101
  aria-haspopup="listbox"
100
102
  aria-expanded={args.open}
103
+ aria-invalid={args.invalid || undefined}
101
104
  aria-label={args.ariaLabel}
102
105
  title={args.title}
103
106
  onClick={args.onOpen}
@@ -114,6 +117,7 @@ function triggerNode(args: {
114
117
  function loadingNode(args: {
115
118
  readonly id: string | undefined;
116
119
  readonly className: string;
120
+ readonly invalid: boolean;
117
121
  readonly ariaLabel: string | undefined;
118
122
  readonly loadingText: string | undefined;
119
123
  }): ReactNode {
@@ -124,6 +128,7 @@ function loadingNode(args: {
124
128
  className={`${args.className} wb-selectLoading`}
125
129
  disabled
126
130
  aria-busy="true"
131
+ aria-invalid={args.invalid || undefined}
127
132
  aria-label={args.ariaLabel}
128
133
  title={args.loadingText}
129
134
  >
@@ -131,7 +136,7 @@ function loadingNode(args: {
131
136
  <TextShimmer active>{args.loadingText ?? '…'}</TextShimmer>
132
137
  </span>
133
138
  <span className="wb-selectChevron">
134
- <StateDot state="ongoing" size={14} />
139
+ <Spinner size={14} />
135
140
  </span>
136
141
  </button>
137
142
  );
@@ -357,6 +362,7 @@ export function SelectField(props: SelectFieldProps) {
357
362
  footer,
358
363
  onFooter,
359
364
  disabled = false,
365
+ invalid = false,
360
366
  loading = false,
361
367
  loadingText,
362
368
  fallback,
@@ -379,7 +385,7 @@ export function SelectField(props: SelectFieldProps) {
379
385
  label,
380
386
  hint,
381
387
  className,
382
- loadingNode({ id, className: triggerClass, ariaLabel, loadingText }),
388
+ loadingNode({ id, className: triggerClass, invalid, ariaLabel, loadingText }),
383
389
  );
384
390
  }
385
391
 
@@ -392,6 +398,7 @@ export function SelectField(props: SelectFieldProps) {
392
398
  id={id}
393
399
  name={name}
394
400
  className="wb-selectFallback"
401
+ invalid={invalid}
395
402
  aria-label={ariaLabel}
396
403
  value={fallback.value}
397
404
  onChange={(event) => fallback.onChange(event.currentTarget.value)}
@@ -403,6 +410,7 @@ export function SelectField(props: SelectFieldProps) {
403
410
  id,
404
411
  className: triggerClass,
405
412
  disabled,
413
+ invalid,
406
414
  open: state.open,
407
415
  ariaLabel,
408
416
  title,
@@ -0,0 +1,6 @@
1
+
2
+ .wb-spinner {
3
+ display: inline-flex;
4
+ align-items: center;
5
+ flex: none;
6
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('../lib/react.js', async () => {
4
+ const actual = await vi.importActual<typeof import('../lib/react.js')>('../lib/react.js');
5
+ return { ...actual, h: (type: unknown, props?: unknown, ...children: unknown[]) => ({ type, props: props ?? {}, children }) };
6
+ });
7
+ vi.mock('./Spinner.css', () => ({ default: {} }));
8
+
9
+ import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives';
10
+ import { Spinner } from './Spinner.js';
11
+
12
+ describe('Spinner(loading 那颗)', () => {
13
+ it('画的是 dsh 的 ongoing 状态点(视觉只有这一处实现)', () => {
14
+ const node = Spinner({ size: 12 }) as unknown as { children: readonly { type: unknown; props: Record<string, unknown> }[] };
15
+
16
+ expect(node.children[0]?.type).toBe(StateDot);
17
+ expect(node.children[0]?.props['state']).toBe('ongoing');
18
+ expect(node.children[0]?.props['size']).toBe(12);
19
+ });
20
+
21
+ it('没给 label 时对读屏隐藏(它是装饰,话由旁边的文案说)', () => {
22
+ const node = Spinner({}) as unknown as { props: Record<string, unknown> };
23
+
24
+ expect(node.props['aria-hidden']).toBe(true);
25
+ expect(node.props['role']).toBeUndefined();
26
+ });
27
+
28
+ it('给了 label 就是一条状态播报', () => {
29
+ const node = Spinner({ label: '正在读环境' }) as unknown as { props: Record<string, unknown> };
30
+
31
+ expect(node.props['role']).toBe('status');
32
+ expect(node.props['aria-label']).toBe('正在读环境');
33
+ expect(node.props['aria-hidden']).toBeUndefined();
34
+ });
35
+ });
@@ -0,0 +1,19 @@
1
+ import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives';
2
+ import { h } from '../lib/react.js';
3
+ import './Spinner.css';
4
+
5
+ export interface SpinnerProps {
6
+ readonly size?: number;
7
+ readonly label?: string | undefined;
8
+ }
9
+
10
+ export function Spinner({ size = 14, label }: SpinnerProps) {
11
+ return (
12
+ <span
13
+ className="wb-spinner"
14
+ {...(label === undefined ? { 'aria-hidden': true } : { role: 'status', 'aria-label': label })}
15
+ >
16
+ <StateDot state="ongoing" size={size} />
17
+ </span>
18
+ );
19
+ }
@@ -27,6 +27,6 @@
27
27
  }
28
28
 
29
29
  :where(.wb-textField) {
30
- min-height: var(--wb-len-32);
31
- padding: var(--wb-len-6) var(--wb-len-12);
30
+ min-height: var(--wb-len-28);
31
+ padding: var(--wb-len-4) var(--wb-len-10);
32
32
  }