@glowhop/core-tour 1.2.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,38 @@
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
+
3
36
  ## 1.2.0
4
37
 
5
38
  ### 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. |
@@ -58,6 +58,13 @@ export declare class DomTourViewDriver<T> implements TourViewDriver<T> {
58
58
  * (the caller gave up and is tearing the presentation down).
59
59
  */
60
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;
61
68
  private activeTarget;
62
69
  private targetFocusedAtFreeze;
63
70
  private lastTargetRect;
@@ -74,7 +81,7 @@ export declare class DomTourViewDriver<T> implements TourViewDriver<T> {
74
81
  private rafId;
75
82
  private rafCancel;
76
83
  private root;
77
- private scrollAbort;
84
+ private cancelScroll;
78
85
  constructor(commands?: TourViewCommands);
79
86
  setCommands(commands: TourViewCommands): void;
80
87
  registerRoot(element: HTMLElement | null): void;
@@ -91,7 +98,33 @@ export declare class DomTourViewDriver<T> implements TourViewDriver<T> {
91
98
  private syncModality;
92
99
  private releaseModality;
93
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
+ */
94
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;
95
128
  private attachStepResources;
96
129
  /**
97
130
  * Binds the step's custom event handlers to its target element. Split out
@@ -102,7 +135,15 @@ export declare class DomTourViewDriver<T> implements TourViewDriver<T> {
102
135
  private attachTargetResources;
103
136
  private listen;
104
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;
105
144
  private updatePosition;
145
+ /** Per-frame follow-up for the popover and pointer, once they are on screen. */
146
+ private trackStepUi;
106
147
  private observeDynamicOperation;
107
148
  private handleKeydown;
108
149
  private handleOverlayClick;
@@ -176,7 +217,31 @@ export declare class DomTourViewDriver<T> implements TourViewDriver<T> {
176
217
  private cancelAnimationsOnAbort;
177
218
  private cancelElementAnimations;
178
219
  private isCurrentGeneration;
179
- 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;
180
245
  private throwIfAborted;
181
246
  private throwIfStale;
182
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"],
@@ -1913,6 +1916,7 @@ class DomTourViewDriver {
1913
1916
  generation = 0;
1914
1917
  active = false;
1915
1918
  frozen = false;
1919
+ awaitingStepUi = false;
1916
1920
  activeTarget = null;
1917
1921
  targetFocusedAtFreeze = false;
1918
1922
  lastTargetRect = null;
@@ -1929,7 +1933,7 @@ class DomTourViewDriver {
1929
1933
  rafId = null;
1930
1934
  rafCancel = null;
1931
1935
  root = null;
1932
- scrollAbort = null;
1936
+ cancelScroll = null;
1933
1937
  constructor(commands) {
1934
1938
  this.commands = commands ?? null;
1935
1939
  }
@@ -2000,6 +2004,7 @@ class DomTourViewDriver {
2000
2004
  this.lastTargetRect = null;
2001
2005
  this.lastViewport = null;
2002
2006
  this.presentationDirty = false;
2007
+ this.awaitingStepUi = true;
2003
2008
  if (replaceVisiblePopover) {
2004
2009
  const listener = (event) => this.queueTransitionKeydown(event, step, generation);
2005
2010
  const currentWindow = this.getWindow();
@@ -2013,12 +2018,13 @@ class DomTourViewDriver {
2013
2018
  return;
2014
2019
  this.activeTarget = target;
2015
2020
  this.syncModality(step.behavior?.allowInteraction === true);
2016
- await this.scrollTargetIntoView(step, target, signal);
2021
+ const scrolling = this.beginTargetScroll(step, target, signal);
2017
2022
  this.throwIfStale(generation, signal);
2018
2023
  this.initializeElements(step);
2019
- const targetRect = target.getBoundingClientRect();
2020
- await this.appear(targetRect, step, !replaceVisiblePopover, onBeforePopoverAppear);
2024
+ const resolveRect = () => target.getBoundingClientRect();
2025
+ await this.appear(resolveRect, step, generation, scrolling, replaceVisiblePopover, onBeforePopoverAppear);
2021
2026
  this.throwIfStale(generation, signal);
2027
+ const targetRect = resolveRect();
2022
2028
  this.lastTargetRect = snapshotRect(targetRect);
2023
2029
  this.lastViewport = snapshotViewport(target);
2024
2030
  this.active = true;
@@ -2051,6 +2057,7 @@ class DomTourViewDriver {
2051
2057
  this.throwIfStale(generation, signal);
2052
2058
  this.active = false;
2053
2059
  this.frozen = false;
2060
+ this.awaitingStepUi = false;
2054
2061
  this.activeTarget = null;
2055
2062
  this.targetFocusedAtFreeze = false;
2056
2063
  this.currentStep = null;
@@ -2078,6 +2085,7 @@ class DomTourViewDriver {
2078
2085
  this.scrollLock.deactivate();
2079
2086
  this.active = false;
2080
2087
  this.frozen = false;
2088
+ this.awaitingStepUi = false;
2081
2089
  this.activeTarget = null;
2082
2090
  this.targetFocusedAtFreeze = false;
2083
2091
  this.currentStep = null;
@@ -2117,7 +2125,8 @@ class DomTourViewDriver {
2117
2125
  return;
2118
2126
  this.activeTarget = target;
2119
2127
  this.initializeElements(step);
2120
- await this.appear(targetRect, step);
2128
+ this.awaitingStepUi = true;
2129
+ await this.appear(() => targetRect, step, generation, null, false);
2121
2130
  this.throwIfStale(generation);
2122
2131
  this.activateFocus(step, target, this.direction, generation);
2123
2132
  this.syncScrollLock(step);
@@ -2190,19 +2199,34 @@ class DomTourViewDriver {
2190
2199
  element.setAttribute("inert", previous);
2191
2200
  }
2192
2201
  }
2193
- async appear(targetRect, step, appearPopover = true, onBeforePopoverAppear) {
2194
- const pointerEnabled = this.isPointerEnabled(step);
2195
- const popoverPlacement = this.popover?.resolvePosition(targetRect, step).placement;
2196
- 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) {
2197
2214
  await onBeforePopoverAppear();
2198
2215
  this.syncControlState(step);
2199
2216
  this.syncShortcutLabels(step);
2200
- } : undefined;
2201
- const popoverTransition = this.popover ? this.popover.moveToTarget(targetRect, step, appearPopover, commitStep) : Promise.resolve(commitStep?.());
2202
- await Promise.all([
2203
- this.overlay?.moveToTarget(targetRect, step) ?? Promise.resolve(),
2204
- popoverTransition,
2205
- 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()
2206
2230
  ]);
2207
2231
  }
2208
2232
  attachStepResources(step, target, generation, signal) {
@@ -2275,17 +2299,11 @@ class DomTourViewDriver {
2275
2299
  schedulePosition(generation = this.generation) {
2276
2300
  if (!this.isCurrentGeneration(generation) || !this.currentStep || this.rafId !== null || this.frozen)
2277
2301
  return;
2278
- const owner = this.currentStep.target?.ownerDocument?.defaultView;
2279
- const ownerRequest = owner?.requestAnimationFrame;
2280
- const ownerCancel = owner?.cancelAnimationFrame;
2281
- const ownerHasFrameCapability = typeof ownerRequest === "function" || typeof ownerCancel === "function";
2282
- const request = ownerHasFrameCapability ? ownerRequest : globalThis.requestAnimationFrame;
2283
- const cancel = ownerHasFrameCapability ? ownerCancel : globalThis.cancelAnimationFrame;
2284
- if (typeof request !== "function" || typeof cancel !== "function")
2302
+ const frames = this.frameScheduler(this.currentStep.target);
2303
+ if (!frames)
2285
2304
  return;
2286
- const frameWindow = ownerHasFrameCapability && owner ? owner : globalThis;
2287
- this.rafCancel = (id) => cancel.call(frameWindow, id);
2288
- this.rafId = request.call(frameWindow, () => {
2305
+ this.rafCancel = frames.cancel;
2306
+ this.rafId = frames.request(() => {
2289
2307
  this.rafId = null;
2290
2308
  this.rafCancel = null;
2291
2309
  if (!this.isCurrentGeneration(generation))
@@ -2294,13 +2312,29 @@ class DomTourViewDriver {
2294
2312
  this.schedulePosition(generation);
2295
2313
  });
2296
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
+ }
2297
2330
  updatePosition(generation) {
2298
2331
  const step = this.currentStep;
2299
2332
  const target = step?.target;
2300
2333
  if (!this.isCurrentGeneration(generation) || !step || !target)
2301
2334
  return;
2302
2335
  if (!this.isCurrentTargetAvailable(target)) {
2303
- this.freezeForDisconnectedTarget(step, target, generation);
2336
+ if (!this.awaitingStepUi)
2337
+ this.freezeForDisconnectedTarget(step, target, generation);
2304
2338
  return;
2305
2339
  }
2306
2340
  const targetRect = target.getBoundingClientRect();
@@ -2317,6 +2351,14 @@ class DomTourViewDriver {
2317
2351
  this.syncShortcutLabels(step);
2318
2352
  }
2319
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) {
2320
2362
  const popoverPlacement = this.popover?.updatePosition(targetRect, step, (reposition) => this.observeDynamicOperation(reposition, generation));
2321
2363
  if (presentationChanged) {
2322
2364
  this.pointer?.syncVisibility(this.isPointerEnabled(step), targetRect, step, popoverPlacement);
@@ -2330,10 +2372,6 @@ class DomTourViewDriver {
2330
2372
  } else if (this.pointer?.getElement()?.getAttribute("aria-hidden") !== "true") {
2331
2373
  this.observeDynamicOperation(this.pointer?.disappear(), generation);
2332
2374
  }
2333
- this.lastTargetRect = targetSnapshot;
2334
- this.lastViewport = viewportSnapshot;
2335
- if (presentationChanged)
2336
- this.presentationDirty = false;
2337
2375
  }
2338
2376
  observeDynamicOperation(operation, generation) {
2339
2377
  if (!operation)
@@ -2600,8 +2638,8 @@ class DomTourViewDriver {
2600
2638
  return step.behavior?.allowInteraction === true && step.indicator?.disabled !== true;
2601
2639
  }
2602
2640
  cleanupStepResources() {
2603
- this.scrollAbort?.abort();
2604
- this.scrollAbort = null;
2641
+ this.cancelScroll?.();
2642
+ this.cancelScroll = null;
2605
2643
  this.presentationDirty = false;
2606
2644
  if (this.rafId !== null)
2607
2645
  this.rafCancel?.(this.rafId);
@@ -2715,45 +2753,77 @@ class DomTourViewDriver {
2715
2753
  isCurrentGeneration(generation) {
2716
2754
  return !this.disposed && generation === this.generation;
2717
2755
  }
2718
- async scrollTargetIntoView(step, target, signal) {
2756
+ beginTargetScroll(step, target, signal) {
2719
2757
  this.throwIfAborted(signal);
2720
- if (step.behavior?.disableAutoScroll || isInViewport(target.getBoundingClientRect(), target))
2721
- return;
2758
+ if (step.behavior?.disableAutoScroll)
2759
+ return null;
2760
+ if (isInViewport(target.getBoundingClientRect(), target))
2761
+ return null;
2722
2762
  const currentWindow = this.getWindow(target);
2723
2763
  if (!currentWindow)
2724
- return;
2725
- const controller = new AbortController;
2726
- this.scrollAbort = controller;
2727
- 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;
2728
2787
  let timeout = null;
2729
- const abort = () => finish(abortError2());
2788
+ let left = scroller.scrollLeft;
2789
+ let top = scroller.scrollTop;
2790
+ let stillFrames = -SCROLL_SETTLE_GRACE_FRAMES;
2730
2791
  const finish = (error) => {
2731
- currentWindow.removeEventListener("scrollend", complete);
2732
- signal.removeEventListener("abort", abort);
2733
- controller.signal.removeEventListener("abort", abort);
2792
+ if (frame !== null)
2793
+ frames.cancel(frame);
2734
2794
  if (timeout !== null)
2735
2795
  clearTimeout(timeout);
2736
- if (this.scrollAbort === controller)
2737
- this.scrollAbort = null;
2796
+ signal.removeEventListener("abort", abort);
2797
+ owner?.removeEventListener("visibilitychange", stopIfHidden);
2798
+ if (this.cancelScroll === abort)
2799
+ this.cancelScroll = null;
2738
2800
  if (error)
2739
2801
  reject(error);
2740
2802
  else
2741
2803
  resolve();
2742
2804
  };
2743
- const complete = () => finish();
2744
- 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;
2745
2823
  signal.addEventListener("abort", abort, { once: true });
2746
- controller.signal.addEventListener("abort", abort, { once: true });
2747
- timeout = setTimeout(complete, DEFAULT_SCROLL_END_TIMEOUT);
2748
- try {
2749
- target.scrollIntoView({
2750
- behavior: prefersReducedMotion(target) ? "instant" : step.behavior?.scroll?.behavior ?? "smooth",
2751
- block: step.behavior?.scroll?.block ?? "center",
2752
- inline: step.behavior?.scroll?.inline ?? "nearest"
2753
- });
2754
- } catch (error) {
2755
- finish(error instanceof Error ? error : new Error(String(error)));
2756
- }
2824
+ owner?.addEventListener("visibilitychange", stopIfHidden);
2825
+ timeout = setTimeout(finish, SCROLL_SETTLE_TIMEOUT);
2826
+ frame = frames.request(watch);
2757
2827
  });
2758
2828
  }
2759
2829
  throwIfAborted(signal) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glowhop/core-tour",
3
- "version": "1.2.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"
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";