@glowhop/core-tour 1.1.0 → 1.3.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 +39 -0
- package/README.md +1 -1
- package/dom/tour-view-driver.d.ts +138 -3
- package/elements/overlay.d.ts +12 -1
- package/elements/popover.d.ts +9 -1
- package/index.js +294 -111
- package/package.json +11 -2
- package/runtime/tour-controller.d.ts +41 -0
- package/types/index.d.ts +14 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# @glowhop/core-tour
|
|
2
2
|
|
|
3
|
+
## 1.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- b38bc4a: Present a step while its scroll is still in flight.
|
|
8
|
+
|
|
9
|
+
Entering a step whose target was off screen used to stall: the tour waited for
|
|
10
|
+
the smooth scroll to finish before initialising anything, so the previous step's
|
|
11
|
+
elements sat frozen for the whole journey and everything then snapped into place
|
|
12
|
+
at once. On Safari before 18.2, where `scrollend` does not exist, that wait was a
|
|
13
|
+
full second on every step.
|
|
14
|
+
|
|
15
|
+
The scroll now runs alongside the presentation. The spotlight appears
|
|
16
|
+
immediately and tracks the target as the page travels; the popover and the
|
|
17
|
+
pointer enter once the page has come to rest, on a rect that will not move
|
|
18
|
+
again. When a step scrolls, the spotlight moves with the page rather than
|
|
19
|
+
morphing from the previous step's cutout; steps that do not scroll keep the
|
|
20
|
+
morph.
|
|
21
|
+
|
|
22
|
+
Scroll completion is detected by watching the scroller hold still rather than by
|
|
23
|
+
listening for `scrollend`, so every engine behaves the same. A hidden document,
|
|
24
|
+
which neither animates a smooth scroll nor runs frames often enough to watch one
|
|
25
|
+
settle, does not wait at all.
|
|
26
|
+
|
|
27
|
+
When a step scrolls is unchanged: only when part of its target falls outside the
|
|
28
|
+
viewport, and never when `disableAutoScroll` is set.
|
|
29
|
+
|
|
30
|
+
Two smaller behaviour changes fall out of this. The pointer now arrives together
|
|
31
|
+
with the popover rather than with the spotlight, since its placement is resolved
|
|
32
|
+
against the popover's. And a target lost while its step is still scrolling no
|
|
33
|
+
longer freezes the presentation, because a freeze in that window could never be
|
|
34
|
+
recovered.
|
|
35
|
+
|
|
36
|
+
## 1.2.0
|
|
37
|
+
|
|
38
|
+
### Minor Changes
|
|
39
|
+
|
|
40
|
+
- 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.
|
|
41
|
+
|
|
3
42
|
## 1.1.0
|
|
4
43
|
|
|
5
44
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ const workflow = tour.create("intro").step({ id: "welcome", target: "#welcome",
|
|
|
22
22
|
| --- | --- | --- |
|
|
23
23
|
| Placement | `popover.placementTryOrder`, `indicator.placementTryOrder` | Try `top`, `bottom`, `left`, `right`; the resolved position may be `center`. |
|
|
24
24
|
| Interaction | `behavior.allowInteraction` | Allows pointer interaction through the overlay. |
|
|
25
|
-
| Scroll | step/start `scroll` | Uses `behavior`, `block`, and `inline` scroll options. |
|
|
25
|
+
| Scroll | step/start `scroll` | Uses `behavior`, `block`, and `inline` scroll options. The step appears without waiting for the scroll. |
|
|
26
26
|
| Callbacks | `onStart`, `onCancel`, `onFinish`; `beforeAdvance`, `beforePrevious`, `beforeCancel` | Start callbacks are workflow options; transition callbacks are step builder methods. |
|
|
27
27
|
| Actions | `.do(fn)`, `.wait(ms)`, `.waitUntil(fn)`, `.waitUntilElement(selector)` | `waitUntil` defaults to a 16 ms interval and 3000 ms timeout. |
|
|
28
28
|
| Target events | `.onTargetEvent("click", fn)` | Handlers receive the event and step context. |
|
|
@@ -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,23 @@ 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
|
+
/**
|
|
62
|
+
* True between the spotlight's entrance and the popover's, the window in
|
|
63
|
+
* which a step's scroll is still travelling. The tracking loop drives the
|
|
64
|
+
* spotlight alone while it is set, and a lost target stops the tracking
|
|
65
|
+
* instead of freezing the presentation.
|
|
66
|
+
*/
|
|
67
|
+
private awaitingStepUi;
|
|
68
|
+
private activeTarget;
|
|
69
|
+
private targetFocusedAtFreeze;
|
|
42
70
|
private lastTargetRect;
|
|
43
71
|
private lastViewport;
|
|
44
72
|
private inertBranches;
|
|
@@ -53,7 +81,7 @@ export declare class DomTourViewDriver<T> implements TourViewDriver<T> {
|
|
|
53
81
|
private rafId;
|
|
54
82
|
private rafCancel;
|
|
55
83
|
private root;
|
|
56
|
-
private
|
|
84
|
+
private cancelScroll;
|
|
57
85
|
constructor(commands?: TourViewCommands);
|
|
58
86
|
setCommands(commands: TourViewCommands): void;
|
|
59
87
|
registerRoot(element: HTMLElement | null): void;
|
|
@@ -70,11 +98,52 @@ export declare class DomTourViewDriver<T> implements TourViewDriver<T> {
|
|
|
70
98
|
private syncModality;
|
|
71
99
|
private releaseModality;
|
|
72
100
|
private restoreInertBranches;
|
|
101
|
+
/**
|
|
102
|
+
* Brings the spotlight onto the target, then hands the step's popover and
|
|
103
|
+
* pointer over, in that order but not in lockstep.
|
|
104
|
+
*
|
|
105
|
+
* The spotlight's entrance never gates the popover's: the two animate side
|
|
106
|
+
* by side, as they always have. What does gate the popover is the step's
|
|
107
|
+
* scroll. It is pinned by a transform it only rewrites on entrance, so a
|
|
108
|
+
* rect that is still travelling would make it jump through a fade every
|
|
109
|
+
* fifty pixels; it waits for the page to stop and enters on a rect that will
|
|
110
|
+
* not move again. Meanwhile the spotlight tracks the target down the page.
|
|
111
|
+
*/
|
|
73
112
|
private appear;
|
|
113
|
+
/**
|
|
114
|
+
* Retires the outgoing popover, commits the incoming step's content in its
|
|
115
|
+
* place, waits out the step's scroll, and brings the popover and pointer in.
|
|
116
|
+
*
|
|
117
|
+
* Deliberately one async frame. With nothing to retire, nothing to commit
|
|
118
|
+
* and nothing to scroll, the entrance animations are created in the tick
|
|
119
|
+
* this was called in — which is what callers that abort mid-flight rely on,
|
|
120
|
+
* since `cancelAnimationsOnAbort` can only cancel animations that exist.
|
|
121
|
+
*
|
|
122
|
+
* The content commit runs even when no popover is mounted: it carries the
|
|
123
|
+
* controller's step-index commit and must not be skipped.
|
|
124
|
+
*/
|
|
125
|
+
private presentStepUi;
|
|
126
|
+
/** The popover and pointer entrance itself, started synchronously. */
|
|
127
|
+
private enterStepUi;
|
|
74
128
|
private attachStepResources;
|
|
129
|
+
/**
|
|
130
|
+
* Binds the step's custom event handlers to its target element. Split out
|
|
131
|
+
* from `attachStepResources` so a lost-then-recovered target can be
|
|
132
|
+
* rebound on its own by `retarget()`, without re-subscribing the
|
|
133
|
+
* step-level resources (props, capabilities, controls) that never left.
|
|
134
|
+
*/
|
|
135
|
+
private attachTargetResources;
|
|
75
136
|
private listen;
|
|
76
137
|
private schedulePosition;
|
|
138
|
+
/**
|
|
139
|
+
* Frame scheduling for the target's own realm, falling back to the ambient
|
|
140
|
+
* one when that realm exposes no frame callbacks. Shared by the tracking
|
|
141
|
+
* loop and the scroll sentinel so both read the same clock.
|
|
142
|
+
*/
|
|
143
|
+
private frameScheduler;
|
|
77
144
|
private updatePosition;
|
|
145
|
+
/** Per-frame follow-up for the popover and pointer, once they are on screen. */
|
|
146
|
+
private trackStepUi;
|
|
78
147
|
private observeDynamicOperation;
|
|
79
148
|
private handleKeydown;
|
|
80
149
|
private handleOverlayClick;
|
|
@@ -100,13 +169,79 @@ export declare class DomTourViewDriver<T> implements TourViewDriver<T> {
|
|
|
100
169
|
private isLiveDisabled;
|
|
101
170
|
private isPointerEnabled;
|
|
102
171
|
private cleanupStepResources;
|
|
172
|
+
private cleanupTargetResources;
|
|
103
173
|
private isCurrentTargetAvailable;
|
|
104
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Holds the presentation exactly where it is when its target disappears,
|
|
176
|
+
* instead of tearing it down: overlay, popover and pointer stay mounted at
|
|
177
|
+
* their last known rect, focus guard and scroll lock stay engaged, and only
|
|
178
|
+
* the target's own listeners (now pointing at a dead node) are removed.
|
|
179
|
+
* The generation is deliberately left untouched — popover buttons, the
|
|
180
|
+
* keyboard shortcuts and any pending capability/focus bookkeeping must
|
|
181
|
+
* keep working while frozen, since the popover is the user's escape hatch
|
|
182
|
+
* out of a tour whose target never comes back. `commands.targetDisconnected`
|
|
183
|
+
* drives the actual recovery (grace period, then the configured strategy)
|
|
184
|
+
* and eventually calls back into `retarget()` or `clear()`.
|
|
185
|
+
*/
|
|
186
|
+
private freezeForDisconnectedTarget;
|
|
187
|
+
/**
|
|
188
|
+
* Blocks (or restores) interaction with the underlying page independently
|
|
189
|
+
* of `step.behavior.allowInteraction`. Used to force interaction off while
|
|
190
|
+
* frozen — the cutout no longer corresponds to anything after a reflow, so
|
|
191
|
+
* it must not let clicks through even on a step that normally allows them —
|
|
192
|
+
* and to restore the step's own setting once retargeted.
|
|
193
|
+
*/
|
|
194
|
+
private applyInteractionLock;
|
|
195
|
+
private isFocusInsideTarget;
|
|
196
|
+
/**
|
|
197
|
+
* Resumes a presentation frozen by `freezeForDisconnectedTarget` on its new
|
|
198
|
+
* target: reattaches the target-bound listeners, restores the step's own
|
|
199
|
+
* interaction setting, and lets the existing reposition loop tween overlay
|
|
200
|
+
* and popover to the new rect on the next frame. Deliberately skips
|
|
201
|
+
* `appear()` (no re-entrance animation) and `activateFocus()` (focus stays
|
|
202
|
+
* where the user left it), only reclaiming it if it was on the target that
|
|
203
|
+
* just disappeared.
|
|
204
|
+
*/
|
|
205
|
+
retarget(step: ActiveStep<T>, signal: AbortSignal): Promise<void>;
|
|
206
|
+
/**
|
|
207
|
+
* Walks the presentation from where it froze to the new target's box. The
|
|
208
|
+
* per-frame loop can't do this on its own: it only tweens the cutout when
|
|
209
|
+
* the step's own visuals changed, and a target that reappears elsewhere is
|
|
210
|
+
* a pure geometry jump, which would snap. `animateTo` and the popover's
|
|
211
|
+
* reposition both fall back to an instant move when the step isn't
|
|
212
|
+
* animated, so this respects `animated: false` and reduced motion without
|
|
213
|
+
* asking about them.
|
|
214
|
+
*/
|
|
215
|
+
private moveToRetargetedRect;
|
|
105
216
|
private beginGeneration;
|
|
106
217
|
private cancelAnimationsOnAbort;
|
|
107
218
|
private cancelElementAnimations;
|
|
108
219
|
private isCurrentGeneration;
|
|
109
|
-
|
|
220
|
+
/**
|
|
221
|
+
* Starts the step's scroll and returns a promise that settles once the page
|
|
222
|
+
* has stopped moving, or `null` when there is nothing to wait for — the step
|
|
223
|
+
* opts out, the scroll was applied instantly, or no scroller can be measured.
|
|
224
|
+
*
|
|
225
|
+
* `scrollIntoView` is called synchronously so a throwing call still rejects
|
|
226
|
+
* the `show()` that asked for it. Only the wait is deferred, which is what
|
|
227
|
+
* lets the backdrop appear while the page is still travelling.
|
|
228
|
+
*/
|
|
229
|
+
private beginTargetScroll;
|
|
230
|
+
/**
|
|
231
|
+
* Resolves once the scroller has held still for a couple of frames.
|
|
232
|
+
*
|
|
233
|
+
* Deliberately not the `scrollend` event: Safari only fires it from 18.2, so
|
|
234
|
+
* older versions would fall through to the safety timeout on every step, and
|
|
235
|
+
* the presentation would stay pinned to a stale position long after the page
|
|
236
|
+
* actually stopped. Watching the offset costs a frame loop the browser is
|
|
237
|
+
* already running during a smooth scroll, works everywhere, and settles just
|
|
238
|
+
* as quickly when the browser decides there was nothing to scroll at all.
|
|
239
|
+
*
|
|
240
|
+
* Returns `null` when the offset cannot be read or frames cannot be
|
|
241
|
+
* requested — there is then no way to observe the scroll, so callers treat it
|
|
242
|
+
* as already finished rather than blocking on something unobservable.
|
|
243
|
+
*/
|
|
244
|
+
private waitForScrollToSettle;
|
|
110
245
|
private throwIfAborted;
|
|
111
246
|
private throwIfStale;
|
|
112
247
|
private getWindow;
|
package/elements/overlay.d.ts
CHANGED
|
@@ -7,7 +7,18 @@ export default class OverlayElement extends GlowTourElement {
|
|
|
7
7
|
private visualState;
|
|
8
8
|
private cssPathDSupported;
|
|
9
9
|
setInteractionAllowed(allowed: boolean): void;
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Brings the cutout onto `nextPosition`, morphing from wherever it was.
|
|
12
|
+
*
|
|
13
|
+
* Pass `tracked` when the caller will drive the geometry itself frame by
|
|
14
|
+
* frame, as it does while a step's scroll is in flight. An animation on the
|
|
15
|
+
* `d` property overrides the inline value for as long as it runs, so those
|
|
16
|
+
* per-frame writes would be invisible and the cutout would land on the rect
|
|
17
|
+
* captured here — the target's position before the page moved — then jump.
|
|
18
|
+
* Tracked, the geometry is committed once and left alone; only opacity is
|
|
19
|
+
* ever animated.
|
|
20
|
+
*/
|
|
21
|
+
moveToTarget(nextPosition: DOMRect, step: TourElementStep, tracked?: boolean): Promise<void>;
|
|
11
22
|
animateTo(position: DOMRect, step: TourElementStep): Promise<void>;
|
|
12
23
|
_getNextStyles(position: DOMRect, step: TourElementStep): Keyframe;
|
|
13
24
|
/** The backdrop with a hole punched around `position`, as a CSS `d` value. */
|
package/elements/popover.d.ts
CHANGED
|
@@ -16,7 +16,15 @@ export default class PopoverElement extends GlowTourElement {
|
|
|
16
16
|
resolvePosition(targetPosition: DOMRect, step: TourElementStep): PopoverPosition;
|
|
17
17
|
private _centerPosition;
|
|
18
18
|
private _applyPositionState;
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Fades the popover in at `nextPosition` and commits that placement.
|
|
21
|
+
*
|
|
22
|
+
* The outgoing half of a step change is {@link disappear}, deliberately kept
|
|
23
|
+
* separate: between the two, the caller swaps the step's content while the
|
|
24
|
+
* popover is off screen, and — when the step scrolls — waits for the scroll
|
|
25
|
+
* to settle so this entrance reads a rect that will not move again.
|
|
26
|
+
*/
|
|
27
|
+
present(nextPosition: DOMRect, step: TourElementStep): Promise<void>;
|
|
20
28
|
initializeProps(): void;
|
|
21
29
|
updatePosition(nextPosition: DOMRect, step: TourElementStep, onReposition?: (reposition: Promise<void>) => void): ResolvedPlacement;
|
|
22
30
|
cancelAnimations(): void;
|
package/index.js
CHANGED
|
@@ -655,7 +655,7 @@ class OverlayElement extends GlowTourElement {
|
|
|
655
655
|
this.element.style.setProperty("pointer-events", allowed ? "none" : "auto");
|
|
656
656
|
this.element.setAttribute("data-glow-tour-allow-interaction", String(allowed));
|
|
657
657
|
}
|
|
658
|
-
async moveToTarget(nextPosition, step) {
|
|
658
|
+
async moveToTarget(nextPosition, step, tracked = false) {
|
|
659
659
|
const nextVisualState = this._getVisualState(step);
|
|
660
660
|
const path = this._getPathElement();
|
|
661
661
|
if (!path) {
|
|
@@ -675,8 +675,13 @@ class OverlayElement extends GlowTourElement {
|
|
|
675
675
|
...this._getAnimationOptions(),
|
|
676
676
|
fill: "none"
|
|
677
677
|
}, path);
|
|
678
|
-
if (!animation2 || await this._waitForAnimation(animation2))
|
|
679
|
-
this.applyStyles(path, keyframe);
|
|
678
|
+
if (!animation2 || await this._waitForAnimation(animation2)) {
|
|
679
|
+
this.applyStyles(path, tracked ? { opacity } : keyframe);
|
|
680
|
+
}
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (tracked) {
|
|
684
|
+
this.applyStyles(path, keyframe);
|
|
680
685
|
return;
|
|
681
686
|
}
|
|
682
687
|
const baseStyle = {
|
|
@@ -1509,12 +1514,7 @@ class PopoverElement extends GlowTourElement {
|
|
|
1509
1514
|
}
|
|
1510
1515
|
}
|
|
1511
1516
|
}
|
|
1512
|
-
async
|
|
1513
|
-
if (!appear) {
|
|
1514
|
-
await this._disappear();
|
|
1515
|
-
}
|
|
1516
|
-
if (onChange)
|
|
1517
|
-
await onChange();
|
|
1517
|
+
async present(nextPosition, step) {
|
|
1518
1518
|
await this._appear(nextPosition, step);
|
|
1519
1519
|
}
|
|
1520
1520
|
initializeProps() {
|
|
@@ -1892,7 +1892,10 @@ class ScrollLock {
|
|
|
1892
1892
|
}
|
|
1893
1893
|
|
|
1894
1894
|
// packages/core/src/dom/tour-view-driver.ts
|
|
1895
|
-
var
|
|
1895
|
+
var SCROLL_SETTLE_STILL_FRAMES = 2;
|
|
1896
|
+
var SCROLL_SETTLE_GRACE_FRAMES = 3;
|
|
1897
|
+
var SCROLL_SETTLE_EPSILON = 0.5;
|
|
1898
|
+
var SCROLL_SETTLE_TIMEOUT = 2000;
|
|
1896
1899
|
var ACTIVE_MODAL_BY_DOCUMENT = new WeakMap;
|
|
1897
1900
|
var DEFAULT_SHORTCUTS = {
|
|
1898
1901
|
previous: ["ArrowLeft", "Backspace"],
|
|
@@ -1904,6 +1907,7 @@ class DomTourViewDriver {
|
|
|
1904
1907
|
scrollLock = new ScrollLock;
|
|
1905
1908
|
modalToken = {};
|
|
1906
1909
|
stepCleanups = [];
|
|
1910
|
+
targetCleanups = [];
|
|
1907
1911
|
commands;
|
|
1908
1912
|
direction = "advance";
|
|
1909
1913
|
currentStep = null;
|
|
@@ -1911,6 +1915,10 @@ class DomTourViewDriver {
|
|
|
1911
1915
|
disposed = false;
|
|
1912
1916
|
generation = 0;
|
|
1913
1917
|
active = false;
|
|
1918
|
+
frozen = false;
|
|
1919
|
+
awaitingStepUi = false;
|
|
1920
|
+
activeTarget = null;
|
|
1921
|
+
targetFocusedAtFreeze = false;
|
|
1914
1922
|
lastTargetRect = null;
|
|
1915
1923
|
lastViewport = null;
|
|
1916
1924
|
inertBranches = [];
|
|
@@ -1925,7 +1933,7 @@ class DomTourViewDriver {
|
|
|
1925
1933
|
rafId = null;
|
|
1926
1934
|
rafCancel = null;
|
|
1927
1935
|
root = null;
|
|
1928
|
-
|
|
1936
|
+
cancelScroll = null;
|
|
1929
1937
|
constructor(commands) {
|
|
1930
1938
|
this.commands = commands ?? null;
|
|
1931
1939
|
}
|
|
@@ -1987,12 +1995,16 @@ class DomTourViewDriver {
|
|
|
1987
1995
|
this.cleanupStepResources();
|
|
1988
1996
|
this.throwIfStale(generation, signal);
|
|
1989
1997
|
this.active = false;
|
|
1998
|
+
this.frozen = false;
|
|
1999
|
+
this.activeTarget = null;
|
|
2000
|
+
this.targetFocusedAtFreeze = false;
|
|
1990
2001
|
this.currentStep = step;
|
|
1991
2002
|
this.currentSignal = signal;
|
|
1992
2003
|
this.direction = direction;
|
|
1993
2004
|
this.lastTargetRect = null;
|
|
1994
2005
|
this.lastViewport = null;
|
|
1995
2006
|
this.presentationDirty = false;
|
|
2007
|
+
this.awaitingStepUi = true;
|
|
1996
2008
|
if (replaceVisiblePopover) {
|
|
1997
2009
|
const listener = (event) => this.queueTransitionKeydown(event, step, generation);
|
|
1998
2010
|
const currentWindow = this.getWindow();
|
|
@@ -2004,13 +2016,15 @@ class DomTourViewDriver {
|
|
|
2004
2016
|
const target = step.target;
|
|
2005
2017
|
if (!target)
|
|
2006
2018
|
return;
|
|
2019
|
+
this.activeTarget = target;
|
|
2007
2020
|
this.syncModality(step.behavior?.allowInteraction === true);
|
|
2008
|
-
|
|
2021
|
+
const scrolling = this.beginTargetScroll(step, target, signal);
|
|
2009
2022
|
this.throwIfStale(generation, signal);
|
|
2010
2023
|
this.initializeElements(step);
|
|
2011
|
-
const
|
|
2012
|
-
await this.appear(
|
|
2024
|
+
const resolveRect = () => target.getBoundingClientRect();
|
|
2025
|
+
await this.appear(resolveRect, step, generation, scrolling, replaceVisiblePopover, onBeforePopoverAppear);
|
|
2013
2026
|
this.throwIfStale(generation, signal);
|
|
2027
|
+
const targetRect = resolveRect();
|
|
2014
2028
|
this.lastTargetRect = snapshotRect(targetRect);
|
|
2015
2029
|
this.lastViewport = snapshotViewport(target);
|
|
2016
2030
|
this.active = true;
|
|
@@ -2042,6 +2056,10 @@ class DomTourViewDriver {
|
|
|
2042
2056
|
this.scrollLock.deactivate();
|
|
2043
2057
|
this.throwIfStale(generation, signal);
|
|
2044
2058
|
this.active = false;
|
|
2059
|
+
this.frozen = false;
|
|
2060
|
+
this.awaitingStepUi = false;
|
|
2061
|
+
this.activeTarget = null;
|
|
2062
|
+
this.targetFocusedAtFreeze = false;
|
|
2045
2063
|
this.currentStep = null;
|
|
2046
2064
|
this.currentSignal = null;
|
|
2047
2065
|
this.lastTargetRect = null;
|
|
@@ -2066,6 +2084,10 @@ class DomTourViewDriver {
|
|
|
2066
2084
|
this.focusGuard.deactivate();
|
|
2067
2085
|
this.scrollLock.deactivate();
|
|
2068
2086
|
this.active = false;
|
|
2087
|
+
this.frozen = false;
|
|
2088
|
+
this.awaitingStepUi = false;
|
|
2089
|
+
this.activeTarget = null;
|
|
2090
|
+
this.targetFocusedAtFreeze = false;
|
|
2069
2091
|
this.currentStep = null;
|
|
2070
2092
|
this.currentSignal = null;
|
|
2071
2093
|
this.overlay?.release();
|
|
@@ -2084,7 +2106,7 @@ class DomTourViewDriver {
|
|
|
2084
2106
|
this.commands = null;
|
|
2085
2107
|
}
|
|
2086
2108
|
refreshRegisteredElements() {
|
|
2087
|
-
if (!this.active || !this.currentStep || !this.lastTargetRect)
|
|
2109
|
+
if (!this.active || this.frozen || !this.currentStep || !this.lastTargetRect)
|
|
2088
2110
|
return;
|
|
2089
2111
|
const generation = this.beginGeneration();
|
|
2090
2112
|
this.cleanupStepResources();
|
|
@@ -2101,8 +2123,10 @@ class DomTourViewDriver {
|
|
|
2101
2123
|
const signal = this.currentSignal;
|
|
2102
2124
|
if (this.disposed || !step || !target || !targetRect || !signal)
|
|
2103
2125
|
return;
|
|
2126
|
+
this.activeTarget = target;
|
|
2104
2127
|
this.initializeElements(step);
|
|
2105
|
-
|
|
2128
|
+
this.awaitingStepUi = true;
|
|
2129
|
+
await this.appear(() => targetRect, step, generation, null, false);
|
|
2106
2130
|
this.throwIfStale(generation);
|
|
2107
2131
|
this.activateFocus(step, target, this.direction, generation);
|
|
2108
2132
|
this.syncScrollLock(step);
|
|
@@ -2175,19 +2199,34 @@ class DomTourViewDriver {
|
|
|
2175
2199
|
element.setAttribute("inert", previous);
|
|
2176
2200
|
}
|
|
2177
2201
|
}
|
|
2178
|
-
async appear(
|
|
2179
|
-
const
|
|
2180
|
-
const
|
|
2181
|
-
const
|
|
2202
|
+
async appear(resolveRect, step, generation, scrolling, hadVisiblePopover, onBeforePopoverAppear) {
|
|
2203
|
+
const spotlightRect = resolveRect();
|
|
2204
|
+
const stepUi = this.presentStepUi(resolveRect, step, generation, scrolling, hadVisiblePopover, onBeforePopoverAppear);
|
|
2205
|
+
const spotlight = this.overlay?.moveToTarget(spotlightRect, step, scrolling !== null);
|
|
2206
|
+
if (scrolling)
|
|
2207
|
+
this.schedulePosition(generation);
|
|
2208
|
+
await Promise.all([spotlight ?? Promise.resolve(), stepUi]);
|
|
2209
|
+
}
|
|
2210
|
+
async presentStepUi(resolveRect, step, generation, scrolling, hadVisiblePopover, onBeforePopoverAppear) {
|
|
2211
|
+
if (hadVisiblePopover)
|
|
2212
|
+
await this.popover?.disappear();
|
|
2213
|
+
if (onBeforePopoverAppear) {
|
|
2182
2214
|
await onBeforePopoverAppear();
|
|
2183
2215
|
this.syncControlState(step);
|
|
2184
2216
|
this.syncShortcutLabels(step);
|
|
2185
|
-
}
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2217
|
+
}
|
|
2218
|
+
if (scrolling)
|
|
2219
|
+
await scrolling;
|
|
2220
|
+
if (!this.isCurrentGeneration(generation) || this.currentSignal?.aborted)
|
|
2221
|
+
return;
|
|
2222
|
+
this.awaitingStepUi = false;
|
|
2223
|
+
await this.enterStepUi(resolveRect(), step);
|
|
2224
|
+
}
|
|
2225
|
+
enterStepUi(targetRect, step) {
|
|
2226
|
+
const popoverPlacement = this.popover?.resolvePosition(targetRect, step).placement;
|
|
2227
|
+
return Promise.all([
|
|
2228
|
+
this.popover?.present(targetRect, step) ?? Promise.resolve(),
|
|
2229
|
+
this.isPointerEnabled(step) ? this.pointer?.moveToTarget(targetRect, step, true, popoverPlacement) ?? Promise.resolve() : this.pointer?.disappear() ?? Promise.resolve()
|
|
2191
2230
|
]);
|
|
2192
2231
|
}
|
|
2193
2232
|
attachStepResources(step, target, generation, signal) {
|
|
@@ -2212,6 +2251,26 @@ class DomTourViewDriver {
|
|
|
2212
2251
|
if (active)
|
|
2213
2252
|
this.flushPendingKeyboardCommand(step, generation);
|
|
2214
2253
|
}) ?? (() => {}));
|
|
2254
|
+
this.attachTargetResources(step, target, generation, signal);
|
|
2255
|
+
const currentWindow = this.getWindow(target);
|
|
2256
|
+
if (typeof currentWindow?.addEventListener === "function") {
|
|
2257
|
+
this.listen(currentWindow, "keydown", (event) => {
|
|
2258
|
+
if (this.isCurrentGeneration(generation))
|
|
2259
|
+
this.handleKeydown(event);
|
|
2260
|
+
});
|
|
2261
|
+
this.listen(currentWindow, "click", (event) => {
|
|
2262
|
+
if (this.isCurrentGeneration(generation) && this.activeTarget) {
|
|
2263
|
+
this.handleOverlayClick(event, step, this.activeTarget);
|
|
2264
|
+
}
|
|
2265
|
+
});
|
|
2266
|
+
}
|
|
2267
|
+
this.attachButtonHandlers(step);
|
|
2268
|
+
this.observeControls(step, generation);
|
|
2269
|
+
this.syncControlState(step);
|
|
2270
|
+
this.syncShortcutLabels(step);
|
|
2271
|
+
this.schedulePosition(generation);
|
|
2272
|
+
}
|
|
2273
|
+
attachTargetResources(step, target, generation, signal) {
|
|
2215
2274
|
for (const handler of step.definition.eventHandlers) {
|
|
2216
2275
|
const listener = (event) => {
|
|
2217
2276
|
if (!this.isCurrentGeneration(generation))
|
|
@@ -2230,44 +2289,21 @@ class DomTourViewDriver {
|
|
|
2230
2289
|
return this.commands?.reportError(error);
|
|
2231
2290
|
});
|
|
2232
2291
|
};
|
|
2233
|
-
this.listen(target, handler.event, listener);
|
|
2234
|
-
}
|
|
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
|
-
});
|
|
2292
|
+
this.listen(target, handler.event, listener, undefined, this.targetCleanups);
|
|
2246
2293
|
}
|
|
2247
|
-
this.attachButtonHandlers(step);
|
|
2248
|
-
this.observeControls(step, generation);
|
|
2249
|
-
this.syncControlState(step);
|
|
2250
|
-
this.syncShortcutLabels(step);
|
|
2251
|
-
this.schedulePosition(generation);
|
|
2252
2294
|
}
|
|
2253
|
-
listen(target, type, listener, options) {
|
|
2295
|
+
listen(target, type, listener, options, bucket = this.stepCleanups) {
|
|
2254
2296
|
target.addEventListener(type, listener, options);
|
|
2255
|
-
|
|
2297
|
+
bucket.push(() => target.removeEventListener(type, listener, options));
|
|
2256
2298
|
}
|
|
2257
2299
|
schedulePosition(generation = this.generation) {
|
|
2258
|
-
if (!this.isCurrentGeneration(generation) || !this.currentStep || this.rafId !== null)
|
|
2300
|
+
if (!this.isCurrentGeneration(generation) || !this.currentStep || this.rafId !== null || this.frozen)
|
|
2259
2301
|
return;
|
|
2260
|
-
const
|
|
2261
|
-
|
|
2262
|
-
const ownerCancel = owner?.cancelAnimationFrame;
|
|
2263
|
-
const ownerHasFrameCapability = typeof ownerRequest === "function" || typeof ownerCancel === "function";
|
|
2264
|
-
const request = ownerHasFrameCapability ? ownerRequest : globalThis.requestAnimationFrame;
|
|
2265
|
-
const cancel = ownerHasFrameCapability ? ownerCancel : globalThis.cancelAnimationFrame;
|
|
2266
|
-
if (typeof request !== "function" || typeof cancel !== "function")
|
|
2302
|
+
const frames = this.frameScheduler(this.currentStep.target);
|
|
2303
|
+
if (!frames)
|
|
2267
2304
|
return;
|
|
2268
|
-
|
|
2269
|
-
this.
|
|
2270
|
-
this.rafId = request.call(frameWindow, () => {
|
|
2305
|
+
this.rafCancel = frames.cancel;
|
|
2306
|
+
this.rafId = frames.request(() => {
|
|
2271
2307
|
this.rafId = null;
|
|
2272
2308
|
this.rafCancel = null;
|
|
2273
2309
|
if (!this.isCurrentGeneration(generation))
|
|
@@ -2276,13 +2312,29 @@ class DomTourViewDriver {
|
|
|
2276
2312
|
this.schedulePosition(generation);
|
|
2277
2313
|
});
|
|
2278
2314
|
}
|
|
2315
|
+
frameScheduler(context) {
|
|
2316
|
+
const owner = context?.ownerDocument?.defaultView;
|
|
2317
|
+
const ownerRequest = owner?.requestAnimationFrame;
|
|
2318
|
+
const ownerCancel = owner?.cancelAnimationFrame;
|
|
2319
|
+
const ownerHasFrameCapability = typeof ownerRequest === "function" || typeof ownerCancel === "function";
|
|
2320
|
+
const request = ownerHasFrameCapability ? ownerRequest : globalThis.requestAnimationFrame;
|
|
2321
|
+
const cancel = ownerHasFrameCapability ? ownerCancel : globalThis.cancelAnimationFrame;
|
|
2322
|
+
if (typeof request !== "function" || typeof cancel !== "function")
|
|
2323
|
+
return null;
|
|
2324
|
+
const frameWindow = ownerHasFrameCapability && owner ? owner : globalThis;
|
|
2325
|
+
return {
|
|
2326
|
+
request: (callback) => request.call(frameWindow, callback),
|
|
2327
|
+
cancel: (id) => cancel.call(frameWindow, id)
|
|
2328
|
+
};
|
|
2329
|
+
}
|
|
2279
2330
|
updatePosition(generation) {
|
|
2280
2331
|
const step = this.currentStep;
|
|
2281
2332
|
const target = step?.target;
|
|
2282
2333
|
if (!this.isCurrentGeneration(generation) || !step || !target)
|
|
2283
2334
|
return;
|
|
2284
2335
|
if (!this.isCurrentTargetAvailable(target)) {
|
|
2285
|
-
this.
|
|
2336
|
+
if (!this.awaitingStepUi)
|
|
2337
|
+
this.freezeForDisconnectedTarget(step, target, generation);
|
|
2286
2338
|
return;
|
|
2287
2339
|
}
|
|
2288
2340
|
const targetRect = target.getBoundingClientRect();
|
|
@@ -2299,6 +2351,14 @@ class DomTourViewDriver {
|
|
|
2299
2351
|
this.syncShortcutLabels(step);
|
|
2300
2352
|
}
|
|
2301
2353
|
this.overlay?.updatePosition(targetRect, step, presentationChanged, (transition) => this.observeDynamicOperation(transition, generation));
|
|
2354
|
+
if (!this.awaitingStepUi)
|
|
2355
|
+
this.trackStepUi(targetRect, step, generation, presentationChanged);
|
|
2356
|
+
this.lastTargetRect = targetSnapshot;
|
|
2357
|
+
this.lastViewport = viewportSnapshot;
|
|
2358
|
+
if (presentationChanged)
|
|
2359
|
+
this.presentationDirty = false;
|
|
2360
|
+
}
|
|
2361
|
+
trackStepUi(targetRect, step, generation, presentationChanged) {
|
|
2302
2362
|
const popoverPlacement = this.popover?.updatePosition(targetRect, step, (reposition) => this.observeDynamicOperation(reposition, generation));
|
|
2303
2363
|
if (presentationChanged) {
|
|
2304
2364
|
this.pointer?.syncVisibility(this.isPointerEnabled(step), targetRect, step, popoverPlacement);
|
|
@@ -2312,10 +2372,6 @@ class DomTourViewDriver {
|
|
|
2312
2372
|
} else if (this.pointer?.getElement()?.getAttribute("aria-hidden") !== "true") {
|
|
2313
2373
|
this.observeDynamicOperation(this.pointer?.disappear(), generation);
|
|
2314
2374
|
}
|
|
2315
|
-
this.lastTargetRect = targetSnapshot;
|
|
2316
|
-
this.lastViewport = viewportSnapshot;
|
|
2317
|
-
if (presentationChanged)
|
|
2318
|
-
this.presentationDirty = false;
|
|
2319
2375
|
}
|
|
2320
2376
|
observeDynamicOperation(operation, generation) {
|
|
2321
2377
|
if (!operation)
|
|
@@ -2582,37 +2638,101 @@ class DomTourViewDriver {
|
|
|
2582
2638
|
return step.behavior?.allowInteraction === true && step.indicator?.disabled !== true;
|
|
2583
2639
|
}
|
|
2584
2640
|
cleanupStepResources() {
|
|
2585
|
-
this.
|
|
2586
|
-
this.
|
|
2641
|
+
this.cancelScroll?.();
|
|
2642
|
+
this.cancelScroll = null;
|
|
2587
2643
|
this.presentationDirty = false;
|
|
2588
2644
|
if (this.rafId !== null)
|
|
2589
2645
|
this.rafCancel?.(this.rafId);
|
|
2590
2646
|
this.rafId = null;
|
|
2591
2647
|
this.rafCancel = null;
|
|
2648
|
+
this.cleanupTargetResources();
|
|
2592
2649
|
for (const cleanup of this.stepCleanups.splice(0))
|
|
2593
2650
|
cleanup();
|
|
2594
2651
|
}
|
|
2652
|
+
cleanupTargetResources() {
|
|
2653
|
+
for (const cleanup of this.targetCleanups.splice(0))
|
|
2654
|
+
cleanup();
|
|
2655
|
+
}
|
|
2595
2656
|
isCurrentTargetAvailable(target) {
|
|
2596
2657
|
const rootDocument = this.root?.ownerDocument;
|
|
2597
2658
|
return target.isConnected && (!rootDocument || target.ownerDocument === rootDocument);
|
|
2598
2659
|
}
|
|
2599
|
-
|
|
2600
|
-
if (!this.isCurrentGeneration(generation))
|
|
2660
|
+
freezeForDisconnectedTarget(step, target, generation) {
|
|
2661
|
+
if (!this.isCurrentGeneration(generation) || this.frozen)
|
|
2601
2662
|
return;
|
|
2602
|
-
this.
|
|
2603
|
-
this.
|
|
2604
|
-
|
|
2605
|
-
this.
|
|
2606
|
-
this.
|
|
2607
|
-
this.
|
|
2608
|
-
this.
|
|
2609
|
-
this.
|
|
2610
|
-
this.lastTargetRect = null;
|
|
2611
|
-
this.lastViewport = null;
|
|
2663
|
+
this.frozen = true;
|
|
2664
|
+
if (this.rafId !== null)
|
|
2665
|
+
this.rafCancel?.(this.rafId);
|
|
2666
|
+
this.rafId = null;
|
|
2667
|
+
this.rafCancel = null;
|
|
2668
|
+
this.targetFocusedAtFreeze = this.isFocusInsideTarget(target);
|
|
2669
|
+
this.cleanupTargetResources();
|
|
2670
|
+
this.applyInteractionLock(step, true);
|
|
2612
2671
|
Promise.resolve(this.commands?.targetDisconnected(target)).catch((error) => {
|
|
2613
2672
|
this.commands?.reportError(error).catch(() => {});
|
|
2614
2673
|
});
|
|
2615
2674
|
}
|
|
2675
|
+
applyInteractionLock(step, locked) {
|
|
2676
|
+
const allowed = !locked && step.behavior?.allowInteraction === true;
|
|
2677
|
+
this.overlay?.setInteractionAllowed(allowed);
|
|
2678
|
+
this.syncModality(allowed);
|
|
2679
|
+
const popover = this.popover?.getElement();
|
|
2680
|
+
if (isHTMLElement(popover, this.root ?? popover)) {
|
|
2681
|
+
if (allowed)
|
|
2682
|
+
popover.removeAttribute("aria-modal");
|
|
2683
|
+
else
|
|
2684
|
+
popover.setAttribute("aria-modal", "true");
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
isFocusInsideTarget(target) {
|
|
2688
|
+
const activeElement = target.ownerDocument?.activeElement;
|
|
2689
|
+
if (!activeElement)
|
|
2690
|
+
return false;
|
|
2691
|
+
return activeElement === target || target.contains(activeElement);
|
|
2692
|
+
}
|
|
2693
|
+
async retarget(step, signal) {
|
|
2694
|
+
this.throwIfAborted(signal);
|
|
2695
|
+
if (this.disposed || !this.frozen || this.currentStep !== step)
|
|
2696
|
+
return;
|
|
2697
|
+
const target = step.target;
|
|
2698
|
+
if (!target)
|
|
2699
|
+
return;
|
|
2700
|
+
this.frozen = false;
|
|
2701
|
+
this.currentSignal = signal;
|
|
2702
|
+
this.activeTarget = target;
|
|
2703
|
+
this.applyInteractionLock(step, false);
|
|
2704
|
+
this.attachTargetResources(step, target, this.generation, signal);
|
|
2705
|
+
const popover = this.popover?.getElement();
|
|
2706
|
+
if (isHTMLElement(popover, this.root ?? popover)) {
|
|
2707
|
+
this.focusGuard.update({
|
|
2708
|
+
allowedTarget: target,
|
|
2709
|
+
allowTargetInteraction: step.behavior?.allowInteraction === true,
|
|
2710
|
+
direction: this.direction,
|
|
2711
|
+
fallback: this.root ?? popover.parentElement,
|
|
2712
|
+
popover
|
|
2713
|
+
});
|
|
2714
|
+
}
|
|
2715
|
+
if (this.targetFocusedAtFreeze) {
|
|
2716
|
+
this.targetFocusedAtFreeze = false;
|
|
2717
|
+
if (step.behavior?.allowInteraction === true)
|
|
2718
|
+
target.focus();
|
|
2719
|
+
}
|
|
2720
|
+
this.syncControlState(step);
|
|
2721
|
+
this.syncShortcutLabels(step);
|
|
2722
|
+
this.moveToRetargetedRect(step, target);
|
|
2723
|
+
this.schedulePosition(this.generation);
|
|
2724
|
+
}
|
|
2725
|
+
moveToRetargetedRect(step, target) {
|
|
2726
|
+
const generation = this.generation;
|
|
2727
|
+
const targetRect = target.getBoundingClientRect();
|
|
2728
|
+
this.observeDynamicOperation(this.overlay?.animateTo(targetRect, step), generation);
|
|
2729
|
+
const placement = this.popover?.updatePosition(targetRect, step, (reposition) => this.observeDynamicOperation(reposition, generation));
|
|
2730
|
+
if (this.isPointerEnabled(step)) {
|
|
2731
|
+
this.observeDynamicOperation(this.pointer?.moveToTarget(targetRect, step, true, placement), generation);
|
|
2732
|
+
}
|
|
2733
|
+
this.lastTargetRect = snapshotRect(targetRect);
|
|
2734
|
+
this.lastViewport = snapshotViewport(target);
|
|
2735
|
+
}
|
|
2616
2736
|
beginGeneration() {
|
|
2617
2737
|
this.generation += 1;
|
|
2618
2738
|
this.pendingKeyboardCommand = null;
|
|
@@ -2633,45 +2753,77 @@ class DomTourViewDriver {
|
|
|
2633
2753
|
isCurrentGeneration(generation) {
|
|
2634
2754
|
return !this.disposed && generation === this.generation;
|
|
2635
2755
|
}
|
|
2636
|
-
|
|
2756
|
+
beginTargetScroll(step, target, signal) {
|
|
2637
2757
|
this.throwIfAborted(signal);
|
|
2638
|
-
if (step.behavior?.disableAutoScroll
|
|
2639
|
-
return;
|
|
2758
|
+
if (step.behavior?.disableAutoScroll)
|
|
2759
|
+
return null;
|
|
2760
|
+
if (isInViewport(target.getBoundingClientRect(), target))
|
|
2761
|
+
return null;
|
|
2640
2762
|
const currentWindow = this.getWindow(target);
|
|
2641
2763
|
if (!currentWindow)
|
|
2642
|
-
return;
|
|
2643
|
-
const
|
|
2644
|
-
|
|
2645
|
-
|
|
2764
|
+
return null;
|
|
2765
|
+
const behavior = prefersReducedMotion(target) ? "instant" : step.behavior?.scroll?.behavior ?? "smooth";
|
|
2766
|
+
target.scrollIntoView({
|
|
2767
|
+
behavior,
|
|
2768
|
+
block: step.behavior?.scroll?.block ?? "center",
|
|
2769
|
+
inline: step.behavior?.scroll?.inline ?? "nearest"
|
|
2770
|
+
});
|
|
2771
|
+
if (behavior === "instant")
|
|
2772
|
+
return null;
|
|
2773
|
+
return this.waitForScrollToSettle(target, signal);
|
|
2774
|
+
}
|
|
2775
|
+
waitForScrollToSettle(target, signal) {
|
|
2776
|
+
const owner = ownerDocument(target);
|
|
2777
|
+
const scroller = owner?.scrollingElement;
|
|
2778
|
+
if (typeof scroller?.scrollTop !== "number")
|
|
2779
|
+
return null;
|
|
2780
|
+
const frames = this.frameScheduler(target);
|
|
2781
|
+
if (!frames)
|
|
2782
|
+
return null;
|
|
2783
|
+
if (owner?.visibilityState === "hidden")
|
|
2784
|
+
return null;
|
|
2785
|
+
return new Promise((resolve, reject) => {
|
|
2786
|
+
let frame = null;
|
|
2646
2787
|
let timeout = null;
|
|
2647
|
-
|
|
2788
|
+
let left = scroller.scrollLeft;
|
|
2789
|
+
let top = scroller.scrollTop;
|
|
2790
|
+
let stillFrames = -SCROLL_SETTLE_GRACE_FRAMES;
|
|
2648
2791
|
const finish = (error) => {
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
controller.signal.removeEventListener("abort", abort);
|
|
2792
|
+
if (frame !== null)
|
|
2793
|
+
frames.cancel(frame);
|
|
2652
2794
|
if (timeout !== null)
|
|
2653
2795
|
clearTimeout(timeout);
|
|
2654
|
-
|
|
2655
|
-
|
|
2796
|
+
signal.removeEventListener("abort", abort);
|
|
2797
|
+
owner?.removeEventListener("visibilitychange", stopIfHidden);
|
|
2798
|
+
if (this.cancelScroll === abort)
|
|
2799
|
+
this.cancelScroll = null;
|
|
2656
2800
|
if (error)
|
|
2657
2801
|
reject(error);
|
|
2658
2802
|
else
|
|
2659
2803
|
resolve();
|
|
2660
2804
|
};
|
|
2661
|
-
const
|
|
2662
|
-
|
|
2805
|
+
const abort = () => finish(abortError2());
|
|
2806
|
+
const stopIfHidden = () => {
|
|
2807
|
+
if (owner?.visibilityState === "hidden")
|
|
2808
|
+
finish();
|
|
2809
|
+
};
|
|
2810
|
+
const watch = () => {
|
|
2811
|
+
frame = null;
|
|
2812
|
+
const nextLeft = scroller.scrollLeft;
|
|
2813
|
+
const nextTop = scroller.scrollTop;
|
|
2814
|
+
const still = Math.abs(nextLeft - left) <= SCROLL_SETTLE_EPSILON && Math.abs(nextTop - top) <= SCROLL_SETTLE_EPSILON;
|
|
2815
|
+
left = nextLeft;
|
|
2816
|
+
top = nextTop;
|
|
2817
|
+
stillFrames = still ? stillFrames + 1 : 0;
|
|
2818
|
+
if (stillFrames >= SCROLL_SETTLE_STILL_FRAMES)
|
|
2819
|
+
return finish();
|
|
2820
|
+
frame = frames.request(watch);
|
|
2821
|
+
};
|
|
2822
|
+
this.cancelScroll = abort;
|
|
2663
2823
|
signal.addEventListener("abort", abort, { once: true });
|
|
2664
|
-
|
|
2665
|
-
timeout = setTimeout(
|
|
2666
|
-
|
|
2667
|
-
target.scrollIntoView({
|
|
2668
|
-
behavior: prefersReducedMotion(target) ? "instant" : step.behavior?.scroll?.behavior ?? "smooth",
|
|
2669
|
-
block: step.behavior?.scroll?.block ?? "center",
|
|
2670
|
-
inline: step.behavior?.scroll?.inline ?? "nearest"
|
|
2671
|
-
});
|
|
2672
|
-
} catch (error) {
|
|
2673
|
-
finish(error instanceof Error ? error : new Error(String(error)));
|
|
2674
|
-
}
|
|
2824
|
+
owner?.addEventListener("visibilitychange", stopIfHidden);
|
|
2825
|
+
timeout = setTimeout(finish, SCROLL_SETTLE_TIMEOUT);
|
|
2826
|
+
frame = frames.request(watch);
|
|
2675
2827
|
});
|
|
2676
2828
|
}
|
|
2677
2829
|
throwIfAborted(signal) {
|
|
@@ -3305,6 +3457,7 @@ function prefixReservations(document2) {
|
|
|
3305
3457
|
|
|
3306
3458
|
// packages/core/src/runtime/tour-controller.ts
|
|
3307
3459
|
var DEFAULT_TARGET_TIMEOUT = 3000;
|
|
3460
|
+
var TARGET_LOSS_GRACE_MS = 150;
|
|
3308
3461
|
var DISPOSED_ERROR_MESSAGE = "Tour controller is disposed";
|
|
3309
3462
|
function resolveStartIndex(workflow, startAt) {
|
|
3310
3463
|
if (startAt === undefined)
|
|
@@ -3335,6 +3488,7 @@ class TourController {
|
|
|
3335
3488
|
direction = "advance";
|
|
3336
3489
|
status = "idle";
|
|
3337
3490
|
error = null;
|
|
3491
|
+
recoveringTarget = null;
|
|
3338
3492
|
operationToken = 0;
|
|
3339
3493
|
publicationRevision = 0;
|
|
3340
3494
|
operation = null;
|
|
@@ -3617,34 +3771,63 @@ class TourController {
|
|
|
3617
3771
|
this.assertCurrent(operation);
|
|
3618
3772
|
}
|
|
3619
3773
|
}
|
|
3774
|
+
async pollForTarget(step, operation, budgetMs) {
|
|
3775
|
+
const startedAt = Date.now();
|
|
3776
|
+
while (true) {
|
|
3777
|
+
const target = await step.resolveTarget(this.signalFor(operation));
|
|
3778
|
+
this.assertCurrent(operation);
|
|
3779
|
+
if (target)
|
|
3780
|
+
return target;
|
|
3781
|
+
if (Date.now() - startedAt >= budgetMs)
|
|
3782
|
+
return null;
|
|
3783
|
+
await abortableDelay(16, this.signalFor(operation));
|
|
3784
|
+
this.assertCurrent(operation);
|
|
3785
|
+
}
|
|
3786
|
+
}
|
|
3620
3787
|
async recoverDisconnectedTarget(target) {
|
|
3621
|
-
if (this.disposed || this.status !== "active")
|
|
3788
|
+
if (this.disposed || this.status !== "active" || this.recoveringTarget === target)
|
|
3622
3789
|
return;
|
|
3623
3790
|
const step = this.currentStep();
|
|
3624
3791
|
if (!step || step.target !== target)
|
|
3625
3792
|
return;
|
|
3793
|
+
this.recoveringTarget = target;
|
|
3626
3794
|
const index = this.index;
|
|
3627
3795
|
const direction = this.direction;
|
|
3628
3796
|
const operation = this.beginOperation();
|
|
3629
3797
|
try {
|
|
3630
|
-
this.setStatus("transitioning");
|
|
3631
|
-
this.assertCurrent(operation);
|
|
3632
|
-
await this.driver.clear(this.signalFor(operation));
|
|
3633
3798
|
this.assertCurrent(operation);
|
|
3634
|
-
const
|
|
3799
|
+
const recoveredDuringGrace = await this.pollForTarget(step, operation, TARGET_LOSS_GRACE_MS);
|
|
3635
3800
|
this.assertCurrent(operation);
|
|
3636
|
-
if (
|
|
3801
|
+
if (recoveredDuringGrace) {
|
|
3802
|
+
step.target = recoveredDuringGrace;
|
|
3803
|
+
await this.driver.retarget(step, this.signalFor(operation));
|
|
3804
|
+
this.assertCurrent(operation);
|
|
3805
|
+
this.publish();
|
|
3806
|
+
return;
|
|
3807
|
+
}
|
|
3808
|
+
const strategy = step.behavior?.missingTargetStrategy ?? "error";
|
|
3809
|
+
if (strategy === "skip") {
|
|
3637
3810
|
await this.advancePastRecoveryMissingTarget(step, index, direction, operation);
|
|
3638
3811
|
return;
|
|
3639
3812
|
}
|
|
3640
|
-
|
|
3641
|
-
|
|
3813
|
+
if (strategy !== "wait")
|
|
3814
|
+
throw this.missingTargetError(step);
|
|
3815
|
+
const timeout = step.behavior?.targetTimeout ?? DEFAULT_TARGET_TIMEOUT;
|
|
3816
|
+
const recoveredAfterWait = await this.pollForTarget(step, operation, Math.max(0, timeout - TARGET_LOSS_GRACE_MS));
|
|
3642
3817
|
this.assertCurrent(operation);
|
|
3643
|
-
|
|
3818
|
+
if (!recoveredAfterWait)
|
|
3819
|
+
throw this.missingTargetError(step);
|
|
3820
|
+
step.target = recoveredAfterWait;
|
|
3821
|
+
await this.driver.retarget(step, this.signalFor(operation));
|
|
3822
|
+
this.assertCurrent(operation);
|
|
3823
|
+
this.publish();
|
|
3644
3824
|
} catch (error) {
|
|
3645
3825
|
try {
|
|
3646
3826
|
await this.handleFailure(error, operation);
|
|
3647
3827
|
} catch {}
|
|
3828
|
+
} finally {
|
|
3829
|
+
if (this.recoveringTarget === target)
|
|
3830
|
+
this.recoveringTarget = null;
|
|
3648
3831
|
}
|
|
3649
3832
|
}
|
|
3650
3833
|
async advancePastMissingTarget(index, direction, operation) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glowhop/core-tour",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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",
|
|
@@ -10,8 +10,17 @@
|
|
|
10
10
|
"keywords": [
|
|
11
11
|
"tour",
|
|
12
12
|
"guided-tour",
|
|
13
|
+
"product-tour",
|
|
13
14
|
"onboarding",
|
|
14
|
-
"
|
|
15
|
+
"walkthrough",
|
|
16
|
+
"user-onboarding",
|
|
17
|
+
"tooltip",
|
|
18
|
+
"spotlight",
|
|
19
|
+
"typescript",
|
|
20
|
+
"dom",
|
|
21
|
+
"headless",
|
|
22
|
+
"framework-agnostic",
|
|
23
|
+
"ssr"
|
|
15
24
|
],
|
|
16
25
|
"engines": {
|
|
17
26
|
"node": ">=18.19.1"
|
|
@@ -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
|
@@ -55,12 +55,12 @@ export interface IndicatorOptions extends BaseOptions {
|
|
|
55
55
|
disabled?: boolean;
|
|
56
56
|
/** Gap between the target and the indicator in pixels. */
|
|
57
57
|
gap?: number;
|
|
58
|
-
/** Placement preference order when positioning the indicator. @default ["
|
|
58
|
+
/** Placement preference order when positioning the indicator. @default ["left", "right", "top", "bottom"] */
|
|
59
59
|
placementTryOrder?: readonly TryOrderOptions[];
|
|
60
60
|
}
|
|
61
61
|
/** Configures the darkened overlay backdrop that highlights the target. */
|
|
62
62
|
export interface OverlayOptions extends BaseOptions {
|
|
63
|
-
/** Color of the overlay backdrop (CSS color).
|
|
63
|
+
/** Color of the overlay backdrop (CSS color). Falls back to the `--glow-tour-overlay-color` theme variable when unset. */
|
|
64
64
|
color?: string;
|
|
65
65
|
/** Opacity of the overlay (0-1). @default 0.7 */
|
|
66
66
|
opacity?: number;
|
|
@@ -77,11 +77,11 @@ export interface PopoverArrowOptions {
|
|
|
77
77
|
color?: string;
|
|
78
78
|
/** Size of the arrow in pixels. @default 12 */
|
|
79
79
|
size?: number;
|
|
80
|
-
/** Border width of the arrow in pixels.
|
|
80
|
+
/** Border width of the arrow in pixels. Falls back to the `--glow-tour-arrow-border-width` theme variable (`1px`) when unset. */
|
|
81
81
|
borderWidth?: number;
|
|
82
82
|
/** Border radius of the arrow in pixels. @default 0 */
|
|
83
83
|
borderRadius?: number;
|
|
84
|
-
/**
|
|
84
|
+
/** Minimum gap the arrow keeps from the popover edges, in pixels. A placement whose arrow would fall inside this margin is rejected in favour of the next one. @default 16 */
|
|
85
85
|
edgePadding?: number;
|
|
86
86
|
/**
|
|
87
87
|
* CSP nonce applied to the `<style>` element GlowTour.js injects for the
|
|
@@ -98,7 +98,7 @@ export interface PopoverArrowOptions {
|
|
|
98
98
|
}
|
|
99
99
|
/** Configures the popover box that displays content for each step. */
|
|
100
100
|
export interface PopoverOptions extends BaseOptions {
|
|
101
|
-
/** Placement preference order for the popover around the target. @default ["
|
|
101
|
+
/** Placement preference order for the popover around the target. @default ["bottom", "top", "right", "left"] */
|
|
102
102
|
placementTryOrder?: readonly TryOrderOptions[];
|
|
103
103
|
/** Arrow configuration. */
|
|
104
104
|
arrow?: PopoverArrowOptions;
|
|
@@ -136,9 +136,16 @@ export interface PopoverOptions extends BaseOptions {
|
|
|
136
136
|
cancel?: readonly string[];
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* Scroll behavior options passed to Element.scrollIntoView().
|
|
141
|
+
*
|
|
142
|
+
* A step scrolls only when part of its target falls outside the viewport, and
|
|
143
|
+
* does not wait for the scroll before presenting: the spotlight appears at once
|
|
144
|
+
* and tracks the target as the page travels, and the popover and pointer enter
|
|
145
|
+
* when the page has come to rest.
|
|
146
|
+
*/
|
|
140
147
|
export interface ScrollOptions {
|
|
141
|
-
/** Scroll animation. @default "
|
|
148
|
+
/** Scroll animation. Forced to `"instant"` when the user prefers reduced motion. @default "smooth" */
|
|
142
149
|
behavior?: "auto" | "smooth";
|
|
143
150
|
/** Vertical alignment of the target in the viewport. @default "center" */
|
|
144
151
|
block?: "start" | "center" | "end" | "nearest";
|