@dsh-workbench/shared 0.2.12 → 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.
- package/dist/components/ChoiceGroup.css +53 -3
- package/dist/components/ChoiceGroup.d.ts +5 -0
- package/dist/components/ChoiceGroup.js +15 -5
- package/dist/components/FormRows.css +53 -0
- package/dist/components/FormRows.d.ts +14 -0
- package/dist/components/FormRows.js +11 -0
- package/dist/components/SelectField.css +12 -4
- package/dist/components/SelectField.d.ts +1 -0
- package/dist/components/SelectField.js +9 -7
- package/dist/components/Spinner.css +6 -0
- package/dist/components/Spinner.d.ts +6 -0
- package/dist/components/Spinner.js +7 -0
- package/dist/components/TextField.css +2 -2
- package/dist/host.d.ts +3 -3
- package/dist/host.js +3 -3
- package/dist/index.d.ts +7 -4
- package/dist/index.js +7 -4
- package/dist/lib/form-problem.d.ts +2 -0
- package/dist/lib/form-problem.js +20 -0
- package/package.json +2 -1
- package/src/components/ChoiceGroup.css +53 -3
- package/src/components/ChoiceGroup.test.tsx +88 -0
- package/src/components/ChoiceGroup.tsx +70 -28
- package/src/components/FormRows.css +53 -0
- package/src/components/FormRows.test.tsx +82 -0
- package/src/components/FormRows.tsx +39 -0
- package/src/components/SelectField.css +12 -4
- package/src/components/SelectField.test.tsx +20 -2
- package/src/components/SelectField.tsx +11 -3
- package/src/components/Spinner.css +6 -0
- package/src/components/Spinner.test.tsx +35 -0
- package/src/components/Spinner.tsx +19 -0
- package/src/components/TextField.css +2 -2
- package/src/host.ts +3 -3
- package/src/index.ts +7 -4
- package/src/lib/form-problem.test.ts +76 -0
- package/src/lib/form-problem.ts +22 -0
|
@@ -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
|
-
{
|
|
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-
|
|
8
|
-
padding: var(--wb-len-
|
|
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-
|
|
104
|
-
padding: var(--wb-len-
|
|
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,
|
|
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 ===
|
|
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
|
-
<
|
|
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,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
|
+
}
|