@c2-markdown-ui/react 0.0.0-stage → 0.4.3

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.
@@ -0,0 +1,39 @@
1
+ import React from 'react';
2
+
3
+ export interface ButtonProps {
4
+ label?: string;
5
+ /** 背景色(十六进制或 CSS 颜色名) */
6
+ color?: string;
7
+ /** 字体颜色(十六进制或 CSS 颜色名) */
8
+ textColor?: string;
9
+ /** 尺寸:small / medium / large,默认 medium */
10
+ size?: 'small' | 'medium' | 'large';
11
+ /** 是否禁用 */
12
+ disabled?: boolean;
13
+ onchange: (value: string) => void;
14
+ }
15
+
16
+ export const Button: React.FC<ButtonProps> = ({
17
+ label = '按钮',
18
+ color,
19
+ textColor,
20
+ size = 'medium',
21
+ disabled = false,
22
+ onchange
23
+ }) => {
24
+ const style: React.CSSProperties = {};
25
+ if (color) style.backgroundColor = color;
26
+ if (textColor) style.color = textColor;
27
+
28
+ return (
29
+ <button
30
+ type="button"
31
+ className={`widget-button size-${size}`}
32
+ style={Object.keys(style).length > 0 ? style : undefined}
33
+ onClick={() => onchange(label)}
34
+ disabled={disabled}
35
+ >
36
+ {label}
37
+ </button>
38
+ );
39
+ };
@@ -0,0 +1,47 @@
1
+ import React, { useState } from 'react';
2
+ import { parseChoices, type ChoiceItem } from './choiceUtils';
3
+
4
+ export interface ButtonGroupProps {
5
+ choices: string[];
6
+ label?: string;
7
+ default?: string;
8
+ /** 是否禁用 */
9
+ disabled?: boolean;
10
+ onchange: (value: string) => void;
11
+ }
12
+
13
+ export const ButtonGroup: React.FC<ButtonGroupProps> = ({
14
+ choices,
15
+ label = '',
16
+ default: initial,
17
+ disabled = false,
18
+ onchange
19
+ }) => {
20
+ const items = parseChoices(choices);
21
+ const [selected, setSelected] = useState(initial ?? items[0]?.value ?? '');
22
+
23
+ const handleClick = (item: ChoiceItem) => {
24
+ if (disabled) return;
25
+ setSelected(item.value);
26
+ onchange(item.value);
27
+ };
28
+
29
+ return (
30
+ <div className="widget-button-group">
31
+ {label && <label>{label}</label>}
32
+ <div role="group" aria-label={label}>
33
+ {items.map((item) => (
34
+ <button
35
+ key={item.value}
36
+ type="button"
37
+ aria-pressed={selected === item.value}
38
+ onClick={() => handleClick(item)}
39
+ disabled={disabled}
40
+ >
41
+ {item.label}
42
+ </button>
43
+ ))}
44
+ </div>
45
+ </div>
46
+ );
47
+ };
@@ -0,0 +1,400 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+
3
+ export interface CascaderProps {
4
+ choices: string[];
5
+ /** 嵌套对象树数据源(与扁平路径 choices 兼容共存,优先于 choices) */
6
+ data?: CascadeNode[];
7
+ /** 远端数据源 URL:设置后运行时 fetch 拉取嵌套树,data/choices 作为初始/后备树 */
8
+ url?: string;
9
+ /** 懒加载模式(url 存在时生效):首次只拉根级,点击父级后以 url?parent=<value> 逐级请求子级 */
10
+ lazy?: boolean;
11
+ label?: string;
12
+ default?: string;
13
+ placeholder?: string;
14
+ hint?: string;
15
+ required?: boolean;
16
+ /** 校验失败提示文案 */
17
+ message?: string;
18
+ /** 外部(form 提交时)传入的错误信息 */
19
+ error?: string;
20
+ /** 是否禁用 */
21
+ disabled?: boolean;
22
+ onchange: (value: string) => void;
23
+ }
24
+
25
+ /** 级联数据节点(嵌套树形,对应 mdui-lang 的 CascadeNode)
26
+ * 取值字段兼容 value/key(无 value 时用 key);展示字段 label 缺省回退 value/key */
27
+ export interface CascadeNode {
28
+ value?: string | number;
29
+ /** value 的别名(key:label 风格数据源) */
30
+ key?: string | number;
31
+ label?: string | number;
32
+ children?: CascadeNode[];
33
+ [extra: string]: any;
34
+ }
35
+
36
+ interface CasNode {
37
+ /** 段 key(无 key:label 时即段文本) */
38
+ value: string;
39
+ /** 段显示文案 */
40
+ label: string;
41
+ children: CasNode[];
42
+ /** 是否叶子(完整路径;lazy 模式下无 children 视为待加载,非叶子) */
43
+ isLeaf: boolean;
44
+ /** lazy 模式:子级是否已请求过(请求返回空数组时为真叶子,标记避免重复请求) */
45
+ loaded?: boolean;
46
+ }
47
+
48
+ /** 解析一段文本:key:label → {value:key, label:label};无冒号则 value=label=段文本 */
49
+ function parseSeg(seg: string): { value: string; label: string } {
50
+ const idx = seg.indexOf(':');
51
+ if (idx > 0) {
52
+ const v = seg.slice(0, idx).trim();
53
+ const l = seg.slice(idx + 1).trim();
54
+ if (v && l) return { value: v, label: l };
55
+ }
56
+ return { value: seg.trim(), label: seg.trim() };
57
+ }
58
+
59
+ /** 默认路径(value 串,/ 分隔)→ 逐级 value 数组;支持 key:label 段与 label 串回退 */
60
+ function pathToValues(tree: CasNode[], path: string | undefined): string[] {
61
+ if (!path) return [];
62
+ const segs = path.split('/').map(s => s.trim()).filter(Boolean);
63
+ const values: string[] = [];
64
+ let level = tree;
65
+ for (const seg of segs) {
66
+ const { value: segVal, label: segLabel } = parseSeg(seg);
67
+ const node = level.find(n => n.value === segVal || n.value === seg || n.label === segLabel || n.label === seg);
68
+ if (!node) return values;
69
+ values.push(node.value);
70
+ level = node.children;
71
+ }
72
+ return values;
73
+ }
74
+
75
+ /** 嵌套对象树 → 内部 CasNode 树(取值 value ?? key,label 缺省回退取值)
76
+ * lazy 模式下无 children 的节点视为"待加载"(isLeaf=false),子级点击父级时请求;
77
+ * 节点上已有的 loaded 标记(lazy 子级请求返回空数组的真叶子)需保留,避免重复请求 */
78
+ function nodesToTree(nodes: CascadeNode[] | undefined, lazy = false): CasNode[] {
79
+ if (!Array.isArray(nodes)) return [];
80
+ return nodes.map(n => {
81
+ const v = n.value ?? n.key;
82
+ const children = n.children;
83
+ const hasChildren = Array.isArray(children) && children.length > 0;
84
+ const wasLoaded = n.loaded === true;
85
+ return {
86
+ value: String(v ?? ''),
87
+ label: String(n.label ?? v ?? ''),
88
+ children: hasChildren ? nodesToTree(children, lazy) : [],
89
+ isLeaf: lazy ? !hasChildren && wasLoaded : !hasChildren,
90
+ loaded: hasChildren || wasLoaded,
91
+ };
92
+ });
93
+ }
94
+
95
+ /**
96
+ * 兼容常见远端嵌套树响应并归一化为 CascadeNode[]:
97
+ * - [{value,label,children}](直接树数组)
98
+ * - {data|options|choices|items|tree|treeData:[...]} 包裹
99
+ * - 条目允许 key/value 取值、name/label 展示、无 children 视为叶子
100
+ */
101
+ function normalizeRemoteTree(json: any): CascadeNode[] {
102
+ let list = json;
103
+ if (!Array.isArray(json)) {
104
+ list = Array.isArray(json?.data) ? json.data
105
+ : Array.isArray(json?.options) ? json.options
106
+ : Array.isArray(json?.choices) ? json.choices
107
+ : Array.isArray(json?.items) ? json.items
108
+ : Array.isArray(json?.tree) ? json.tree
109
+ : Array.isArray(json?.treeData) ? json.treeData
110
+ : [];
111
+ }
112
+ const map = (arr: any[]): CascadeNode[] =>
113
+ (arr || [])
114
+ .map((it: any): CascadeNode | null => {
115
+ if (!it || typeof it !== 'object') return null;
116
+ const value = it.value ?? it.key;
117
+ if (value === undefined || value === null || value === '') return null;
118
+ const node: CascadeNode = { value: String(value) };
119
+ const label = it.label ?? it.name ?? it.text;
120
+ if (label !== undefined && label !== null) node.label = String(label);
121
+ if (Array.isArray(it.children) && it.children.length > 0) {
122
+ node.children = map(it.children);
123
+ }
124
+ return node;
125
+ })
126
+ .filter((n: CascadeNode | null): n is CascadeNode => n !== null);
127
+ return map(list);
128
+ }
129
+
130
+ export const Cascader: React.FC<CascaderProps> = ({
131
+ choices,
132
+ data,
133
+ url,
134
+ lazy = false,
135
+ label = '',
136
+ default: initial,
137
+ placeholder = '',
138
+ hint = '',
139
+ required = false,
140
+ message = '',
141
+ error = '',
142
+ disabled = false,
143
+ onchange
144
+ }) => {
145
+ // ---------- 状态 ----------
146
+ /** 远端拉取的嵌套树(成功后覆盖本地 data/choices;lazy 模式下仅根级,子级逐步填充)
147
+ * 加载失败静默处理:回落本地 data/choices,均无数据时面板显示灰色"暂无数据" */
148
+ const [remoteData, setRemoteData] = useState<CascadeNode[] | null>(null);
149
+ const [loading, setLoading] = useState(false);
150
+ /** lazy 模式:正在加载子级的父级路径串(/ 连接),空 = 无进行中的子级请求 */
151
+ const [loadingPath, setLoadingPath] = useState('');
152
+
153
+ const rootRef = useRef<HTMLDivElement>(null);
154
+
155
+ // 静态 tree:data 优先于 choices(兼容共存),远端拉取成功时以远端为准
156
+ const tree = useMemo<CasNode[]>(() => {
157
+ if (remoteData) return nodesToTree(remoteData, lazy);
158
+ if (Array.isArray(data) && data.length > 0) return nodesToTree(data, lazy);
159
+ // 回退:扁平路径数组 → 多级树(公共前缀合并)
160
+ const root: CasNode[] = [];
161
+ for (const path of choices) {
162
+ if (!path) continue;
163
+ const segs = path.split('/').map(s => s.trim()).filter(Boolean);
164
+ if (segs.length === 0) continue;
165
+ let level = root;
166
+ for (let i = 0; i < segs.length; i++) {
167
+ const { value, label } = parseSeg(segs[i]);
168
+ let node = level.find(n => n.value === value);
169
+ if (!node) {
170
+ node = { value, label, children: [], isLeaf: i === segs.length - 1 };
171
+ level.push(node);
172
+ }
173
+ level = node.children;
174
+ }
175
+ }
176
+ return root;
177
+ }, [remoteData, data, choices, lazy]);
178
+
179
+ /** 逐级选中的 value 数组(空 = 未选择);初值在静态树上计算(远程模式下常为空,属正常) */
180
+ const [selected, setSelected] = useState<string[]>(() => pathToValues(tree, initial));
181
+ /** 面板展开时逐级激活(高亮)的 value 数组 */
182
+ const [active, setActive] = useState<string[]>([]);
183
+ const [open, setOpen] = useState(false);
184
+
185
+ // url 远程模式:非 lazy 一次拉整棵树,lazy 只拉根级(子级点击父级时按需请求)
186
+ useEffect(() => {
187
+ if (!url) { setRemoteData(null); return; }
188
+ let cancelled = false;
189
+ setLoading(true);
190
+ (async () => {
191
+ try {
192
+ const res = await fetch(url);
193
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
194
+ const json: any = await res.json();
195
+ if (cancelled) return;
196
+ const list = normalizeRemoteTree(json);
197
+ if (list.length === 0) throw new Error('响应中没有可用选项');
198
+ setRemoteData(list);
199
+ // 用户尚未选择时回填默认路径(远端加载完成后触发;lazy 模式默认路径仅能回填根级)
200
+ setSelected(prev => (prev.length > 0 ? prev : pathToValues(nodesToTree(list, lazy), initial)));
201
+ } catch {
202
+ // 加载失败:静默回落本地 data/choices
203
+ } finally {
204
+ if (!cancelled) setLoading(false);
205
+ }
206
+ })();
207
+ return () => { cancelled = true; };
208
+ }, [url]);
209
+
210
+ /** lazy 模式:按路径在原始树中找到节点并填充其子级(返回新树,目标节点打 loaded 标记) */
211
+ function fillChildren(treeNodes: CascadeNode[], path: string[], children: CascadeNode[]): CascadeNode[] {
212
+ return treeNodes.map(node => {
213
+ const v = String(node.value ?? node.key ?? '');
214
+ if (v !== path[0]) return node;
215
+ if (path.length === 1) {
216
+ // 目标节点:填充子级;空数组 = 真叶子(loaded 防止重复请求)
217
+ return { ...node, children, loaded: true };
218
+ }
219
+ return { ...node, children: fillChildren(node.children || [], path.slice(1), children) };
220
+ });
221
+ }
222
+
223
+ /** lazy 模式:请求某父级的子级(url?parent=<value>),成功后填充进树 */
224
+ async function loadChildren(node: CasNode, path: string[]) {
225
+ if (!url || !node || node.loaded) return;
226
+ setLoadingPath(path.join('/'));
227
+ try {
228
+ const sep = url.includes('?') ? '&' : '?';
229
+ const res = await fetch(`${url}${sep}parent=${encodeURIComponent(node.value)}`);
230
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
231
+ const json: any = await res.json();
232
+ const list = normalizeRemoteTree(json);
233
+ // 递归更新 remoteData(不可变更新;children 为空时该节点确认为真叶子)
234
+ setRemoteData(prev => (prev ? fillChildren(prev, path, list) : prev));
235
+ } catch {
236
+ // 子级加载失败:静默处理,子层级面板显示灰色"暂无数据"
237
+ } finally {
238
+ setLoadingPath('');
239
+ }
240
+ }
241
+
242
+ // 外部点击关闭面板
243
+ useEffect(() => {
244
+ function onDocClick(e: MouseEvent) {
245
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
246
+ }
247
+ document.addEventListener('mousedown', onDocClick);
248
+ return () => document.removeEventListener('mousedown', onDocClick);
249
+ }, []);
250
+
251
+ /** 当前激活层级对应的子节点列表(level 0 用树根) */
252
+ function levelNodes(level: number): CasNode[] {
253
+ let nodes = tree;
254
+ for (let i = 0; i < level; i++) {
255
+ const v = active[i];
256
+ const node = nodes.find(n => n.value === v);
257
+ if (!node) return [];
258
+ nodes = node.children;
259
+ }
260
+ return nodes;
261
+ }
262
+
263
+ /** 展开层级总数(含当前激活最深一层;lazy 模式下未加载的节点也占一层,展示加载态) */
264
+ const levelCount = useMemo(() => {
265
+ if (!open) return 0;
266
+ let count = 1;
267
+ let nodes = tree;
268
+ for (const v of active) {
269
+ const node = nodes.find(n => n.value === v);
270
+ if (!node || node.isLeaf) break;
271
+ nodes = node.children;
272
+ count++;
273
+ }
274
+ return count;
275
+ }, [open, active, tree]);
276
+
277
+ /** 选中节点 value 数组 → 展示文案串(label 用 / 连接) */
278
+ function displayText(values: string[]): string {
279
+ let nodes = tree;
280
+ const labels: string[] = [];
281
+ for (const v of values) {
282
+ const node = nodes.find(n => n.value === v);
283
+ if (!node) break;
284
+ labels.push(node.label);
285
+ nodes = node.children;
286
+ }
287
+ return labels.join(' / ');
288
+ }
289
+
290
+ function toggleOpen() {
291
+ if (disabled) return;
292
+ setOpen(prev => {
293
+ const next = !prev;
294
+ if (next) {
295
+ // 打开时激活层级同步到已选路径
296
+ setActive(selected.length > 0 ? selected.slice(0, -1) : []);
297
+ }
298
+ return next;
299
+ });
300
+ }
301
+
302
+ /** 点击某层选项:有子级 → 展开下一层;待加载(lazy)→ 请求子级后展开;叶子 → 完成选择并关闭 */
303
+ function pick(level: number, node: CasNode) {
304
+ if (disabled) return;
305
+ const next = active.slice(0, level);
306
+ next.push(node.value);
307
+ if (node.isLeaf) {
308
+ // 叶子节点:完成选择
309
+ setSelected(next);
310
+ setActive([]);
311
+ setOpen(false);
312
+ onchange(next.join('/'));
313
+ } else {
314
+ // 展开下一层;lazy 模式下未加载时先请求子级
315
+ setActive(next);
316
+ if (lazy && url && !node.loaded) {
317
+ void loadChildren(node, next);
318
+ }
319
+ }
320
+ }
321
+
322
+ function clearValue(e: React.MouseEvent) {
323
+ e.stopPropagation();
324
+ setSelected([]);
325
+ setActive([]);
326
+ setOpen(false);
327
+ onchange('');
328
+ }
329
+
330
+ /** 当前激活路径(用于判断正在加载哪一层的子级) */
331
+ const activePath = active.join('/');
332
+
333
+ return (
334
+ <div className="selector widget-cascader" ref={rootRef}>
335
+ {(label || hint || required) && (
336
+ <div className="widget-label-row">
337
+ {required && <span className="widget-required-mark" aria-hidden="true">*</span>}
338
+ {label && <label>{label}</label>}
339
+ {hint && <span className="widget-hint" data-hint={hint}>ⓘ</span>}
340
+ </div>
341
+ )}
342
+ <div className="widget-field-content">
343
+ <button
344
+ type="button"
345
+ className={`cas-input${error ? ' widget-input-error' : ''}`}
346
+ onClick={toggleOpen}
347
+ aria-expanded={open}
348
+ disabled={disabled}
349
+ >
350
+ <span className={`cas-input-text${selected.length === 0 ? ' cas-placeholder' : ''}`}>
351
+ {loading ? '加载中…' : selected.length > 0 ? displayText(selected) : placeholder || '请选择'}
352
+ </span>
353
+ {!disabled && selected.length > 0 ? (
354
+ <span
355
+ className="cas-clear"
356
+ role="button"
357
+ tabIndex={-1}
358
+ aria-label="清空"
359
+ onClick={clearValue}
360
+ >×</span>
361
+ ) : (!disabled && (
362
+ <span className={`cas-arrow${open ? ' cas-arrow-open' : ''}`} aria-hidden="true">▾</span>
363
+ ))}
364
+ </button>
365
+ {error && <div className="widget-error-text" role="alert">{error}</div>}
366
+
367
+ {open && (
368
+ <div className="cas-panel">
369
+ {levelCount === 0 ? (
370
+ <div className="cas-empty">{loading ? '加载中…' : '暂无数据'}</div>
371
+ ) : (
372
+ Array.from({ length: levelCount }, (_, level) => (
373
+ <div className="cas-menu" role="menu" key={level}>
374
+ {levelNodes(level).map(node => (
375
+ <button
376
+ type="button"
377
+ key={node.value}
378
+ className={`cas-option${active[level] === node.value ? ' cas-option-active' : ''}`}
379
+ onClick={() => pick(level, node)}
380
+ >
381
+ <span className="cas-option-label">{node.label}</span>
382
+ {!node.isLeaf && (
383
+ <span className="cas-option-arrow" aria-hidden="true">›</span>
384
+ )}
385
+ </button>
386
+ ))}
387
+ {levelNodes(level).length === 0 && (
388
+ <div className="cas-empty">
389
+ {loadingPath && activePath.startsWith(loadingPath) ? '加载中…' : '暂无数据'}
390
+ </div>
391
+ )}
392
+ </div>
393
+ ))
394
+ )}
395
+ </div>
396
+ )}
397
+ </div>
398
+ </div>
399
+ );
400
+ };