@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.
Files changed (73) hide show
  1. package/README.md +24 -6
  2. package/dist/index.js +4365 -3733
  3. package/package.json +2 -2
  4. package/src/lib/MarkdownUI.tsx +47 -9
  5. package/src/lib/widgets.css +2188 -1372
  6. package/src/widgets/Button.tsx +39 -39
  7. package/src/widgets/ButtonGroup.tsx +47 -47
  8. package/src/widgets/Cascader.tsx +3 -2
  9. package/src/widgets/Chart.tsx +237 -237
  10. package/src/widgets/DatePicker.tsx +451 -434
  11. package/src/widgets/Form.tsx +512 -372
  12. package/src/widgets/Incomplete.tsx +3 -3
  13. package/src/widgets/MultipleChoiceQuestion.tsx +82 -82
  14. package/src/widgets/Quiz.tsx +320 -320
  15. package/src/widgets/Radio.tsx +57 -26
  16. package/src/widgets/Select.tsx +178 -142
  17. package/src/widgets/SelectMulti.tsx +105 -72
  18. package/src/widgets/ShortAnswerQuestion.tsx +101 -101
  19. package/src/widgets/Slider.tsx +70 -56
  20. package/src/widgets/Switch.tsx +14 -2
  21. package/src/widgets/TextInput.tsx +76 -38
  22. package/src/widgets/TreeSelect.tsx +649 -0
  23. package/src/widgets/Widget.tsx +130 -82
  24. package/src/widgets/choiceUtils.ts +90 -45
  25. package/src/widgets/index.ts +17 -16
  26. package/dist/index.d.ts +0 -2
  27. package/dist/index.d.ts.map +0 -1
  28. package/dist/lib/MarkdownUI.d.ts +0 -10
  29. package/dist/lib/MarkdownUI.d.ts.map +0 -1
  30. package/dist/lib/index.d.ts +0 -4
  31. package/dist/lib/index.d.ts.map +0 -1
  32. package/dist/widgets/Button.d.ts +0 -15
  33. package/dist/widgets/Button.d.ts.map +0 -1
  34. package/dist/widgets/ButtonGroup.d.ts +0 -11
  35. package/dist/widgets/ButtonGroup.d.ts.map +0 -1
  36. package/dist/widgets/Cascader.d.ts +0 -34
  37. package/dist/widgets/Cascader.d.ts.map +0 -1
  38. package/dist/widgets/Chart.d.ts +0 -20
  39. package/dist/widgets/Chart.d.ts.map +0 -1
  40. package/dist/widgets/DatePicker.d.ts +0 -28
  41. package/dist/widgets/DatePicker.d.ts.map +0 -1
  42. package/dist/widgets/Form.d.ts +0 -47
  43. package/dist/widgets/Form.d.ts.map +0 -1
  44. package/dist/widgets/Incomplete.d.ts +0 -3
  45. package/dist/widgets/Incomplete.d.ts.map +0 -1
  46. package/dist/widgets/MultipleChoiceQuestion.d.ts +0 -15
  47. package/dist/widgets/MultipleChoiceQuestion.d.ts.map +0 -1
  48. package/dist/widgets/NumberInput.d.ts +0 -26
  49. package/dist/widgets/NumberInput.d.ts.map +0 -1
  50. package/dist/widgets/Quiz.d.ts +0 -37
  51. package/dist/widgets/Quiz.d.ts.map +0 -1
  52. package/dist/widgets/Radio.d.ts +0 -24
  53. package/dist/widgets/Radio.d.ts.map +0 -1
  54. package/dist/widgets/Select.d.ts +0 -15
  55. package/dist/widgets/Select.d.ts.map +0 -1
  56. package/dist/widgets/SelectMulti.d.ts +0 -20
  57. package/dist/widgets/SelectMulti.d.ts.map +0 -1
  58. package/dist/widgets/ShortAnswerQuestion.d.ts +0 -16
  59. package/dist/widgets/ShortAnswerQuestion.d.ts.map +0 -1
  60. package/dist/widgets/Slider.d.ts +0 -13
  61. package/dist/widgets/Slider.d.ts.map +0 -1
  62. package/dist/widgets/Switch.d.ts +0 -17
  63. package/dist/widgets/Switch.d.ts.map +0 -1
  64. package/dist/widgets/TextInput.d.ts +0 -11
  65. package/dist/widgets/TextInput.d.ts.map +0 -1
  66. package/dist/widgets/Widget.d.ts +0 -7
  67. package/dist/widgets/Widget.d.ts.map +0 -1
  68. package/dist/widgets/chartRemoteData.d.ts +0 -18
  69. package/dist/widgets/chartRemoteData.d.ts.map +0 -1
  70. package/dist/widgets/choiceUtils.d.ts +0 -24
  71. package/dist/widgets/choiceUtils.d.ts.map +0 -1
  72. package/dist/widgets/index.d.ts +0 -18
  73. package/dist/widgets/index.d.ts.map +0 -1
@@ -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="widget-button-group">
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: '#9ca3af', fontSize: 13 }}>暂无数据</div>
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="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
- ))}
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
  );
@@ -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
- /** 是否可搜索:开启后下拉面板内置搜索框,按 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
+ 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
- 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
- };
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
+ };