@c2-markdown-ui/react 0.4.3 → 0.4.4

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 (73) hide show
  1. package/README.md +6 -24
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +3733 -4365
  5. package/dist/lib/MarkdownUI.d.ts +10 -0
  6. package/dist/lib/MarkdownUI.d.ts.map +1 -0
  7. package/dist/lib/index.d.ts +4 -0
  8. package/dist/lib/index.d.ts.map +1 -0
  9. package/dist/widgets/Button.d.ts +15 -0
  10. package/dist/widgets/Button.d.ts.map +1 -0
  11. package/dist/widgets/ButtonGroup.d.ts +11 -0
  12. package/dist/widgets/ButtonGroup.d.ts.map +1 -0
  13. package/dist/widgets/Cascader.d.ts +34 -0
  14. package/dist/widgets/Cascader.d.ts.map +1 -0
  15. package/dist/widgets/Chart.d.ts +20 -0
  16. package/dist/widgets/Chart.d.ts.map +1 -0
  17. package/dist/widgets/DatePicker.d.ts +28 -0
  18. package/dist/widgets/DatePicker.d.ts.map +1 -0
  19. package/dist/widgets/Form.d.ts +47 -0
  20. package/dist/widgets/Form.d.ts.map +1 -0
  21. package/dist/widgets/Incomplete.d.ts +3 -0
  22. package/dist/widgets/Incomplete.d.ts.map +1 -0
  23. package/dist/widgets/MultipleChoiceQuestion.d.ts +15 -0
  24. package/dist/widgets/MultipleChoiceQuestion.d.ts.map +1 -0
  25. package/dist/widgets/NumberInput.d.ts +26 -0
  26. package/dist/widgets/NumberInput.d.ts.map +1 -0
  27. package/dist/widgets/Quiz.d.ts +37 -0
  28. package/dist/widgets/Quiz.d.ts.map +1 -0
  29. package/dist/widgets/Radio.d.ts +24 -0
  30. package/dist/widgets/Radio.d.ts.map +1 -0
  31. package/dist/widgets/Select.d.ts +15 -0
  32. package/dist/widgets/Select.d.ts.map +1 -0
  33. package/dist/widgets/SelectMulti.d.ts +20 -0
  34. package/dist/widgets/SelectMulti.d.ts.map +1 -0
  35. package/dist/widgets/ShortAnswerQuestion.d.ts +16 -0
  36. package/dist/widgets/ShortAnswerQuestion.d.ts.map +1 -0
  37. package/dist/widgets/Slider.d.ts +13 -0
  38. package/dist/widgets/Slider.d.ts.map +1 -0
  39. package/dist/widgets/Switch.d.ts +17 -0
  40. package/dist/widgets/Switch.d.ts.map +1 -0
  41. package/dist/widgets/TextInput.d.ts +11 -0
  42. package/dist/widgets/TextInput.d.ts.map +1 -0
  43. package/dist/widgets/Widget.d.ts +7 -0
  44. package/dist/widgets/Widget.d.ts.map +1 -0
  45. package/dist/widgets/chartRemoteData.d.ts +18 -0
  46. package/dist/widgets/chartRemoteData.d.ts.map +1 -0
  47. package/dist/widgets/choiceUtils.d.ts +24 -0
  48. package/dist/widgets/choiceUtils.d.ts.map +1 -0
  49. package/dist/widgets/index.d.ts +18 -0
  50. package/dist/widgets/index.d.ts.map +1 -0
  51. package/package.json +2 -2
  52. package/src/lib/MarkdownUI.tsx +9 -47
  53. package/src/lib/widgets.css +1372 -2188
  54. package/src/widgets/Button.tsx +39 -39
  55. package/src/widgets/ButtonGroup.tsx +47 -47
  56. package/src/widgets/Cascader.tsx +2 -3
  57. package/src/widgets/Chart.tsx +237 -237
  58. package/src/widgets/DatePicker.tsx +434 -451
  59. package/src/widgets/Form.tsx +372 -512
  60. package/src/widgets/Incomplete.tsx +3 -3
  61. package/src/widgets/MultipleChoiceQuestion.tsx +82 -82
  62. package/src/widgets/Quiz.tsx +320 -320
  63. package/src/widgets/Radio.tsx +26 -57
  64. package/src/widgets/Select.tsx +142 -178
  65. package/src/widgets/SelectMulti.tsx +72 -105
  66. package/src/widgets/ShortAnswerQuestion.tsx +101 -101
  67. package/src/widgets/Slider.tsx +56 -70
  68. package/src/widgets/Switch.tsx +2 -14
  69. package/src/widgets/TextInput.tsx +38 -76
  70. package/src/widgets/Widget.tsx +82 -130
  71. package/src/widgets/choiceUtils.ts +45 -90
  72. package/src/widgets/index.ts +16 -17
  73. package/src/widgets/TreeSelect.tsx +0 -649
@@ -14,13 +14,6 @@ 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;
24
17
  /** 是否禁用 */
25
18
  disabled?: boolean;
26
19
  onchange: (value: string) => void;
@@ -47,10 +40,6 @@ export const RadioGroup: React.FC<RadioGroupProps> = ({
47
40
  url,
48
41
  direction,
49
42
  columns,
50
- hint = '',
51
- required = false,
52
- message = '',
53
- error = '',
54
43
  disabled = false,
55
44
  onchange
56
45
  }) => {
@@ -68,7 +57,6 @@ export const RadioGroup: React.FC<RadioGroupProps> = ({
68
57
  if (initial !== undefined && initial !== null && initial !== '') return mapInitial(String(initial));
69
58
  return items[0]?.value ?? '';
70
59
  });
71
- const [localError, setLocalError] = useState('');
72
60
 
73
61
  useEffect(() => {
74
62
  if (!url) { setRemoteItems(null); return; }
@@ -92,22 +80,13 @@ export const RadioGroup: React.FC<RadioGroupProps> = ({
92
80
  const handleChange = (value: string) => {
93
81
  if (disabled) return;
94
82
  setSelected(value);
95
- setLocalError(validate(value));
96
83
  onchange(value);
97
84
  };
98
85
 
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
-
107
86
  // ---------- 按钮样式(antd RadioButton):复用 widget-button-group 视觉 ----------
108
87
  if (button) {
109
88
  return (
110
- <div className={`widget-button-group${shownError ? ' widget-field-error' : ''}`}>
89
+ <div className="widget-button-group">
111
90
  {label && <label>{label}</label>}
112
91
  <div
113
92
  role="group"
@@ -117,7 +96,7 @@ export const RadioGroup: React.FC<RadioGroupProps> = ({
117
96
  style={columns && columns >= 1 ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` } : undefined}
118
97
  >
119
98
  {items.length === 0 && (
120
- <div style={{ color: 'rgba(0, 0, 0, 0.45)', fontSize: 13 }}>暂无数据</div>
99
+ <div style={{ color: '#9ca3af', fontSize: 13 }}>暂无数据</div>
121
100
  )}
122
101
  {items.map((item) => (
123
102
  <button
@@ -131,46 +110,36 @@ export const RadioGroup: React.FC<RadioGroupProps> = ({
131
110
  </button>
132
111
  ))}
133
112
  </div>
134
- {shownError ? <div className="widget-error-text" role="alert">{shownError}</div> : null}
135
113
  </div>
136
114
  );
137
115
  }
138
116
 
139
117
  // ---------- 默认圆点单选样式(antd Radio) ----------
140
118
  return (
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}
119
+ <div className="selector">
120
+ {label && <div className="selector-multi-label">{label}</div>}
121
+ <div
122
+ className="radio-group"
123
+ role="radiogroup"
124
+ aria-label={label}
125
+ data-direction={columns ? 'grid' : (direction || 'vertical')}
126
+ style={columns && columns >= 1 ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` } : undefined}
127
+ >
128
+ {items.length === 0 && (
129
+ <div style={{ color: '#9ca3af', fontSize: 13 }}>暂无数据</div>
130
+ )}
131
+ {items.map((item) => (
132
+ <label key={item.value} className="radio-item">
133
+ <input
134
+ type="radio"
135
+ value={item.value}
136
+ checked={selected === item.value}
137
+ onChange={() => handleChange(item.value)}
138
+ disabled={disabled}
139
+ />
140
+ <span>{item.label}</span>
141
+ </label>
142
+ ))}
174
143
  </div>
175
144
  </div>
176
145
  );
@@ -1,178 +1,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
+ 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
+ /** 是否可搜索:开启后下拉面板内置搜索框,按 key/value 与 label 过滤选项 */
9
+ searchable?: boolean;
10
+ /** 搜索框占位文案 */
11
+ placeholder?: string;
12
+ /** 是否禁用 */
13
+ disabled?: boolean;
14
+ onchange: (value: string | string[]) => void;
15
+ }
16
+
17
+ export const Select: React.FC<SelectProps> = ({
18
+ choices,
19
+ label = '',
20
+ default: initial,
21
+ searchable = false,
22
+ placeholder = '',
23
+ disabled = false,
24
+ onchange
25
+ }) => {
26
+ const items = parseChoices(choices);
27
+ const [value, setValue] = useState(initial ?? items[0]?.value ?? '');
28
+
29
+ const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
30
+ const newValue = e.target.value;
31
+ setValue(newValue);
32
+ onchange(newValue);
33
+ };
34
+
35
+ // ---------- searchable 模式:自定义下拉(触发按钮 + 搜索框 + 滚动选项列表) ----------
36
+ const rootRef = useRef<HTMLDivElement>(null);
37
+ const searchInputRef = useRef<HTMLInputElement>(null);
38
+ const [open, setOpen] = useState(false);
39
+ /** 搜索关键字(按 value/key 与 label 过滤,大小写不敏感) */
40
+ const [query, setQuery] = useState('');
41
+ /** 当前选中项展示文案(value 与 label 不同时优先 label) */
42
+ const display = useMemo(() => {
43
+ const hit = items.find(it => it.value === value);
44
+ return hit ? hit.label : '';
45
+ }, [items, value]);
46
+
47
+ const filtered = useMemo(() => {
48
+ const q = query.trim().toLowerCase();
49
+ if (!q) return items;
50
+ return items.filter(it => it.value.toLowerCase().includes(q) || it.label.toLowerCase().includes(q));
51
+ }, [items, query]);
52
+
53
+ // 外部点击关闭面板
54
+ useEffect(() => {
55
+ function onDocClick(e: MouseEvent) {
56
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
57
+ }
58
+ document.addEventListener('mousedown', onDocClick);
59
+ return () => document.removeEventListener('mousedown', onDocClick);
60
+ }, []);
61
+
62
+ // 打开面板时聚焦搜索框并清空关键字
63
+ useEffect(() => {
64
+ if (open && searchInputRef.current) {
65
+ setQuery('');
66
+ searchInputRef.current.focus();
67
+ }
68
+ }, [open]);
69
+
70
+ function toggleOpen() {
71
+ if (disabled) return;
72
+ setOpen(prev => !prev);
73
+ }
74
+
75
+ /** 选中某选项:回填 value 并关闭面板 */
76
+ function pick(v: string) {
77
+ if (disabled) return;
78
+ setValue(v);
79
+ setOpen(false);
80
+ onchange(v);
81
+ }
82
+
83
+ if (searchable) {
84
+ return (
85
+ <div className="selector" ref={rootRef}>
86
+ {label && <label>{label}</label>}
87
+ <div className="widget-field-content widget-select-content">
88
+ <button
89
+ type="button"
90
+ className="cas-input"
91
+ onClick={toggleOpen}
92
+ aria-expanded={open}
93
+ disabled={disabled}
94
+ >
95
+ <span className={`cas-input-text${display ? '' : ' cas-placeholder'}`}>{display || placeholder || '请选择'}</span>
96
+ <span className={`cas-arrow${open ? ' cas-arrow-open' : ''}`} aria-hidden="true">▾</span>
97
+ </button>
98
+ {open && (
99
+ <div className="cas-panel cas-panel-single">
100
+ <div className="cas-search">
101
+ <input
102
+ ref={searchInputRef}
103
+ type="text"
104
+ className="cas-search-input"
105
+ value={query}
106
+ placeholder="搜索选项…"
107
+ onChange={e => setQuery(e.target.value)}
108
+ />
109
+ </div>
110
+ <div className="cas-menu cas-menu-full" role="menu">
111
+ {filtered.map(it => (
112
+ <button
113
+ type="button"
114
+ key={it.value}
115
+ className={`cas-option${value === it.value ? ' cas-option-active' : ''}`}
116
+ onClick={() => pick(it.value)}
117
+ >
118
+ <span className="cas-option-label">{it.label}</span>
119
+ </button>
120
+ ))}
121
+ {filtered.length === 0 && <div className="cas-empty">无匹配选项</div>}
122
+ </div>
123
+ </div>
124
+ )}
125
+ </div>
126
+ </div>
127
+ );
128
+ }
129
+
130
+ return (
131
+ <div className="selector">
132
+ {label && <label>{label}</label>}
133
+ <select value={value} onChange={handleChange} disabled={disabled}>
134
+ {items.map((item) => (
135
+ <option key={item.value} value={item.value}>
136
+ {item.label}
137
+ </option>
138
+ ))}
139
+ </select>
140
+ </div>
141
+ );
142
+ };
@@ -1,105 +1,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
- };
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
+ disabled?: boolean;
15
+ onchange: (value: string | string[]) => void;
16
+ }
17
+
18
+ export const SelectMulti: React.FC<SelectMultiProps> = ({
19
+ choices,
20
+ label = '',
21
+ default: initial,
22
+ direction,
23
+ columns,
24
+ disabled = false,
25
+ onchange
26
+ }) => {
27
+ const items = parseChoices(choices);
28
+ // 初始勾选值:default 可能是 key、label 或 key:label 原文,统一解析为 key 体系
29
+ const [value, setValue] = useState<string[]>(
30
+ Array.isArray(initial)
31
+ ? initial.map(v => parseChoice(String(v)).value)
32
+ : (initial ? [parseChoice(String(initial)).value] : [])
33
+ );
34
+
35
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
36
+ const choice = e.target.value;
37
+ let newValue: string[];
38
+
39
+ if (e.target.checked) {
40
+ newValue = [...value, choice];
41
+ } else {
42
+ newValue = value.filter(v => v !== choice);
43
+ }
44
+
45
+ setValue(newValue);
46
+ onchange(newValue);
47
+ };
48
+
49
+ return (
50
+ <div className="selector-multi">
51
+ {label && <div className="selector-multi-label">{label}</div>}
52
+ <div
53
+ className="checkbox-group"
54
+ data-direction={columns ? 'grid' : (direction || 'vertical')}
55
+ style={columns && columns >= 1 ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` } : undefined}
56
+ >
57
+ {items.map((item) => (
58
+ <label key={item.value} className="checkbox-item">
59
+ <input
60
+ type="checkbox"
61
+ value={item.value}
62
+ checked={value.includes(item.value)}
63
+ onChange={handleChange}
64
+ disabled={disabled}
65
+ />
66
+ <span>{item.label}</span>
67
+ </label>
68
+ ))}
69
+ </div>
70
+ </div>
71
+ );
72
+ };