@qaiddev/quests-embed 1.3.0 → 1.5.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/README.md CHANGED
@@ -348,8 +348,52 @@ const quests = new QaidQuests(config: QuestsConfig);
348
348
  | Method | Description |
349
349
  |--------|-------------|
350
350
  | `getAnswers()` | Read-only snapshot of the answers collected so far, keyed by question id |
351
+ | `getCurrentQuestionId()` | Id of the question on screen, or `null` if the embed isn't on one (loading, finished, or unmounted) |
352
+ | `goToStep(questionId)` | Jump to a visible question. Returns `false` if it's unknown or hidden by an unmet `visibleIf`. Latched if the embed is still initializing |
353
+ | `update(questionnaire)` | Swap in a new questionnaire without re-mounting. Returns whether it was applied — see below |
351
354
  | `destroy()` | Remove all DOM elements, event listeners, and injected styles. Safe to call multiple times |
352
355
 
356
+ #### `update(questionnaire)`
357
+
358
+ For hosts that render a questionnaire while it is being authored — a live
359
+ preview in an editor, say. Rebuilding the embed on every edit re-mounts the
360
+ shadow root, repaints the loading state, and re-runs both the theme fetch and
361
+ the create call; because that path awaits the network it always paints a blank
362
+ frame first, which is what makes a live preview flicker as the author types.
363
+
364
+ `update()` re-renders the header and the current step, synchronously. The
365
+ shadow root, the resolved theme, the response id and the answers so far all
366
+ survive, and focus is left wherever the host put it — it does not pull focus
367
+ into the form the way stepping through it does.
368
+
369
+ Answers are kept for questions that still exist and dropped for ones that
370
+ don't, so `getAnswers()` never reports an id the questionnaire has no question
371
+ for. The reader's place is kept the same way: if the question on screen is
372
+ still present and visible, the embed stays on it; otherwise the step index is
373
+ clamped into range.
374
+
375
+ It returns `false`, leaving the embed exactly as it was, when:
376
+
377
+ - the questionnaire has no questions,
378
+ - every question is gated off behind an unmet `visibleIf`, so there would be
379
+ nothing to render,
380
+ - or the reader has already completed the form — resuming a submitted response
381
+ isn't something the embed can decide for you, so rebuild if you want the new
382
+ form there.
383
+
384
+ Called before the embed has finished initializing, the update is latched and
385
+ applied as soon as it is ready (and reported as applied), the same way
386
+ `goToStep` is.
387
+
388
+ ```js
389
+ const quests = new QaidQuests({ endpoint, questionnaire, container: "#preview" });
390
+
391
+ // later, as the author edits — no teardown, no refetch, no flicker
392
+ if (!quests.update(nextQuestionnaire)) {
393
+ // refused: rebuild if you need the new questionnaire on screen
394
+ }
395
+ ```
396
+
353
397
  ### Server Protocol
354
398
 
355
399
  The embed talks to your endpoint in three steps:
package/dist/embed.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Answers, QuestsConfig } from "./types";
1
+ import type { Answers, Questionnaire, QuestsConfig } from "./types";
2
2
  export declare class QaidQuests {
3
3
  private config;
4
4
  private questionnaire;
@@ -8,6 +8,8 @@ export declare class QaidQuests {
8
8
  private stepIndex;
9
9
  private visibleQuestions;
10
10
  private pendingGoToStep;
11
+ private pendingUpdate;
12
+ private suppressStepFocus;
11
13
  private hasRenderedStep;
12
14
  private answers;
13
15
  private responseId;
@@ -26,6 +28,8 @@ export declare class QaidQuests {
26
28
  private progressEl;
27
29
  private progressFillEl;
28
30
  private bodyEl;
31
+ private scrollCueEl;
32
+ private overflow;
29
33
  private footerEl;
30
34
  private savingEl;
31
35
  private backdropEl;
@@ -99,6 +103,33 @@ export declare class QaidQuests {
99
103
  * user's place after a forced re-mount.
100
104
  */
101
105
  getCurrentQuestionId(): string | null;
106
+ /**
107
+ * Swap in a new questionnaire without tearing the embed down.
108
+ *
109
+ * Built for editor previews, where the alternative — `destroy()` plus a
110
+ * fresh construction on every edit — re-mounts the shadow root, repaints
111
+ * the loading state, and re-runs both the theme fetch and the create
112
+ * call. Because that path awaits the network it always paints a blank
113
+ * frame first, which is what makes a live preview strobe while the author
114
+ * types. This re-renders the header and the current step and nothing
115
+ * else, synchronously: the shadow root, the resolved theme, the response
116
+ * id and the answers so far all survive.
117
+ *
118
+ * Answers are kept for questions that still exist and dropped for ones
119
+ * that don't, so `getAnswers()` never reports an id the questionnaire has
120
+ * no question for. The reader's place is kept the same way: if the
121
+ * question on screen is still present and visible, the embed stays on it,
122
+ * otherwise the step index is clamped into range.
123
+ *
124
+ * Returns whether the update was applied. It is refused, leaving the
125
+ * embed exactly as it was, when the questionnaire has no questions, or
126
+ * once the reader has completed the form — resuming a submitted response
127
+ * is not something this can decide on the host's behalf, so a host that
128
+ * wants the new form there should rebuild. Called before the embed has
129
+ * finished initializing, the update is latched and applied as soon as it
130
+ * is ready (and reported as applied), the same way `goToStep` is.
131
+ */
132
+ update(questionnaire: Questionnaire): boolean;
102
133
  /**
103
134
  * Jump to the visible question with the given id. Returns true if
104
135
  * the question is currently visible (and the embed navigated to
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Keeping a step readable inside whatever box the host gives us.
3
+ *
4
+ * The card body is the scroll container. Two things were missing: it never
5
+ * adapted its own content to the height available (option tiles have width
6
+ * rules only, so a short container just clipped them), and when content did
7
+ * overflow there was no sign of it at all — the step simply looked cut off.
8
+ *
9
+ * This module owns both. It is DOM-only and free of embed state so it can be
10
+ * driven directly in tests.
11
+ */
12
+ /** Where the scroll position currently sits. */
13
+ export type ScrollEdge = "none" | "top" | "middle" | "bottom";
14
+ /**
15
+ * Below this many pixels of available height, a step switches to its compact
16
+ * layout: smaller image tiles, tighter gaps. Chosen so the common 2-3 option
17
+ * step fits without scrolling in a ~360px tall container.
18
+ */
19
+ export declare const COMPACT_HEIGHT = 360;
20
+ /** Which edges of `el` have content beyond them. */
21
+ export declare function scrollEdgeOf(el: {
22
+ scrollTop: number;
23
+ scrollHeight: number;
24
+ clientHeight: number;
25
+ }): ScrollEdge;
26
+ /**
27
+ * Reflect the scroll state onto the body as `data-qaid-scroll`, which the
28
+ * stylesheet turns into a fade mask, and onto the cue element's hidden state.
29
+ */
30
+ export declare function applyScrollState(body: HTMLElement, cue: HTMLElement | null): ScrollEdge;
31
+ /** Reflect the available height onto the card as `data-qaid-fit`. */
32
+ export declare function applyFitState(card: HTMLElement, availableHeight: number): "compact" | "roomy";
33
+ export interface OverflowWatcher {
34
+ /** Re-measure now — call after rendering a step. */
35
+ refresh(): void;
36
+ /** Detach every listener and observer. */
37
+ destroy(): void;
38
+ }
39
+ /**
40
+ * Watch `body` for scrolling and resizing, keeping the scroll and fit state
41
+ * attributes current.
42
+ *
43
+ * A ResizeObserver rather than a CSS container query on height: a container
44
+ * query needs `container-type: size`, which requires a definite height on both
45
+ * axes, and this card is deliberately content-sized when the host does not
46
+ * constrain it. Declaring it would collapse the body to nothing in exactly the
47
+ * inline case the embed supports.
48
+ */
49
+ export declare function watchOverflow(body: HTMLElement, card: HTMLElement, cue: HTMLElement | null): OverflowWatcher;