@glowhop/core-tour 1.0.2 → 1.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @glowhop/core-tour
2
2
 
3
+ ## 1.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3058e3d: When a step's target is removed from the DOM while its step is showing, the tour now freezes the presentation in place for a short grace period and resumes on the target without a re-entrance animation if it reconnects, instead of immediately unmounting and replaying the appear animation. When the target reappears somewhere else, the cutout is animated to its new box rather than snapping, unless the step opts out of animation. Interaction with the underlying page stays blocked during the freeze even when `allowInteraction` is `true`. If the target doesn't come back within the grace period, `missingTargetStrategy` and `targetTimeout` apply exactly as before — the grace period counts against `targetTimeout` rather than extending it. Under `wait` the presentation stays frozen and the tour stays `active` for the whole budget, so the popover's buttons keep working instead of going dead behind a `transitioning` status.
8
+
9
+ ## 1.1.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 165797b: `allowScroll` now defaults to `true`: the page stays scrollable while a tour runs. Pass `allowScroll: false` to keep the previous scroll-lock behaviour.
14
+
3
15
  ## 1.0.2
4
16
 
5
17
  ### Patch Changes
@@ -17,6 +17,15 @@ export interface TourViewCommands {
17
17
  export interface TourViewDriver<T> {
18
18
  show(step: ActiveStep<T>, direction: TourDirection, signal: AbortSignal, onBeforePopoverAppear?: () => void | Promise<void>): Promise<void> | void;
19
19
  clear(signal: AbortSignal): Promise<void> | void;
20
+ /**
21
+ * Resumes a frozen presentation on `step.target` after its previous target
22
+ * reconnected or was replaced, without unmounting or replaying `appear()`.
23
+ * A no-op when the driver isn't frozen for this step — callers only invoke
24
+ * it in response to a `targetDisconnected` notification they are recovering
25
+ * from, so a stale or superseded call should be silently ignored rather
26
+ * than throw.
27
+ */
28
+ retarget(step: ActiveStep<T>, signal: AbortSignal): Promise<void> | void;
20
29
  dispose(): void;
21
30
  releaseMount?(): void;
22
31
  setCommands?(commands: TourViewCommands): void;
@@ -24,6 +33,7 @@ export interface TourViewDriver<T> {
24
33
  export declare class NoopTourViewDriver<T> implements TourViewDriver<T> {
25
34
  show(_step: ActiveStep<T>, _direction: TourDirection, _signal: AbortSignal, onBeforePopoverAppear?: () => void | Promise<void>): void | Promise<void> | undefined;
26
35
  clear(_signal: AbortSignal): void;
36
+ retarget(_step: ActiveStep<T>, _signal: AbortSignal): void;
27
37
  dispose(): void;
28
38
  releaseMount(): void;
29
39
  }
@@ -32,6 +42,7 @@ export declare class DomTourViewDriver<T> implements TourViewDriver<T> {
32
42
  private readonly scrollLock;
33
43
  private readonly modalToken;
34
44
  private readonly stepCleanups;
45
+ private readonly targetCleanups;
35
46
  private commands;
36
47
  private direction;
37
48
  private currentStep;
@@ -39,6 +50,16 @@ export declare class DomTourViewDriver<T> implements TourViewDriver<T> {
39
50
  private disposed;
40
51
  private generation;
41
52
  private active;
53
+ /**
54
+ * True while the presentation is held in place on a lost target: the
55
+ * reposition loop is stopped and interaction is force-blocked, but overlay,
56
+ * popover and pointer stay mounted at their last known position instead of
57
+ * disappearing. Cleared by `retarget()` (target came back) or `clear()`
58
+ * (the caller gave up and is tearing the presentation down).
59
+ */
60
+ private frozen;
61
+ private activeTarget;
62
+ private targetFocusedAtFreeze;
42
63
  private lastTargetRect;
43
64
  private lastViewport;
44
65
  private inertBranches;
@@ -72,6 +93,13 @@ export declare class DomTourViewDriver<T> implements TourViewDriver<T> {
72
93
  private restoreInertBranches;
73
94
  private appear;
74
95
  private attachStepResources;
96
+ /**
97
+ * Binds the step's custom event handlers to its target element. Split out
98
+ * from `attachStepResources` so a lost-then-recovered target can be
99
+ * rebound on its own by `retarget()`, without re-subscribing the
100
+ * step-level resources (props, capabilities, controls) that never left.
101
+ */
102
+ private attachTargetResources;
75
103
  private listen;
76
104
  private schedulePosition;
77
105
  private updatePosition;
@@ -100,8 +128,50 @@ export declare class DomTourViewDriver<T> implements TourViewDriver<T> {
100
128
  private isLiveDisabled;
101
129
  private isPointerEnabled;
102
130
  private cleanupStepResources;
131
+ private cleanupTargetResources;
103
132
  private isCurrentTargetAvailable;
104
- private stopForDisconnectedTarget;
133
+ /**
134
+ * Holds the presentation exactly where it is when its target disappears,
135
+ * instead of tearing it down: overlay, popover and pointer stay mounted at
136
+ * their last known rect, focus guard and scroll lock stay engaged, and only
137
+ * the target's own listeners (now pointing at a dead node) are removed.
138
+ * The generation is deliberately left untouched — popover buttons, the
139
+ * keyboard shortcuts and any pending capability/focus bookkeeping must
140
+ * keep working while frozen, since the popover is the user's escape hatch
141
+ * out of a tour whose target never comes back. `commands.targetDisconnected`
142
+ * drives the actual recovery (grace period, then the configured strategy)
143
+ * and eventually calls back into `retarget()` or `clear()`.
144
+ */
145
+ private freezeForDisconnectedTarget;
146
+ /**
147
+ * Blocks (or restores) interaction with the underlying page independently
148
+ * of `step.behavior.allowInteraction`. Used to force interaction off while
149
+ * frozen — the cutout no longer corresponds to anything after a reflow, so
150
+ * it must not let clicks through even on a step that normally allows them —
151
+ * and to restore the step's own setting once retargeted.
152
+ */
153
+ private applyInteractionLock;
154
+ private isFocusInsideTarget;
155
+ /**
156
+ * Resumes a presentation frozen by `freezeForDisconnectedTarget` on its new
157
+ * target: reattaches the target-bound listeners, restores the step's own
158
+ * interaction setting, and lets the existing reposition loop tween overlay
159
+ * and popover to the new rect on the next frame. Deliberately skips
160
+ * `appear()` (no re-entrance animation) and `activateFocus()` (focus stays
161
+ * where the user left it), only reclaiming it if it was on the target that
162
+ * just disappeared.
163
+ */
164
+ retarget(step: ActiveStep<T>, signal: AbortSignal): Promise<void>;
165
+ /**
166
+ * Walks the presentation from where it froze to the new target's box. The
167
+ * per-frame loop can't do this on its own: it only tweens the cutout when
168
+ * the step's own visuals changed, and a target that reappears elsewhere is
169
+ * a pure geometry jump, which would snap. `animateTo` and the popover's
170
+ * reposition both fall back to an instant move when the step isn't
171
+ * animated, so this respects `animated: false` and reduced motion without
172
+ * asking about them.
173
+ */
174
+ private moveToRetargetedRect;
105
175
  private beginGeneration;
106
176
  private cancelAnimationsOnAbort;
107
177
  private cancelElementAnimations;
package/index.js CHANGED
@@ -1904,6 +1904,7 @@ class DomTourViewDriver {
1904
1904
  scrollLock = new ScrollLock;
1905
1905
  modalToken = {};
1906
1906
  stepCleanups = [];
1907
+ targetCleanups = [];
1907
1908
  commands;
1908
1909
  direction = "advance";
1909
1910
  currentStep = null;
@@ -1911,6 +1912,9 @@ class DomTourViewDriver {
1911
1912
  disposed = false;
1912
1913
  generation = 0;
1913
1914
  active = false;
1915
+ frozen = false;
1916
+ activeTarget = null;
1917
+ targetFocusedAtFreeze = false;
1914
1918
  lastTargetRect = null;
1915
1919
  lastViewport = null;
1916
1920
  inertBranches = [];
@@ -1987,6 +1991,9 @@ class DomTourViewDriver {
1987
1991
  this.cleanupStepResources();
1988
1992
  this.throwIfStale(generation, signal);
1989
1993
  this.active = false;
1994
+ this.frozen = false;
1995
+ this.activeTarget = null;
1996
+ this.targetFocusedAtFreeze = false;
1990
1997
  this.currentStep = step;
1991
1998
  this.currentSignal = signal;
1992
1999
  this.direction = direction;
@@ -2004,6 +2011,7 @@ class DomTourViewDriver {
2004
2011
  const target = step.target;
2005
2012
  if (!target)
2006
2013
  return;
2014
+ this.activeTarget = target;
2007
2015
  this.syncModality(step.behavior?.allowInteraction === true);
2008
2016
  await this.scrollTargetIntoView(step, target, signal);
2009
2017
  this.throwIfStale(generation, signal);
@@ -2042,6 +2050,9 @@ class DomTourViewDriver {
2042
2050
  this.scrollLock.deactivate();
2043
2051
  this.throwIfStale(generation, signal);
2044
2052
  this.active = false;
2053
+ this.frozen = false;
2054
+ this.activeTarget = null;
2055
+ this.targetFocusedAtFreeze = false;
2045
2056
  this.currentStep = null;
2046
2057
  this.currentSignal = null;
2047
2058
  this.lastTargetRect = null;
@@ -2066,6 +2077,9 @@ class DomTourViewDriver {
2066
2077
  this.focusGuard.deactivate();
2067
2078
  this.scrollLock.deactivate();
2068
2079
  this.active = false;
2080
+ this.frozen = false;
2081
+ this.activeTarget = null;
2082
+ this.targetFocusedAtFreeze = false;
2069
2083
  this.currentStep = null;
2070
2084
  this.currentSignal = null;
2071
2085
  this.overlay?.release();
@@ -2084,7 +2098,7 @@ class DomTourViewDriver {
2084
2098
  this.commands = null;
2085
2099
  }
2086
2100
  refreshRegisteredElements() {
2087
- if (!this.active || !this.currentStep || !this.lastTargetRect)
2101
+ if (!this.active || this.frozen || !this.currentStep || !this.lastTargetRect)
2088
2102
  return;
2089
2103
  const generation = this.beginGeneration();
2090
2104
  this.cleanupStepResources();
@@ -2101,6 +2115,7 @@ class DomTourViewDriver {
2101
2115
  const signal = this.currentSignal;
2102
2116
  if (this.disposed || !step || !target || !targetRect || !signal)
2103
2117
  return;
2118
+ this.activeTarget = target;
2104
2119
  this.initializeElements(step);
2105
2120
  await this.appear(targetRect, step);
2106
2121
  this.throwIfStale(generation);
@@ -2212,6 +2227,26 @@ class DomTourViewDriver {
2212
2227
  if (active)
2213
2228
  this.flushPendingKeyboardCommand(step, generation);
2214
2229
  }) ?? (() => {}));
2230
+ this.attachTargetResources(step, target, generation, signal);
2231
+ const currentWindow = this.getWindow(target);
2232
+ if (typeof currentWindow?.addEventListener === "function") {
2233
+ this.listen(currentWindow, "keydown", (event) => {
2234
+ if (this.isCurrentGeneration(generation))
2235
+ this.handleKeydown(event);
2236
+ });
2237
+ this.listen(currentWindow, "click", (event) => {
2238
+ if (this.isCurrentGeneration(generation) && this.activeTarget) {
2239
+ this.handleOverlayClick(event, step, this.activeTarget);
2240
+ }
2241
+ });
2242
+ }
2243
+ this.attachButtonHandlers(step);
2244
+ this.observeControls(step, generation);
2245
+ this.syncControlState(step);
2246
+ this.syncShortcutLabels(step);
2247
+ this.schedulePosition(generation);
2248
+ }
2249
+ attachTargetResources(step, target, generation, signal) {
2215
2250
  for (const handler of step.definition.eventHandlers) {
2216
2251
  const listener = (event) => {
2217
2252
  if (!this.isCurrentGeneration(generation))
@@ -2230,32 +2265,15 @@ class DomTourViewDriver {
2230
2265
  return this.commands?.reportError(error);
2231
2266
  });
2232
2267
  };
2233
- this.listen(target, handler.event, listener);
2268
+ this.listen(target, handler.event, listener, undefined, this.targetCleanups);
2234
2269
  }
2235
- const currentWindow = this.getWindow(target);
2236
- if (typeof currentWindow?.addEventListener === "function") {
2237
- this.listen(currentWindow, "keydown", (event) => {
2238
- if (this.isCurrentGeneration(generation))
2239
- this.handleKeydown(event);
2240
- });
2241
- this.listen(currentWindow, "click", (event) => {
2242
- if (this.isCurrentGeneration(generation)) {
2243
- this.handleOverlayClick(event, step, target);
2244
- }
2245
- });
2246
- }
2247
- this.attachButtonHandlers(step);
2248
- this.observeControls(step, generation);
2249
- this.syncControlState(step);
2250
- this.syncShortcutLabels(step);
2251
- this.schedulePosition(generation);
2252
2270
  }
2253
- listen(target, type, listener, options) {
2271
+ listen(target, type, listener, options, bucket = this.stepCleanups) {
2254
2272
  target.addEventListener(type, listener, options);
2255
- this.stepCleanups.push(() => target.removeEventListener(type, listener, options));
2273
+ bucket.push(() => target.removeEventListener(type, listener, options));
2256
2274
  }
2257
2275
  schedulePosition(generation = this.generation) {
2258
- if (!this.isCurrentGeneration(generation) || !this.currentStep || this.rafId !== null)
2276
+ if (!this.isCurrentGeneration(generation) || !this.currentStep || this.rafId !== null || this.frozen)
2259
2277
  return;
2260
2278
  const owner = this.currentStep.target?.ownerDocument?.defaultView;
2261
2279
  const ownerRequest = owner?.requestAnimationFrame;
@@ -2282,7 +2300,7 @@ class DomTourViewDriver {
2282
2300
  if (!this.isCurrentGeneration(generation) || !step || !target)
2283
2301
  return;
2284
2302
  if (!this.isCurrentTargetAvailable(target)) {
2285
- this.stopForDisconnectedTarget(target, generation);
2303
+ this.freezeForDisconnectedTarget(step, target, generation);
2286
2304
  return;
2287
2305
  }
2288
2306
  const targetRect = target.getBoundingClientRect();
@@ -2589,30 +2607,94 @@ class DomTourViewDriver {
2589
2607
  this.rafCancel?.(this.rafId);
2590
2608
  this.rafId = null;
2591
2609
  this.rafCancel = null;
2610
+ this.cleanupTargetResources();
2592
2611
  for (const cleanup of this.stepCleanups.splice(0))
2593
2612
  cleanup();
2594
2613
  }
2614
+ cleanupTargetResources() {
2615
+ for (const cleanup of this.targetCleanups.splice(0))
2616
+ cleanup();
2617
+ }
2595
2618
  isCurrentTargetAvailable(target) {
2596
2619
  const rootDocument = this.root?.ownerDocument;
2597
2620
  return target.isConnected && (!rootDocument || target.ownerDocument === rootDocument);
2598
2621
  }
2599
- stopForDisconnectedTarget(target, generation) {
2600
- if (!this.isCurrentGeneration(generation))
2622
+ freezeForDisconnectedTarget(step, target, generation) {
2623
+ if (!this.isCurrentGeneration(generation) || this.frozen)
2601
2624
  return;
2602
- this.beginGeneration();
2603
- this.cleanupStepResources();
2604
- this.releaseModality();
2605
- this.focusGuard.deactivate();
2606
- this.scrollLock.deactivate();
2607
- this.active = false;
2608
- this.currentSignal = null;
2609
- this.currentStep = null;
2610
- this.lastTargetRect = null;
2611
- this.lastViewport = null;
2625
+ this.frozen = true;
2626
+ if (this.rafId !== null)
2627
+ this.rafCancel?.(this.rafId);
2628
+ this.rafId = null;
2629
+ this.rafCancel = null;
2630
+ this.targetFocusedAtFreeze = this.isFocusInsideTarget(target);
2631
+ this.cleanupTargetResources();
2632
+ this.applyInteractionLock(step, true);
2612
2633
  Promise.resolve(this.commands?.targetDisconnected(target)).catch((error) => {
2613
2634
  this.commands?.reportError(error).catch(() => {});
2614
2635
  });
2615
2636
  }
2637
+ applyInteractionLock(step, locked) {
2638
+ const allowed = !locked && step.behavior?.allowInteraction === true;
2639
+ this.overlay?.setInteractionAllowed(allowed);
2640
+ this.syncModality(allowed);
2641
+ const popover = this.popover?.getElement();
2642
+ if (isHTMLElement(popover, this.root ?? popover)) {
2643
+ if (allowed)
2644
+ popover.removeAttribute("aria-modal");
2645
+ else
2646
+ popover.setAttribute("aria-modal", "true");
2647
+ }
2648
+ }
2649
+ isFocusInsideTarget(target) {
2650
+ const activeElement = target.ownerDocument?.activeElement;
2651
+ if (!activeElement)
2652
+ return false;
2653
+ return activeElement === target || target.contains(activeElement);
2654
+ }
2655
+ async retarget(step, signal) {
2656
+ this.throwIfAborted(signal);
2657
+ if (this.disposed || !this.frozen || this.currentStep !== step)
2658
+ return;
2659
+ const target = step.target;
2660
+ if (!target)
2661
+ return;
2662
+ this.frozen = false;
2663
+ this.currentSignal = signal;
2664
+ this.activeTarget = target;
2665
+ this.applyInteractionLock(step, false);
2666
+ this.attachTargetResources(step, target, this.generation, signal);
2667
+ const popover = this.popover?.getElement();
2668
+ if (isHTMLElement(popover, this.root ?? popover)) {
2669
+ this.focusGuard.update({
2670
+ allowedTarget: target,
2671
+ allowTargetInteraction: step.behavior?.allowInteraction === true,
2672
+ direction: this.direction,
2673
+ fallback: this.root ?? popover.parentElement,
2674
+ popover
2675
+ });
2676
+ }
2677
+ if (this.targetFocusedAtFreeze) {
2678
+ this.targetFocusedAtFreeze = false;
2679
+ if (step.behavior?.allowInteraction === true)
2680
+ target.focus();
2681
+ }
2682
+ this.syncControlState(step);
2683
+ this.syncShortcutLabels(step);
2684
+ this.moveToRetargetedRect(step, target);
2685
+ this.schedulePosition(this.generation);
2686
+ }
2687
+ moveToRetargetedRect(step, target) {
2688
+ const generation = this.generation;
2689
+ const targetRect = target.getBoundingClientRect();
2690
+ this.observeDynamicOperation(this.overlay?.animateTo(targetRect, step), generation);
2691
+ const placement = this.popover?.updatePosition(targetRect, step, (reposition) => this.observeDynamicOperation(reposition, generation));
2692
+ if (this.isPointerEnabled(step)) {
2693
+ this.observeDynamicOperation(this.pointer?.moveToTarget(targetRect, step, true, placement), generation);
2694
+ }
2695
+ this.lastTargetRect = snapshotRect(targetRect);
2696
+ this.lastViewport = snapshotViewport(target);
2697
+ }
2616
2698
  beginGeneration() {
2617
2699
  this.generation += 1;
2618
2700
  this.pendingKeyboardCommand = null;
@@ -2983,7 +3065,7 @@ class ActiveStep {
2983
3065
  this.props = createStepPropsStore(this.initialProps, reportSubscriberError, path);
2984
3066
  this.behavior = mergeStepBehavior(defaults.behavior, definition.behavior);
2985
3067
  this.animated = defaults.animated;
2986
- this.allowScroll = defaults.allowScroll === true;
3068
+ this.allowScroll = defaults.allowScroll !== false;
2987
3069
  }
2988
3070
  reset() {
2989
3071
  this.props.set(this.initialProps);
@@ -3305,6 +3387,7 @@ function prefixReservations(document2) {
3305
3387
 
3306
3388
  // packages/core/src/runtime/tour-controller.ts
3307
3389
  var DEFAULT_TARGET_TIMEOUT = 3000;
3390
+ var TARGET_LOSS_GRACE_MS = 150;
3308
3391
  var DISPOSED_ERROR_MESSAGE = "Tour controller is disposed";
3309
3392
  function resolveStartIndex(workflow, startAt) {
3310
3393
  if (startAt === undefined)
@@ -3335,6 +3418,7 @@ class TourController {
3335
3418
  direction = "advance";
3336
3419
  status = "idle";
3337
3420
  error = null;
3421
+ recoveringTarget = null;
3338
3422
  operationToken = 0;
3339
3423
  publicationRevision = 0;
3340
3424
  operation = null;
@@ -3617,34 +3701,63 @@ class TourController {
3617
3701
  this.assertCurrent(operation);
3618
3702
  }
3619
3703
  }
3704
+ async pollForTarget(step, operation, budgetMs) {
3705
+ const startedAt = Date.now();
3706
+ while (true) {
3707
+ const target = await step.resolveTarget(this.signalFor(operation));
3708
+ this.assertCurrent(operation);
3709
+ if (target)
3710
+ return target;
3711
+ if (Date.now() - startedAt >= budgetMs)
3712
+ return null;
3713
+ await abortableDelay(16, this.signalFor(operation));
3714
+ this.assertCurrent(operation);
3715
+ }
3716
+ }
3620
3717
  async recoverDisconnectedTarget(target) {
3621
- if (this.disposed || this.status !== "active")
3718
+ if (this.disposed || this.status !== "active" || this.recoveringTarget === target)
3622
3719
  return;
3623
3720
  const step = this.currentStep();
3624
3721
  if (!step || step.target !== target)
3625
3722
  return;
3723
+ this.recoveringTarget = target;
3626
3724
  const index = this.index;
3627
3725
  const direction = this.direction;
3628
3726
  const operation = this.beginOperation();
3629
3727
  try {
3630
- this.setStatus("transitioning");
3631
3728
  this.assertCurrent(operation);
3632
- await this.driver.clear(this.signalFor(operation));
3729
+ const recoveredDuringGrace = await this.pollForTarget(step, operation, TARGET_LOSS_GRACE_MS);
3633
3730
  this.assertCurrent(operation);
3634
- const recoveredTarget = await this.resolveTarget(step, operation);
3635
- this.assertCurrent(operation);
3636
- if (!recoveredTarget) {
3731
+ if (recoveredDuringGrace) {
3732
+ step.target = recoveredDuringGrace;
3733
+ await this.driver.retarget(step, this.signalFor(operation));
3734
+ this.assertCurrent(operation);
3735
+ this.publish();
3736
+ return;
3737
+ }
3738
+ const strategy = step.behavior?.missingTargetStrategy ?? "error";
3739
+ if (strategy === "skip") {
3637
3740
  await this.advancePastRecoveryMissingTarget(step, index, direction, operation);
3638
3741
  return;
3639
3742
  }
3640
- step.target = recoveredTarget;
3641
- await this.driver.show(step, direction, this.signalFor(operation));
3743
+ if (strategy !== "wait")
3744
+ throw this.missingTargetError(step);
3745
+ const timeout = step.behavior?.targetTimeout ?? DEFAULT_TARGET_TIMEOUT;
3746
+ const recoveredAfterWait = await this.pollForTarget(step, operation, Math.max(0, timeout - TARGET_LOSS_GRACE_MS));
3642
3747
  this.assertCurrent(operation);
3643
- this.setStatus("active");
3748
+ if (!recoveredAfterWait)
3749
+ throw this.missingTargetError(step);
3750
+ step.target = recoveredAfterWait;
3751
+ await this.driver.retarget(step, this.signalFor(operation));
3752
+ this.assertCurrent(operation);
3753
+ this.publish();
3644
3754
  } catch (error) {
3645
3755
  try {
3646
3756
  await this.handleFailure(error, operation);
3647
3757
  } catch {}
3758
+ } finally {
3759
+ if (this.recoveringTarget === target)
3760
+ this.recoveringTarget = null;
3648
3761
  }
3649
3762
  }
3650
3763
  async advancePastMissingTarget(index, direction, operation) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glowhop/core-tour",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "Framework-agnostic tour controller and DOM runtime for GlowTour.js.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/Glowhop/GlowTour.js#readme",
@@ -2,6 +2,16 @@ import { WorkflowBuilder } from "../builder";
2
2
  import type { WorkflowDefinition } from "../definition";
3
3
  import { type TourViewDriver } from "../dom/tour-view-driver";
4
4
  import type { GlowTour, GlowTourOptions, RunOptions, StartOptions, TourEventSource, TourState } from "../types";
5
+ /**
6
+ * How long a step stays frozen on its last known position after its target
7
+ * disappears from the DOM, before the configured `missingTargetStrategy`
8
+ * takes over. Covers the dominant case — a framework remounting the target
9
+ * within a frame or two — without a visible unmount/remount flicker. Not
10
+ * configurable: it is a presentation detail of the recovery, not a policy
11
+ * choice; `missingTargetStrategy` and `targetTimeout` remain the only knobs.
12
+ * Exported for the test suite's timing assertions only.
13
+ */
14
+ export declare const TARGET_LOSS_GRACE_MS = 150;
5
15
  interface TourControllerOptions<T> extends GlowTourOptions {
6
16
  assertCanRun?: (workflow: WorkflowDefinition<T>) => Document | void;
7
17
  onDispose?: () => void;
@@ -17,6 +27,14 @@ export declare class TourController<T> {
17
27
  private direction;
18
28
  private status;
19
29
  private error;
30
+ /**
31
+ * The target currently being recovered from a disconnect, if any. The
32
+ * public status stays "active" through the grace period (see
33
+ * TARGET_LOSS_GRACE_MS), so it can no longer serve as the re-entrancy guard
34
+ * a repeated or overlapping `targetDisconnected` notification for the same
35
+ * target relies on — this field takes over that job instead.
36
+ */
37
+ private recoveringTarget;
20
38
  private operationToken;
21
39
  private publicationRevision;
22
40
  private operation;
@@ -49,6 +67,29 @@ export declare class TourController<T> {
49
67
  private transition;
50
68
  private runActions;
51
69
  private resolveTarget;
70
+ /**
71
+ * Repeatedly re-resolves `step.target` until it succeeds or `budgetMs`
72
+ * elapses, polling every 16ms like `resolveTarget`. Unlike `resolveTarget`
73
+ * it never applies `missingTargetStrategy` itself — callers decide what a
74
+ * timed-out budget means (grace period vs. a "wait" strategy's own
75
+ * timeout), so the same polling loop serves both.
76
+ */
77
+ private pollForTarget;
78
+ /**
79
+ * Recovers from a target disconnecting while its step is on screen. The
80
+ * driver has already frozen the presentation in place (overlay, popover,
81
+ * pointer held at their last position; focus guard and scroll lock still
82
+ * engaged) and stopped polling geometry — this only decides how long to
83
+ * keep it frozen and what to do once that budget runs out.
84
+ *
85
+ * The public status stays "active" for the whole freeze, "wait" included.
86
+ * A frozen presentation isn't a transition: nothing is animating, the step
87
+ * and its index are unchanged, and the popover is still on screen. Calling
88
+ * it "transitioning" would close `canNavigate` and leave the user staring
89
+ * at a live-looking popover whose buttons are dead for the rest of the
90
+ * budget — the popover is the escape hatch out of a target that never
91
+ * comes back, so it has to keep working.
92
+ */
52
93
  private recoverDisconnectedTarget;
53
94
  private advancePastMissingTarget;
54
95
  private advancePastRecoveryMissingTarget;
package/types/index.d.ts CHANGED
@@ -186,10 +186,10 @@ export interface StartOptions<T> {
186
186
  /** Allow users to cancel the tour. @default true */
187
187
  cancellable?: boolean;
188
188
  /**
189
- * Locks page scroll while the tour is active, restoring it on finish,
190
- * cancel, error, or dispose.
189
+ * Leaves page scroll available while the tour is active. Set `false` to lock
190
+ * scroll instead, restoring it on finish, cancel, error, or dispose.
191
191
  *
192
- * @default false
192
+ * @default true
193
193
  */
194
194
  allowScroll?: boolean;
195
195
  /** Default overlay options for all steps. */