@glowhop/core-tour 1.4.0 → 1.5.1

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,24 @@
1
1
  # @glowhop/core-tour
2
2
 
3
+ ## 1.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - e388604: Fix the popover blinking when a step scrolls its target into view on a page that scrolls inside a container rather than the document. The scroll counted as finished a few frames in, so the popover appeared mid-scroll, stepped aside as if the user were scrolling, and came back once the page stopped. The popover now waits until the target itself has stopped moving.
8
+
9
+ ## 1.5.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 781a1f7: Report the wait when a step's target is still resolving. A target given as an async resolver, or one the `"wait"` strategy is polling for, keeps the tour on the step the user asked to leave: for as long as that wait lasts, the popover on screen carries `data-glow-tour-awaiting-target` and its advance control is disabled, so a tour waiting on a slow target no longer looks idle with a button that does nothing. Both are cleared when the target settles, and `TourState` gains `awaitingTarget` so a UI that renders its own controls - every adapter's trigger components, and any custom one - can show the wait rather than read it from the DOM. Cancel and previous stay available, a target that resolves synchronously never enters this state, and the freeze of a target lost mid-step is unchanged.
14
+ - 91d778a: Add `tour.hidePopover()` and `tour.showPopover()`, also returned by `useGlowTour` and `injectGlowTour`. Hiding the popover keeps the overlay, the indicator and the scroll lock, and the tour keeps running: the page leaves `inert`, focus moves from the popover to the target, and the keyboard shortcuts do nothing until the popover is shown again. It stays hidden across steps, until `showPopover()` or the end of the tour, and a new `start()` shows it again. Showing it replays its entrance, makes the step modal again and moves focus back into it. `TourState` gains `popoverHidden`.
15
+ - d63e152: Step the popover aside while the user scrolls. The popover and the pointer used to chase the target through a fade every few pixels of travel, and settled in the middle of the screen once the target had scrolled out of view. They now fade out at the first scroll and come back once the page has been still for a moment, while the spotlight keeps following the target. When the user left part of the target outside the viewport, the step scrolls it back after the new `behavior.scroll.returnDelay` (500 ms by default, `false` to leave the page where the user put it); scrolling again restarts the wait, and a wheel or touch drag during the scroll back hands the page back. Steps with `allowScroll: false` or `autoScroll: false` do not scroll back.
16
+
17
+ ### Patch Changes
18
+
19
+ - 2c568ab: Point each package's npm homepage to its page on glowtour.dev instead of the GitHub README, and describe what each package does in its npm description. Package metadata only: no code, API or export changes.
20
+ - f8259bd: Keep the tour aligned on a phone page that is wider than the device and can be zoomed out. The browser then grows the layout viewport past the initial containing block: the overlay stopped short of the bottom of the screen, leaving an undimmed band, and the popover was placed and clamped against the smaller device-width box. The overlay now spans the taller of `100%` and `100lvh`, and placement measures the box `position: fixed` elements actually use.
21
+
3
22
  ## 1.4.0
4
23
 
5
24
  ### Minor Changes
package/config/index.js CHANGED
@@ -480,7 +480,7 @@ var BEHAVIOR_KEYS = [
480
480
  "scroll",
481
481
  "overlayClick"
482
482
  ];
483
- var SCROLL_KEYS = ["behavior", "block", "inline"];
483
+ var SCROLL_KEYS = ["behavior", "block", "inline", "returnDelay"];
484
484
  var CONFIG_VERSION = "1.1";
485
485
  var BUILTIN_ACTION_KEYS = {
486
486
  wait: ["type", "ms"],
@@ -847,6 +847,8 @@ function validateScrollShape(path, value, issues) {
847
847
  validateOptionalEnum(`${path}.behavior`, value.behavior, ["auto", "smooth"], issues);
848
848
  validateOptionalEnum(`${path}.block`, value.block, ["start", "center", "end", "nearest"], issues);
849
849
  validateOptionalEnum(`${path}.inline`, value.inline, ["start", "center", "end", "nearest"], issues);
850
+ if (value.returnDelay !== false)
851
+ validateOptionalFiniteNonNegative(`${path}.returnDelay`, value.returnDelay, issues);
850
852
  }
851
853
  function validateBehaviorShape(path, value, issues) {
852
854
  if (value === undefined)
@@ -27,6 +27,17 @@ export interface TourViewDriver<T> {
27
27
  * than throw.
28
28
  */
29
29
  retarget(step: ActiveStep<T>, signal: AbortSignal): Promise<void> | void;
30
+ /**
31
+ * Reports that the navigation under way is waiting for the next step's
32
+ * target to resolve. The presented step has not left yet, so its popover is
33
+ * the one that carries the wait.
34
+ */
35
+ setTargetPending?(pending: boolean): void;
36
+ /**
37
+ * Hides or shows the popover of the running tour. A step still entering reads it as it is
38
+ * presented. Every `start()` shows it again first.
39
+ */
40
+ setPopoverHidden?(hidden: boolean): void;
30
41
  dispose(): void;
31
42
  releaseMount?(): void;
32
43
  setCommands?(commands: TourViewCommands): void;
@@ -66,6 +77,14 @@ export declare class DomTourViewDriver<T> implements TourViewDriver<T> {
66
77
  * instead of freezing the presentation.
67
78
  */
68
79
  private awaitingStepUi;
80
+ /**
81
+ * True while the popover and pointer stand aside for a user scroll, until the
82
+ * page has been still for `USER_SCROLL_IDLE_DELAY` and, when the target was
83
+ * left off screen, the step has scrolled it back. The spotlight keeps
84
+ * tracking the target throughout.
85
+ */
86
+ private userScrolling;
87
+ private scrollTimer?;
69
88
  private activeTarget;
70
89
  private targetFocusedAtFreeze;
71
90
  private lastTargetRect;
@@ -85,6 +104,10 @@ export declare class DomTourViewDriver<T> implements TourViewDriver<T> {
85
104
  private handledTriggerClick;
86
105
  private popover;
87
106
  private presentationDirty;
107
+ /** True while the controller waits for the next step's target, see `setTargetPending`. */
108
+ private targetPending;
109
+ /** True while the consumer hides the popover, see `setPopoverHidden`. */
110
+ private popoverHidden;
88
111
  /**
89
112
  * The focus a clear gives back once the popover has faded out. Kept past a clear that a new show
90
113
  * supersedes, so that tour returns focus there instead of to the fading popover.
@@ -104,6 +127,21 @@ export declare class DomTourViewDriver<T> implements TourViewDriver<T> {
104
127
  registerOverlay(element: SVGSVGElement | null): void;
105
128
  registerPopover(element: HTMLElement | null): void;
106
129
  registerPointer(element: HTMLElement | null): void;
130
+ /**
131
+ * Flags the presented popover as waiting for the next step's target, and
132
+ * disables its advance control for as long as the wait lasts: the step the
133
+ * user asked to leave stays on screen, and an advance that is already
134
+ * refused by the controller must not keep looking available. Cleared by the
135
+ * controller when the target settles, and by any teardown.
136
+ */
137
+ setTargetPending(pending: boolean): void;
138
+ /**
139
+ * Hides the popover and hands the page back while it is hidden: nothing is left to trap focus
140
+ * in, so the page leaves `inert`, focus moves from the popover to the target, and the shortcuts
141
+ * stop. The overlay, the pointer and the scroll lock stay. Showing it again replays the entrance
142
+ * and makes the step modal again. A step still entering applies it once presented.
143
+ */
144
+ setPopoverHidden(hidden: boolean): void;
107
145
  show(step: ActiveStep<T>, direction: TourDirection, signal: AbortSignal, onBeforePopoverAppear?: () => void | Promise<void>): Promise<void>;
108
146
  clear(signal: AbortSignal): Promise<void>;
109
147
  releaseMount(): void;
@@ -114,6 +152,8 @@ export declare class DomTourViewDriver<T> implements TourViewDriver<T> {
114
152
  private syncModality;
115
153
  private claimModal;
116
154
  private releaseModality;
155
+ /** Gives the page back without releasing the document's modal claim. */
156
+ private liftModality;
117
157
  private restoreInertBranches;
118
158
  /**
119
159
  * Brings the spotlight onto the target, then hands the step's popover and
@@ -163,6 +203,34 @@ export declare class DomTourViewDriver<T> implements TourViewDriver<T> {
163
203
  private updatePosition;
164
204
  /** Per-frame follow-up for the popover and pointer, once they are on screen. */
165
205
  private trackStepUi;
206
+ /**
207
+ * A scroll of the page, or of a scroller around the target, while the step is
208
+ * on screen. The popover and pointer stand aside at the first one, since
209
+ * following a travelling rect would fade them out and in every few pixels,
210
+ * and come back once the page has been still for a moment.
211
+ */
212
+ private onUserScroll;
213
+ private waitForScrollIdle;
214
+ /**
215
+ * The page has been still for a moment: brings the popover and pointer back,
216
+ * after `scroll.returnDelay` has scrolled the target back into view when the
217
+ * user left part of it outside.
218
+ */
219
+ private settleScroll;
220
+ /**
221
+ * Scrolls the target back in. As on entrance, `awaitingStepUi` leaves the
222
+ * spotlight to follow the page on its own and the scroll events this raises
223
+ * to be ignored; set together with `userScrolling`, it marks the scroll back,
224
+ * which a wheel or touch drag interrupts.
225
+ */
226
+ private scrollBack;
227
+ private revealAfterScroll;
228
+ private stopUserScroll;
229
+ /**
230
+ * Moves the popover along with its target and returns its placement. A hidden popover stays
231
+ * where it is: moving it would fade it back in. The pointer still keeps clear of its placement.
232
+ */
233
+ private placePopover;
166
234
  private observeDynamicOperation;
167
235
  private handleKeydown;
168
236
  /**
@@ -180,6 +248,23 @@ export declare class DomTourViewDriver<T> implements TourViewDriver<T> {
180
248
  private queueTransitionClick;
181
249
  private flushPendingCommand;
182
250
  private loopFocus;
251
+ /**
252
+ * Makes a presented step modal and moves focus into its popover, or keeps the popover hidden. A
253
+ * popover shown again after the entrance passed over it enters now.
254
+ */
255
+ private engagePopover;
256
+ /**
257
+ * Fades the popover out and stops guarding focus. Focus left in the popover goes to the target,
258
+ * or is dropped when the target cannot take it: `inert` would otherwise drop it on the body. The
259
+ * focus the tour gives back when it ends is kept for the step that shows the popover again.
260
+ */
261
+ private concealPopover;
262
+ /**
263
+ * Replays the popover's entrance on the rect the step was last placed on (a frozen step has no
264
+ * live target to measure), then makes the step modal again.
265
+ */
266
+ private revealPopover;
267
+ private isPopoverConcealed;
183
268
  private activateFocus;
184
269
  private syncScrollLock;
185
270
  private syncShortcutLabels;
@@ -231,8 +316,9 @@ export declare class DomTourViewDriver<T> implements TourViewDriver<T> {
231
316
  private applyInteraction;
232
317
  /**
233
318
  * Applies a `behavior.allowInteraction` changed through the step props while the step is on
234
- * screen. A step still entering reads the new value when it presents, and a frozen one keeps
235
- * interaction off until `retarget()` restores it. The indicator fades in or out instead of snapping.
319
+ * screen, or stepped aside for a user scroll. A step still entering reads the new value when it
320
+ * presents, and a frozen one keeps interaction off until `retarget()` restores it. The indicator
321
+ * fades in or out instead of snapping.
236
322
  */
237
323
  private syncInteraction;
238
324
  private isFocusInsideTarget;
@@ -270,8 +356,16 @@ export declare class DomTourViewDriver<T> implements TourViewDriver<T> {
270
356
  * lets the backdrop appear while the page is still travelling.
271
357
  */
272
358
  private beginTargetScroll;
359
+ /** Scrolls the target in as the step asks, and returns the settle wait as `beginTargetScroll` does. */
360
+ private scrollTargetIntoView;
273
361
  /**
274
- * Resolves once the scroller has held still for a couple of frames.
362
+ * Resolves once the scroller, and the target it carries, have held still for
363
+ * a couple of frames.
364
+ *
365
+ * The target's own position is watched alongside the document's offset: a
366
+ * page that scrolls inside a container rather than the document moves the
367
+ * target while `scrollingElement` sits at zero, and would otherwise read as
368
+ * settled a few frames in, presenting the popover mid-scroll.
275
369
  *
276
370
  * Deliberately not the `scrollend` event: Safari only fires it from 18.2, so
277
371
  * older versions would fall through to the safety timeout on every step, and
@@ -14,6 +14,11 @@ export default class PopoverElement extends GlowTourElement {
14
14
  private readonly mutationLease;
15
15
  protected _getNextStyles(position: DOMRect, step: TourElementStep): Keyframe;
16
16
  resolvePosition(targetPosition: DOMRect, step: TourElementStep): PopoverPosition;
17
+ /**
18
+ * Marks the popover as waiting for the next step's target. Written through the mutation lease,
19
+ * like every other attribute the popover owns, so unbinding the element gives it back clean.
20
+ */
21
+ setAwaitingTarget(awaiting: boolean): void;
17
22
  private _centerPosition;
18
23
  private _applyPositionState;
19
24
  /**
package/index.js CHANGED
@@ -417,12 +417,34 @@ function isNode(value, context) {
417
417
  const Node = ownerWindow(context)?.Node ?? globalThis.Node;
418
418
  return typeof Node === "function" && value instanceof Node;
419
419
  }
420
+ var fixedBoxes = new WeakMap;
421
+ function fixedContainingBlock(document2, root) {
422
+ const view = document2.defaultView;
423
+ const key = `${root.clientWidth} ${root.clientHeight} ${view?.innerWidth} ${view?.innerHeight}`;
424
+ const cached = fixedBoxes.get(document2);
425
+ if (cached?.key === key)
426
+ return cached;
427
+ const probe = document2.createElement?.("div");
428
+ if (!probe?.style)
429
+ return null;
430
+ probe.style.cssText = "position:fixed;top:0;right:0;bottom:0;left:0;visibility:hidden;pointer-events:none";
431
+ root.appendChild(probe);
432
+ const { height, width } = probe.getBoundingClientRect();
433
+ probe.remove();
434
+ if (!(width > 0 && height > 0))
435
+ return null;
436
+ const box = { height, key, width };
437
+ fixedBoxes.set(document2, box);
438
+ return box;
439
+ }
420
440
  function viewportDimensions(context) {
421
- const root = ownerDocument(context)?.documentElement;
441
+ const document2 = ownerDocument(context);
442
+ const root = document2?.documentElement;
422
443
  const width = root?.clientWidth;
423
444
  const height = root?.clientHeight;
424
445
  if (typeof width === "number" && width > 0 && typeof height === "number" && height > 0) {
425
- return { width, height };
446
+ const fixed = document2 && root && fixedContainingBlock(document2, root);
447
+ return fixed ? { height: fixed.height, width: fixed.width } : { width, height };
426
448
  }
427
449
  const currentWindow = ownerWindow(context);
428
450
  return {
@@ -467,14 +489,17 @@ function roundedRectPath(rect, viewport, options, context) {
467
489
  "Z"
468
490
  ].join(" ");
469
491
  }
470
- async function resolveTargetElement(target, options, path = "target") {
492
+ function isPendingTarget(value) {
493
+ return typeof value?.then === "function";
494
+ }
495
+ function resolveTargetElement(target, options, path = "target") {
471
496
  const rootDocument = options.document;
497
+ const validate = (element) => rootDocument ? validateTargetElement(element, rootDocument, path) : element;
472
498
  if (typeof target === "string") {
473
- const element = rootDocument ? rootDocument.querySelector(target) : typeof document === "undefined" ? null : document.querySelector(target);
474
- return rootDocument ? validateTargetElement(element, rootDocument, path) : element;
499
+ return validate(rootDocument ? rootDocument.querySelector(target) : typeof document === "undefined" ? null : document.querySelector(target));
475
500
  } else if (typeof target === "function") {
476
- const element = await target({ signal: options.signal });
477
- return rootDocument ? validateTargetElement(element, rootDocument, path) : element;
501
+ const element = target({ signal: options.signal });
502
+ return isPendingTarget(element) ? element.then(validate) : validate(element);
478
503
  }
479
504
  if (rootDocument)
480
505
  return validateTargetElement(target, rootDocument, path);
@@ -754,7 +779,7 @@ class OverlayElement extends GlowTourElement {
754
779
  for (const [name, value] of Object.entries(OVERLAY_IDLE_ATTRIBUTES)) {
755
780
  el.setAttribute(name, value);
756
781
  }
757
- el.style.setProperty("height", "100lvh");
782
+ el.style.setProperty("height", "max(100%, 100lvh)");
758
783
  this.syncViewBox();
759
784
  }
760
785
  _getPathElement() {
@@ -1493,6 +1518,9 @@ class PopoverElement extends GlowTourElement {
1493
1518
  }
1494
1519
  return this._centerPosition(popoverPosition, viewport);
1495
1520
  }
1521
+ setAwaitingTarget(awaiting) {
1522
+ this.mutationLease.setAttribute("data-glow-tour-awaiting-target", awaiting ? "" : null);
1523
+ }
1496
1524
  _centerPosition(popoverPosition, viewport) {
1497
1525
  return {
1498
1526
  arrowOffset: null,
@@ -1999,7 +2027,8 @@ function mergeScrollOptions(defaults, overrides) {
1999
2027
  return {
2000
2028
  behavior: overrides?.behavior ?? defaults?.behavior,
2001
2029
  block: overrides?.block ?? defaults?.block,
2002
- inline: overrides?.inline ?? defaults?.inline
2030
+ inline: overrides?.inline ?? defaults?.inline,
2031
+ returnDelay: overrides?.returnDelay ?? defaults?.returnDelay
2003
2032
  };
2004
2033
  }
2005
2034
  function mergeAnimationOptions(defaults, overrides) {
@@ -2071,6 +2100,8 @@ var SCROLL_SETTLE_STILL_FRAMES = 2;
2071
2100
  var SCROLL_SETTLE_GRACE_FRAMES = 3;
2072
2101
  var SCROLL_SETTLE_EPSILON = 0.5;
2073
2102
  var SCROLL_SETTLE_TIMEOUT = 2000;
2103
+ var USER_SCROLL_IDLE_DELAY = 150;
2104
+ var DEFAULT_SCROLL_RETURN_DELAY = 500;
2074
2105
  var ACTIVE_MODAL_BY_DOCUMENT = new WeakMap;
2075
2106
  var DEFAULT_SHORTCUTS = {
2076
2107
  previous: ["ArrowLeft", "Backspace"],
@@ -2092,6 +2123,8 @@ class DomTourViewDriver {
2092
2123
  active = false;
2093
2124
  frozen = false;
2094
2125
  awaitingStepUi = false;
2126
+ userScrolling = false;
2127
+ scrollTimer;
2095
2128
  activeTarget = null;
2096
2129
  targetFocusedAtFreeze = false;
2097
2130
  lastTargetRect = null;
@@ -2106,6 +2139,8 @@ class DomTourViewDriver {
2106
2139
  handledTriggerClick = null;
2107
2140
  popover = null;
2108
2141
  presentationDirty = false;
2142
+ targetPending = false;
2143
+ popoverHidden = false;
2109
2144
  focusToRestore = null;
2110
2145
  appliedAllowInteraction = false;
2111
2146
  pointerFading = false;
@@ -2164,11 +2199,40 @@ class DomTourViewDriver {
2164
2199
  this.pointer?.initializeProps();
2165
2200
  this.refreshRegisteredElements();
2166
2201
  }
2202
+ setTargetPending(pending) {
2203
+ if (this.disposed || this.targetPending === pending)
2204
+ return;
2205
+ this.targetPending = pending;
2206
+ this.popover?.setAwaitingTarget(pending);
2207
+ const step = this.currentStep;
2208
+ if (!step)
2209
+ return;
2210
+ const focused = this.popover?.getElement()?.ownerDocument.activeElement;
2211
+ const refocus = pending && step.autoFocuses() && this.findTriggers("advance").includes(focused);
2212
+ this.syncControlState(step);
2213
+ if (refocus)
2214
+ this.focusGuard.focus();
2215
+ }
2216
+ setPopoverHidden(hidden) {
2217
+ if (this.disposed || this.popoverHidden === hidden)
2218
+ return;
2219
+ this.popoverHidden = hidden;
2220
+ const step = this.currentStep;
2221
+ const target = this.activeTarget;
2222
+ if (!this.active || !step || !target)
2223
+ return;
2224
+ if (hidden) {
2225
+ this.liftModality();
2226
+ this.concealPopover();
2227
+ } else {
2228
+ this.revealPopover(step, target, this.generation);
2229
+ }
2230
+ }
2167
2231
  async show(step, direction, signal, onBeforePopoverAppear) {
2168
2232
  this.throwIfAborted(signal);
2169
2233
  const generation = this.beginGeneration();
2170
2234
  const removeAbort = this.cancelAnimationsOnAbort(signal);
2171
- const replaceVisiblePopover = this.active && onBeforePopoverAppear !== undefined;
2235
+ const replaceVisiblePopover = this.active && !this.popoverHidden && onBeforePopoverAppear !== undefined;
2172
2236
  let removeTransitionListeners = () => {};
2173
2237
  try {
2174
2238
  this.cleanupStepResources();
@@ -2216,8 +2280,7 @@ class DomTourViewDriver {
2216
2280
  this.lastTargetRect = snapshotRect(targetRect);
2217
2281
  this.lastViewport = snapshotViewport(target);
2218
2282
  this.active = true;
2219
- this.syncModality(step.allowsInteraction());
2220
- this.activateFocus(step, target, direction, generation);
2283
+ this.engagePopover(step, target, direction, generation);
2221
2284
  this.syncScrollLock(step);
2222
2285
  this.throwIfStale(generation, signal);
2223
2286
  removeTransitionListeners();
@@ -2238,6 +2301,7 @@ class DomTourViewDriver {
2238
2301
  this.throwIfAborted(signal);
2239
2302
  const generation = this.beginGeneration();
2240
2303
  const removeAbort = this.cancelAnimationsOnAbort(signal);
2304
+ this.setTargetPending(false);
2241
2305
  try {
2242
2306
  this.cleanupStepResources();
2243
2307
  this.releaseModality();
@@ -2270,6 +2334,7 @@ class DomTourViewDriver {
2270
2334
  releaseMount() {
2271
2335
  if (this.disposed)
2272
2336
  return;
2337
+ this.setTargetPending(false);
2273
2338
  this.beginGeneration();
2274
2339
  this.cleanupStepResources();
2275
2340
  this.releaseModality();
@@ -2320,8 +2385,7 @@ class DomTourViewDriver {
2320
2385
  this.awaitingStepUi = true;
2321
2386
  await this.appear(() => targetRect, step, generation, null, false);
2322
2387
  this.throwIfStale(generation);
2323
- this.syncModality(step.allowsInteraction());
2324
- this.activateFocus(step, target, this.direction, generation);
2388
+ this.engagePopover(step, target, this.direction, generation);
2325
2389
  this.syncScrollLock(step);
2326
2390
  this.throwIfStale(generation);
2327
2391
  this.attachStepResources(step, target, generation, signal);
@@ -2343,6 +2407,10 @@ class DomTourViewDriver {
2343
2407
  return;
2344
2408
  }
2345
2409
  this.claimModal();
2410
+ if (this.popoverHidden) {
2411
+ this.liftModality();
2412
+ return;
2413
+ }
2346
2414
  const popover = this.popover?.getElement();
2347
2415
  if (isHTMLElement(popover, this.root ?? popover))
2348
2416
  popover.setAttribute("aria-modal", "true");
@@ -2378,13 +2446,16 @@ class DomTourViewDriver {
2378
2446
  this.modalDocument = document2;
2379
2447
  }
2380
2448
  releaseModality() {
2381
- this.popover?.getElement()?.removeAttribute("aria-modal");
2382
- this.restoreInertBranches();
2449
+ this.liftModality();
2383
2450
  const document2 = this.modalDocument;
2384
2451
  if (document2 && ACTIVE_MODAL_BY_DOCUMENT.get(document2) === this.modalToken) {
2385
2452
  ACTIVE_MODAL_BY_DOCUMENT.delete(document2);
2386
2453
  }
2387
2454
  this.modalDocument = null;
2455
+ }
2456
+ liftModality() {
2457
+ this.popover?.getElement()?.removeAttribute("aria-modal");
2458
+ this.restoreInertBranches();
2388
2459
  this.modalRoot = null;
2389
2460
  }
2390
2461
  restoreInertBranches() {
@@ -2423,7 +2494,7 @@ class DomTourViewDriver {
2423
2494
  enterStepUi(targetRect, step) {
2424
2495
  const popoverPlacement = this.popover?.resolvePosition(targetRect, this.elementProps(step)).placement;
2425
2496
  return Promise.all([
2426
- this.popover?.present(targetRect, this.elementProps(step)) ?? Promise.resolve(),
2497
+ !this.popoverHidden && this.popover?.present(targetRect, this.elementProps(step)) || Promise.resolve(),
2427
2498
  this.presentPointer(targetRect, step, popoverPlacement) ?? Promise.resolve()
2428
2499
  ]);
2429
2500
  }
@@ -2472,6 +2543,20 @@ class DomTourViewDriver {
2472
2543
  }
2473
2544
  });
2474
2545
  }
2546
+ const targetDocument = ownerDocument(target);
2547
+ if (typeof targetDocument?.addEventListener === "function") {
2548
+ const passive = { capture: true, passive: true };
2549
+ this.listen(targetDocument, "scroll", (event) => this.onUserScroll(event, step, generation), passive);
2550
+ const interrupt = () => {
2551
+ if (!this.isCurrentGeneration(generation) || !this.userScrolling || !this.awaitingStepUi)
2552
+ return;
2553
+ this.awaitingStepUi = false;
2554
+ this.cancelScroll?.();
2555
+ this.waitForScrollIdle(step, generation);
2556
+ };
2557
+ this.listen(targetDocument, "wheel", interrupt, passive);
2558
+ this.listen(targetDocument, "touchmove", interrupt, passive);
2559
+ }
2475
2560
  this.attachButtonHandlers(step);
2476
2561
  this.observeControls(step, generation);
2477
2562
  this.syncControlState(step);
@@ -2567,7 +2652,7 @@ class DomTourViewDriver {
2567
2652
  this.syncShortcutLabels(step);
2568
2653
  }
2569
2654
  this.overlay?.updatePosition(targetRect, this.elementProps(step), presentationChanged, (transition) => this.observeDynamicOperation(transition, generation));
2570
- if (!this.awaitingStepUi)
2655
+ if (!this.awaitingStepUi && !this.userScrolling)
2571
2656
  this.trackStepUi(targetRect, step, generation, presentationChanged);
2572
2657
  this.lastTargetRect = targetSnapshot;
2573
2658
  this.lastViewport = viewportSnapshot;
@@ -2575,7 +2660,7 @@ class DomTourViewDriver {
2575
2660
  this.presentationDirty = false;
2576
2661
  }
2577
2662
  trackStepUi(targetRect, step, generation, presentationChanged) {
2578
- const popoverPlacement = this.popover?.updatePosition(targetRect, this.elementProps(step), (reposition) => this.observeDynamicOperation(reposition, generation));
2663
+ const popoverPlacement = this.placePopover(targetRect, step, generation);
2579
2664
  if (presentationChanged) {
2580
2665
  if (this.pointerFading)
2581
2666
  this.pointerFading = false;
@@ -2592,6 +2677,68 @@ class DomTourViewDriver {
2592
2677
  this.observeDynamicOperation(this.pointer?.disappear(), generation);
2593
2678
  }
2594
2679
  }
2680
+ onUserScroll(event, step, generation) {
2681
+ const target = this.activeTarget;
2682
+ if (!target || !this.isCurrentGeneration(generation) || this.frozen || this.awaitingStepUi || step.detached || !step.allowsScroll() || !event.target?.contains?.(target))
2683
+ return;
2684
+ if (!this.userScrolling) {
2685
+ this.userScrolling = true;
2686
+ this.popover?.cancelAnimations();
2687
+ this.pointer?.cancelAnimations();
2688
+ if (!this.popoverHidden)
2689
+ this.observeDynamicOperation(this.popover?.disappear(false), generation);
2690
+ this.observeDynamicOperation(this.pointer?.disappear(), generation);
2691
+ }
2692
+ this.waitForScrollIdle(step, generation);
2693
+ }
2694
+ waitForScrollIdle(step, generation) {
2695
+ clearTimeout(this.scrollTimer);
2696
+ this.scrollTimer = setTimeout(() => this.settleScroll(step, generation), USER_SCROLL_IDLE_DELAY);
2697
+ }
2698
+ settleScroll(step, generation) {
2699
+ const target = this.activeTarget;
2700
+ if (!target)
2701
+ return;
2702
+ const behavior = step.props.get().behavior;
2703
+ const returnDelay = behavior?.scroll?.returnDelay ?? DEFAULT_SCROLL_RETURN_DELAY;
2704
+ if (returnDelay === false || behavior?.autoScroll === false || isInViewport(target.getBoundingClientRect(), target))
2705
+ this.revealAfterScroll(step, target, generation);
2706
+ else
2707
+ this.scrollTimer = setTimeout(() => void this.scrollBack(step, target, generation), returnDelay);
2708
+ }
2709
+ async scrollBack(step, target, generation) {
2710
+ const signal = this.currentSignal;
2711
+ if (!signal)
2712
+ return;
2713
+ const scrollingBack = () => this.userScrolling && this.awaitingStepUi && !signal.aborted && this.isCurrentGeneration(generation);
2714
+ this.awaitingStepUi = true;
2715
+ try {
2716
+ await (this.scrollTargetIntoView(step, target, signal) ?? this.waitForScrollToSettle(target, signal));
2717
+ } catch (error) {
2718
+ if (scrollingBack())
2719
+ this.observeDynamicOperation(Promise.reject(error), generation);
2720
+ }
2721
+ if (scrollingBack())
2722
+ this.revealAfterScroll(step, target, generation);
2723
+ }
2724
+ revealAfterScroll(step, target, generation) {
2725
+ this.stopUserScroll();
2726
+ this.awaitingStepUi = false;
2727
+ this.pointerFading = false;
2728
+ this.popover?.cancelAnimations();
2729
+ this.pointer?.cancelAnimations();
2730
+ this.observeDynamicOperation(this.enterStepUi(presentationRect(step, target), step), generation);
2731
+ }
2732
+ stopUserScroll() {
2733
+ clearTimeout(this.scrollTimer);
2734
+ this.userScrolling = false;
2735
+ }
2736
+ placePopover(targetRect, step, generation) {
2737
+ if (this.popoverHidden) {
2738
+ return this.popover?.resolvePosition(targetRect, this.elementProps(step)).placement;
2739
+ }
2740
+ return this.popover?.updatePosition(targetRect, this.elementProps(step), (reposition) => this.observeDynamicOperation(reposition, generation));
2741
+ }
2595
2742
  observeDynamicOperation(operation, generation) {
2596
2743
  if (!operation)
2597
2744
  return;
@@ -2606,7 +2753,7 @@ class DomTourViewDriver {
2606
2753
  }
2607
2754
  handleKeydown(event) {
2608
2755
  const step = this.currentStep;
2609
- if (!step || event.defaultPrevented || event.isComposing || event.ctrlKey || event.metaKey || event.altKey)
2756
+ if (!step || this.popoverHidden || event.defaultPrevented || event.isComposing || event.ctrlKey || event.metaKey || event.altKey)
2610
2757
  return;
2611
2758
  if (event.key === "Tab" && !step.allowsInteraction()) {
2612
2759
  this.loopFocus(event);
@@ -2699,6 +2846,44 @@ class DomTourViewDriver {
2699
2846
  focusable[0]?.focus();
2700
2847
  }
2701
2848
  }
2849
+ engagePopover(step, target, direction, generation) {
2850
+ this.syncModality(step.allowsInteraction());
2851
+ if (this.popoverHidden)
2852
+ this.concealPopover();
2853
+ else if (this.isPopoverConcealed())
2854
+ this.revealPopover(step, target, generation);
2855
+ else
2856
+ this.activateFocus(step, target, direction, generation);
2857
+ }
2858
+ concealPopover() {
2859
+ this.focusToRestore = this.focusGuard.release() ?? this.focusToRestore;
2860
+ const popover = this.popover;
2861
+ const element = popover?.getElement();
2862
+ if (!popover || !element)
2863
+ return;
2864
+ const focused = element.ownerDocument.activeElement;
2865
+ if (isHTMLElement(focused, element) && element.contains(focused)) {
2866
+ focused.blur();
2867
+ this.activeTarget?.focus();
2868
+ }
2869
+ if (this.isPopoverConcealed())
2870
+ return;
2871
+ popover.cancelAnimations();
2872
+ this.observeDynamicOperation(popover.disappear(), this.generation);
2873
+ }
2874
+ revealPopover(step, target, generation) {
2875
+ this.focusGuard.captureInitialFocus(target, this.focusToRestore);
2876
+ this.popover?.cancelAnimations();
2877
+ this.observeDynamicOperation(this.popover?.present(this.lastTargetRect, this.elementProps(step)).then(() => {
2878
+ if (!this.isCurrentGeneration(generation) || this.popoverHidden)
2879
+ return;
2880
+ this.syncModality(!this.frozen && step.allowsInteraction());
2881
+ this.activateFocus(step, target, this.direction, generation);
2882
+ }), generation);
2883
+ }
2884
+ isPopoverConcealed() {
2885
+ return this.popover?.getElement()?.getAttribute("aria-hidden") === "true";
2886
+ }
2702
2887
  activateFocus(step, target, direction, generation) {
2703
2888
  const popover = this.popover?.getElement();
2704
2889
  if (!isHTMLElement(popover, this.root ?? popover))
@@ -2867,6 +3052,7 @@ class DomTourViewDriver {
2867
3052
  cleanupStepResources() {
2868
3053
  this.cancelScroll?.();
2869
3054
  this.cancelScroll = null;
3055
+ this.stopUserScroll();
2870
3056
  this.presentationDirty = false;
2871
3057
  this.pointerFading = false;
2872
3058
  if (this.rafId !== null)
@@ -2896,6 +3082,14 @@ class DomTourViewDriver {
2896
3082
  this.targetFocusedAtFreeze = this.isFocusInsideTarget(target);
2897
3083
  this.cleanupTargetResources();
2898
3084
  this.applyInteractionLock(step, true);
3085
+ if (this.userScrolling) {
3086
+ this.stopUserScroll();
3087
+ const targetRect = this.lastTargetRect;
3088
+ this.popover?.cancelAnimations();
3089
+ if (targetRect && !this.popoverHidden) {
3090
+ this.observeDynamicOperation(this.popover?.present(targetRect, this.elementProps(step)), generation);
3091
+ }
3092
+ }
2899
3093
  Promise.resolve(this.commands?.targetDisconnected(target)).catch((error) => {
2900
3094
  this.commands?.reportError(error).catch(() => {});
2901
3095
  });
@@ -2909,7 +3103,7 @@ class DomTourViewDriver {
2909
3103
  this.applyInteractionLock(step, false);
2910
3104
  this.appliedAllowInteraction = step.allowsInteraction();
2911
3105
  const popover = this.popover?.getElement();
2912
- if (isHTMLElement(popover, this.root ?? popover)) {
3106
+ if (!this.popoverHidden && isHTMLElement(popover, this.root ?? popover)) {
2913
3107
  this.focusGuard.update({
2914
3108
  allowedTarget: target,
2915
3109
  allowTargetInteraction: step.allowsInteraction(),
@@ -2921,12 +3115,14 @@ class DomTourViewDriver {
2921
3115
  }
2922
3116
  syncInteraction(step) {
2923
3117
  const target = this.activeTarget;
2924
- if (this.disposed || !this.active || this.frozen || this.awaitingStepUi || this.currentStep !== step || !target)
3118
+ if (this.disposed || !this.active || this.frozen || this.awaitingStepUi && !this.userScrolling || this.currentStep !== step || !target)
2925
3119
  return;
2926
3120
  const focusWasInTarget = this.isFocusInsideTarget(target);
2927
3121
  this.applyInteraction(step, target);
2928
3122
  if (focusWasInTarget && !step.allowsInteraction() && step.autoFocuses())
2929
3123
  this.focusGuard.focus();
3124
+ if (this.userScrolling)
3125
+ return;
2930
3126
  const targetRect = target.getBoundingClientRect();
2931
3127
  this.pointer?.cancelAnimations();
2932
3128
  this.observeDynamicOperation(this.presentPointer(targetRect, step, this.popover?.resolvePosition(targetRect, this.elementProps(step)).placement), this.generation);
@@ -2965,7 +3161,7 @@ class DomTourViewDriver {
2965
3161
  const generation = this.generation;
2966
3162
  const targetRect = presentationRect(step, target);
2967
3163
  this.observeDynamicOperation(this.overlay?.animateTo(targetRect, this.elementProps(step)), generation);
2968
- const placement = this.popover?.updatePosition(targetRect, this.elementProps(step), (reposition) => this.observeDynamicOperation(reposition, generation));
3164
+ const placement = this.placePopover(targetRect, step, generation);
2969
3165
  if (this.isPointerEnabled(step)) {
2970
3166
  this.observeDynamicOperation(this.pointer?.moveToTarget(targetRect, step.props.get(), true, placement), generation);
2971
3167
  }
@@ -2998,14 +3194,17 @@ class DomTourViewDriver {
2998
3194
  return null;
2999
3195
  if (isInViewport(target.getBoundingClientRect(), target))
3000
3196
  return null;
3001
- const currentWindow = this.getWindow(target);
3002
- if (!currentWindow)
3197
+ if (!this.getWindow(target))
3003
3198
  return null;
3004
- const behavior = prefersReducedMotion(target) ? "instant" : step.props.get().behavior?.scroll?.behavior ?? "smooth";
3199
+ return this.scrollTargetIntoView(step, target, signal);
3200
+ }
3201
+ scrollTargetIntoView(step, target, signal) {
3202
+ const scroll = step.props.get().behavior?.scroll;
3203
+ const behavior = prefersReducedMotion(target) ? "instant" : scroll?.behavior ?? "smooth";
3005
3204
  target.scrollIntoView({
3006
3205
  behavior,
3007
- block: step.props.get().behavior?.scroll?.block ?? "center",
3008
- inline: step.props.get().behavior?.scroll?.inline ?? "nearest"
3206
+ block: scroll?.block ?? "center",
3207
+ inline: scroll?.inline ?? "nearest"
3009
3208
  });
3010
3209
  if (behavior === "instant")
3011
3210
  return null;
@@ -3026,6 +3225,7 @@ class DomTourViewDriver {
3026
3225
  let timeout = null;
3027
3226
  let left = scroller.scrollLeft;
3028
3227
  let top = scroller.scrollTop;
3228
+ let rect = target.getBoundingClientRect();
3029
3229
  let stillFrames = -SCROLL_SETTLE_GRACE_FRAMES;
3030
3230
  const finish = (error) => {
3031
3231
  if (frame !== null)
@@ -3050,9 +3250,11 @@ class DomTourViewDriver {
3050
3250
  frame = null;
3051
3251
  const nextLeft = scroller.scrollLeft;
3052
3252
  const nextTop = scroller.scrollTop;
3053
- const still = Math.abs(nextLeft - left) <= SCROLL_SETTLE_EPSILON && Math.abs(nextTop - top) <= SCROLL_SETTLE_EPSILON;
3253
+ const nextRect = target.getBoundingClientRect();
3254
+ const still = Math.abs(nextLeft - left) <= SCROLL_SETTLE_EPSILON && Math.abs(nextTop - top) <= SCROLL_SETTLE_EPSILON && Math.abs(nextRect.left - rect.left) <= SCROLL_SETTLE_EPSILON && Math.abs(nextRect.top - rect.top) <= SCROLL_SETTLE_EPSILON;
3054
3255
  left = nextLeft;
3055
3256
  top = nextTop;
3257
+ rect = nextRect;
3056
3258
  stillFrames = still ? stillFrames + 1 : 0;
3057
3259
  if (stillFrames >= SCROLL_SETTLE_STILL_FRAMES)
3058
3260
  return finish();
@@ -3284,8 +3486,8 @@ class ActiveStep {
3284
3486
  allowsScroll() {
3285
3487
  return this.props.get().behavior?.allowScroll !== false;
3286
3488
  }
3287
- async resolveTarget(signal) {
3288
- return await resolveTargetElement(this.definition.target, { document: this.rootDocument, signal }, this.path);
3489
+ resolveTarget(signal) {
3490
+ return resolveTargetElement(this.definition.target, { document: this.rootDocument, signal }, this.path);
3289
3491
  }
3290
3492
  detach() {
3291
3493
  const body = (this.rootDocument ?? globalThis.document)?.body ?? null;
@@ -3628,7 +3830,9 @@ class TourController {
3628
3830
  recoveringTarget = null;
3629
3831
  operationToken = 0;
3630
3832
  publicationRevision = 0;
3833
+ awaitingTargetOperation = null;
3631
3834
  operation = null;
3835
+ popoverHidden = false;
3632
3836
  disposed = false;
3633
3837
  retainedPresentation = null;
3634
3838
  stateListeners = new Set;
@@ -3662,7 +3866,7 @@ class TourController {
3662
3866
  canPrevious: () => this.canNavigate("previous"),
3663
3867
  cancel: (source) => this.cancel(source),
3664
3868
  goTo: (id) => this.goTo(id),
3665
- isAdvanceDisabled: () => !this.isPresentedAdvanceAvailable(),
3869
+ isAdvanceDisabled: () => this.awaitingTargetOperation !== null || !this.isPresentedAdvanceAvailable(),
3666
3870
  isCancelDisabled: () => !this.isPresentedCancelAvailable(),
3667
3871
  isPreviousDisabled: () => !this.isPresentedPreviousAvailable(),
3668
3872
  previous: (source) => this.previous(source),
@@ -3703,6 +3907,8 @@ class TourController {
3703
3907
  this.error = null;
3704
3908
  this.commandSource = "api";
3705
3909
  this.retainedPresentation = retainedPresentation;
3910
+ this.popoverHidden = false;
3911
+ this.driver.setPopoverHidden?.(false);
3706
3912
  this.pendingTourStart = undefined;
3707
3913
  try {
3708
3914
  this.setStatus("starting");
@@ -3756,6 +3962,17 @@ class TourController {
3756
3962
  await this.handleFailure(error, operation);
3757
3963
  }
3758
3964
  }
3965
+ setPopoverHidden(hidden) {
3966
+ this.assertNotDisposed();
3967
+ if (!this.isRunning() || this.popoverHidden === hidden)
3968
+ return;
3969
+ this.popoverHidden = hidden;
3970
+ this.driver.setPopoverHidden?.(hidden);
3971
+ this.publish();
3972
+ }
3973
+ isRunning() {
3974
+ return this.status === "starting" || this.status === "transitioning" || this.status === "active";
3975
+ }
3759
3976
  dispose() {
3760
3977
  if (this.disposed)
3761
3978
  return;
@@ -3924,23 +4141,38 @@ class TourController {
3924
4141
  const timeout = missingTarget?.timeout ?? DEFAULT_TARGET_TIMEOUT;
3925
4142
  const startedAt = Date.now();
3926
4143
  step.detached = false;
3927
- while (true) {
3928
- const target = await step.resolveTarget(signal);
3929
- this.assertCurrent(operation);
3930
- if (target)
3931
- return target;
3932
- if (strategy === "skip")
3933
- return null;
3934
- const body = strategy === "detached" && step.detach();
3935
- if (body)
3936
- return body;
3937
- if (strategy !== "wait" || Date.now() - startedAt >= timeout) {
3938
- throw this.missingTargetError(step);
4144
+ try {
4145
+ while (true) {
4146
+ const pending = step.resolveTarget(signal);
4147
+ if (isPendingTarget(pending))
4148
+ this.setAwaitingTarget(operation, true);
4149
+ const target = await pending;
4150
+ this.assertCurrent(operation);
4151
+ if (target)
4152
+ return target;
4153
+ if (strategy === "skip")
4154
+ return null;
4155
+ const body = strategy === "detached" && step.detach();
4156
+ if (body)
4157
+ return body;
4158
+ if (strategy !== "wait" || Date.now() - startedAt >= timeout) {
4159
+ throw this.missingTargetError(step);
4160
+ }
4161
+ this.setAwaitingTarget(operation, true);
4162
+ await abortableDelay(16, signal);
4163
+ this.assertCurrent(operation);
3939
4164
  }
3940
- await abortableDelay(16, signal);
3941
- this.assertCurrent(operation);
4165
+ } finally {
4166
+ this.setAwaitingTarget(operation, false);
3942
4167
  }
3943
4168
  }
4169
+ setAwaitingTarget(operation, awaiting) {
4170
+ if (this.awaitingTargetOperation === operation === awaiting)
4171
+ return;
4172
+ this.awaitingTargetOperation = awaiting ? operation : null;
4173
+ this.driver.setTargetPending?.(awaiting);
4174
+ this.publish();
4175
+ }
3944
4176
  async pollForTarget(step, operation, budgetMs) {
3945
4177
  const startedAt = Date.now();
3946
4178
  while (true) {
@@ -4125,6 +4357,10 @@ class TourController {
4125
4357
  this.operationToken += 1;
4126
4358
  this.operation?.abort();
4127
4359
  this.operation = null;
4360
+ if (this.awaitingTargetOperation !== null) {
4361
+ this.awaitingTargetOperation = null;
4362
+ this.driver.setTargetPending?.(false);
4363
+ }
4128
4364
  }
4129
4365
  signalFor(operation) {
4130
4366
  this.assertCurrent(operation);
@@ -4288,6 +4524,8 @@ class TourController {
4288
4524
  isFirstStep,
4289
4525
  isLastStep,
4290
4526
  status: this.status,
4527
+ awaitingTarget: this.awaitingTargetOperation !== null,
4528
+ popoverHidden: this.popoverHidden && this.isRunning(),
4291
4529
  error: this.error
4292
4530
  });
4293
4531
  }
@@ -4326,7 +4564,9 @@ function createGlowTour(options = {}) {
4326
4564
  create: (name, options2) => controller.create(name, options2),
4327
4565
  dispose: () => controller.dispose(),
4328
4566
  goTo: (id) => controller.goTo(id),
4567
+ hidePopover: () => controller.setPopoverHidden(true),
4329
4568
  previous: () => controller.previous(),
4569
+ showPopover: () => controller.setPopoverHidden(false),
4330
4570
  start: (workflow, runOptions) => controller.start(workflow, runOptions),
4331
4571
  state: controller.state
4332
4572
  };
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@glowhop/core-tour",
3
- "version": "1.4.0",
4
- "description": "Framework-agnostic tour controller and DOM runtime for GlowTour.js.",
3
+ "version": "1.5.1",
4
+ "description": "Framework-agnostic product tour engine for GlowTour.js: the tour controller and DOM runtime behind onboarding tours in any framework.",
5
5
  "license": "MIT",
6
- "homepage": "https://github.com/Glowhop/GlowTour.js#readme",
6
+ "homepage": "https://glowtour.dev/",
7
7
  "bugs": {
8
8
  "url": "https://github.com/Glowhop/GlowTour.js/issues"
9
9
  },
@@ -20,7 +20,9 @@
20
20
  "dom",
21
21
  "headless",
22
22
  "framework-agnostic",
23
- "ssr"
23
+ "ssr",
24
+ "onboarding-tour",
25
+ "intro-js-alternative"
24
26
  ],
25
27
  "engines": {
26
28
  "node": ">=18.19.1"
@@ -22,7 +22,8 @@ export declare class ActiveStep<T> {
22
22
  autoFocuses(): boolean;
23
23
  /** Reads `behavior.allowScroll` live: `props.update({ behavior })` changes it while the step runs. */
24
24
  allowsScroll(): boolean;
25
- resolveTarget(signal: AbortSignal): Promise<HTMLElement | null>;
25
+ /** Returns the target, or the promise to wait on when the resolver is async. */
26
+ resolveTarget(signal: AbortSignal): HTMLElement | Promise<HTMLElement | null> | null;
26
27
  /** Marks the step detached and returns the body it stands on, or `null` without a document. */
27
28
  detach(): HTMLElement;
28
29
  snapshot(): Readonly<{
@@ -37,7 +37,14 @@ export declare class TourController<T> {
37
37
  private recoveringTarget;
38
38
  private operationToken;
39
39
  private publicationRevision;
40
+ /**
41
+ * The operation currently parked on a target that has not resolved yet, or `null`. The step being
42
+ * left is still on screen while a navigation waits, so the wait is reported on its presentation.
43
+ */
44
+ private awaitingTargetOperation;
40
45
  private operation;
46
+ /** Set by `hidePopover()`, see `TourState.popoverHidden`. Only a running tour reports it. */
47
+ private popoverHidden;
41
48
  private disposed;
42
49
  private retainedPresentation;
43
50
  private readonly stateListeners;
@@ -63,6 +70,9 @@ export declare class TourController<T> {
63
70
  /** The direction of a jump from the current step to the step at `index`. */
64
71
  private directionTo;
65
72
  cancel(source?: TourEventSource): Promise<void>;
73
+ /** `tour.hidePopover()` and `tour.showPopover()`. */
74
+ setPopoverHidden(hidden: boolean): void;
75
+ private isRunning;
66
76
  dispose(): void;
67
77
  isDisposed(): boolean;
68
78
  /** @internal Called by the private root bridge before it releases DOM resources. */
@@ -84,6 +94,14 @@ export declare class TourController<T> {
84
94
  private runStepHook;
85
95
  private runActions;
86
96
  private resolveTarget;
97
+ /**
98
+ * Reports a navigation waiting on the next step's target. The presentation on screen still
99
+ * belongs to the step being left, so the driver marks that popover and disables its advance
100
+ * control until the target settles. Keyed by operation: a superseded navigation never clears the
101
+ * wait its replacement declared. The freeze of a target lost mid-step is deliberately not a wait
102
+ * here, see `recoverDisconnectedTarget`.
103
+ */
104
+ private setAwaitingTarget;
87
105
  /**
88
106
  * Repeatedly re-resolves `step.target` until it succeeds or `budgetMs`
89
107
  * elapses, polling every 16ms like `resolveTarget`. Unlike `resolveTarget`
package/types/index.d.ts CHANGED
@@ -174,12 +174,16 @@ export interface PopoverOptions extends BaseOptions {
174
174
  gap?: number;
175
175
  }
176
176
  /**
177
- * Scroll behavior options passed to Element.scrollIntoView().
177
+ * How a step scrolls its target into view, on entrance and after the user scrolled away.
178
178
  *
179
179
  * A step scrolls only when part of its target falls outside the viewport, and
180
180
  * does not wait for the scroll before presenting: the spotlight appears at once
181
181
  * and tracks the target as the page travels, and the popover and pointer enter
182
182
  * when the page has come to rest.
183
+ *
184
+ * While the user scrolls, the popover and pointer step aside and the spotlight
185
+ * keeps following the target. Once the page is still they come back, after
186
+ * `returnDelay` scrolls the target back into view if it was left outside it.
183
187
  */
184
188
  export interface ScrollOptions {
185
189
  /** Scroll animation. Forced to `"instant"` when the user prefers reduced motion. @default "smooth" */
@@ -188,6 +192,12 @@ export interface ScrollOptions {
188
192
  block?: "start" | "center" | "end" | "nearest";
189
193
  /** Horizontal alignment of the target in the viewport. @default "nearest" */
190
194
  inline?: "start" | "center" | "end" | "nearest";
195
+ /**
196
+ * Milliseconds to wait, once the user stops scrolling with part of the target outside the
197
+ * viewport, before scrolling it back into view. `false` leaves the page where the user put it.
198
+ * Ignored when `autoScroll` is `false`. @default 500
199
+ */
200
+ returnDelay?: number | false;
191
201
  }
192
202
  /** Animation timing configuration. */
193
203
  export interface AnimationOptions {
@@ -380,6 +390,17 @@ export interface TourState<T> {
380
390
  readonly isLastStep: boolean;
381
391
  /** Current status of the tour. */
382
392
  readonly status: TourStatus;
393
+ /**
394
+ * Whether a navigation is waiting for the next step's target to resolve, i.e. an async resolver
395
+ * or the `"wait"` missing-target strategy. The step being left stays on screen meanwhile, and its
396
+ * advance control is refused, so a UI can show the wait instead of looking idle.
397
+ */
398
+ readonly awaitingTarget: boolean;
399
+ /**
400
+ * Whether `hidePopover()` hid the popover of the running tour. `false` again after
401
+ * `showPopover()`, and whenever a tour starts or ends.
402
+ */
403
+ readonly popoverHidden: boolean;
383
404
  /** Error encountered during the tour, if any. */
384
405
  readonly error: Error | null;
385
406
  }
@@ -407,6 +428,18 @@ export interface GlowTour<T> {
407
428
  goTo(id: string): Promise<void>;
408
429
  /** Cancel the current tour. */
409
430
  cancel(): Promise<void>;
431
+ /**
432
+ * Show the popover again after `hidePopover()`, and move focus into it when the step auto
433
+ * focuses. Does nothing when no tour is running.
434
+ */
435
+ showPopover(): void;
436
+ /**
437
+ * Hide the popover of the running tour. The overlay, the indicator and the scroll lock stay; the
438
+ * page is no longer inert, focus leaves the popover for the target, and the keyboard shortcuts
439
+ * do nothing until `showPopover()`. The popover stays hidden across steps, until `showPopover()`
440
+ * or the end of the tour. Does nothing when no tour is running.
441
+ */
442
+ hidePopover(): void;
410
443
  /** Dispose the tour and free resources. */
411
444
  dispose(): void;
412
445
  /** Observable store of the current tour state. */
package/utils/utils.d.ts CHANGED
@@ -10,15 +10,16 @@ export declare function isNode(value: unknown, context?: Node | null): value is
10
10
  * `getBoundingClientRect()` reports coordinates in.
11
11
  *
12
12
  * Deliberately not `innerWidth`/`innerHeight`: those measure the *visual*
13
- * viewport, which on mobile shrinks and grows with the browser's URL bar and on
14
- * desktop includes the classic scrollbar. Either gap skews the overlay's
15
- * `viewBox` against its own `100%`-sized box, and the default
16
- * `preserveAspectRatio` then scales and centres the backdrop — leaving undimmed
17
- * bands and a cutout that no longer lines up with its target.
13
+ * viewport on some engines, which on mobile shrinks and grows with the
14
+ * browser's URL bar and pinch zoom, and on desktop includes the classic
15
+ * scrollbar. Either gap skews the overlay's `viewBox` against its own
16
+ * `100%`-sized box, and the default `preserveAspectRatio` then scales and
17
+ * centres the backdrop — leaving undimmed bands and a cutout that no longer
18
+ * lines up with its target.
18
19
  */
19
20
  export declare function viewportDimensions(context?: Node | null): {
20
- width: number;
21
21
  height: number;
22
+ width: number;
22
23
  };
23
24
  /**
24
25
  * The box the overlay `<svg>` is actually painted into, in CSS pixels.
@@ -36,8 +37,8 @@ export declare function viewportDimensions(context?: Node | null): {
36
37
  * yet — detached nodes, server-rendered markup, test doubles.
37
38
  */
38
39
  export declare function paintedBoxDimensions(element?: Element | null): {
39
- width: number;
40
40
  height: number;
41
+ width: number;
41
42
  };
42
43
  export declare function isInViewport(rect: {
43
44
  left: number;
@@ -55,7 +56,14 @@ export declare function roundedRectPath(rect: RectGeometry, viewport: {
55
56
  padding: number;
56
57
  radius: number;
57
58
  }, context?: Node | null): string;
59
+ /** Whether a target resolution is still pending, i.e. the resolver returned a promise. */
60
+ export declare function isPendingTarget(value: HTMLElement | null | Promise<HTMLElement | null>): value is Promise<HTMLElement | null>;
61
+ /**
62
+ * Resolves a step's target. Deliberately not `async`: a selector or an element target settles
63
+ * synchronously, and only a resolver that returns a promise hands back something to wait on.
64
+ * Callers use {@link isPendingTarget} to tell the two apart, and a tour that has to wait says so.
65
+ */
58
66
  export declare function resolveTargetElement(target: TargetResolver, options: {
59
67
  readonly document?: Document;
60
68
  readonly signal: AbortSignal;
61
- }, path?: string): Promise<HTMLElement | null>;
69
+ }, path?: string): HTMLElement | null | Promise<HTMLElement | null>;