@c2-markdown-ui/svelte 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,140 @@
1
+ <script lang="ts">
2
+ import Button from './Button.svelte';
3
+ import ButtonGroup from './ButtonGroup.svelte';
4
+ import Select from './Select.svelte';
5
+ import SelectMulti from './SelectMulti.svelte';
6
+ import RadioGroup from './Radio.svelte';
7
+ import Cascader from './Cascader.svelte';
8
+ import TreeSelect from './TreeSelect.svelte';
9
+ import Slider from './Slider.svelte';
10
+ import Switch from './Switch.svelte';
11
+ import TextInput from './TextInput.svelte';
12
+ import NumberInput from './NumberInput.svelte';
13
+ import DatePicker from './DatePicker.svelte';
14
+ import Text from './Text.svelte';
15
+ import Form from './Form.svelte';
16
+ import Row from './Row.svelte';
17
+ import Chart from './Chart.svelte';
18
+ import MultipleChoiceQuestion from './MultipleChoiceQuestion.svelte';
19
+ import ShortAnswerQuestion from './ShortAnswerQuestion.svelte';
20
+ import Quiz from './Quiz.svelte';
21
+ import Incomplete from './Incomplete.svelte';
22
+
23
+ interface GridCol {
24
+ span?: number;
25
+ offset?: number;
26
+ align?: 'top' | 'middle' | 'bottom';
27
+ widget?: any;
28
+ }
29
+ interface Props {
30
+ id?: string;
31
+ cols: GridCol[];
32
+ /** 列间距(px),类似 antd Row 的 gutter */
33
+ gutter?: number;
34
+ /** 水平排列:left / center / right / between */
35
+ justify?: 'left' | 'center' | 'right' | 'between';
36
+ /** 垂直对齐:top / middle / bottom */
37
+ align?: 'top' | 'middle' | 'bottom';
38
+ /** 子组件事件冒泡(子组件无行为语义,透传即可) */
39
+ onchange?: (value: any) => void;
40
+ }
41
+
42
+ let { id, cols, gutter, justify, align, onchange }: Props = $props();
43
+
44
+ // 计算每列 grid span(24 栅格):显式 span 优先,无 span 均分剩余
45
+ function computeSpans(cols: GridCol[]): number[] {
46
+ const spans = cols.map(c =>
47
+ typeof c.span === 'number' && c.span >= 1 && c.span <= 24 ? Math.round(c.span) : undefined
48
+ );
49
+ const fixedTotal = spans.reduce<number>((acc, s) => acc + (s ?? 0), 0);
50
+ const autoCount = spans.filter(s => s === undefined).length;
51
+ let autoSpan = 0;
52
+ if (autoCount > 0) {
53
+ const remaining = 24 - fixedTotal;
54
+ // 剩余栅格不足时占满整行(grid auto-flow 会自动换行),避免被挤进窄列导致文字竖排
55
+ if (remaining <= 0) {
56
+ autoSpan = 24;
57
+ } else {
58
+ autoSpan = Math.max(Math.floor(remaining / autoCount), 1);
59
+ }
60
+ }
61
+ return spans.map(s => s ?? autoSpan);
62
+ }
63
+
64
+ const colAlign: Record<string, string> = { top: 'start', middle: 'center', bottom: 'end' };
65
+ const rowAlign: Record<string, string> = { top: 'start', middle: 'center', bottom: 'end' };
66
+ const rowJustify: Record<string, string> = {
67
+ left: 'start', center: 'center', right: 'end', between: 'space-between'
68
+ };
69
+ </script>
70
+
71
+ <div
72
+ class="widget-grid-row"
73
+ data-row-id={id}
74
+ style={[
75
+ gutter !== undefined ? `--row-gutter: ${gutter}px` : '',
76
+ `justify-content: ${rowJustify[justify || 'left']}`,
77
+ `align-items: ${rowAlign[align || 'middle']}`
78
+ ].filter(Boolean).join('; ')}
79
+ >
80
+ {#each cols as col, ci (ci)}
81
+ {@const span = computeSpans(cols)[ci]}
82
+ {@const offset = typeof col.offset === 'number' && col.offset >= 0 ? Math.round(col.offset) : 0}
83
+ <div
84
+ class="widget-grid-col"
85
+ data-col-index={ci}
86
+ data-field-type={col.widget?.type}
87
+ style={[
88
+ `grid-column: ${offset > 0 ? `${offset + 1} / span ${span}` : `span ${span}`}`,
89
+ col.align ? `align-self: ${colAlign[col.align]}` : ''
90
+ ].filter(Boolean).join('; ')}
91
+ >
92
+ {#if col.widget}
93
+ <!-- 子组件包裹 div:data-field-id 供编辑器字段级定位(col 自身不带,便于双击 col 空白打开 col 面板) -->
94
+ <div class="widget-grid-field" data-field-id={col.widget.id} data-field-type={col.widget.type}>
95
+ {#if col.widget.type === 'button'}
96
+ <Button {...col.widget} onchange={(v) => onchange?.(v)} />
97
+ {:else if col.widget.type === 'button-group'}
98
+ <ButtonGroup {...col.widget} choices={col.widget.choices ?? []} onchange={(v) => onchange?.(v)} />
99
+ {:else if col.widget.type === 'select'}
100
+ <Select {...col.widget} choices={col.widget.choices ?? []} onchange={(v) => onchange?.(v)} />
101
+ {:else if col.widget.type === 'select-multi'}
102
+ <SelectMulti {...col.widget} choices={col.widget.choices ?? []} onchange={(v) => onchange?.(v)} />
103
+ {:else if col.widget.type === 'radio-group'}
104
+ <RadioGroup {...col.widget} choices={col.widget.choices ?? []} onchange={(v) => onchange?.(v)} />
105
+ {:else if col.widget.type === 'cascade'}
106
+ <Cascader {...col.widget} choices={col.widget.choices ?? []} onchange={(v) => onchange?.(v)} />
107
+ {:else if col.widget.type === 'tree-select'}
108
+ <TreeSelect {...col.widget} choices={col.widget.choices ?? []} onchange={(v) => onchange?.(v)} />
109
+ {:else if col.widget.type === 'slider'}
110
+ <Slider {...col.widget} min={col.widget.min ?? 0} max={col.widget.max ?? 100} onchange={(v) => onchange?.(v)} />
111
+ {:else if col.widget.type === 'switch'}
112
+ <Switch {...col.widget} onchange={(v) => onchange?.(v)} />
113
+ {:else if col.widget.type === 'text-input'}
114
+ <TextInput {...col.widget} onchange={(v) => onchange?.(v)} />
115
+ {:else if col.widget.type === 'number-input'}
116
+ <NumberInput {...col.widget} onchange={(v) => onchange?.(v)} />
117
+ {:else if col.widget.type === 'date-picker'}
118
+ <DatePicker {...col.widget} onchange={(v) => onchange?.(v)} />
119
+ {:else if col.widget.type === 'text'}
120
+ <Text {...col.widget} />
121
+ {:else if col.widget.type === 'form'}
122
+ <Form {...col.widget} onchange={(v) => onchange?.(v)} />
123
+ {:else if col.widget.type === 'row'}
124
+ <Row {...col.widget} onchange={(v) => onchange?.(v)} />
125
+ {:else if col.widget.type === 'chart-line' || col.widget.type === 'chart-bar' || col.widget.type === 'chart-pie' || col.widget.type === 'chart-scatter'}
126
+ <Chart {...col.widget} type={col.widget.type} onchange={(v) => onchange?.(v)} />
127
+ {:else if col.widget.type === 'multiple-choice-question'}
128
+ <MultipleChoiceQuestion {...col.widget} onchange={(v) => onchange?.(v)} />
129
+ {:else if col.widget.type === 'short-answer-question'}
130
+ <ShortAnswerQuestion {...col.widget} onchange={(v) => onchange?.(v)} />
131
+ {:else if col.widget.type === 'quiz'}
132
+ <Quiz {...col.widget} onchange={(v) => onchange?.(v)} />
133
+ {:else if col.widget.type === 'incomplete'}
134
+ <Incomplete {...col.widget} onchange={(v: any) => onchange?.(v)} />
135
+ {/if}
136
+ </div>
137
+ {/if}
138
+ </div>
139
+ {/each}
140
+ </div>
@@ -0,0 +1,276 @@
1
+ <script lang="ts">
2
+ import { normalizeRemoteChoices, parseChoices, resolveChoiceValue, type RemoteChoice } from './remoteChoices';
3
+ interface Props {
4
+ choices: string[];
5
+ label?: string;
6
+ default?: string;
7
+ /** 远端数据源 URL:设置后运行时 fetch 拉取选项,choices 作为初始/后备选项 */
8
+ url?: string;
9
+ /** 是否可搜索:开启后下拉面板内置搜索框,按 key/value 与 label 过滤选项 */
10
+ searchable?: boolean;
11
+ hint?: string;
12
+ /** 是否必填(select 必有选中值,仅语义标记) */
13
+ required?: boolean;
14
+ /** 是否禁用 */
15
+ disabled?: boolean;
16
+ onchange: (value: string | string[]) => void;
17
+ }
18
+ let {
19
+ choices,
20
+ label = '',
21
+ default: initial,
22
+ url = '',
23
+ searchable = false,
24
+ hint = '',
25
+ required = false,
26
+ disabled = false,
27
+ onchange
28
+ }: Props = $props();
29
+
30
+ // 远端选项:拉取成功后覆盖本地 choices(value=表单取值,label=展示文案)
31
+ // 加载失败静默处理:回落本地 choices,均无数据时下拉显示灰色"暂无数据"
32
+ let remoteChoices = $state<RemoteChoice[] | null>(null);
33
+ let loading = $state(false);
34
+
35
+ // 初始选中值:default → choices[0] 的 key → 远端第一项
36
+ let val = $state(initial ?? parseChoices(choices)[0]?.value ?? '');
37
+
38
+ $effect(() => {
39
+ if (!url) return;
40
+ loading = true;
41
+ let cancelled = false;
42
+ (async () => {
43
+ try {
44
+ const res = await fetch(url);
45
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
46
+ const json: any = await res.json();
47
+ if (cancelled) return;
48
+ const list = normalizeRemoteChoices(json);
49
+ if (list.length === 0) throw new Error('响应中没有可用选项');
50
+ remoteChoices = list;
51
+ // 当前值不在远端取值中时:default/本地值按 label 映射到远端 value,否则回落第一项
52
+ if (!list.some(r => r.value === val)) {
53
+ const mapped = initial !== undefined ? resolveChoiceValue(initial, list) : val;
54
+ val = list.some(r => r.value === mapped) ? mapped : list[0].value;
55
+ onchange(val);
56
+ }
57
+ } catch {
58
+ // 加载失败:静默回落本地 choices
59
+ } finally {
60
+ if (!cancelled) loading = false;
61
+ }
62
+ })();
63
+ return () => { cancelled = true; };
64
+ });
65
+
66
+ /** 展示条目:远端拉取成功用 {value,label};否则本地 choices(支持 key:label 格式) */
67
+ const items = $derived(remoteChoices ?? parseChoices(choices));
68
+
69
+ // ---------- searchable 模式:自定义下拉(触发按钮 + 搜索框 + 滚动选项列表) ----------
70
+ let open = $state(false);
71
+ /** 搜索关键字(按 value/key 与 label 过滤,大小写不敏感) */
72
+ let query = $state('');
73
+ let searchEl: HTMLInputElement | undefined = $state();
74
+ let rootEl: HTMLElement | undefined = $state();
75
+
76
+ /** 当前选中项展示文案(value 与 label 不同时优先 label) */
77
+ const display = $derived(items.find(it => it.value === val)?.label ?? '');
78
+
79
+ /** 展示用的选项列表:searchable 模式按关键字过滤,普通模式全量 */
80
+ const filtered = $derived.by(() => {
81
+ if (!searchable) return items;
82
+ const q = query.trim().toLowerCase();
83
+ if (!q) return items;
84
+ return items.filter(it => it.value.toLowerCase().includes(q) || it.label.toLowerCase().includes(q));
85
+ });
86
+
87
+ // 外部点击关闭面板
88
+ $effect(() => {
89
+ function onDocClick(e: MouseEvent) {
90
+ if (rootEl && !rootEl.contains(e.target as Node)) open = false;
91
+ }
92
+ document.addEventListener('mousedown', onDocClick);
93
+ return () => document.removeEventListener('mousedown', onDocClick);
94
+ });
95
+
96
+ // 打开面板时清空关键字并聚焦搜索框(仅 searchable 模式有搜索框)
97
+ $effect(() => {
98
+ if (open && searchable) {
99
+ query = '';
100
+ if (searchEl) searchEl.focus();
101
+ }
102
+ });
103
+
104
+ function toggleOpen() {
105
+ if (disabled) return;
106
+ open = !open;
107
+ }
108
+
109
+ /** 选中某选项:回填 value 并关闭面板 */
110
+ function pick(v: string) {
111
+ if (disabled) return;
112
+ val = v;
113
+ open = false;
114
+ onchange(v);
115
+ }
116
+ </script>
117
+
118
+ <div class="selector" bind:this={rootEl}>
119
+ {#if label || hint || required}
120
+ <div class="widget-label-row">
121
+ {#if required}<span class="widget-required-mark" aria-hidden="true">*</span>{/if}
122
+ {#if label}<label>{label}</label>{/if}
123
+ {#if hint}<span class="widget-hint" data-hint={hint}>ⓘ</span>{/if}
124
+ </div>
125
+ {/if}
126
+ <div class="widget-field-content widget-select-content">
127
+ <button type="button" class="cas-input" onclick={toggleOpen} aria-expanded={open} {disabled}>
128
+ <span class="cas-input-text" class:cas-placeholder={!display}>
129
+ {loading ? '加载中…' : display || '请选择'}
130
+ </span>
131
+ <span class="cas-arrow" class:cas-arrow-open={open} aria-hidden="true">▾</span>
132
+ </button>
133
+ {#if open}
134
+ <div class="cas-panel cas-panel-single">
135
+ {#if searchable}
136
+ <div class="cas-search">
137
+ <input
138
+ type="text"
139
+ class="cas-search-input"
140
+ bind:this={searchEl}
141
+ bind:value={query}
142
+ placeholder="搜索选项…"
143
+ />
144
+ </div>
145
+ {/if}
146
+ <div class="cas-menu cas-menu-full" role="menu">
147
+ {#each filtered as it (it.value)}
148
+ <button
149
+ type="button"
150
+ class="cas-option"
151
+ class:cas-option-active={val === it.value}
152
+ onclick={() => pick(it.value)}
153
+ >
154
+ <span class="cas-option-label">{it.label}</span>
155
+ </button>
156
+ {/each}
157
+ {#if filtered.length === 0}
158
+ <div class="cas-empty">{loading ? '加载中…' : query.trim() ? '无匹配选项' : '暂无数据'}</div>
159
+ {/if}
160
+ </div>
161
+ </div>
162
+ {/if}
163
+ </div>
164
+ </div>
165
+
166
+ <style>
167
+ .widget-select-content {
168
+ position: relative;
169
+ display: flex;
170
+ flex-direction: column;
171
+ }
172
+ .cas-input {
173
+ display: flex;
174
+ align-items: center;
175
+ gap: 0.375rem;
176
+ width: 100%;
177
+ padding: 0.625rem 0.75rem;
178
+ border: 1px solid #d9d9d9;
179
+ border-radius: 6px;
180
+ font-size: 0.875rem;
181
+ background-color: #ffffff;
182
+ cursor: pointer;
183
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
184
+ text-align: left;
185
+ }
186
+ .cas-input:hover { border-color: #4096ff; }
187
+ .cas-input:focus {
188
+ outline: none;
189
+ border-color: #1677ff;
190
+ box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
191
+ }
192
+ /* 禁用态:置灰 + 禁止交互 */
193
+ .cas-input:disabled {
194
+ background-color: #f5f5f5;
195
+ color: rgba(0, 0, 0, 0.25);
196
+ border-color: #d9d9d9;
197
+ cursor: not-allowed;
198
+ }
199
+ .cas-input:disabled:hover { border-color: #d9d9d9; }
200
+
201
+ .cas-input-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 1 auto; min-width: 0; }
202
+ .cas-placeholder { color: rgba(0, 0, 0, 0.45); }
203
+ .cas-arrow { color: rgba(0, 0, 0, 0.45); font-size: 0.75rem; transition: transform 0.2s ease; }
204
+ .cas-arrow-open { transform: rotate(180deg); }
205
+
206
+ /* 可搜索面板:纵向排搜索框 + 选项列表 */
207
+ .cas-panel {
208
+ position: absolute;
209
+ z-index: 50;
210
+ top: calc(100% + 4px);
211
+ left: 0;
212
+ display: flex;
213
+ background: #ffffff;
214
+ border: 1px solid #d9d9d9;
215
+ border-radius: 8px;
216
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
217
+ }
218
+ .cas-panel-single {
219
+ flex-direction: column;
220
+ min-width: 100%;
221
+ width: auto;
222
+ }
223
+ .cas-search {
224
+ padding: 0.5rem;
225
+ border-bottom: 1px solid #f0f0f0;
226
+ }
227
+ .cas-search-input {
228
+ width: 100%;
229
+ padding: 0.375rem 0.625rem;
230
+ border: 1px solid #d9d9d9;
231
+ border-radius: 4px;
232
+ font-size: 0.8125rem;
233
+ box-sizing: border-box;
234
+ }
235
+ .cas-search-input:focus {
236
+ outline: none;
237
+ border-color: #1677ff;
238
+ box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
239
+ }
240
+ .cas-menu {
241
+ max-height: 240px;
242
+ overflow-y: auto;
243
+ padding: 0.375rem;
244
+ }
245
+ .cas-menu-full {
246
+ width: 100%;
247
+ flex: 1 1 auto;
248
+ min-width: 0;
249
+ }
250
+ .cas-option {
251
+ display: flex;
252
+ align-items: center;
253
+ justify-content: space-between;
254
+ gap: 0.5rem;
255
+ width: 100%;
256
+ padding: 0.375rem 0.5rem;
257
+ border: none;
258
+ border-radius: 4px;
259
+ background: transparent;
260
+ font-size: 0.875rem;
261
+ color: rgba(0, 0, 0, 0.88);
262
+ cursor: pointer;
263
+ text-align: left;
264
+ transition: background 0.15s ease;
265
+ }
266
+ .cas-option:hover { background: #f5f5f5; }
267
+ .cas-option-active { background: #e6f4ff; color: #1677ff; font-weight: 500; }
268
+ .cas-option-active:hover { background: #bae0ff; }
269
+ .cas-option-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
270
+ .cas-empty {
271
+ padding: 0.75rem 0.5rem;
272
+ font-size: 0.8125rem;
273
+ color: rgba(0, 0, 0, 0.45);
274
+ text-align: center;
275
+ }
276
+ </style>
@@ -0,0 +1,130 @@
1
+ <script lang="ts">
2
+ import { normalizeRemoteChoices, parseChoice, parseChoices, resolveChoiceValue, type RemoteChoice } from './remoteChoices';
3
+ interface Props {
4
+ /** 选项:字符串数组(每项支持 key:label 格式)或对象数组({key|value,label},取值为 key/value,展示 label) */
5
+ choices: string[] | Array<{ value?: string; key?: string; label?: string }>;
6
+ label?: string;
7
+ default?: string | string[];
8
+ /** 远端数据源 URL:设置后运行时 fetch 拉取选项,choices 作为初始/后备选项 */
9
+ url?: string;
10
+ /** 选项排列方向:vertical 纵向(默认)/ horizontal 横向 */
11
+ direction?: 'vertical' | 'horizontal';
12
+ /** 自定义布局:每行列数(设置后按 N 列网格排列,优先于 direction) */
13
+ columns?: number;
14
+ hint?: string;
15
+ /** 是否必填:至少勾选一项 */
16
+ required?: boolean;
17
+ /** 校验失败提示文案 */
18
+ message?: string;
19
+ /** 外部(form 提交时)传入的错误信息 */
20
+ error?: string;
21
+ /** 是否禁用 */
22
+ disabled?: boolean;
23
+ onchange: (value: string | string[]) => void;
24
+ }
25
+ let { choices, label = '', default: initial, url = '', direction = 'vertical', columns = 0, hint = '', required = false, message = '', error = '', disabled = false, onchange }: Props = $props();
26
+
27
+ // 初始勾选值:default 可能是 key、label 或 key:label 原文,统一解析为 key 体系;
28
+ // 对象数组 choices 时 default 写 label 也映射回 value
29
+ const localItems = parseChoices(choices);
30
+ const mapInitial = (v: any): string => {
31
+ const s = String(v);
32
+ const hit = localItems.find(it => it.value === s || it.label === s);
33
+ return hit ? hit.value : parseChoice(s).value;
34
+ };
35
+ let val = $state(
36
+ Array.isArray(initial)
37
+ ? initial.map(mapInitial)
38
+ : (initial ? [mapInitial(initial)] : [])
39
+ );
40
+ let localError = $state('');
41
+
42
+ // 远端选项:拉取成功后覆盖本地 choices(value=表单取值,label=展示文案)
43
+ // 加载失败静默处理:回落本地 choices,均无数据时显示灰色"暂无数据"
44
+ let remoteChoices = $state<RemoteChoice[] | null>(null);
45
+ let loading = $state(false);
46
+
47
+ $effect(() => {
48
+ if (!url) return;
49
+ loading = true;
50
+ let cancelled = false;
51
+ (async () => {
52
+ try {
53
+ const res = await fetch(url);
54
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
55
+ const json: any = await res.json();
56
+ if (cancelled) return;
57
+ const list = normalizeRemoteChoices(json);
58
+ if (list.length === 0) throw new Error('响应中没有可用选项');
59
+ remoteChoices = list;
60
+ // 勾选值处理:默认值按 label 映射到远端 value,剔除仍不在远端取值中的项
61
+ if (val.length > 0) {
62
+ const mapped = val.map(v => resolveChoiceValue(v, list));
63
+ const filtered = mapped.filter(v => list.some(r => r.value === v));
64
+ if (filtered.join(',') !== val.join(',')) {
65
+ val = filtered;
66
+ onchange(val);
67
+ }
68
+ }
69
+ } catch {
70
+ // 加载失败:静默回落本地 choices
71
+ } finally {
72
+ if (!cancelled) loading = false;
73
+ }
74
+ })();
75
+ return () => { cancelled = true; };
76
+ });
77
+
78
+ function validate(v: string[]): string {
79
+ if (required && v.length === 0) return message || (label ? `请填写${label}` : '请至少选择一项');
80
+ return '';
81
+ }
82
+
83
+ const shownError = $derived(error || localError);
84
+ /** 展示条目:远端拉取成功用 {value,label};否则本地 choices(支持 key:label 格式与对象数组) */
85
+ const items = $derived(remoteChoices ?? localItems);
86
+ </script>
87
+
88
+ <div class="selector-multi" class:widget-field-error={!!shownError}>
89
+ {#if label || hint || required}
90
+ <div class="widget-label-row">
91
+ {#if required}<span class="widget-required-mark" aria-hidden="true">*</span>{/if}
92
+ {#if label}<div class="selector-multi-label">{label}</div>{/if}
93
+ {#if hint}<span class="widget-hint" data-hint={hint}>ⓘ</span>{/if}
94
+ </div>
95
+ {/if}
96
+ <div class="widget-field-content">
97
+ <!-- 选项布局:columns 自定义列数(grid)优先;direction 横向/纵向(默认纵向) -->
98
+ <div
99
+ class="checkbox-group"
100
+ data-direction={columns >= 1 ? 'grid' : direction}
101
+ style={columns >= 1 ? `grid-template-columns: repeat(${columns}, minmax(0, 1fr))` : ''}
102
+ >
103
+ {#if items.length === 0}
104
+ <div style="color: rgba(0, 0, 0, 0.45); font-size: 13px;">{loading ? '加载中…' : '暂无数据'}</div>
105
+ {/if}
106
+ {#each items as it (it.value)}
107
+ <label class="checkbox-item">
108
+ <input
109
+ type="checkbox"
110
+ value={it.value}
111
+ checked={val.includes(it.value)}
112
+ {disabled}
113
+ onchange={(e) => {
114
+ const target = e.target as HTMLInputElement;
115
+ if (target.checked) {
116
+ val = [...val, it.value];
117
+ } else {
118
+ val = val.filter(v => v !== it.value);
119
+ }
120
+ localError = validate(val);
121
+ onchange(val);
122
+ }}
123
+ />
124
+ <span>{it.label}</span>
125
+ </label>
126
+ {/each}
127
+ </div>
128
+ {#if shownError}<div class="widget-error-text" role="alert">{shownError}</div>{/if}
129
+ </div>
130
+ </div>
@@ -0,0 +1,57 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ question: string;
4
+ placeholder?: string;
5
+ correctAnswer?: string;
6
+ correctAnswers?: string[];
7
+ showFeedback?: boolean;
8
+ /** 是否禁用:禁用后输入框与提交按钮不可交互 */
9
+ disabled?: boolean;
10
+ onchange: (value: { answer: string; isCorrect?: boolean }) => void;
11
+ }
12
+ let { question, placeholder = 'Type your answer here...', correctAnswer = undefined, correctAnswers = undefined, showFeedback = false, disabled = false, onchange }: Props = $props();
13
+ let answer = $state('');
14
+ let hasAnswered = $state(false);
15
+ let isCorrect: boolean | undefined = $state(undefined);
16
+
17
+ function submit() {
18
+ if (disabled) return;
19
+ const val = answer.trim();
20
+ if (!val) return;
21
+ hasAnswered = true;
22
+ const normalized = (correctAnswers && correctAnswers.length > 0) ? correctAnswers : (correctAnswer !== undefined ? [correctAnswer] : undefined);
23
+ const correct = normalized ? normalized.some(a => a.toLowerCase() === val.toLowerCase()) : undefined;
24
+ isCorrect = correct;
25
+ onchange({ answer: val, isCorrect: correct });
26
+ }
27
+
28
+ function feedbackMessage() {
29
+ if (!hasAnswered || !showFeedback) return '';
30
+ const hasAnswers = (correctAnswers && correctAnswers.length > 0) || (correctAnswer !== undefined);
31
+ if (!hasAnswers) return `Your answer: ${answer.trim()}`;
32
+ if (correctAnswers && correctAnswers.length > 0) {
33
+ return isCorrect ? '✓ Correct!' : `✗ Incorrect. Accepted answers: ${(correctAnswers || []).join(', ')}`;
34
+ }
35
+ return isCorrect ? '✓ Correct!' : `✗ Incorrect. The correct answer is: ${correctAnswer}`;
36
+ }
37
+ </script>
38
+
39
+ <div class="widget-saq">
40
+ <div class="saq-question">{question}</div>
41
+ <div class="saq-input-container">
42
+ <input
43
+ type="text"
44
+ bind:value={answer}
45
+ {placeholder}
46
+ onkeydown={(e) => { if (e.key === 'Enter' && !hasAnswered && !disabled) submit(); }}
47
+ disabled={disabled || (hasAnswered && showFeedback)}
48
+ class={`saq-input ${hasAnswered && showFeedback ? (isCorrect ? 'saq-input-correct' : 'saq-input-incorrect') : ''}`}
49
+ />
50
+ {#if !hasAnswered}
51
+ <button class="saq-submit" onclick={submit} disabled={disabled || !answer.trim()}>Submit</button>
52
+ {/if}
53
+ </div>
54
+ {#if hasAnswered && showFeedback && feedbackMessage()}
55
+ <div class={`saq-feedback ${isCorrect ? 'correct' : 'incorrect'}`}>{feedbackMessage()}</div>
56
+ {/if}
57
+ </div>
@@ -0,0 +1,46 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ min: number;
4
+ max: number;
5
+ step?: number;
6
+ label?: string;
7
+ default?: number;
8
+ hint?: string;
9
+ /** 是否必填(slider 始终有值,仅语义标记) */
10
+ required?: boolean;
11
+ /** 是否禁用 */
12
+ disabled?: boolean;
13
+ onchange: (value: number) => void;
14
+ }
15
+ let { min, max, step = 1, label = '', default: initial = min, hint = '', required = false, disabled = false, onchange }: Props = $props();
16
+ let value = $state(initial);
17
+ </script>
18
+
19
+ <div class="widget-slider">
20
+ {#if label || hint || required}
21
+ <div class="widget-label-row">
22
+ {#if required}<span class="widget-required-mark" aria-hidden="true">*</span>{/if}
23
+ {#if label}<label>{label}</label>{/if}
24
+ {#if hint}<span class="widget-hint" data-hint={hint}>ⓘ</span>{/if}
25
+ </div>
26
+ {/if}
27
+ <div class="widget-field-content">
28
+ <div class="slider-container">
29
+ <div class="slider-values">
30
+ <span class="min-value">{min}</span>
31
+ <span class="current-value">{value}</span>
32
+ <span class="max-value">{max}</span>
33
+ </div>
34
+ <input
35
+ type="range"
36
+ bind:value
37
+ {min}
38
+ {max}
39
+ {step}
40
+ {disabled}
41
+ onmouseup={() => onchange(value)}
42
+ ontouchend={() => onchange(value)}
43
+ />
44
+ </div>
45
+ </div>
46
+ </div>