@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.
- package/CHANGELOG.md +13 -0
- package/README.md +17 -1
- package/dist/react-native-platform/layer/native-renderer.d.ts.map +1 -1
- package/dist/react-native-platform/layer/native-renderer.js +21 -2
- package/dist/react-native-platform/layer/native-renderer.js.map +1 -1
- package/dist/react-platform/layer/styles.d.ts +1 -1
- package/dist/react-platform/layer/styles.d.ts.map +1 -1
- package/dist/react-platform/layer/styles.js +41 -1
- package/dist/react-platform/layer/styles.js.map +1 -1
- package/dist/react-platform/layer/web-renderer.d.ts.map +1 -1
- package/dist/react-platform/layer/web-renderer.js +5 -1
- package/dist/react-platform/layer/web-renderer.js.map +1 -1
- package/dist/shared/engine/tour-engine.d.ts +5 -0
- package/dist/shared/engine/tour-engine.d.ts.map +1 -1
- package/dist/shared/engine/tour-engine.js +45 -0
- package/dist/shared/engine/tour-engine.js.map +1 -1
- package/dist/shared/renderer/motion.d.ts +5 -2
- package/dist/shared/renderer/motion.d.ts.map +1 -1
- package/dist/shared/renderer/motion.js +5 -2
- package/dist/shared/renderer/motion.js.map +1 -1
- package/dist/shared/renderer/renderer.type.d.ts +7 -0
- package/dist/shared/renderer/renderer.type.d.ts.map +1 -1
- package/dist/shared/renderer/tour-overlay.d.ts.map +1 -1
- package/dist/shared/renderer/tour-overlay.js +58 -20
- package/dist/shared/renderer/tour-overlay.js.map +1 -1
- package/dist/shared/types/tour-i18n.type.d.ts +3 -1
- package/dist/shared/types/tour-i18n.type.d.ts.map +1 -1
- package/dist/shared/utils/i18n.util.d.ts.map +1 -1
- package/dist/shared/utils/i18n.util.js +2 -0
- package/dist/shared/utils/i18n.util.js.map +1 -1
- package/package.json +4 -2
- package/src/react-native-platform/layer/native-renderer.tsx +35 -1
- package/src/react-platform/layer/styles.ts +41 -1
- package/src/react-platform/layer/web-renderer.tsx +11 -1
- package/src/shared/engine/tour-engine.ts +56 -0
- package/src/shared/renderer/motion.ts +5 -2
- package/src/shared/renderer/renderer.type.ts +8 -0
- package/src/shared/renderer/tour-overlay.tsx +79 -27
- package/src/shared/types/tour-i18n.type.ts +3 -1
- 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;
|
|
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.
|
|
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
|
-
/**
|
|
21
|
-
|
|
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.
|
|
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(
|
|
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
|
|
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
|
|
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={
|
|
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
|
|
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(
|
|
376
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 {
|