@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,178 @@
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
+ };
@@ -0,0 +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
+ 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
+ };
@@ -0,0 +1,101 @@
1
+ import React, { useState } from 'react';
2
+
3
+ export interface ShortAnswerQuestionProps {
4
+ question: string;
5
+ placeholder?: string;
6
+ correctAnswer?: string;
7
+ correctAnswers?: string[];
8
+ showFeedback?: boolean;
9
+ /** 是否禁用:禁用后输入框与提交按钮不可交互 */
10
+ disabled?: boolean;
11
+ onchange: (value: { answer: string; isCorrect?: boolean }) => void;
12
+ }
13
+
14
+ export const ShortAnswerQuestion: React.FC<ShortAnswerQuestionProps> = ({
15
+ question,
16
+ placeholder = "Type your answer here...",
17
+ correctAnswer,
18
+ correctAnswers,
19
+ showFeedback = false,
20
+ disabled = false,
21
+ onchange
22
+ }) => {
23
+ const [answer, setAnswer] = useState('');
24
+ const [hasAnswered, setHasAnswered] = useState(false);
25
+ const [isCorrect, setIsCorrect] = useState<boolean | undefined>(undefined);
26
+
27
+ const handleSubmit = () => {
28
+ if (disabled) return;
29
+ if (!answer.trim()) return;
30
+
31
+ setHasAnswered(true);
32
+
33
+ const normalized = (correctAnswers && correctAnswers.length > 0)
34
+ ? correctAnswers
35
+ : (typeof correctAnswer === 'string' ? [correctAnswer] : undefined);
36
+
37
+ const correct = normalized
38
+ ? normalized.some(a => a.toLowerCase() === answer.trim().toLowerCase())
39
+ : undefined;
40
+
41
+ setIsCorrect(correct);
42
+ onchange({ answer: answer.trim(), isCorrect: correct });
43
+ };
44
+
45
+ const handleKeyDown = (e: React.KeyboardEvent) => {
46
+ if (e.key === 'Enter' && !hasAnswered && !disabled) {
47
+ handleSubmit();
48
+ }
49
+ };
50
+
51
+ const getFeedbackMessage = () => {
52
+ if (!hasAnswered || !showFeedback) return null;
53
+
54
+ const hasAnswers = (correctAnswers && correctAnswers.length > 0) || typeof correctAnswer !== 'undefined';
55
+ if (!hasAnswers) {
56
+ return `Your answer: ${answer.trim()}`;
57
+ }
58
+
59
+ if (correctAnswers && correctAnswers.length > 0) {
60
+ return isCorrect
61
+ ? '✓ Correct!'
62
+ : `✗ Incorrect. Accepted answers: ${correctAnswers.join(', ')}`;
63
+ }
64
+ return isCorrect ? '✓ Correct!' : `✗ Incorrect. The correct answer is: ${correctAnswer}`;
65
+ };
66
+
67
+ return (
68
+ <div className="widget-saq">
69
+ <div className="saq-question">{question}</div>
70
+ <div className="saq-input-container">
71
+ <input
72
+ type="text"
73
+ value={answer}
74
+ onChange={(e) => setAnswer(e.target.value)}
75
+ onKeyDown={handleKeyDown}
76
+ placeholder={placeholder}
77
+ disabled={disabled || (hasAnswered && showFeedback)}
78
+ className={`saq-input ${
79
+ hasAnswered && showFeedback
80
+ ? isCorrect ? 'saq-input-correct' : 'saq-input-incorrect'
81
+ : ''
82
+ }`}
83
+ />
84
+ {!hasAnswered && (
85
+ <button
86
+ onClick={handleSubmit}
87
+ disabled={disabled || !answer.trim()}
88
+ className="saq-submit"
89
+ >
90
+ Submit
91
+ </button>
92
+ )}
93
+ </div>
94
+ {hasAnswered && showFeedback && getFeedbackMessage() && (
95
+ <div className={`saq-feedback ${isCorrect ? 'correct' : 'incorrect'}`}>
96
+ {getFeedbackMessage()}
97
+ </div>
98
+ )}
99
+ </div>
100
+ );
101
+ };
@@ -0,0 +1,71 @@
1
+ import React, { useState } from 'react';
2
+
3
+ export interface SliderProps {
4
+ min: number;
5
+ max: number;
6
+ step?: number;
7
+ label?: string;
8
+ default?: number;
9
+ /** 是否禁用 */
10
+ disabled?: boolean;
11
+ /** 表单项说明:label 后 ⓘ,悬浮显示 tooltip */
12
+ hint?: string;
13
+ /** 是否必填(slider 始终有值,仅语义标记) */
14
+ required?: boolean;
15
+ onchange: (value: number) => void;
16
+ }
17
+
18
+ export const Slider: React.FC<SliderProps> = ({
19
+ min,
20
+ max,
21
+ step = 1,
22
+ label = '',
23
+ default: initial = min,
24
+ disabled = false,
25
+ hint = '',
26
+ required = false,
27
+ onchange
28
+ }) => {
29
+ const [value, setValue] = useState(initial);
30
+
31
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
32
+ const newValue = Number(e.target.value);
33
+ setValue(newValue);
34
+ };
35
+
36
+ const handleComplete = () => {
37
+ onchange(value);
38
+ };
39
+
40
+ return (
41
+ <div className="widget-slider">
42
+ {label || hint || required ? (
43
+ <div className="widget-label-row">
44
+ {required && <span className="widget-required-mark" aria-hidden="true">*</span>}
45
+ {label && <label>{label}</label>}
46
+ {hint && <span className="widget-hint" data-hint={hint}>ⓘ</span>}
47
+ </div>
48
+ ) : null}
49
+ <div className="widget-field-content">
50
+ <div className="slider-container">
51
+ <div className="slider-values">
52
+ <span className="min-value">{min}</span>
53
+ <span className="current-value">{value}</span>
54
+ <span className="max-value">{max}</span>
55
+ </div>
56
+ <input
57
+ type="range"
58
+ value={value}
59
+ min={min}
60
+ max={max}
61
+ step={step}
62
+ onChange={handleChange}
63
+ onMouseUp={handleComplete}
64
+ onTouchEnd={handleComplete}
65
+ disabled={disabled}
66
+ />
67
+ </div>
68
+ </div>
69
+ </div>
70
+ );
71
+ };
@@ -0,0 +1,68 @@
1
+ import React, { useState } from 'react';
2
+
3
+ export interface SwitchProps {
4
+ label?: string;
5
+ /** 默认是否选中 */
6
+ default?: boolean;
7
+ /** 选中时展示的文案(antd checkedChildren) */
8
+ checkedText?: string;
9
+ /** 未选中时展示的文案(antd unCheckedChildren) */
10
+ unCheckedText?: string;
11
+ /** 是否禁用 */
12
+ disabled?: boolean;
13
+ /** 表单项说明:label 后 ⓘ,悬浮显示 tooltip */
14
+ hint?: string;
15
+ /** 是否必填(switch 始终有值,仅语义标记) */
16
+ required?: boolean;
17
+ onchange: (value: boolean) => void;
18
+ }
19
+
20
+ /** 开关组件(参考 antd Switch):布尔切换控件,可配置选中/未选中文案。
21
+ 与表单其他字段同构(label-row 在前、内容在后),表单 layout:horizontal 时 label 在左、开关在右 */
22
+ export const Switch: React.FC<SwitchProps> = ({
23
+ label = '',
24
+ default: initial = false,
25
+ checkedText = '',
26
+ unCheckedText = '',
27
+ disabled = false,
28
+ hint = '',
29
+ required = false,
30
+ onchange
31
+ }) => {
32
+ const [checked, setChecked] = useState(initial);
33
+
34
+ const toggle = () => {
35
+ if (disabled) return;
36
+ const next = !checked;
37
+ setChecked(next);
38
+ onchange(next);
39
+ };
40
+
41
+ return (
42
+ <div className="widget-switch">
43
+ {label || hint || required ? (
44
+ <div className="widget-label-row switch-label-row">
45
+ {required && <span className="widget-required-mark" aria-hidden="true">*</span>}
46
+ {label && <label>{label}</label>}
47
+ {hint && <span className="widget-hint" data-hint={hint}>ⓘ</span>}
48
+ </div>
49
+ ) : null}
50
+ <div className="widget-field-content">
51
+ <button
52
+ type="button"
53
+ role="switch"
54
+ aria-checked={checked}
55
+ aria-label={label}
56
+ className="switch-track"
57
+ data-checked={checked}
58
+ onClick={toggle}
59
+ disabled={disabled}
60
+ >
61
+ <span className="switch-thumb" />
62
+ {checkedText && <span className="switch-text switch-text-checked">{checkedText}</span>}
63
+ {unCheckedText && <span className="switch-text switch-text-unchecked">{unCheckedText}</span>}
64
+ </button>
65
+ </div>
66
+ </div>
67
+ );
68
+ };
@@ -0,0 +1,76 @@
1
+ import React, { useState } from 'react';
2
+
3
+ export interface TextInputProps {
4
+ placeholder?: string;
5
+ label?: string;
6
+ default?: string;
7
+ hint?: string;
8
+ /** 是否必填:label 前红色 * */
9
+ required?: boolean;
10
+ /** 正则校验规则(JS 正则源字符串) */
11
+ pattern?: string;
12
+ /** 校验失败提示文案 */
13
+ message?: string;
14
+ /** 外部(form 提交时)传入的错误信息,优先于组件内部即时校验 */
15
+ error?: string;
16
+ /** 是否禁用 */
17
+ disabled?: boolean;
18
+ onchange: (value: string) => void;
19
+ }
20
+
21
+ export const TextInput: React.FC<TextInputProps> = ({
22
+ placeholder = '',
23
+ label = '',
24
+ default: initial = '',
25
+ hint = '',
26
+ required = false,
27
+ pattern,
28
+ message = '',
29
+ error = '',
30
+ disabled = false,
31
+ onchange
32
+ }) => {
33
+ const [value, setValue] = useState(initial);
34
+ const [localError, setLocalError] = useState('');
35
+
36
+ /** 校验当前值:返回错误文案或空串 */
37
+ const validate = (v: string): string => {
38
+ if (required && !v.trim()) return message || (label ? `请填写${label}` : '该项为必填项');
39
+ if (pattern && v && !new RegExp(pattern).test(v)) return message || (label ? `${label}格式不正确` : '格式不正确');
40
+ return '';
41
+ };
42
+
43
+ const commit = () => {
44
+ setLocalError(validate(value));
45
+ onchange(value);
46
+ };
47
+
48
+ const shownError = error || localError;
49
+
50
+ return (
51
+ <div className={`widget-text-input${shownError ? ' widget-field-error' : ''}`}>
52
+ {label || hint || required ? (
53
+ <div className="widget-label-row">
54
+ {required && <span className="widget-required-mark" aria-hidden="true">*</span>}
55
+ {label && <label>{label}</label>}
56
+ {hint && <span className="widget-hint" data-hint={hint}>ⓘ</span>}
57
+ </div>
58
+ ) : null}
59
+ <div className="widget-field-content">
60
+ <input
61
+ type="text"
62
+ className={shownError ? 'widget-input-error' : undefined}
63
+ value={value}
64
+ onChange={(e) => {
65
+ setValue(e.target.value);
66
+ if (localError) setLocalError(validate(e.target.value));
67
+ }}
68
+ onBlur={commit}
69
+ placeholder={placeholder}
70
+ disabled={disabled}
71
+ />
72
+ {shownError ? <div className="widget-error-text" role="alert">{shownError}</div> : null}
73
+ </div>
74
+ </div>
75
+ );
76
+ };