pi-ask-popup 0.1.0 → 0.1.1
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/README.md +95 -32
- package/docs/configuration.md +93 -0
- package/docs/hosts.md +71 -0
- package/docs/keyboard.md +65 -0
- package/docs/tool-schema.md +124 -0
- package/package.json +12 -2
- package/preview/popup-submit.webp +0 -0
- package/preview/popup-with-notes.webp +0 -0
- package/preview/popup-with-tab.webp +0 -0
- package/src/ask-user-question.ts +50 -24
- package/src/config.ts +66 -23
- package/src/rpc-fallback.ts +30 -15
- package/src/state/build-questionnaire.ts +3 -1
- package/src/state/external-editor.ts +6 -2
- package/src/state/key-router.ts +129 -41
- package/src/state/questionnaire-session.ts +35 -13
- package/src/state/row-intent.ts +6 -2
- package/src/state/selectors/derivations.ts +18 -6
- package/src/state/selectors/focus.ts +6 -2
- package/src/state/selectors/projections.ts +3 -1
- package/src/state/state-reducer.ts +99 -33
- package/src/tool/response-envelope.ts +32 -20
- package/src/tool/types.ts +13 -2
- package/src/view/component-binding.ts +3 -1
- package/src/view/components/inline-input.ts +3 -1
- package/src/view/components/multi-select-view.ts +15 -5
- package/src/view/components/preview/markdown-content-cache.ts +21 -6
- package/src/view/components/preview/preview-block-renderer.ts +8 -1
- package/src/view/components/preview/preview-box-renderer.ts +4 -5
- package/src/view/components/preview/preview-layout-decider.ts +20 -15
- package/src/view/components/preview/preview-pane.ts +54 -18
- package/src/view/components/wrapping-select.ts +15 -5
- package/src/view/dialog-builder.ts +24 -8
- package/src/view/props-adapter.ts +21 -7
- package/src/view/tab-content-strategy.ts +60 -20
package/src/state/key-router.ts
CHANGED
|
@@ -2,6 +2,13 @@ import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
|
2
2
|
import type { QuestionAnswer } from "../tool/types.js";
|
|
3
3
|
import { ROW_INTENT_META } from "./row-intent.js";
|
|
4
4
|
import type { QuestionnaireRuntime, QuestionnaireState } from "./state.js";
|
|
5
|
+
type JsonValue =
|
|
6
|
+
| string
|
|
7
|
+
| number
|
|
8
|
+
| boolean
|
|
9
|
+
| null
|
|
10
|
+
| JsonValue[]
|
|
11
|
+
| { readonly [key: string]: JsonValue };
|
|
5
12
|
|
|
6
13
|
const KEYBIND_UP = "tui.select.up";
|
|
7
14
|
const KEYBIND_DOWN = "tui.select.down";
|
|
@@ -59,14 +66,20 @@ function isConfirm(kb: QuestionnaireKeybindings, data: string): boolean {
|
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
export function wrapTab(index: number, total: number): number {
|
|
62
|
-
if (total <= 0)
|
|
69
|
+
if (total <= 0) {
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
63
72
|
return ((index % total) + total) % total;
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
export function allAnswered(state: QuestionnaireState, runtime: QuestionnaireRuntime): boolean {
|
|
67
|
-
if (runtime.questions.length === 0)
|
|
76
|
+
if (runtime.questions.length === 0) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
68
79
|
for (let i = 0; i < runtime.questions.length; i++) {
|
|
69
|
-
if (!state.answers.has(i))
|
|
80
|
+
if (!state.answers.has(i)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
70
83
|
}
|
|
71
84
|
return true;
|
|
72
85
|
}
|
|
@@ -79,8 +92,12 @@ function computeAutoAdvanceTab(
|
|
|
79
92
|
state: QuestionnaireState,
|
|
80
93
|
runtime: QuestionnaireRuntime,
|
|
81
94
|
): number | undefined {
|
|
82
|
-
if (!runtime.isMulti)
|
|
83
|
-
|
|
95
|
+
if (!runtime.isMulti) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
if (state.currentTab < runtime.questions.length - 1) {
|
|
99
|
+
return state.currentTab + 1;
|
|
100
|
+
}
|
|
84
101
|
return runtime.questions.length;
|
|
85
102
|
}
|
|
86
103
|
|
|
@@ -89,7 +106,9 @@ function buildSingleSelectAnswer(
|
|
|
89
106
|
runtime: QuestionnaireRuntime,
|
|
90
107
|
): QuestionAnswer | null {
|
|
91
108
|
const q = runtime.questions[state.currentTab];
|
|
92
|
-
if (!q)
|
|
109
|
+
if (!q) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
93
112
|
|
|
94
113
|
const item = runtime.currentItem;
|
|
95
114
|
|
|
@@ -102,7 +121,9 @@ function buildSingleSelectAnswer(
|
|
|
102
121
|
answer: label.length > 0 ? label : null,
|
|
103
122
|
};
|
|
104
123
|
}
|
|
105
|
-
if (!item)
|
|
124
|
+
if (!item) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
106
127
|
if (item.kind === "other") {
|
|
107
128
|
return null;
|
|
108
129
|
}
|
|
@@ -117,14 +138,22 @@ function buildSingleSelectAnswer(
|
|
|
117
138
|
};
|
|
118
139
|
}
|
|
119
140
|
|
|
141
|
+
function isString(value: JsonValue | undefined): value is string {
|
|
142
|
+
return typeof value === "string";
|
|
143
|
+
}
|
|
144
|
+
|
|
120
145
|
function buildMultiSelected(state: QuestionnaireState, runtime: QuestionnaireRuntime): string[] {
|
|
121
146
|
const q = runtime.questions[state.currentTab];
|
|
122
|
-
if (!q)
|
|
147
|
+
if (!q) {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
123
150
|
const out: string[] = [];
|
|
124
151
|
for (let i = 0; i < q.options.length; i++) {
|
|
125
152
|
if (state.multiSelectChecked.has(i)) {
|
|
126
153
|
const label = q.options[i]?.label;
|
|
127
|
-
if (
|
|
154
|
+
if (isString(label)) {
|
|
155
|
+
out.push(label);
|
|
156
|
+
}
|
|
128
157
|
}
|
|
129
158
|
}
|
|
130
159
|
return out;
|
|
@@ -135,7 +164,9 @@ function tabSwitchAction(
|
|
|
135
164
|
state: QuestionnaireState,
|
|
136
165
|
runtime: QuestionnaireRuntime,
|
|
137
166
|
): QuestionnaireAction | null {
|
|
138
|
-
if (!runtime.isMulti)
|
|
167
|
+
if (!runtime.isMulti) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
139
170
|
const total = totalTabs(runtime);
|
|
140
171
|
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
|
141
172
|
return { kind: "tab_switch", nextTab: wrapTab(state.currentTab + 1, total) };
|
|
@@ -174,14 +205,22 @@ function prevNavOnUp(
|
|
|
174
205
|
// the user can read the now-uncovered transcript without accidentally mutating
|
|
175
206
|
// answers or notes. The collapse toggle itself is already handled above.
|
|
176
207
|
function routeCollapsed(kb: QuestionnaireKeybindings, data: string): QuestionnaireAction {
|
|
177
|
-
if (kb.matches(data, KEYBIND_CANCEL))
|
|
208
|
+
if (kb.matches(data, KEYBIND_CANCEL)) {
|
|
209
|
+
return { kind: "cancel" };
|
|
210
|
+
}
|
|
178
211
|
return { kind: "ignore" };
|
|
179
212
|
}
|
|
180
213
|
|
|
181
214
|
function routeNotesMode(kb: QuestionnaireKeybindings, data: string): QuestionnaireAction {
|
|
182
|
-
if (kb.matches(data, KEYBIND_CANCEL))
|
|
183
|
-
|
|
184
|
-
|
|
215
|
+
if (kb.matches(data, KEYBIND_CANCEL)) {
|
|
216
|
+
return { kind: "notes_exit" };
|
|
217
|
+
}
|
|
218
|
+
if (kb.matches(data, KEYBIND_NEW_LINE)) {
|
|
219
|
+
return { kind: "notes_forward", data };
|
|
220
|
+
}
|
|
221
|
+
if (isConfirm(kb, data)) {
|
|
222
|
+
return { kind: "notes_exit" };
|
|
223
|
+
}
|
|
185
224
|
return { kind: "notes_forward", data };
|
|
186
225
|
}
|
|
187
226
|
|
|
@@ -193,22 +232,39 @@ function routeInputMode(
|
|
|
193
232
|
): QuestionnaireAction {
|
|
194
233
|
// Newline takes precedence over confirmation if a user configuration binds
|
|
195
234
|
// the same physical key to both semantic actions.
|
|
196
|
-
if (kb.matches(data, KEYBIND_NEW_LINE))
|
|
235
|
+
if (kb.matches(data, KEYBIND_NEW_LINE)) {
|
|
236
|
+
return { kind: "ignore" };
|
|
237
|
+
}
|
|
197
238
|
if (isConfirm(kb, data)) {
|
|
198
239
|
const answer = buildSingleSelectAnswer(state, runtime);
|
|
199
|
-
if (!answer)
|
|
240
|
+
if (!answer) {
|
|
241
|
+
return { kind: "ignore" };
|
|
242
|
+
}
|
|
200
243
|
return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) };
|
|
201
244
|
}
|
|
202
245
|
// Treat Pi's Ctrl+U line-kill binding as an explicit whole-draft clear,
|
|
203
246
|
// independent of the current cursor position.
|
|
204
|
-
if (kb.matches(data, KEYBIND_CLEAR))
|
|
205
|
-
|
|
247
|
+
if (kb.matches(data, KEYBIND_CLEAR)) {
|
|
248
|
+
return { kind: "input_clear" };
|
|
249
|
+
}
|
|
250
|
+
if (kb.matches(data, KEYBIND_EXTERNAL_EDITOR)) {
|
|
206
251
|
return { kind: "input_edit", value: runtime.inputBuffer };
|
|
207
|
-
|
|
208
|
-
if (kb.matches(data,
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (kb.matches(data,
|
|
252
|
+
}
|
|
253
|
+
if (kb.matches(data, KEYBIND_CANCEL)) {
|
|
254
|
+
return { kind: "cancel" };
|
|
255
|
+
}
|
|
256
|
+
if (kb.matches(data, KEYBIND_EDITOR_UP) && runtime.canMoveInputUp) {
|
|
257
|
+
return { kind: "ignore" };
|
|
258
|
+
}
|
|
259
|
+
if (kb.matches(data, KEYBIND_EDITOR_DOWN) && runtime.canMoveInputDown) {
|
|
260
|
+
return { kind: "ignore" };
|
|
261
|
+
}
|
|
262
|
+
if (kb.matches(data, KEYBIND_UP)) {
|
|
263
|
+
return prevNavOnUp(state, runtime);
|
|
264
|
+
}
|
|
265
|
+
if (kb.matches(data, KEYBIND_DOWN)) {
|
|
266
|
+
return nextNavOnDown(state, runtime);
|
|
267
|
+
}
|
|
212
268
|
return { kind: "ignore" };
|
|
213
269
|
}
|
|
214
270
|
|
|
@@ -218,12 +274,17 @@ function routeSubmitTab(
|
|
|
218
274
|
state: QuestionnaireState,
|
|
219
275
|
runtime: QuestionnaireRuntime,
|
|
220
276
|
): QuestionnaireAction {
|
|
221
|
-
if (kb.matches(data, KEYBIND_CANCEL))
|
|
277
|
+
if (kb.matches(data, KEYBIND_CANCEL)) {
|
|
278
|
+
return { kind: "cancel" };
|
|
279
|
+
}
|
|
222
280
|
const tab = tabSwitchAction(data, state, runtime);
|
|
223
|
-
if (tab)
|
|
281
|
+
if (tab) {
|
|
282
|
+
return tab;
|
|
283
|
+
}
|
|
224
284
|
if (kb.matches(data, KEYBIND_UP) || kb.matches(data, KEYBIND_DOWN)) {
|
|
225
285
|
const delta = kb.matches(data, KEYBIND_DOWN) ? 1 : -1;
|
|
226
286
|
const next = wrapTab(state.submitChoiceIndex + delta, 2);
|
|
287
|
+
// SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
|
|
227
288
|
return { kind: "submit_nav", nextIndex: (next === 1 ? 1 : 0) as 0 | 1 };
|
|
228
289
|
}
|
|
229
290
|
if (isConfirm(kb, data)) {
|
|
@@ -257,20 +318,28 @@ function routeMultiSelectTab(
|
|
|
257
318
|
// `blocksMultiToggle` (the Next sentinel) or `activatesInputMode` (the "Type
|
|
258
319
|
// something." row — it is an inline input, not a checkable option).
|
|
259
320
|
if (data === SPACE_KEY) {
|
|
260
|
-
if (focusedMeta?.blocksMultiToggle)
|
|
261
|
-
|
|
321
|
+
if (focusedMeta?.blocksMultiToggle) {
|
|
322
|
+
return { kind: "ignore" };
|
|
323
|
+
}
|
|
324
|
+
if (focusedMeta?.activatesInputMode) {
|
|
325
|
+
return { kind: "ignore" };
|
|
326
|
+
}
|
|
262
327
|
return { kind: "toggle", index: state.optionIndex };
|
|
263
328
|
}
|
|
264
329
|
if (isConfirm(kb, data)) {
|
|
265
330
|
// Enter on the "Type something." row is handled by the inputMode block above
|
|
266
331
|
// (→ confirm kind:"custom"). Defensive: never enter the toggle/multi_confirm
|
|
267
332
|
// path for an inputMode-activating row.
|
|
268
|
-
if (focusedMeta?.activatesInputMode)
|
|
333
|
+
if (focusedMeta?.activatesInputMode) {
|
|
334
|
+
return { kind: "ignore" };
|
|
335
|
+
}
|
|
269
336
|
// Enter on a regular row toggles (matching Space) — committing the question is now
|
|
270
337
|
// gated behind explicit focus on a row whose META declares `autoSubmitsInMulti`
|
|
271
338
|
// (the Next sentinel), so Enter on options is a no-cost way to flip checkboxes
|
|
272
339
|
// without leaving the keyboard home row.
|
|
273
|
-
if (!focusedMeta?.autoSubmitsInMulti)
|
|
340
|
+
if (!focusedMeta?.autoSubmitsInMulti) {
|
|
341
|
+
return { kind: "toggle", index: state.optionIndex };
|
|
342
|
+
}
|
|
274
343
|
// Enter on Next: carry autoAdvanceTab so the host can advance to the next tab in
|
|
275
344
|
// multi-question mode, OR submit the dialog in single-question mode
|
|
276
345
|
// (autoAdvanceTab === undefined when !isMulti). Without this, a single multi-select
|
|
@@ -281,7 +350,9 @@ function routeMultiSelectTab(
|
|
|
281
350
|
autoAdvanceTab: computeAutoAdvanceTab(state, runtime),
|
|
282
351
|
};
|
|
283
352
|
}
|
|
284
|
-
if (kb.matches(data, KEYBIND_CANCEL))
|
|
353
|
+
if (kb.matches(data, KEYBIND_CANCEL)) {
|
|
354
|
+
return { kind: "cancel" };
|
|
355
|
+
}
|
|
285
356
|
return { kind: "ignore" };
|
|
286
357
|
}
|
|
287
358
|
|
|
@@ -293,10 +364,14 @@ function routeSingleSelectTab(
|
|
|
293
364
|
): QuestionnaireAction {
|
|
294
365
|
if (isConfirm(kb, data)) {
|
|
295
366
|
const answer = buildSingleSelectAnswer(state, runtime);
|
|
296
|
-
if (!answer)
|
|
367
|
+
if (!answer) {
|
|
368
|
+
return { kind: "ignore" };
|
|
369
|
+
}
|
|
297
370
|
return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) };
|
|
298
371
|
}
|
|
299
|
-
if (kb.matches(data, KEYBIND_CANCEL))
|
|
372
|
+
if (kb.matches(data, KEYBIND_CANCEL)) {
|
|
373
|
+
return { kind: "cancel" };
|
|
374
|
+
}
|
|
300
375
|
return { kind: "ignore" };
|
|
301
376
|
}
|
|
302
377
|
|
|
@@ -329,28 +404,39 @@ export function routeKey(
|
|
|
329
404
|
// undefined into matchesKey reaches parseKeyId().toLowerCase() and crashes the
|
|
330
405
|
// entire host process, so keep the runtime boundary defensive even though the
|
|
331
406
|
// TypeScript contract requires a string. The "off" literal is deliberate:
|
|
332
|
-
// importing COLLAPSE_KEY_OFF would pull ../config.js (and its
|
|
333
|
-
//
|
|
407
|
+
// importing COLLAPSE_KEY_OFF would pull ../config.js (and its config loader
|
|
408
|
+
// graph) into this pure module for a string that cannot change.
|
|
334
409
|
if (
|
|
335
|
-
|
|
410
|
+
isString(runtime.collapseKey) &&
|
|
336
411
|
runtime.collapseKey !== "off" &&
|
|
412
|
+
// SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
|
|
337
413
|
matchesKey(data, runtime.collapseKey as Parameters<typeof matchesKey>[1])
|
|
338
414
|
) {
|
|
339
415
|
return { kind: "toggle_collapsed" };
|
|
340
416
|
}
|
|
341
417
|
|
|
342
|
-
if (state.collapsed)
|
|
343
|
-
|
|
344
|
-
|
|
418
|
+
if (state.collapsed) {
|
|
419
|
+
return routeCollapsed(kb, data);
|
|
420
|
+
}
|
|
421
|
+
if (state.notesVisible) {
|
|
422
|
+
return routeNotesMode(kb, data);
|
|
423
|
+
}
|
|
424
|
+
if (state.inputMode) {
|
|
425
|
+
return routeInputMode(kb, data, state, runtime);
|
|
426
|
+
}
|
|
345
427
|
if (runtime.isMulti && state.currentTab === runtime.questions.length) {
|
|
346
428
|
return routeSubmitTab(kb, data, state, runtime);
|
|
347
429
|
}
|
|
348
430
|
|
|
349
431
|
const tab = tabSwitchAction(data, state, runtime);
|
|
350
|
-
if (tab)
|
|
432
|
+
if (tab) {
|
|
433
|
+
return tab;
|
|
434
|
+
}
|
|
351
435
|
|
|
352
436
|
const q = runtime.questions[state.currentTab];
|
|
353
|
-
if (!q)
|
|
437
|
+
if (!q) {
|
|
438
|
+
return { kind: "ignore" };
|
|
439
|
+
}
|
|
354
440
|
|
|
355
441
|
// Universal `n` activation (FR-1): the notes editor opens on every question tab
|
|
356
442
|
// (single- or multi-select, preview or no-preview). The blocks above already
|
|
@@ -373,6 +459,8 @@ export function routeKey(
|
|
|
373
459
|
return nextNavOnDown(state, runtime);
|
|
374
460
|
}
|
|
375
461
|
|
|
376
|
-
if (q.multiSelect)
|
|
462
|
+
if (q.multiSelect) {
|
|
463
|
+
return routeMultiSelectTab(kb, data, state, runtime);
|
|
464
|
+
}
|
|
377
465
|
return routeSingleSelectTab(kb, data, state, runtime);
|
|
378
466
|
}
|
|
@@ -152,7 +152,9 @@ export class QuestionnaireSession {
|
|
|
152
152
|
|
|
153
153
|
this.component = this.assembleComponent(built, config.theme);
|
|
154
154
|
this.viewAdapter.apply(this.state);
|
|
155
|
-
if (this.state.deadline !== undefined)
|
|
155
|
+
if (this.state.deadline !== undefined) {
|
|
156
|
+
this.startTimer();
|
|
157
|
+
}
|
|
156
158
|
}
|
|
157
159
|
|
|
158
160
|
private assembleComponent(
|
|
@@ -180,15 +182,17 @@ export class QuestionnaireSession {
|
|
|
180
182
|
* falls back to cancel-only rather than telling the user to press "Off".
|
|
181
183
|
*/
|
|
182
184
|
private startTimer(): void {
|
|
183
|
-
if (this.timer !== undefined)
|
|
185
|
+
if (this.timer !== undefined) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
184
188
|
this.timer = setInterval(() => {
|
|
185
189
|
this.commit({ kind: "tick", now: Date.now() });
|
|
186
190
|
}, 1000);
|
|
187
191
|
// Don't keep the process alive after Pi exits.
|
|
188
192
|
// SAFETY: Node's Timeout has unref, DOM/Bun number does not; guard ensures we only call when present.
|
|
189
|
-
if (typeof (this.timer as
|
|
193
|
+
if (typeof (this.timer as { unref?: () => void }).unref === "function") {
|
|
190
194
|
// SAFETY: same guard as above — only called when unref is a function.
|
|
191
|
-
(this.timer as
|
|
195
|
+
(this.timer as { unref: () => void }).unref();
|
|
192
196
|
}
|
|
193
197
|
}
|
|
194
198
|
|
|
@@ -200,9 +204,13 @@ export class QuestionnaireSession {
|
|
|
200
204
|
}
|
|
201
205
|
|
|
202
206
|
private formatRemaining(): string | undefined {
|
|
203
|
-
if (this.state.timerCancelled)
|
|
207
|
+
if (this.state.timerCancelled) {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
204
210
|
const ms = this.state.remainingMs;
|
|
205
|
-
if (ms === undefined)
|
|
211
|
+
if (ms === undefined) {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
206
214
|
const secs = Math.max(0, Math.ceil(ms / 1000));
|
|
207
215
|
return `${secs}s`;
|
|
208
216
|
}
|
|
@@ -221,7 +229,9 @@ export class QuestionnaireSession {
|
|
|
221
229
|
}
|
|
222
230
|
|
|
223
231
|
dispatch(data: string): void {
|
|
224
|
-
if (this.inputEditorOpen)
|
|
232
|
+
if (this.inputEditorOpen) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
225
235
|
const action = routeKey(data, this.state, this.runtime());
|
|
226
236
|
if (action.kind === "ignore") {
|
|
227
237
|
this.handleIgnoreInline(data);
|
|
@@ -233,7 +243,9 @@ export class QuestionnaireSession {
|
|
|
233
243
|
private commit(action: QuestionnaireAction): void {
|
|
234
244
|
const result = reduce(this.state, action, this.applyContext());
|
|
235
245
|
this.state = result.state;
|
|
236
|
-
for (const effect of result.effects)
|
|
246
|
+
for (const effect of result.effects) {
|
|
247
|
+
this.runEffect(effect);
|
|
248
|
+
}
|
|
237
249
|
this.state = this.mirrorNotesDraft(this.state);
|
|
238
250
|
this.viewAdapter.apply(this.state);
|
|
239
251
|
}
|
|
@@ -274,7 +286,9 @@ export class QuestionnaireSession {
|
|
|
274
286
|
// A no-op until the handle arrives, and suppressed entirely without a
|
|
275
287
|
// raw terminal listener: see `canReopenWhileHidden`. The state still
|
|
276
288
|
// says collapsed either way, so the view renders the one-line row.
|
|
277
|
-
if (this.canReopenWhileHidden)
|
|
289
|
+
if (this.canReopenWhileHidden) {
|
|
290
|
+
this.overlayHandle?.setHidden(effect.hidden);
|
|
291
|
+
}
|
|
278
292
|
return;
|
|
279
293
|
case "clear_timer":
|
|
280
294
|
this.clearTimer();
|
|
@@ -294,12 +308,16 @@ export class QuestionnaireSession {
|
|
|
294
308
|
* nothing.
|
|
295
309
|
*/
|
|
296
310
|
private openInputEditorAsync(value: string): void {
|
|
297
|
-
if (this.inputEditorOpen)
|
|
311
|
+
if (this.inputEditorOpen) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
298
314
|
this.inputEditorOpen = true;
|
|
299
315
|
void this.editInput(value).then(
|
|
300
316
|
(edited) => {
|
|
301
317
|
this.inputEditorOpen = false;
|
|
302
|
-
if (edited !== undefined)
|
|
318
|
+
if (edited !== undefined) {
|
|
319
|
+
this.commit({ kind: "input_replace", value: edited });
|
|
320
|
+
}
|
|
303
321
|
},
|
|
304
322
|
() => {
|
|
305
323
|
this.inputEditorOpen = false;
|
|
@@ -326,7 +344,9 @@ export class QuestionnaireSession {
|
|
|
326
344
|
timerWasCancelled = true;
|
|
327
345
|
}
|
|
328
346
|
if (!this.state.inputMode) {
|
|
329
|
-
if (timerWasCancelled)
|
|
347
|
+
if (timerWasCancelled) {
|
|
348
|
+
this.viewAdapter.apply(this.state);
|
|
349
|
+
}
|
|
330
350
|
return;
|
|
331
351
|
}
|
|
332
352
|
this.inlineInput.handleInput(data);
|
|
@@ -377,6 +397,8 @@ export class QuestionnaireSession {
|
|
|
377
397
|
* else.
|
|
378
398
|
*/
|
|
379
399
|
toggleCollapsedExternal(): void {
|
|
380
|
-
if (!this.inputEditorOpen)
|
|
400
|
+
if (!this.inputEditorOpen) {
|
|
401
|
+
this.commit({ kind: "toggle_collapsed" });
|
|
402
|
+
}
|
|
381
403
|
}
|
|
382
404
|
}
|
package/src/state/row-intent.ts
CHANGED
|
@@ -147,10 +147,14 @@ export function sentinelsToAppend(question: QuestionData): SentinelKind[] {
|
|
|
147
147
|
const out: SentinelKind[] = [];
|
|
148
148
|
for (const kind of SENTINEL_KINDS) {
|
|
149
149
|
const meta = ROW_INTENT_META[kind];
|
|
150
|
-
if (!meta.livesInMainList)
|
|
150
|
+
if (!meta.livesInMainList) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
151
153
|
const appends =
|
|
152
154
|
question.multiSelect === true ? meta.autoAppendOnMultiSelect : meta.autoAppendOnSingleSelect;
|
|
153
|
-
if (appends)
|
|
155
|
+
if (appends) {
|
|
156
|
+
out.push(kind);
|
|
157
|
+
}
|
|
154
158
|
}
|
|
155
159
|
return out;
|
|
156
160
|
}
|
|
@@ -15,17 +15,27 @@ export function selectConfirmedIndicator(
|
|
|
15
15
|
items: readonly WrappingSelectItem[],
|
|
16
16
|
): { index: number; labelOverride?: string } | undefined {
|
|
17
17
|
const q = questions[currentTab];
|
|
18
|
-
if (!q || q.multiSelect === true)
|
|
18
|
+
if (!q || q.multiSelect === true) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
19
21
|
const prior = answers.get(currentTab);
|
|
20
|
-
if (!prior)
|
|
22
|
+
if (!prior) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
21
25
|
if (prior.kind === "custom") {
|
|
22
26
|
const otherIndex = items.findIndex((it) => it.kind === "other");
|
|
23
|
-
if (otherIndex < 0)
|
|
27
|
+
if (otherIndex < 0) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
24
30
|
return { index: otherIndex, labelOverride: prior.answer ?? "" };
|
|
25
31
|
}
|
|
26
|
-
if (prior.kind !== "option" || typeof prior.answer !== "string")
|
|
32
|
+
if (prior.kind !== "option" || typeof prior.answer !== "string") {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
27
35
|
const index = items.findIndex((it) => it.kind === "option" && it.label === prior.answer);
|
|
28
|
-
if (index < 0)
|
|
36
|
+
if (index < 0) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
29
39
|
return { index };
|
|
30
40
|
}
|
|
31
41
|
|
|
@@ -35,6 +45,8 @@ export function selectConfirmedIndicator(
|
|
|
35
45
|
* machinery picks the right body component independently. Defensive against `totalQuestions === 0`.
|
|
36
46
|
*/
|
|
37
47
|
export function selectActivePreviewPaneIndex(currentTab: number, totalQuestions: number): number {
|
|
38
|
-
if (totalQuestions <= 0)
|
|
48
|
+
if (totalQuestions <= 0) {
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
39
51
|
return Math.min(currentTab, totalQuestions - 1);
|
|
40
52
|
}
|
|
@@ -11,7 +11,11 @@ export function selectActiveView(
|
|
|
11
11
|
state: { notesVisible: boolean; currentTab: number },
|
|
12
12
|
totalQuestions: number,
|
|
13
13
|
): ActiveView {
|
|
14
|
-
if (state.notesVisible)
|
|
15
|
-
|
|
14
|
+
if (state.notesVisible) {
|
|
15
|
+
return "notes";
|
|
16
|
+
}
|
|
17
|
+
if (state.currentTab === totalQuestions) {
|
|
18
|
+
return "submit";
|
|
19
|
+
}
|
|
16
20
|
return "options";
|
|
17
21
|
}
|
|
@@ -44,7 +44,9 @@ function nextLabelFor(ctx: PerTabBindingContext): string {
|
|
|
44
44
|
|
|
45
45
|
export const selectMultiSelectProps: PerTabSelector<MultiSelectViewProps> = (state, ctx) => {
|
|
46
46
|
const question = ctx.questions[ctx.i];
|
|
47
|
-
if (!question)
|
|
47
|
+
if (!question) {
|
|
48
|
+
return emptyMultiSelectProps(ctx);
|
|
49
|
+
}
|
|
48
50
|
const focused = ctx.activeView === "options";
|
|
49
51
|
const rows = question.options.map((_option, i) => ({
|
|
50
52
|
checked: state.multiSelectChecked.has(i),
|