@c2-markdown-ui/react 0.4.4 → 0.4.5
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/README.md +24 -6
- package/dist/index.js +4365 -3733
- package/package.json +2 -2
- package/src/lib/MarkdownUI.tsx +47 -9
- package/src/lib/widgets.css +2188 -1372
- package/src/widgets/Button.tsx +39 -39
- package/src/widgets/ButtonGroup.tsx +47 -47
- package/src/widgets/Cascader.tsx +3 -2
- package/src/widgets/Chart.tsx +237 -237
- package/src/widgets/DatePicker.tsx +451 -434
- package/src/widgets/Form.tsx +512 -372
- package/src/widgets/Incomplete.tsx +3 -3
- package/src/widgets/MultipleChoiceQuestion.tsx +82 -82
- package/src/widgets/Quiz.tsx +320 -320
- package/src/widgets/Radio.tsx +57 -26
- package/src/widgets/Select.tsx +178 -142
- package/src/widgets/SelectMulti.tsx +105 -72
- package/src/widgets/ShortAnswerQuestion.tsx +101 -101
- package/src/widgets/Slider.tsx +70 -56
- package/src/widgets/Switch.tsx +14 -2
- package/src/widgets/TextInput.tsx +76 -38
- package/src/widgets/TreeSelect.tsx +649 -0
- package/src/widgets/Widget.tsx +130 -82
- package/src/widgets/choiceUtils.ts +90 -45
- package/src/widgets/index.ts +17 -16
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +0 -1
- package/dist/lib/MarkdownUI.d.ts +0 -10
- package/dist/lib/MarkdownUI.d.ts.map +0 -1
- package/dist/lib/index.d.ts +0 -4
- package/dist/lib/index.d.ts.map +0 -1
- package/dist/widgets/Button.d.ts +0 -15
- package/dist/widgets/Button.d.ts.map +0 -1
- package/dist/widgets/ButtonGroup.d.ts +0 -11
- package/dist/widgets/ButtonGroup.d.ts.map +0 -1
- package/dist/widgets/Cascader.d.ts +0 -34
- package/dist/widgets/Cascader.d.ts.map +0 -1
- package/dist/widgets/Chart.d.ts +0 -20
- package/dist/widgets/Chart.d.ts.map +0 -1
- package/dist/widgets/DatePicker.d.ts +0 -28
- package/dist/widgets/DatePicker.d.ts.map +0 -1
- package/dist/widgets/Form.d.ts +0 -47
- package/dist/widgets/Form.d.ts.map +0 -1
- package/dist/widgets/Incomplete.d.ts +0 -3
- package/dist/widgets/Incomplete.d.ts.map +0 -1
- package/dist/widgets/MultipleChoiceQuestion.d.ts +0 -15
- package/dist/widgets/MultipleChoiceQuestion.d.ts.map +0 -1
- package/dist/widgets/NumberInput.d.ts +0 -26
- package/dist/widgets/NumberInput.d.ts.map +0 -1
- package/dist/widgets/Quiz.d.ts +0 -37
- package/dist/widgets/Quiz.d.ts.map +0 -1
- package/dist/widgets/Radio.d.ts +0 -24
- package/dist/widgets/Radio.d.ts.map +0 -1
- package/dist/widgets/Select.d.ts +0 -15
- package/dist/widgets/Select.d.ts.map +0 -1
- package/dist/widgets/SelectMulti.d.ts +0 -20
- package/dist/widgets/SelectMulti.d.ts.map +0 -1
- package/dist/widgets/ShortAnswerQuestion.d.ts +0 -16
- package/dist/widgets/ShortAnswerQuestion.d.ts.map +0 -1
- package/dist/widgets/Slider.d.ts +0 -13
- package/dist/widgets/Slider.d.ts.map +0 -1
- package/dist/widgets/Switch.d.ts +0 -17
- package/dist/widgets/Switch.d.ts.map +0 -1
- package/dist/widgets/TextInput.d.ts +0 -11
- package/dist/widgets/TextInput.d.ts.map +0 -1
- package/dist/widgets/Widget.d.ts +0 -7
- package/dist/widgets/Widget.d.ts.map +0 -1
- package/dist/widgets/chartRemoteData.d.ts +0 -18
- package/dist/widgets/chartRemoteData.d.ts.map +0 -1
- package/dist/widgets/choiceUtils.d.ts +0 -24
- package/dist/widgets/choiceUtils.d.ts.map +0 -1
- package/dist/widgets/index.d.ts +0 -18
- package/dist/widgets/index.d.ts.map +0 -1
package/src/widgets/Radio.tsx
CHANGED
|
@@ -14,6 +14,13 @@ export interface RadioGroupProps {
|
|
|
14
14
|
direction?: 'vertical' | 'horizontal';
|
|
15
15
|
/** 自定义布局:每行列数(设置后按 N 列网格排列,优先于 direction) */
|
|
16
16
|
columns?: number;
|
|
17
|
+
hint?: string;
|
|
18
|
+
/** 是否必填 */
|
|
19
|
+
required?: boolean;
|
|
20
|
+
/** 校验失败提示文案 */
|
|
21
|
+
message?: string;
|
|
22
|
+
/** 外部(form 提交时)传入的错误信息,优先于组件内部即时校验 */
|
|
23
|
+
error?: string;
|
|
17
24
|
/** 是否禁用 */
|
|
18
25
|
disabled?: boolean;
|
|
19
26
|
onchange: (value: string) => void;
|
|
@@ -40,6 +47,10 @@ export const RadioGroup: React.FC<RadioGroupProps> = ({
|
|
|
40
47
|
url,
|
|
41
48
|
direction,
|
|
42
49
|
columns,
|
|
50
|
+
hint = '',
|
|
51
|
+
required = false,
|
|
52
|
+
message = '',
|
|
53
|
+
error = '',
|
|
43
54
|
disabled = false,
|
|
44
55
|
onchange
|
|
45
56
|
}) => {
|
|
@@ -57,6 +68,7 @@ export const RadioGroup: React.FC<RadioGroupProps> = ({
|
|
|
57
68
|
if (initial !== undefined && initial !== null && initial !== '') return mapInitial(String(initial));
|
|
58
69
|
return items[0]?.value ?? '';
|
|
59
70
|
});
|
|
71
|
+
const [localError, setLocalError] = useState('');
|
|
60
72
|
|
|
61
73
|
useEffect(() => {
|
|
62
74
|
if (!url) { setRemoteItems(null); return; }
|
|
@@ -80,13 +92,22 @@ export const RadioGroup: React.FC<RadioGroupProps> = ({
|
|
|
80
92
|
const handleChange = (value: string) => {
|
|
81
93
|
if (disabled) return;
|
|
82
94
|
setSelected(value);
|
|
95
|
+
setLocalError(validate(value));
|
|
83
96
|
onchange(value);
|
|
84
97
|
};
|
|
85
98
|
|
|
99
|
+
/** 校验当前值:返回错误文案或空串 */
|
|
100
|
+
const validate = (v: string): string => {
|
|
101
|
+
if (required && v === '') return message || (label ? `请填写${label}` : '请至少选择一项');
|
|
102
|
+
return '';
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const shownError = error || localError;
|
|
106
|
+
|
|
86
107
|
// ---------- 按钮样式(antd RadioButton):复用 widget-button-group 视觉 ----------
|
|
87
108
|
if (button) {
|
|
88
109
|
return (
|
|
89
|
-
<div className=
|
|
110
|
+
<div className={`widget-button-group${shownError ? ' widget-field-error' : ''}`}>
|
|
90
111
|
{label && <label>{label}</label>}
|
|
91
112
|
<div
|
|
92
113
|
role="group"
|
|
@@ -96,7 +117,7 @@ export const RadioGroup: React.FC<RadioGroupProps> = ({
|
|
|
96
117
|
style={columns && columns >= 1 ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` } : undefined}
|
|
97
118
|
>
|
|
98
119
|
{items.length === 0 && (
|
|
99
|
-
<div style={{ color: '
|
|
120
|
+
<div style={{ color: 'rgba(0, 0, 0, 0.45)', fontSize: 13 }}>暂无数据</div>
|
|
100
121
|
)}
|
|
101
122
|
{items.map((item) => (
|
|
102
123
|
<button
|
|
@@ -110,36 +131,46 @@ export const RadioGroup: React.FC<RadioGroupProps> = ({
|
|
|
110
131
|
</button>
|
|
111
132
|
))}
|
|
112
133
|
</div>
|
|
134
|
+
{shownError ? <div className="widget-error-text" role="alert">{shownError}</div> : null}
|
|
113
135
|
</div>
|
|
114
136
|
);
|
|
115
137
|
}
|
|
116
138
|
|
|
117
139
|
// ---------- 默认圆点单选样式(antd Radio) ----------
|
|
118
140
|
return (
|
|
119
|
-
<div className=
|
|
120
|
-
{label
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
>
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
<div className={`selector-multi${shownError ? ' widget-field-error' : ''}`}>
|
|
142
|
+
{label || hint || required ? (
|
|
143
|
+
<div className="widget-label-row">
|
|
144
|
+
{required && <span className="widget-required-mark" aria-hidden="true">*</span>}
|
|
145
|
+
{label && <div className="selector-multi-label">{label}</div>}
|
|
146
|
+
{hint && <span className="widget-hint" data-hint={hint}>ⓘ</span>}
|
|
147
|
+
</div>
|
|
148
|
+
) : null}
|
|
149
|
+
<div className="widget-field-content">
|
|
150
|
+
<div
|
|
151
|
+
className="radio-group"
|
|
152
|
+
role="radiogroup"
|
|
153
|
+
aria-label={label}
|
|
154
|
+
data-direction={columns ? 'grid' : (direction || 'vertical')}
|
|
155
|
+
style={columns && columns >= 1 ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` } : undefined}
|
|
156
|
+
>
|
|
157
|
+
{items.length === 0 && (
|
|
158
|
+
<div style={{ color: 'rgba(0, 0, 0, 0.45)', fontSize: 13 }}>暂无数据</div>
|
|
159
|
+
)}
|
|
160
|
+
{items.map((item) => (
|
|
161
|
+
<label key={item.value} className="radio-item">
|
|
162
|
+
<input
|
|
163
|
+
type="radio"
|
|
164
|
+
value={item.value}
|
|
165
|
+
checked={selected === item.value}
|
|
166
|
+
onChange={() => handleChange(item.value)}
|
|
167
|
+
disabled={disabled}
|
|
168
|
+
/>
|
|
169
|
+
<span>{item.label}</span>
|
|
170
|
+
</label>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
{shownError ? <div className="widget-error-text" role="alert">{shownError}</div> : null}
|
|
143
174
|
</div>
|
|
144
175
|
</div>
|
|
145
176
|
);
|
package/src/widgets/Select.tsx
CHANGED
|
@@ -1,142 +1,178 @@
|
|
|
1
|
-
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import { parseChoices } from './choiceUtils';
|
|
3
|
-
|
|
4
|
-
export interface SelectProps {
|
|
5
|
-
choices: string[];
|
|
6
|
-
label?: string;
|
|
7
|
-
default?: string;
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
const [
|
|
39
|
-
/**
|
|
40
|
-
const [
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { normalizeRemoteChoices, parseChoices, resolveChoiceValue, type ChoiceItem } from './choiceUtils';
|
|
3
|
+
|
|
4
|
+
export interface SelectProps {
|
|
5
|
+
choices: string[];
|
|
6
|
+
label?: string;
|
|
7
|
+
default?: string;
|
|
8
|
+
/** 远端数据源 URL:设置后运行时 fetch 拉取选项,choices 作为初始/后备选项 */
|
|
9
|
+
url?: string;
|
|
10
|
+
/** 是否可搜索:开启后下拉面板内置搜索框,按 key/value 与 label 过滤选项 */
|
|
11
|
+
searchable?: boolean;
|
|
12
|
+
/** 搜索框占位文案 */
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
hint?: string;
|
|
15
|
+
/** 是否必填:label 前红色 * */
|
|
16
|
+
required?: boolean;
|
|
17
|
+
/** 是否禁用 */
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
onchange: (value: string | string[]) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const Select: React.FC<SelectProps> = ({
|
|
23
|
+
choices,
|
|
24
|
+
label = '',
|
|
25
|
+
default: initial,
|
|
26
|
+
url = '',
|
|
27
|
+
searchable = false,
|
|
28
|
+
placeholder = '',
|
|
29
|
+
hint = '',
|
|
30
|
+
required = false,
|
|
31
|
+
disabled = false,
|
|
32
|
+
onchange
|
|
33
|
+
}) => {
|
|
34
|
+
// 展示条目:远端拉取成功用 {value,label},否则本地 choices(url 失败静默回落本地)
|
|
35
|
+
const [remoteItems, setRemoteItems] = useState<ChoiceItem[] | null>(null);
|
|
36
|
+
const items = useMemo(() => remoteItems ?? parseChoices(choices), [remoteItems, choices]);
|
|
37
|
+
|
|
38
|
+
const [value, setValue] = useState(initial ?? items[0]?.value ?? '');
|
|
39
|
+
/** 远端加载中:回显文案与空态显示"加载中…" */
|
|
40
|
+
const [loading, setLoading] = useState(false);
|
|
41
|
+
|
|
42
|
+
// ---------- url 远端模式:拉取成功后覆盖本地 choices,失败静默回落本地 ----------
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!url) { setRemoteItems(null); return; }
|
|
45
|
+
let cancelled = false;
|
|
46
|
+
setLoading(true);
|
|
47
|
+
(async () => {
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(url);
|
|
50
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
51
|
+
const json: any = await res.json();
|
|
52
|
+
if (cancelled) return;
|
|
53
|
+
const list = normalizeRemoteChoices(json);
|
|
54
|
+
if (list.length === 0) throw new Error('响应中没有可用选项');
|
|
55
|
+
setRemoteItems(list);
|
|
56
|
+
// 当前值不在远端取值中时:default/本地值按 label 映射到远端 value,否则回落第一项
|
|
57
|
+
setValue(prev => {
|
|
58
|
+
if (list.some(r => r.value === prev)) return prev;
|
|
59
|
+
const mapped = initial !== undefined ? resolveChoiceValue(initial, list) : prev;
|
|
60
|
+
const next = list.some(r => r.value === mapped) ? mapped : list[0].value;
|
|
61
|
+
if (next !== prev) onchange(next);
|
|
62
|
+
return next;
|
|
63
|
+
});
|
|
64
|
+
} catch {
|
|
65
|
+
// 加载失败:静默回落本地 choices
|
|
66
|
+
} finally {
|
|
67
|
+
if (!cancelled) setLoading(false);
|
|
68
|
+
}
|
|
69
|
+
})();
|
|
70
|
+
return () => { cancelled = true; };
|
|
71
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
72
|
+
}, [url]);
|
|
73
|
+
|
|
74
|
+
// ---------- 自定义下拉面板(antd 风格):searchable 带搜索框,普通模式纯选项列表 ----------
|
|
75
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
76
|
+
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
77
|
+
const [open, setOpen] = useState(false);
|
|
78
|
+
/** 搜索关键字(按 value/key 与 label 过滤,大小写不敏感) */
|
|
79
|
+
const [query, setQuery] = useState('');
|
|
80
|
+
/** 当前选中项展示文案(value 与 label 不同时优先 label) */
|
|
81
|
+
const display = useMemo(() => {
|
|
82
|
+
const hit = items.find(it => it.value === value);
|
|
83
|
+
return hit ? hit.label : '';
|
|
84
|
+
}, [items, value]);
|
|
85
|
+
|
|
86
|
+
const filtered = useMemo(() => {
|
|
87
|
+
const q = query.trim().toLowerCase();
|
|
88
|
+
if (!q) return items;
|
|
89
|
+
return items.filter(it => it.value.toLowerCase().includes(q) || it.label.toLowerCase().includes(q));
|
|
90
|
+
}, [items, query]);
|
|
91
|
+
|
|
92
|
+
// 外部点击关闭面板
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
function onDocClick(e: MouseEvent) {
|
|
95
|
+
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
|
96
|
+
}
|
|
97
|
+
document.addEventListener('mousedown', onDocClick);
|
|
98
|
+
return () => document.removeEventListener('mousedown', onDocClick);
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
// 打开面板时聚焦搜索框并清空关键字(仅 searchable 模式有搜索框)
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (open && searchable && searchInputRef.current) {
|
|
104
|
+
setQuery('');
|
|
105
|
+
searchInputRef.current.focus();
|
|
106
|
+
}
|
|
107
|
+
}, [open, searchable]);
|
|
108
|
+
|
|
109
|
+
function toggleOpen() {
|
|
110
|
+
if (disabled) return;
|
|
111
|
+
setOpen(prev => !prev);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 选中某选项:回填 value 并关闭面板 */
|
|
115
|
+
function pick(v: string) {
|
|
116
|
+
if (disabled) return;
|
|
117
|
+
setValue(v);
|
|
118
|
+
setOpen(false);
|
|
119
|
+
onchange(v);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const filteredOptions = searchable ? filtered : items;
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className="selector" ref={rootRef}>
|
|
126
|
+
{label || hint || required ? (
|
|
127
|
+
<div className="widget-label-row">
|
|
128
|
+
{required && <span className="widget-required-mark" aria-hidden="true">*</span>}
|
|
129
|
+
{label && <label>{label}</label>}
|
|
130
|
+
{hint && <span className="widget-hint" data-hint={hint}>ⓘ</span>}
|
|
131
|
+
</div>
|
|
132
|
+
) : null}
|
|
133
|
+
<div className="widget-field-content widget-select-content">
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
className="cas-input"
|
|
137
|
+
onClick={toggleOpen}
|
|
138
|
+
aria-expanded={open}
|
|
139
|
+
disabled={disabled}
|
|
140
|
+
>
|
|
141
|
+
<span className={`cas-input-text${display || loading ? '' : ' cas-placeholder'}`}>{loading ? '加载中…' : (display || placeholder || '请选择')}</span>
|
|
142
|
+
<span className={`cas-arrow${open ? ' cas-arrow-open' : ''}`} aria-hidden="true">▾</span>
|
|
143
|
+
</button>
|
|
144
|
+
{open && (
|
|
145
|
+
<div className="cas-panel cas-panel-single">
|
|
146
|
+
{searchable && (
|
|
147
|
+
<div className="cas-search">
|
|
148
|
+
<input
|
|
149
|
+
ref={searchInputRef}
|
|
150
|
+
type="text"
|
|
151
|
+
className="cas-search-input"
|
|
152
|
+
value={query}
|
|
153
|
+
placeholder="搜索选项…"
|
|
154
|
+
onChange={e => setQuery(e.target.value)}
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
<div className="cas-menu cas-menu-full" role="menu">
|
|
159
|
+
{filteredOptions.map(it => (
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
key={it.value}
|
|
163
|
+
className={`cas-option${value === it.value ? ' cas-option-active' : ''}`}
|
|
164
|
+
onClick={() => pick(it.value)}
|
|
165
|
+
>
|
|
166
|
+
<span className="cas-option-label">{it.label}</span>
|
|
167
|
+
</button>
|
|
168
|
+
))}
|
|
169
|
+
{filteredOptions.length === 0 && (
|
|
170
|
+
<div className="cas-empty">{loading ? '加载中…' : query.trim() ? '无匹配选项' : '暂无数据'}</div>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
};
|
|
@@ -1,72 +1,105 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
2
|
-
import { parseChoice, parseChoices } from './choiceUtils';
|
|
3
|
-
|
|
4
|
-
export interface SelectMultiProps {
|
|
5
|
-
/** 选项:字符串数组(每项支持 key:label 格式)或对象数组({key|value,label},取值为 key/value,展示 label) */
|
|
6
|
-
choices: string[] | Array<{ value?: string; key?: string; label?: string }>;
|
|
7
|
-
label?: string;
|
|
8
|
-
default?: string | string[];
|
|
9
|
-
/** 选项排列方向:vertical 纵向(默认)/ horizontal 横向 */
|
|
10
|
-
direction?: 'vertical' | 'horizontal';
|
|
11
|
-
/** 自定义布局:每行列数(设置后按 N 列网格排列,优先于 direction) */
|
|
12
|
-
columns?: number;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { parseChoice, parseChoices } from './choiceUtils';
|
|
3
|
+
|
|
4
|
+
export interface SelectMultiProps {
|
|
5
|
+
/** 选项:字符串数组(每项支持 key:label 格式)或对象数组({key|value,label},取值为 key/value,展示 label) */
|
|
6
|
+
choices: string[] | Array<{ value?: string; key?: string; label?: string }>;
|
|
7
|
+
label?: string;
|
|
8
|
+
default?: string | string[];
|
|
9
|
+
/** 选项排列方向:vertical 纵向(默认)/ horizontal 横向 */
|
|
10
|
+
direction?: 'vertical' | 'horizontal';
|
|
11
|
+
/** 自定义布局:每行列数(设置后按 N 列网格排列,优先于 direction) */
|
|
12
|
+
columns?: number;
|
|
13
|
+
hint?: string;
|
|
14
|
+
/** 是否必填 */
|
|
15
|
+
required?: boolean;
|
|
16
|
+
/** 校验失败提示文案 */
|
|
17
|
+
message?: string;
|
|
18
|
+
/** 外部(form 提交时)传入的错误信息,优先于组件内部即时校验 */
|
|
19
|
+
error?: string;
|
|
20
|
+
/** 是否禁用 */
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
onchange: (value: string | string[]) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const SelectMulti: React.FC<SelectMultiProps> = ({
|
|
26
|
+
choices,
|
|
27
|
+
label = '',
|
|
28
|
+
default: initial,
|
|
29
|
+
direction,
|
|
30
|
+
columns,
|
|
31
|
+
hint = '',
|
|
32
|
+
required = false,
|
|
33
|
+
message = '',
|
|
34
|
+
error = '',
|
|
35
|
+
disabled = false,
|
|
36
|
+
onchange
|
|
37
|
+
}) => {
|
|
38
|
+
const items = parseChoices(choices);
|
|
39
|
+
// 初始勾选值:default 可能是 key、label 或 key:label 原文,统一解析为 key 体系
|
|
40
|
+
const [value, setValue] = useState<string[]>(
|
|
41
|
+
Array.isArray(initial)
|
|
42
|
+
? initial.map(v => parseChoice(String(v)).value)
|
|
43
|
+
: (initial ? [parseChoice(String(initial)).value] : [])
|
|
44
|
+
);
|
|
45
|
+
const [localError, setLocalError] = useState('');
|
|
46
|
+
|
|
47
|
+
/** 校验当前值:返回错误文案或空串 */
|
|
48
|
+
const validate = (v: string[]): string => {
|
|
49
|
+
if (required && v.length === 0) return message || (label ? `请填写${label}` : '请至少选择一项');
|
|
50
|
+
return '';
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const shownError = error || localError;
|
|
54
|
+
|
|
55
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
56
|
+
const choice = e.target.value;
|
|
57
|
+
let newValue: string[];
|
|
58
|
+
|
|
59
|
+
if (e.target.checked) {
|
|
60
|
+
newValue = [...value, choice];
|
|
61
|
+
} else {
|
|
62
|
+
newValue = value.filter(v => v !== choice);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setValue(newValue);
|
|
66
|
+
setLocalError(validate(newValue));
|
|
67
|
+
onchange(newValue);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className={`selector-multi${shownError ? ' widget-field-error' : ''}`}>
|
|
72
|
+
{label || hint || required ? (
|
|
73
|
+
<div className="widget-label-row">
|
|
74
|
+
{required && <span className="widget-required-mark" aria-hidden="true">*</span>}
|
|
75
|
+
{label && <div className="selector-multi-label">{label}</div>}
|
|
76
|
+
{hint && <span className="widget-hint" data-hint={hint}>ⓘ</span>}
|
|
77
|
+
</div>
|
|
78
|
+
) : null}
|
|
79
|
+
<div className="widget-field-content">
|
|
80
|
+
<div
|
|
81
|
+
className="checkbox-group"
|
|
82
|
+
data-direction={columns ? 'grid' : (direction || 'vertical')}
|
|
83
|
+
style={columns && columns >= 1 ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` } : undefined}
|
|
84
|
+
>
|
|
85
|
+
{items.length === 0 && (
|
|
86
|
+
<div style={{ color: 'rgba(0, 0, 0, 0.45)', fontSize: '13px' }}>暂无数据</div>
|
|
87
|
+
)}
|
|
88
|
+
{items.map((item) => (
|
|
89
|
+
<label key={item.value} className="checkbox-item">
|
|
90
|
+
<input
|
|
91
|
+
type="checkbox"
|
|
92
|
+
value={item.value}
|
|
93
|
+
checked={value.includes(item.value)}
|
|
94
|
+
onChange={handleChange}
|
|
95
|
+
disabled={disabled}
|
|
96
|
+
/>
|
|
97
|
+
<span>{item.label}</span>
|
|
98
|
+
</label>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
{shownError ? <div className="widget-error-text" role="alert">{shownError}</div> : null}
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
};
|