@robertlinde/react-tour-kit 1.0.3 → 1.1.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +17 -1
  3. package/dist/react-native-platform/layer/native-renderer.d.ts.map +1 -1
  4. package/dist/react-native-platform/layer/native-renderer.js +21 -2
  5. package/dist/react-native-platform/layer/native-renderer.js.map +1 -1
  6. package/dist/react-platform/layer/styles.d.ts +1 -1
  7. package/dist/react-platform/layer/styles.d.ts.map +1 -1
  8. package/dist/react-platform/layer/styles.js +41 -1
  9. package/dist/react-platform/layer/styles.js.map +1 -1
  10. package/dist/react-platform/layer/web-renderer.d.ts.map +1 -1
  11. package/dist/react-platform/layer/web-renderer.js +5 -1
  12. package/dist/react-platform/layer/web-renderer.js.map +1 -1
  13. package/dist/shared/engine/tour-engine.d.ts +5 -0
  14. package/dist/shared/engine/tour-engine.d.ts.map +1 -1
  15. package/dist/shared/engine/tour-engine.js +45 -0
  16. package/dist/shared/engine/tour-engine.js.map +1 -1
  17. package/dist/shared/renderer/motion.d.ts +5 -2
  18. package/dist/shared/renderer/motion.d.ts.map +1 -1
  19. package/dist/shared/renderer/motion.js +5 -2
  20. package/dist/shared/renderer/motion.js.map +1 -1
  21. package/dist/shared/renderer/renderer.type.d.ts +7 -0
  22. package/dist/shared/renderer/renderer.type.d.ts.map +1 -1
  23. package/dist/shared/renderer/tour-overlay.d.ts.map +1 -1
  24. package/dist/shared/renderer/tour-overlay.js +58 -20
  25. package/dist/shared/renderer/tour-overlay.js.map +1 -1
  26. package/dist/shared/types/tour-i18n.type.d.ts +3 -1
  27. package/dist/shared/types/tour-i18n.type.d.ts.map +1 -1
  28. package/dist/shared/utils/i18n.util.d.ts.map +1 -1
  29. package/dist/shared/utils/i18n.util.js +2 -0
  30. package/dist/shared/utils/i18n.util.js.map +1 -1
  31. package/package.json +4 -2
  32. package/src/react-native-platform/layer/native-renderer.tsx +35 -1
  33. package/src/react-platform/layer/styles.ts +41 -1
  34. package/src/react-platform/layer/web-renderer.tsx +11 -1
  35. package/src/shared/engine/tour-engine.ts +56 -0
  36. package/src/shared/renderer/motion.ts +5 -2
  37. package/src/shared/renderer/renderer.type.ts +8 -0
  38. package/src/shared/renderer/tour-overlay.tsx +79 -27
  39. package/src/shared/types/tour-i18n.type.ts +3 -1
  40. package/src/shared/utils/i18n.util.ts +2 -0
@@ -1 +1 @@
1
- {"version":3,"file":"i18n.util.js","sourceRoot":"","sources":["../../../src/shared/utils/i18n.util.ts"],"names":[],"mappings":"AAIA,MAAM,CAAC,MAAM,WAAW,GAAiB;IACvC,SAAS,EAAE,MAAM;IACjB,SAAS,EAAE,MAAM;IACjB,WAAW,EAAE,QAAQ;IACrB,mBAAmB,EAAE,sBAAsB;IAC3C,cAAc,EAAE,YAAY;IAC5B,oBAAoB,EAAE,mBAAmB;CAC1C,CAAC;AAEF,MAAM,QAAQ,GAAG;IACf,WAAW;IACX,WAAW;IACX,aAAa;IACb,qBAAqB;IACrB,gBAAgB;IAChB,sBAAsB;CAC0B,CAAC;AAEnD,MAAM,UAAU,WAAW,CAAC,IAA0B;IACpD,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,WAAW,CAAC;IACrB,CAAC;IAED,MAAM,QAAQ,GAAG,EAAC,GAAG,WAAW,EAAC,CAAC;IAClC,KAAK,MAAM,GAAG,IAAI,QAAQ,EAAE,CAAC;QAC3B,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC;QACxB,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;YAC9B,QAAQ,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;QACxB,CAAC;IACH,CAAC;IAED,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,qGAAqG;AACrG,MAAM,UAAU,cAAc,CAAC,QAAgB,EAAE,MAAiD;IAChG,OAAO,QAAQ,CAAC,UAAU,CAAC,aAAa,EAAE,CAAC,KAAK,EAAE,IAAY,EAAE,EAAE,CAChE,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAC3D,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"i18n.util.js","sourceRoot":"","sources":["../../../src/shared/utils/i18n.util.ts"],"names":[],"mappings":"AAIA,MAAM,CAAC,MAAM,WAAW,GAAiB;IACvC,SAAS,EAAE,MAAM;IACjB,SAAS,EAAE,MAAM;IACjB,WAAW,EAAE,QAAQ;IACrB,mBAAmB,EAAE,sBAAsB;IAC3C,cAAc,EAAE,YAAY;IAC5B,oBAAoB,EAAE,mBAAmB;IACzC,gBAAgB,EAAE,UAAU;CAC7B,CAAC;AAEF,MAAM,QAAQ,GAAG;IACf,WAAW;IACX,WAAW;IACX,aAAa;IACb,qBAAqB;IACrB,gBAAgB;IAChB,sBAAsB;IACtB,kBAAkB;CAC8B,CAAC;AAEnD,MAAM,UAAU,WAAW,CAAC,IAA0B;IACpD,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,WAAW,CAAC;IACrB,CAAC;IAED,MAAM,QAAQ,GAAG,EAAC,GAAG,WAAW,EAAC,CAAC;IAClC,KAAK,MAAM,GAAG,IAAI,QAAQ,EAAE,CAAC;QAC3B,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC;QACxB,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;YAC9B,QAAQ,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;QACxB,CAAC;IACH,CAAC;IAED,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,qGAAqG;AACrG,MAAM,UAAU,cAAc,CAAC,QAAgB,EAAE,MAAiD;IAChG,OAAO,QAAQ,CAAC,UAAU,CAAC,aAAa,EAAE,CAAC,KAAK,EAAE,IAAY,EAAE,EAAE,CAChE,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAC3D,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robertlinde/react-tour-kit",
3
- "version": "1.0.3",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "description": "Guided product tours for React and React Native. Smooth, accessible, and guaranteed to stay on screen: the target is scrolled into view and the tooltip never renders out of sight.",
6
6
  "author": "Robert Linde",
@@ -50,7 +50,7 @@
50
50
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
51
51
  "build": "npm run clean && tsc -p tsconfig.build.json",
52
52
  "dev": "tsc -p tsconfig.build.json --watch",
53
- "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.tooling.json",
53
+ "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.tooling.json && tsc -p scripts",
54
54
  "lint": "xo --ignore \"demos/**\"",
55
55
  "lint:fix": "xo --fix --ignore \"demos/**\"",
56
56
  "format": "prettier --write .",
@@ -64,6 +64,8 @@
64
64
  "check:package": "publint --strict && attw --pack . --profile esm-only",
65
65
  "smoke:node": "node test/smoke-node.mjs",
66
66
  "demo:web": "npm run dev --prefix demos/react",
67
+ "demo:showcase": "npm run showcase --prefix demos/react",
68
+ "demo:record": "node scripts/record-demo.ts",
67
69
  "demo:ios": "npm run ios --prefix demos/react-native",
68
70
  "demo:android": "npm run android --prefix demos/react-native",
69
71
  "prepare": "husky || true"
@@ -10,6 +10,7 @@ import {
10
10
  } from 'react';
11
11
  import {
12
12
  AccessibilityInfo,
13
+ ActivityIndicator,
13
14
  Animated,
14
15
  BackHandler,
15
16
  Easing,
@@ -46,6 +47,7 @@ import type {
46
47
  TextPartProps,
47
48
  TooltipFrameProps,
48
49
  TourRenderer,
50
+ WaitingProps,
49
51
  } from '../../shared/renderer/renderer.type.js';
50
52
 
51
53
  const emptySize: Size = {width: 0, height: 0};
@@ -512,6 +514,36 @@ function TooltipFrame({children, layout, entrance, isBusy, isHidden, onSize, onI
512
514
  );
513
515
  }
514
516
 
517
+ /** A spinner in the middle of the layer, faded in on the UI thread; presses go through to the dimmed area. */
518
+ function Waiting({label, theme}: WaitingProps): ReactNode {
519
+ const [opacity] = useState(() => new Animated.Value(0));
520
+ useEffect(() => {
521
+ const animation = Animated.timing(opacity, {
522
+ toValue: 1,
523
+ duration: motion.fadeInMs,
524
+ easing: standardEasing,
525
+ useNativeDriver,
526
+ });
527
+ animation.start();
528
+ return (): void => {
529
+ animation.stop();
530
+ };
531
+ }, [opacity]);
532
+
533
+ return (
534
+ <View pointerEvents="none" style={[StyleSheet.absoluteFill, styles.waitingLayer]}>
535
+ <Animated.View
536
+ accessible
537
+ accessibilityRole="progressbar"
538
+ accessibilityLabel={label}
539
+ style={[styles.card, styles.waitingCard, {backgroundColor: theme.tooltipBackground, opacity}]}
540
+ >
541
+ <ActivityIndicator color={theme.primaryColor} />
542
+ </Animated.View>
543
+ </View>
544
+ );
545
+ }
546
+
515
547
  function MeasureFrame({children, maxWidth, maxHeight, onSize}: MeasureFrameProps): ReactNode {
516
548
  return (
517
549
  <View
@@ -728,7 +760,7 @@ const card: CardParts = {
728
760
  };
729
761
 
730
762
  /** How React Native draws the tour. */
731
- export const nativeRenderer: TourRenderer = {Layer, Backdrop, Spotlight, TooltipFrame, MeasureFrame, card};
763
+ export const nativeRenderer: TourRenderer = {Layer, Backdrop, Spotlight, TooltipFrame, MeasureFrame, Waiting, card};
732
764
 
733
765
  const styles = StyleSheet.create({
734
766
  piece: {position: 'absolute', top: 0, left: 0},
@@ -737,6 +769,8 @@ const styles = StyleSheet.create({
737
769
  verticalBar: {width: ringWidth, height: barLength},
738
770
  hidden: {opacity: 0},
739
771
  measure: {position: 'absolute', top: 0, left: 0, opacity: 0},
772
+ waitingLayer: {alignItems: 'center', justifyContent: 'center'},
773
+ waitingCard: {width: 56, height: 56, alignItems: 'center', justifyContent: 'center'},
740
774
  card: {
741
775
  borderRadius: 14,
742
776
  ...Platform.select({
@@ -252,9 +252,49 @@ export const tourCss = `
252
252
  }
253
253
  }
254
254
 
255
+ /* While a step takes a while to prepare: a spinner in the middle, on the tooltip's background. */
256
+ .rtk-waiting {
257
+ position: fixed !important;
258
+ top: 50% !important;
259
+ left: 50% !important;
260
+ translate: -50% -50%;
261
+ box-sizing: border-box;
262
+ display: flex;
263
+ align-items: center;
264
+ justify-content: center;
265
+ width: 56px;
266
+ height: 56px;
267
+ margin: 0 !important;
268
+ border-radius: var(--rtk-radius);
269
+ background: var(--rtk-bg);
270
+ box-shadow: 0 12px 32px -8px rgba(15, 23, 42, 0.35), 0 4px 12px -6px rgba(15, 23, 42, 0.2);
271
+ pointer-events: none !important;
272
+ animation: rtk-fade-in var(--rtk-fade-duration, 200ms) var(--rtk-ease) both;
273
+ }
274
+ .rtk-spinner {
275
+ box-sizing: border-box;
276
+ width: 24px;
277
+ height: 24px;
278
+ border: 3px solid rgba(127, 127, 127, 0.25);
279
+ border: 3px solid color-mix(in srgb, var(--rtk-primary) 20%, transparent);
280
+ border-top-color: var(--rtk-primary);
281
+ border-radius: 50%;
282
+ animation: rtk-spin 0.8s linear infinite;
283
+ }
284
+ @keyframes rtk-fade-in {
285
+ from { opacity: 0; }
286
+ }
287
+ @keyframes rtk-spin {
288
+ to { rotate: 1turn; }
289
+ }
290
+ @media (prefers-reduced-motion: reduce) {
291
+ .rtk-spinner { animation-duration: 2s; }
292
+ }
293
+
255
294
  @media (forced-colors: active) {
256
295
  .rtk-spotlight { outline: 3px solid Highlight; }
257
- .rtk-card { border: 1px solid CanvasText; }
296
+ .rtk-card, .rtk-waiting { border: 1px solid CanvasText; }
297
+ .rtk-spinner { border-color: GrayText; border-top-color: Highlight; }
258
298
  /* Without backgrounds, a border is what shows a button is one. */
259
299
  .rtk-button { border: 1px solid ButtonText !important; }
260
300
  .rtk-button:disabled { border-color: GrayText !important; }
@@ -28,6 +28,7 @@ import type {
28
28
  TextPartProps,
29
29
  TooltipFrameProps,
30
30
  TourRenderer,
31
+ WaitingProps,
31
32
  } from '../../shared/renderer/renderer.type.js';
32
33
  import {handlesKey, pageDirection} from '../dom/elements.util.js';
33
34
  import {keepReachable} from '../dom/keep-reachable.util.js';
@@ -473,6 +474,15 @@ function TooltipFrame({children, layout, entrance, isBusy, isHidden, onSize}: To
473
474
  );
474
475
  }
475
476
 
477
+ /** A spinner in the middle of the screen; presses go through to the dimmed area. */
478
+ function Waiting({label}: WaitingProps): ReactNode {
479
+ return (
480
+ <div className="rtk-waiting" role="progressbar" aria-label={label}>
481
+ <span aria-hidden="true" className="rtk-spinner" />
482
+ </div>
483
+ );
484
+ }
485
+
476
486
  function MeasureFrame({children, maxWidth, maxHeight, onSize}: MeasureFrameProps): ReactNode {
477
487
  const ref = useRef<HTMLDivElement | null>(null);
478
488
  useIsomorphicLayoutEffect(() => {
@@ -611,4 +621,4 @@ const card: CardParts = {
611
621
  };
612
622
 
613
623
  /** How the web draws the tour. */
614
- export const webRenderer: TourRenderer = {Layer, Backdrop, Spotlight, TooltipFrame, MeasureFrame, card};
624
+ export const webRenderer: TourRenderer = {Layer, Backdrop, Spotlight, TooltipFrame, MeasureFrame, Waiting, card};
@@ -96,6 +96,11 @@ export type TourViewState = {
96
96
  readonly tourKey: number;
97
97
  readonly steps: readonly TourStep[];
98
98
  readonly shown: ShownStep | undefined;
99
+ /**
100
+ * While the next step is prepared: the shown step's target has gone (the app moved on, for example
101
+ * `onBeforeStep` navigated), so the shown step must not stay on screen.
102
+ */
103
+ readonly isShownTargetGone: boolean;
99
104
  readonly measure: MeasureRequest | undefined;
100
105
  };
101
106
 
@@ -140,6 +145,8 @@ type State = {
140
145
  readonly currentStep: number;
141
146
  readonly pendingStepIndex: number | undefined;
142
147
  readonly shown: ShownStep | undefined;
148
+ /** See {@link TourViewState.isShownTargetGone}. */
149
+ readonly isShownTargetGone: boolean;
143
150
  readonly measure: MeasureRequest | undefined;
144
151
  /** Where the target being revealed currently is (only meaningful while revealing). */
145
152
  readonly revealHighlight: Box | undefined;
@@ -228,6 +235,7 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine
228
235
  currentStep: 0,
229
236
  pendingStepIndex: undefined,
230
237
  shown: undefined,
238
+ isShownTargetGone: false,
231
239
  measure: undefined,
232
240
  revealHighlight: undefined,
233
241
  };
@@ -239,6 +247,7 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine
239
247
  tourKey: 0,
240
248
  steps: emptySteps,
241
249
  shown: undefined,
250
+ isShownTargetGone: false,
242
251
  measure: undefined,
243
252
  };
244
253
 
@@ -248,6 +257,8 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine
248
257
  let transition: AbortController | undefined;
249
258
  /** Observation of the step on screen. */
250
259
  let stepScope: AbortController | undefined;
260
+ /** While the next step is prepared: watching whether the step on screen's target goes away. */
261
+ let goneScope: AbortController | undefined;
251
262
  let shownDetails: ShownDetails | undefined;
252
263
  let measureSequence = 0;
253
264
  /** A scroll that brings the shown target back into view is running. */
@@ -290,6 +301,7 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine
290
301
  tourKey: state.session?.key ?? previous.tourKey,
291
302
  steps: state.session?.steps ?? emptySteps,
292
303
  shown: state.shown,
304
+ isShownTargetGone: state.isShownTargetGone,
293
305
  measure: state.measure,
294
306
  };
295
307
  const isUnchanged =
@@ -299,6 +311,7 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine
299
311
  next.tourKey === previous.tourKey &&
300
312
  next.steps === previous.steps &&
301
313
  next.shown === previous.shown &&
314
+ next.isShownTargetGone === previous.isShownTargetGone &&
302
315
  next.measure === previous.measure;
303
316
  return isUnchanged ? previous : next;
304
317
  }
@@ -573,9 +586,43 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine
573
586
  keepInViewScope = undefined;
574
587
  const controller = new AbortController();
575
588
  transition = controller;
589
+ watchShownTarget().catch(reportError);
576
590
  return controller;
577
591
  }
578
592
 
593
+ /**
594
+ * While the next step is prepared, the step on screen stays (a quick step change shows nothing in
595
+ * between) and follows its target. Once that target is gone, though, the app has moved on (for
596
+ * example `onBeforeStep` navigated), and the step must not stay over what is there now.
597
+ */
598
+ async function watchShownTarget(): Promise<void> {
599
+ goneScope?.abort();
600
+ const {shown} = state;
601
+ const target = shown?.step.target;
602
+ if (!shown || target === undefined || state.isShownTargetGone) {
603
+ return;
604
+ }
605
+
606
+ const scope = new AbortController();
607
+ goneScope = scope;
608
+ let last = shownDetails?.measurement;
609
+ while (!scope.signal.aborted) {
610
+ // eslint-disable-next-line no-await-in-loop -- one change at a time
611
+ const current = await options.adapter.waitForMove(target, last, scope.signal);
612
+ if (scope.signal.aborted || state.shown !== shown) {
613
+ return;
614
+ }
615
+
616
+ if (!current) {
617
+ setState({isShownTargetGone: true});
618
+ return;
619
+ }
620
+
621
+ last = current;
622
+ void remeasure(scope.signal);
623
+ }
624
+ }
625
+
579
626
  /**
580
627
  * Prepares the steps from `requested` on, in the direction of travel, until one can be shown.
581
628
  * Steps whose target cannot be found or brought into view are skipped.
@@ -744,6 +791,8 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine
744
791
  transition = undefined;
745
792
  }
746
793
 
794
+ goneScope?.abort();
795
+ goneScope = undefined;
747
796
  shownDetails = {measurement, size};
748
797
  lastTooltipSize = size;
749
798
  const highlight = measurement ? highlightOf(measurement) : undefined;
@@ -752,6 +801,7 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine
752
801
  currentStep: index,
753
802
  pendingStepIndex: undefined,
754
803
  measure: undefined,
804
+ isShownTargetGone: false,
755
805
  shown: {
756
806
  transitionId: id,
757
807
  stepIndex: index,
@@ -952,6 +1002,8 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine
952
1002
  transition = undefined;
953
1003
  stepScope?.abort();
954
1004
  stepScope = undefined;
1005
+ goneScope?.abort();
1006
+ goneScope = undefined;
955
1007
  shownDetails = undefined;
956
1008
  for (const resolve of sizeWaiters.values()) {
957
1009
  resolve(lastTooltipSize ?? fallbackTooltipSize);
@@ -966,6 +1018,7 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine
966
1018
  currentStep: 0,
967
1019
  pendingStepIndex: undefined,
968
1020
  shown: undefined,
1021
+ isShownTargetGone: false,
969
1022
  measure: undefined,
970
1023
  });
971
1024
  safeInvoke(() => session.onTourEnd?.(session.tourId, info));
@@ -1015,6 +1068,7 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine
1015
1068
  currentStep: 0,
1016
1069
  pendingStepIndex: 0,
1017
1070
  shown: undefined,
1071
+ isShownTargetGone: false,
1018
1072
  measure: undefined,
1019
1073
  });
1020
1074
  void prepare(0, 1);
@@ -1083,6 +1137,8 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine
1083
1137
  transition = undefined;
1084
1138
  stepScope?.abort();
1085
1139
  stepScope = undefined;
1140
+ goneScope?.abort();
1141
+ goneScope = undefined;
1086
1142
  }
1087
1143
 
1088
1144
  function resume(): void {
@@ -17,8 +17,11 @@ export const motion = {
17
17
  /** Distance (from the target's side) and scale the tooltip enters from. */
18
18
  enterOffset: 6,
19
19
  enterScale: 0.96,
20
- /** If the first step takes longer than this to prepare (navigation, data), the screen is dimmed meanwhile. */
21
- waitingDimDelayMs: 300,
20
+ /**
21
+ * If a step takes longer than this to prepare (navigation, data, a target that is still
22
+ * rendering), the tour shows that it is waiting: the highlight closes and a spinner appears.
23
+ */
24
+ waitingDelayMs: 300,
22
25
  /** Control points of the standard easing curve (`cubic-bezier`). */
23
26
  easing: [0.2, 0, 0, 1],
24
27
  } as const;
@@ -25,6 +25,8 @@ export type TourRenderer = {
25
25
  readonly TooltipFrame: ComponentType<TooltipFrameProps>;
26
26
  /** Renders a tooltip invisibly with given size limits and reports its size. */
27
27
  readonly MeasureFrame: ComponentType<MeasureFrameProps>;
28
+ /** A spinner in the middle of the layer, while the tour waits for a step that takes a while to prepare. */
29
+ readonly Waiting: ComponentType<WaitingProps>;
28
30
  /** The pieces of the built-in tooltip card. */
29
31
  readonly card: CardParts;
30
32
  };
@@ -104,6 +106,12 @@ export type Entrance = {
104
106
  readonly delayMs: number;
105
107
  };
106
108
 
109
+ export type WaitingProps = {
110
+ /** Accessible name of the spinner. */
111
+ readonly label: string;
112
+ readonly theme: ResolvedTheme;
113
+ };
114
+
107
115
  export type MeasureFrameProps = {
108
116
  readonly children: ReactNode;
109
117
  readonly maxWidth: number;
@@ -86,14 +86,15 @@ function OverlayContent({
86
86
  isClosing,
87
87
  onClosed,
88
88
  }: OverlayContentProps): ReactNode {
89
- const {Layer, Backdrop, Spotlight} = renderer;
89
+ const {Layer, Backdrop, Spotlight, Waiting} = renderer;
90
90
  const id = useId();
91
91
  const titleId = `${id}title`;
92
92
  const descriptionId = `${id}description`;
93
93
  const {shown, phase, measure} = view;
94
94
  const totalSteps = view.steps.length;
95
95
  // The first step can take a while (navigation in onBeforeStep): dim the screen after a moment.
96
- const isWaitingDimmed = useDelayedFlag(!shown && !isClosing, motion.waitingDimDelayMs);
96
+ const isWaitingDimmed = useDelayedFlag(!shown && !isClosing, motion.waitingDelayMs);
97
+ const {isPreparing, isWaiting, isShownGone} = usePreparation(view, isClosing);
97
98
  // Once on screen, the overlay stays until the tour ends (also when another tour takes over).
98
99
  const [hasBeenVisible, setHasBeenVisible] = useState(false);
99
100
  const isShowingSomething = shown !== undefined || isWaitingDimmed;
@@ -107,8 +108,8 @@ function OverlayContent({
107
108
  useEffect(() => {
108
109
  visibleSince.current = isVisible ? Date.now() : undefined;
109
110
  }, [isVisible]);
110
- const spotlight = useSpotlight(view, isVisible, isReducedMotion);
111
- const announcement = useAnnouncement(shown, totalSteps, i18n, isClosing);
111
+ const spotlight = useSpotlight(view, isVisible, isReducedMotion, isWaiting || isShownGone);
112
+ const announcement = useAnnouncement(view, i18n, isClosing, isWaiting);
112
113
 
113
114
  // Fade out, then unmount.
114
115
  useEffect(() => {
@@ -155,10 +156,11 @@ function OverlayContent({
155
156
 
156
157
  const onBackdropPress = (point: Point): void => {
157
158
  // Not while the overlay is still appearing (a double click on the button that started the
158
- // tour), nor while a step change is under way (a second tap on Next falls through the busy tooltip).
159
+ // tour), nor right after a step change began (a second tap on Next falls through the busy
160
+ // tooltip). Once the tour shows that it is waiting, a press leaves it.
159
161
  const since = visibleSince.current;
160
162
  const hasFadedIn = since !== undefined && Date.now() - since >= motion.fadeInMs;
161
- const isChangingStep = shown !== undefined && phase !== 'showing';
163
+ const isChangingStep = shown !== undefined && phase !== 'showing' && !isWaiting;
162
164
  if (!isOverlayDismissible || isClosing || !hasFadedIn || isChangingStep) {
163
165
  return;
164
166
  }
@@ -170,9 +172,6 @@ function OverlayContent({
170
172
  };
171
173
 
172
174
  const context = useMemo<RendererContextValue>(() => ({renderer, theme, i18n}), [renderer, theme, i18n]);
173
- const counter = shown
174
- ? formatTemplate(i18n.stepCounterTemplate, {current: shown.stepIndex + 1, total: totalSteps})
175
- : '';
176
175
 
177
176
  return (
178
177
  <RendererContext.Provider value={context}>
@@ -184,7 +183,7 @@ function OverlayContent({
184
183
  theme={theme}
185
184
  titleId={shown ? titleId : undefined}
186
185
  descriptionId={shown ? descriptionId : undefined}
187
- fallbackLabel={(shown ? textOf(shown.step.title) : '') || counter}
186
+ fallbackLabel={fallbackLabelOf(shown, totalSteps, i18n)}
188
187
  announcement={announcement}
189
188
  stepKey={shown?.transitionId}
190
189
  onIntent={onIntent}
@@ -199,8 +198,8 @@ function OverlayContent({
199
198
  Tooltip={Tooltip}
200
199
  shown={shown}
201
200
  totalSteps={totalSteps}
202
- isBusy={phase === 'preparing' && !isClosing}
203
- isHidden={phase === 'revealing'}
201
+ isBusy={isPreparing}
202
+ isHidden={phase === 'revealing' || isWaiting || isShownGone}
204
203
  isDelayed={spotlight.isMorphing}
205
204
  isReducedMotion={isReducedMotion}
206
205
  titleId={titleId}
@@ -208,6 +207,7 @@ function OverlayContent({
208
207
  onIntent={onIntent}
209
208
  />
210
209
  ) : null}
210
+ {isWaiting ? <Waiting label={i18n.loadingAriaLabel} theme={theme} /> : null}
211
211
  {measure && !isClosing ? (
212
212
  <MeasuredTooltip
213
213
  key={measure.id}
@@ -334,6 +334,21 @@ function MeasuredTooltip({engine, renderer, Tooltip, request, totalSteps}: Measu
334
334
  );
335
335
  }
336
336
 
337
+ /**
338
+ * While the next step is prepared. Preparing it takes a while (`isWaiting`): the highlight closes and
339
+ * a spinner shows that the tour is waiting. The app moved on before the next step is ready (the step
340
+ * on screen's target is gone, `isShownGone`): the step goes at once rather than stay over what is
341
+ * there now, and the spinner still waits for its delay.
342
+ */
343
+ function usePreparation(
344
+ view: TourViewState,
345
+ isClosing: boolean,
346
+ ): {readonly isPreparing: boolean; readonly isWaiting: boolean; readonly isShownGone: boolean} {
347
+ const isPreparing = view.phase === 'preparing' && !isClosing;
348
+ const isWaiting = useDelayedFlag(isPreparing, motion.waitingDelayMs);
349
+ return {isPreparing, isWaiting, isShownGone: isPreparing && view.isShownTargetGone};
350
+ }
351
+
337
352
  /** True once `isWaiting` has been true for `delayMs`, as long as it stays true. */
338
353
  function useDelayedFlag(isWaiting: boolean, delayMs: number): boolean {
339
354
  const [isDue, setIsDue] = useState(false);
@@ -365,20 +380,46 @@ type SpotlightState = {
365
380
  readonly isMorphing: boolean;
366
381
  };
367
382
 
383
+ /** What the spotlight marks, and the key under which a move there is animated. */
384
+ function spotlightTarget(
385
+ view: TourViewState,
386
+ isWaiting: boolean,
387
+ ): {readonly hole: Box | undefined; readonly key: string} {
388
+ const {shown, phase, revealHighlight} = view;
389
+ const shownKey = shown?.transitionId ?? 'none';
390
+ // While the tour waits for a step, nothing is highlighted: the hole closes.
391
+ if (isWaiting) {
392
+ return {hole: undefined, key: `waiting:${shownKey}`};
393
+ }
394
+
395
+ if (phase === 'revealing') {
396
+ return {
397
+ hole: revealHighlight ? inflate(revealHighlight, tourLayout.highlightPadding) : undefined,
398
+ key: `reveal:${shownKey}`,
399
+ };
400
+ }
401
+
402
+ const hole = shown?.highlight ? inflate(shown.highlight, tourLayout.highlightPadding) : undefined;
403
+ return {hole, key: `${shownKey}:${hole ? 'open' : 'closed'}`};
404
+ }
405
+
368
406
  /**
369
407
  * Where the spotlight's hole is and how it gets there. While the next target is scrolled into
370
408
  * view, the hole goes with it (frame by frame, or straight to where it will end up); otherwise it
371
409
  * marks the step on screen. Moving to another step is animated; following a target that moves on
372
410
  * its own (layout changes, resizing) is instant, like the tooltip, also while the spotlight is
373
- * still on its way. Nothing animates before the overlay has appeared.
411
+ * still on its way. While the tour waits for a step, the hole closes. Nothing animates before the
412
+ * overlay has appeared.
374
413
  */
375
- function useSpotlight(view: TourViewState, isVisible: boolean, isReducedMotion: boolean): SpotlightState {
376
- const {shown, phase, revealHighlight, followsScroll} = view;
414
+ function useSpotlight(
415
+ view: TourViewState,
416
+ isVisible: boolean,
417
+ isReducedMotion: boolean,
418
+ isWaiting: boolean,
419
+ ): SpotlightState {
420
+ const {phase, followsScroll} = view;
377
421
  const isRevealing = phase === 'revealing';
378
- const source = isRevealing ? revealHighlight : shown?.highlight;
379
- const hole = source ? inflate(source, tourLayout.highlightPadding) : undefined;
380
- const shownKey = shown?.transitionId ?? 'none';
381
- const key = isRevealing ? `reveal:${shownKey}` : `${shownKey}:${hole ? 'open' : 'closed'}`;
422
+ const {hole, key} = spotlightTarget(view, isWaiting);
382
423
 
383
424
  // The key at which the overlay appeared: it appears in place, later changes animate.
384
425
  const [appearedAtKey, setAppearedAtKey] = useState<string | undefined>(undefined);
@@ -409,26 +450,37 @@ function useSpotlight(view: TourViewState, isVisible: boolean, isReducedMotion:
409
450
  }
410
451
 
411
452
  /** "2 of 5: Title" when the step changes (the first step is announced by the tour appearing). */
412
- function useAnnouncement(
413
- shown: ShownStep | undefined,
414
- totalSteps: number,
415
- i18n: ResolvedI18n,
416
- isClosing: boolean,
417
- ): string {
453
+ function useAnnouncement(view: TourViewState, i18n: ResolvedI18n, isClosing: boolean, isWaiting: boolean): string {
454
+ const {shown, phase} = view;
418
455
  const [firstKey, setFirstKey] = useState<number | undefined>(undefined);
419
456
  if (shown && firstKey === undefined) {
420
457
  setFirstKey(shown.transitionId);
421
458
  }
422
459
 
423
- if (!shown || isClosing || shown.transitionId === firstKey) {
460
+ // While the tour waits for a step, that is what is said; a step once it is on screen.
461
+ if (isWaiting) {
462
+ return i18n.loadingAriaLabel;
463
+ }
464
+
465
+ if (!shown || isClosing || phase !== 'showing' || shown.transitionId === firstKey) {
424
466
  return '';
425
467
  }
426
468
 
427
- const counter = formatTemplate(i18n.stepCounterTemplate, {current: shown.stepIndex + 1, total: totalSteps});
469
+ const counter = formatTemplate(i18n.stepCounterTemplate, {current: shown.stepIndex + 1, total: view.steps.length});
428
470
  const title = textOf(shown.step.title);
429
471
  return title ? `${counter}: ${title}` : counter;
430
472
  }
431
473
 
474
+ /** Name of the tour dialog for a custom tooltip without a title element: the step's title, or its counter. */
475
+ function fallbackLabelOf(shown: ShownStep | undefined, totalSteps: number, i18n: ResolvedI18n): string {
476
+ if (!shown) {
477
+ return '';
478
+ }
479
+
480
+ const counter = formatTemplate(i18n.stepCounterTemplate, {current: shown.stepIndex + 1, total: totalSteps});
481
+ return textOf(shown.step.title) || counter;
482
+ }
483
+
432
484
  /** The plain text of React content (strings, numbers, arrays and the children of elements). */
433
485
  export function textOf(node: ReactNode): string {
434
486
  if (typeof node === 'string' || typeof node === 'number') {
@@ -1,4 +1,4 @@
1
- /** Texts of the built-in tooltip. Every key is optional. */
1
+ /** Texts of the tour (most of them in the built-in tooltip). Every key is optional. */
2
2
  export type TourI18n = {
3
3
  /** @defaultValue `'Next'` */
4
4
  readonly nextLabel?: string;
@@ -12,4 +12,6 @@ export type TourI18n = {
12
12
  readonly closeAriaLabel?: string;
13
13
  /** Accessible name of each step dot; `{step}` and `{total}` are replaced. @defaultValue `'Go to step {step}'` */
14
14
  readonly dotAriaLabelTemplate?: string;
15
+ /** Accessible name of the spinner shown while a step takes a while to prepare. @defaultValue `'Loading…'` */
16
+ readonly loadingAriaLabel?: string;
15
17
  };
@@ -9,6 +9,7 @@ export const defaultI18n: ResolvedI18n = {
9
9
  stepCounterTemplate: '{current} of {total}',
10
10
  closeAriaLabel: 'Close tour',
11
11
  dotAriaLabelTemplate: 'Go to step {step}',
12
+ loadingAriaLabel: 'Loading…',
12
13
  };
13
14
 
14
15
  const i18nKeys = [
@@ -18,6 +19,7 @@ const i18nKeys = [
18
19
  'stepCounterTemplate',
19
20
  'closeAriaLabel',
20
21
  'dotAriaLabelTemplate',
22
+ 'loadingAriaLabel',
21
23
  ] as const satisfies ReadonlyArray<keyof TourI18n>;
22
24
 
23
25
  export function resolveI18n(i18n: TourI18n | undefined): ResolvedI18n {