@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 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 scrollAbort;
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
- private stopForDisconnectedTarget;
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
- private scrollTargetIntoView;
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;
@@ -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
- moveToTarget(nextPosition: DOMRect, step: TourElementStep): Promise<void>;
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. */
@@ -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
- moveToTarget(nextPosition: DOMRect, step: TourElementStep, appear: boolean, onChange?: () => void | Promise<void>): Promise<void>;
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 moveToTarget(nextPosition, step, appear, onChange) {
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 DEFAULT_SCROLL_END_TIMEOUT = 1000;
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
- scrollAbort = null;
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
- await this.scrollTargetIntoView(step, target, signal);
2021
+ const scrolling = this.beginTargetScroll(step, target, signal);
2009
2022
  this.throwIfStale(generation, signal);
2010
2023
  this.initializeElements(step);
2011
- const targetRect = target.getBoundingClientRect();
2012
- await this.appear(targetRect, step, !replaceVisiblePopover, onBeforePopoverAppear);
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
- await this.appear(targetRect, step);
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(targetRect, step, appearPopover = true, onBeforePopoverAppear) {
2179
- const pointerEnabled = this.isPointerEnabled(step);
2180
- const popoverPlacement = this.popover?.resolvePosition(targetRect, step).placement;
2181
- const commitStep = onBeforePopoverAppear ? async () => {
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
- } : undefined;
2186
- const popoverTransition = this.popover ? this.popover.moveToTarget(targetRect, step, appearPopover, commitStep) : Promise.resolve(commitStep?.());
2187
- await Promise.all([
2188
- this.overlay?.moveToTarget(targetRect, step) ?? Promise.resolve(),
2189
- popoverTransition,
2190
- pointerEnabled ? this.pointer?.moveToTarget(targetRect, step, true, popoverPlacement) ?? Promise.resolve() : this.pointer?.disappear() ?? Promise.resolve()
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
- this.stepCleanups.push(() => target.removeEventListener(type, listener, options));
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 owner = this.currentStep.target?.ownerDocument?.defaultView;
2261
- const ownerRequest = owner?.requestAnimationFrame;
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
- const frameWindow = ownerHasFrameCapability && owner ? owner : globalThis;
2269
- this.rafCancel = (id) => cancel.call(frameWindow, id);
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.stopForDisconnectedTarget(target, generation);
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.scrollAbort?.abort();
2586
- this.scrollAbort = null;
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
- stopForDisconnectedTarget(target, generation) {
2600
- if (!this.isCurrentGeneration(generation))
2660
+ freezeForDisconnectedTarget(step, target, generation) {
2661
+ if (!this.isCurrentGeneration(generation) || this.frozen)
2601
2662
  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;
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
- async scrollTargetIntoView(step, target, signal) {
2756
+ beginTargetScroll(step, target, signal) {
2637
2757
  this.throwIfAborted(signal);
2638
- if (step.behavior?.disableAutoScroll || isInViewport(target.getBoundingClientRect(), target))
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 controller = new AbortController;
2644
- this.scrollAbort = controller;
2645
- await new Promise((resolve, reject) => {
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
- const abort = () => finish(abortError2());
2788
+ let left = scroller.scrollLeft;
2789
+ let top = scroller.scrollTop;
2790
+ let stillFrames = -SCROLL_SETTLE_GRACE_FRAMES;
2648
2791
  const finish = (error) => {
2649
- currentWindow.removeEventListener("scrollend", complete);
2650
- signal.removeEventListener("abort", abort);
2651
- controller.signal.removeEventListener("abort", abort);
2792
+ if (frame !== null)
2793
+ frames.cancel(frame);
2652
2794
  if (timeout !== null)
2653
2795
  clearTimeout(timeout);
2654
- if (this.scrollAbort === controller)
2655
- this.scrollAbort = null;
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 complete = () => finish();
2662
- currentWindow.addEventListener("scrollend", complete, { once: true });
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
- controller.signal.addEventListener("abort", abort, { once: true });
2665
- timeout = setTimeout(complete, DEFAULT_SCROLL_END_TIMEOUT);
2666
- try {
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 recoveredTarget = await this.resolveTarget(step, operation);
3799
+ const recoveredDuringGrace = await this.pollForTarget(step, operation, TARGET_LOSS_GRACE_MS);
3635
3800
  this.assertCurrent(operation);
3636
- if (!recoveredTarget) {
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
- step.target = recoveredTarget;
3641
- await this.driver.show(step, direction, this.signalFor(operation));
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
- this.setStatus("active");
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.1.0",
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
- "dom"
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 ["bottom", "top", "right", "left"] */
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). @default "rgba(0, 0, 0, 0.5)" */
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. @default 0 */
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
- /** Gap between arrow tip and the target edge in pixels. @default 8 */
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 ["top", "bottom", "right", "left"] */
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
- /** Scroll behavior options passed to Element.scrollIntoView(). */
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 "auto" */
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";