@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 +12 -0
- package/dom/tour-view-driver.d.ts +71 -1
- package/index.js +158 -45
- package/package.json +1 -1
- package/runtime/tour-controller.d.ts +41 -0
- package/types/index.d.ts +3 -3
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
2600
|
-
if (!this.isCurrentGeneration(generation))
|
|
2622
|
+
freezeForDisconnectedTarget(step, target, generation) {
|
|
2623
|
+
if (!this.isCurrentGeneration(generation) || this.frozen)
|
|
2601
2624
|
return;
|
|
2602
|
-
this.
|
|
2603
|
-
this.
|
|
2604
|
-
|
|
2605
|
-
this.
|
|
2606
|
-
this.
|
|
2607
|
-
this.
|
|
2608
|
-
this.
|
|
2609
|
-
this.
|
|
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
|
|
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.
|
|
3729
|
+
const recoveredDuringGrace = await this.pollForTarget(step, operation, TARGET_LOSS_GRACE_MS);
|
|
3633
3730
|
this.assertCurrent(operation);
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
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
|
-
|
|
3641
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
*
|
|
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
|
|
192
|
+
* @default true
|
|
193
193
|
*/
|
|
194
194
|
allowScroll?: boolean;
|
|
195
195
|
/** Default overlay options for all steps. */
|