lite-questionnaire 1.0.0

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.
package/input.ts ADDED
@@ -0,0 +1,513 @@
1
+ /**
2
+ * 全局输入分发
3
+ *
4
+ * 接收按键数据,根据当前问题类型分发到对应模块。
5
+ * 处理全局按键(← → 切换问题、Esc 取消、Enter 提交/前进、Tab 编辑自定义)。
6
+ */
7
+
8
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
9
+ import type { Editor } from "@earendil-works/pi-tui";
10
+ import type { Core } from "./core";
11
+ import type { Question } from "./types";
12
+ import { getOptionsWithCustom } from "./render";
13
+ import { handleSelectInput } from "./modules/select";
14
+ import { handleMultiSelectInput } from "./modules/multiSelect";
15
+ import { enterTextEdit, saveTextDraft } from "./modules/text";
16
+ import type { ThemeLike } from "./modules/shared";
17
+
18
+ /** 提交回调类型 */
19
+ export type SubmitCallback = (cancelled: boolean) => void;
20
+
21
+ /**
22
+ * 创建全局输入处理器。
23
+ * 返回 handleInput 函数,供 ctx.ui.custom 使用。
24
+ */
25
+ export function createInputHandler(
26
+ core: Core,
27
+ editor: Editor,
28
+ submitFn: SubmitCallback,
29
+ onUpdate: () => void,
30
+ theme: ThemeLike,
31
+ originalQuestions: Question[],
32
+ saveFn: () => void,
33
+ ) {
34
+ /**
35
+ * 提交当前问题的答案(答案已由各模块填充到 core)
36
+ */
37
+ function submitCurrentQuestion(options: { allowEmpty?: boolean } = {}): boolean {
38
+ const q = core.currentQuestion();
39
+ if (!q) return false;
40
+
41
+ const state = core.getUIState();
42
+
43
+ // 根据类型构建答案
44
+ switch (q.type) {
45
+ case "select": {
46
+ const opts = getOptionsWithCustom(q);
47
+ const selIdx = state.selectedIndices[0] ?? -1;
48
+ const selected = opts[selIdx];
49
+ const isCustom = selected?.isCustom === true;
50
+ const customText = state.customText?.trim() || "";
51
+ const hasSelection = !!selected && (!isCustom || customText.length > 0);
52
+
53
+ if (!hasSelection) {
54
+ core.deleteAnswer(q.id);
55
+ return true;
56
+ }
57
+
58
+ const value = isCustom ? customText : selected.value;
59
+ const label = isCustom ? customText : selected.label;
60
+ const validation = core.validate(q.id, [value], [label]);
61
+ if (!validation.valid) {
62
+ core.errorMessage = validation.message || null;
63
+ onUpdate();
64
+ return false;
65
+ }
66
+
67
+ core.saveAnswer(q.id, { value, label, wasCustom: isCustom || undefined });
68
+ return true;
69
+ }
70
+
71
+ case "multiSelect": {
72
+ const opts = getOptionsWithCustom(q);
73
+ const selIndices = state.selectedIndices;
74
+ const customText = state.customText?.trim() || "";
75
+
76
+ if (selIndices.length === 0) {
77
+ core.deleteAnswer(q.id);
78
+ return true;
79
+ }
80
+
81
+ const values: string[] = [];
82
+ const labels: string[] = [];
83
+ let hasCustom = false;
84
+
85
+ for (const idx of selIndices) {
86
+ const opt = opts[idx];
87
+ if (!opt) continue;
88
+ if (opt.isCustom) {
89
+ if (!customText) continue;
90
+ values.push(customText);
91
+ labels.push(customText);
92
+ hasCustom = true;
93
+ } else {
94
+ values.push(opt.value);
95
+ labels.push(opt.label);
96
+ }
97
+ }
98
+
99
+ if (values.length === 0) {
100
+ core.deleteAnswer(q.id);
101
+ return true;
102
+ }
103
+
104
+ // 约束校验
105
+ const validation = core.validate(q.id, values, labels);
106
+ if (!validation.valid) {
107
+ core.errorMessage = validation.message || null;
108
+ onUpdate();
109
+ return false; // 校验失败,不前进
110
+ }
111
+
112
+ core.saveAnswer(q.id, { values, labels, wasCustom: hasCustom || undefined });
113
+ return true;
114
+ }
115
+
116
+ case "text": {
117
+ const state = core.getUIState();
118
+ const existing = core.answers.get(q.id);
119
+ const existingText = existing && 'text' in existing ? existing.text : "";
120
+ const rawText = core.inputMode && core.inputQuestionId === q.id
121
+ ? editor.getText()
122
+ : (state.textDraft || existingText);
123
+ const text = rawText.trim();
124
+ state.textDraft = rawText;
125
+ core.saveUIState(state);
126
+
127
+ if (!text) {
128
+ core.deleteAnswer(q.id);
129
+ if (options.allowEmpty) {
130
+ return true;
131
+ }
132
+ core.leaveCurrentQuestion();
133
+ core.errorMessage = "请输入内容";
134
+ onUpdate();
135
+ return false;
136
+ }
137
+
138
+ // 约束校验
139
+ const validation = core.validate(q.id, [text], [text]);
140
+ if (!validation.valid) {
141
+ core.errorMessage = validation.message || null;
142
+ onUpdate();
143
+ return false;
144
+ }
145
+
146
+ core.saveAnswer(q.id, { text });
147
+ return true;
148
+ }
149
+
150
+ case "confirm": {
151
+ const label = state.confirmValue ? (q.yesLabel || "是") : (q.noLabel || "否");
152
+ core.saveAnswer(q.id, { confirmed: state.confirmValue, label });
153
+ return true;
154
+ }
155
+
156
+ case "rating": {
157
+ const v = state.ratingValue;
158
+ const annot = q.annotations?.[String(v)] || "";
159
+ core.saveAnswer(q.id, { value: v, annotation: annot });
160
+ return true;
161
+ }
162
+
163
+ default:
164
+ return true;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * 前进到下一个问题或提交页。提交当前问题的答案(如果尚未提交)。
170
+ */
171
+ function advance() {
172
+ const q = core.currentQuestion();
173
+ const answeredId = q?.id;
174
+
175
+ if (q) {
176
+ // 每次 Enter 都以当前草稿重新提交,允许回退修改已答问题
177
+ const submitted = submitCurrentQuestion();
178
+ if (!submitted) return; // 校验失败
179
+ core.leaveCurrentQuestion();
180
+ }
181
+
182
+ // 重新展开子问题(根据最新答案插入/移除子问题)
183
+ if (answeredId) {
184
+ core.reexpand(originalQuestions);
185
+ // 找到刚处理完的问题的新位置,前进到下一题
186
+ const answeredNewIdx = core.questions.findIndex((q) => q.id === answeredId);
187
+ if (answeredNewIdx >= 0) {
188
+ core.currentIndex = Math.min(answeredNewIdx + 1, core.questions.length);
189
+ } else {
190
+ core.advance();
191
+ }
192
+ } else {
193
+ core.advance();
194
+ }
195
+
196
+ // 单题问卷答完后直接返回结果;多题问卷进入提交页汇总确认
197
+ if (core.questions.length === 1 && core.currentIndex >= core.questions.length && core.allAnswered()) {
198
+ saveFn();
199
+ submitFn(false);
200
+ return;
201
+ }
202
+
203
+ // 标记新问题为已访问,但不自动进入编辑态;text/confirm/rating 需按 Tab 编辑
204
+ const newQ = core.currentQuestion();
205
+ if (newQ) {
206
+ core.visited.add(newQ.id);
207
+ }
208
+ core.inputMode = false;
209
+ core.inputQuestionId = null;
210
+ editor.setText("");
211
+ // 自动保存状态
212
+ saveFn();
213
+ onUpdate();
214
+ }
215
+
216
+ /**
217
+ * 通过 Tab 栏左右切换离开当前问题。
218
+ * 有有效草稿/默认值时保存答案;空值允许离开并标红;约束失败时阻止离开。
219
+ */
220
+ function switchTab(direction: "prev" | "next") {
221
+ const submitted = submitCurrentQuestion({ allowEmpty: true });
222
+ if (!submitted) return;
223
+ core.leaveCurrentQuestion();
224
+ if (direction === "prev") {
225
+ core.prevTab();
226
+ } else {
227
+ core.nextTab();
228
+ }
229
+ core.inputMode = false;
230
+ core.inputQuestionId = null;
231
+ editor.setText("");
232
+ saveFn();
233
+ onUpdate();
234
+ }
235
+
236
+ /**
237
+ * 主输入处理函数
238
+ */
239
+ function handleInput(data: string) {
240
+ // ─── 编辑模式(自定义/text/confirm/rating) ───
241
+ if (core.inputMode) {
242
+ const q = core.currentQuestion();
243
+ if (!q) return;
244
+
245
+ if (matchesKey(data, Key.escape)) {
246
+ if (q.type === "text") {
247
+ saveTextDraft(core, editor);
248
+ }
249
+ core.inputMode = false;
250
+ core.inputQuestionId = null;
251
+ editor.setText("");
252
+ onUpdate();
253
+ return;
254
+ }
255
+
256
+ if (q.type === "confirm") {
257
+ if (matchesKey(data, Key.left)) {
258
+ const state = core.getUIState();
259
+ state.confirmValue = true;
260
+ core.saveUIState(state);
261
+ onUpdate();
262
+ return;
263
+ }
264
+ if (matchesKey(data, Key.right)) {
265
+ const state = core.getUIState();
266
+ state.confirmValue = false;
267
+ core.saveUIState(state);
268
+ onUpdate();
269
+ return;
270
+ }
271
+ if (matchesKey(data, Key.enter)) {
272
+ advance();
273
+ return;
274
+ }
275
+ return;
276
+ }
277
+
278
+ if (q.type === "rating") {
279
+ if (matchesKey(data, Key.left)) {
280
+ const state = core.getUIState();
281
+ state.ratingValue = Math.max(1, state.ratingValue - 1);
282
+ core.saveUIState(state);
283
+ onUpdate();
284
+ return;
285
+ }
286
+ if (matchesKey(data, Key.right)) {
287
+ const state = core.getUIState();
288
+ state.ratingValue = Math.min(5, state.ratingValue + 1);
289
+ core.saveUIState(state);
290
+ onUpdate();
291
+ return;
292
+ }
293
+ const numMatch = /^[1-9]$/.exec(data);
294
+ if (numMatch) {
295
+ const target = parseInt(numMatch[0], 10);
296
+ if (target >= 1 && target <= 5) {
297
+ const state = core.getUIState();
298
+ state.ratingValue = target;
299
+ core.saveUIState(state);
300
+ onUpdate();
301
+ }
302
+ return;
303
+ }
304
+ if (matchesKey(data, Key.enter)) {
305
+ advance();
306
+ return;
307
+ }
308
+ return;
309
+ }
310
+
311
+ if (q.type === "text") {
312
+ if (matchesKey(data, Key.enter)) {
313
+ advance();
314
+ return;
315
+ }
316
+ editor.handleInput(data);
317
+ onUpdate();
318
+ return;
319
+ }
320
+
321
+ if (matchesKey(data, Key.enter)) {
322
+ // 保存自定义内容,返回选项列表
323
+ const text = editor.getText().trim();
324
+ const state = core.getUIState();
325
+ state.customText = text || null;
326
+ core.saveUIState(state);
327
+ core.inputMode = false;
328
+ core.inputQuestionId = null;
329
+ editor.setText("");
330
+ onUpdate();
331
+ return;
332
+ }
333
+ editor.handleInput(data);
334
+ onUpdate();
335
+ return;
336
+ }
337
+
338
+ // ─── 提交页 ───
339
+ if (core.isSubmitTab()) {
340
+ if (matchesKey(data, Key.enter)) {
341
+ if (core.allAnswered()) {
342
+ submitFn(false);
343
+ }
344
+ // 未全部回答时不响应
345
+ return;
346
+ }
347
+ if (matchesKey(data, Key.escape)) {
348
+ submitFn(true);
349
+ return;
350
+ }
351
+ if (matchesKey(data, Key.left)) {
352
+ core.prevTab();
353
+ onUpdate();
354
+ return;
355
+ }
356
+ if (matchesKey(data, Key.right)) {
357
+ core.nextTab();
358
+ onUpdate();
359
+ return;
360
+ }
361
+ return;
362
+ }
363
+
364
+ const q = core.currentQuestion();
365
+ if (!q) return;
366
+
367
+ const isMultiQuestion = core.questions.length > 1;
368
+
369
+ // ─── 全局按键 ───
370
+
371
+ // Esc: 取消问卷
372
+ if (matchesKey(data, Key.escape)) {
373
+ submitFn(true);
374
+ return;
375
+ }
376
+
377
+ // ← → 切换问题(多问题模式);题目编辑态需先按 Tab 进入
378
+ if (isMultiQuestion) {
379
+ if (matchesKey(data, Key.left)) {
380
+ switchTab("prev");
381
+ return;
382
+ }
383
+ if (matchesKey(data, Key.right)) {
384
+ switchTab("next");
385
+ return;
386
+ }
387
+ }
388
+
389
+ // ─── 类型特定输入 ───
390
+
391
+ switch (q.type) {
392
+ case "select": {
393
+ // 先尝试类型特定按键
394
+ if (handleSelectInput(core, data, editor, theme)) {
395
+ onUpdate();
396
+ return;
397
+ }
398
+
399
+ // Tab: 在“自定义”选项上进入编辑模式(与 Space 选择独立)
400
+ if (matchesKey(data, Key.tab)) {
401
+ const state = core.getUIState();
402
+ const opts = getOptionsWithCustom(q);
403
+ if (opts[state.optionIndex]?.isCustom) {
404
+ core.inputMode = true;
405
+ core.inputQuestionId = q.id;
406
+ editor.setText(state.customText || "");
407
+ onUpdate();
408
+ return;
409
+ }
410
+ }
411
+
412
+ // Space: 标定/取消选项
413
+ if (matchesKey(data, Key.space)) {
414
+ const state = core.getUIState();
415
+ if (state.selectedIndices.includes(state.optionIndex)) {
416
+ state.selectedIndices = [];
417
+ } else {
418
+ state.selectedIndices = [state.optionIndex];
419
+ }
420
+ core.saveUIState(state);
421
+ onUpdate();
422
+ return;
423
+ }
424
+
425
+ // Enter: 提交
426
+ if (matchesKey(data, Key.enter)) {
427
+ advance();
428
+ return;
429
+ }
430
+ break;
431
+ }
432
+
433
+ case "multiSelect": {
434
+ if (handleMultiSelectInput(core, data, editor, theme)) {
435
+ onUpdate();
436
+ return;
437
+ }
438
+
439
+ // Tab: 在“自定义”选项上进入编辑模式(与 Space 勾选独立)
440
+ if (matchesKey(data, Key.tab)) {
441
+ const state = core.getUIState();
442
+ const opts = getOptionsWithCustom(q);
443
+ if (opts[state.optionIndex]?.isCustom) {
444
+ core.inputMode = true;
445
+ core.inputQuestionId = q.id;
446
+ editor.setText(state.customText || "");
447
+ onUpdate();
448
+ return;
449
+ }
450
+ }
451
+
452
+ // Space: 切换勾选
453
+ if (matchesKey(data, Key.space)) {
454
+ const state = core.getUIState();
455
+ const idx = state.selectedIndices.indexOf(state.optionIndex);
456
+ if (idx >= 0) {
457
+ state.selectedIndices.splice(idx, 1);
458
+ } else {
459
+ // 检查 maxSelect 限制(自定义选项也计入选择数)
460
+ if (q.maxSelect && state.selectedIndices.length >= q.maxSelect) {
461
+ core.errorMessage = `最多选择 ${q.maxSelect} 项`;
462
+ } else {
463
+ state.selectedIndices.push(state.optionIndex);
464
+ core.errorMessage = null;
465
+ }
466
+ }
467
+ core.saveUIState(state);
468
+ onUpdate();
469
+ return;
470
+ }
471
+
472
+ // Enter: 提交
473
+ if (matchesKey(data, Key.enter)) {
474
+ advance();
475
+ return;
476
+ }
477
+ break;
478
+ }
479
+
480
+ case "text": {
481
+ if (matchesKey(data, Key.tab)) {
482
+ core.inputMode = true;
483
+ core.inputQuestionId = q.id;
484
+ enterTextEdit(core, editor);
485
+ onUpdate();
486
+ return;
487
+ }
488
+ if (matchesKey(data, Key.enter)) {
489
+ advance();
490
+ return;
491
+ }
492
+ return;
493
+ }
494
+
495
+ case "confirm":
496
+ case "rating": {
497
+ if (matchesKey(data, Key.tab)) {
498
+ core.inputMode = true;
499
+ core.inputQuestionId = q.id;
500
+ onUpdate();
501
+ return;
502
+ }
503
+ if (matchesKey(data, Key.enter)) {
504
+ advance();
505
+ return;
506
+ }
507
+ break;
508
+ }
509
+ }
510
+ }
511
+
512
+ return handleInput;
513
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 确认问题模块
3
+ *
4
+ * 内联 Y/N 双按钮,← → 切换高亮,Enter 确认。
5
+ */
6
+
7
+ import { truncateToWidth } from "@earendil-works/pi-tui";
8
+ import type { Core } from "../core";
9
+ import type { ThemeLike } from "./shared";
10
+
11
+ /**
12
+ * 渲染确认问题的双按钮 UI
13
+ */
14
+ export function renderConfirmQuestion(
15
+ core: Core,
16
+ width: number,
17
+ theme: ThemeLike,
18
+ ): string[] {
19
+ const q = core.currentQuestion();
20
+ if (!q || q.type !== "confirm") return [];
21
+
22
+ const lines: string[] = [];
23
+ const state = core.getUIState();
24
+ const yesLabel = q.yesLabel || "是";
25
+ const noLabel = q.noLabel || "否";
26
+ const selectedYes = state.confirmValue === true;
27
+
28
+ // 渲染两个按钮
29
+ const yesBtn = selectedYes
30
+ ? theme.bg("selectedBg", theme.fg("text", ` [${yesLabel}] `))
31
+ : ` [${yesLabel}] `;
32
+ const noBtn = !selectedYes
33
+ ? theme.bg("selectedBg", theme.fg("text", ` [${noLabel}] `))
34
+ : ` [${noLabel}] `;
35
+
36
+ const arrow = selectedYes
37
+ ? theme.fg("accent", ">") + " " + yesBtn + " " + noBtn
38
+ : " " + yesBtn + " " + theme.fg("accent", ">") + " " + noBtn;
39
+
40
+ lines.push(truncateToWidth(" " + arrow, width));
41
+ return lines;
42
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * 多选问题模块
3
+ *
4
+ * Space 切换勾选 + Enter 批量提交。
5
+ */
6
+
7
+ import { matchesKey, Key, truncateToWidth } from "@earendil-works/pi-tui";
8
+ import type { Editor } from "@earendil-works/pi-tui";
9
+ import type { Core } from "../core";
10
+ import { getOptionsWithCustom } from "../render";
11
+ import type { ThemeLike } from "./shared";
12
+
13
+ /**
14
+ * 渲染多选问题的选项列表(checkbox 风格)
15
+ */
16
+ export function renderMultiSelectOptions(
17
+ core: Core,
18
+ width: number,
19
+ theme: ThemeLike,
20
+ ): string[] {
21
+ const q = core.currentQuestion();
22
+ if (!q || q.type !== "multiSelect") return [];
23
+
24
+ const lines: string[] = [];
25
+ const state = core.getUIState();
26
+ const opts = getOptionsWithCustom(q);
27
+
28
+ for (let i = 0; i < opts.length; i++) {
29
+ const opt = opts[i];
30
+ const isCursor = i === state.optionIndex;
31
+ const hasCustomText = opt.isCustom && state.customText !== null;
32
+ const checked = state.selectedIndices.includes(i);
33
+
34
+ // checkbox
35
+ let box: string;
36
+ if (isCursor) {
37
+ box = theme.fg("accent", `> [${checked ? "x" : " "}]`);
38
+ } else {
39
+ box = ` [${checked ? theme.fg("success", "x") : " "}]`;
40
+ }
41
+
42
+ const labelColor = isCursor ? "accent" : checked ? "success" : "text";
43
+ let displayLabel = opt.label;
44
+ if (hasCustomText) {
45
+ displayLabel = `"${state.customText}"`;
46
+ }
47
+
48
+ const num = `${i + 1}`;
49
+ lines.push(truncateToWidth(" " + box + " " + theme.fg(labelColor, `${num}. ${displayLabel}`), width));
50
+
51
+ if (opt.description) {
52
+ lines.push(truncateToWidth(" " + theme.fg("muted", opt.description), width));
53
+ }
54
+ }
55
+
56
+ return lines;
57
+ }
58
+
59
+ /**
60
+ * 多选输入处理。
61
+ * 返回 true 表示输入被处理。
62
+ */
63
+ export function handleMultiSelectInput(
64
+ core: Core,
65
+ data: string,
66
+ _editor: Editor,
67
+ _theme: ThemeLike,
68
+ ): boolean {
69
+ const q = core.currentQuestion();
70
+ if (!q || q.type !== "multiSelect") return false;
71
+
72
+ const state = core.getUIState();
73
+ const opts = getOptionsWithCustom(q);
74
+
75
+ // ↑ ↓ 导航
76
+ if (matchesKey(data, Key.up)) {
77
+ state.optionIndex = Math.max(0, state.optionIndex - 1);
78
+ core.saveUIState(state);
79
+ return true;
80
+ }
81
+ if (matchesKey(data, Key.down)) {
82
+ state.optionIndex = Math.min(opts.length - 1, state.optionIndex + 1);
83
+ core.saveUIState(state);
84
+ return true;
85
+ }
86
+
87
+ // 数字键 1-9 快捷跳转
88
+ const numMatch = /^[1-9]$/.exec(data);
89
+ if (numMatch) {
90
+ const target = parseInt(numMatch[0], 10) - 1;
91
+ if (target < opts.length) {
92
+ state.optionIndex = target;
93
+ core.saveUIState(state);
94
+ return true;
95
+ }
96
+ return true;
97
+ }
98
+
99
+ return false;
100
+ }