@robertlinde/react-tour-kit 1.0.2 → 1.1.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +5 -3
  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/dom/keep-reachable.util.d.ts +8 -0
  7. package/dist/react-platform/dom/keep-reachable.util.d.ts.map +1 -0
  8. package/dist/react-platform/dom/keep-reachable.util.js +35 -0
  9. package/dist/react-platform/dom/keep-reachable.util.js.map +1 -0
  10. package/dist/react-platform/dom/web-platform.d.ts.map +1 -1
  11. package/dist/react-platform/dom/web-platform.js +6 -3
  12. package/dist/react-platform/dom/web-platform.js.map +1 -1
  13. package/dist/react-platform/layer/styles.d.ts +1 -1
  14. package/dist/react-platform/layer/styles.d.ts.map +1 -1
  15. package/dist/react-platform/layer/styles.js +41 -1
  16. package/dist/react-platform/layer/styles.js.map +1 -1
  17. package/dist/react-platform/layer/web-renderer.d.ts.map +1 -1
  18. package/dist/react-platform/layer/web-renderer.js +27 -6
  19. package/dist/react-platform/layer/web-renderer.js.map +1 -1
  20. package/dist/shared/renderer/motion.d.ts +5 -2
  21. package/dist/shared/renderer/motion.d.ts.map +1 -1
  22. package/dist/shared/renderer/motion.js +5 -2
  23. package/dist/shared/renderer/motion.js.map +1 -1
  24. package/dist/shared/renderer/renderer.type.d.ts +7 -0
  25. package/dist/shared/renderer/renderer.type.d.ts.map +1 -1
  26. package/dist/shared/renderer/tour-overlay.d.ts.map +1 -1
  27. package/dist/shared/renderer/tour-overlay.js +49 -20
  28. package/dist/shared/renderer/tour-overlay.js.map +1 -1
  29. package/dist/shared/types/tour-i18n.type.d.ts +3 -1
  30. package/dist/shared/types/tour-i18n.type.d.ts.map +1 -1
  31. package/dist/shared/utils/i18n.util.d.ts.map +1 -1
  32. package/dist/shared/utils/i18n.util.js +2 -0
  33. package/dist/shared/utils/i18n.util.js.map +1 -1
  34. package/package.json +2 -1
  35. package/src/react-native-platform/layer/native-renderer.tsx +35 -1
  36. package/src/react-platform/dom/keep-reachable.util.ts +43 -0
  37. package/src/react-platform/dom/web-platform.ts +6 -3
  38. package/src/react-platform/layer/styles.ts +41 -1
  39. package/src/react-platform/layer/web-renderer.tsx +37 -7
  40. package/src/shared/renderer/motion.ts +5 -2
  41. package/src/shared/renderer/renderer.type.ts +8 -0
  42. package/src/shared/renderer/tour-overlay.tsx +66 -27
  43. package/src/shared/types/tour-i18n.type.ts +3 -1
  44. package/src/shared/utils/i18n.util.ts +2 -0
@@ -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,8 +28,10 @@ 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';
34
+ import {keepReachable} from '../dom/keep-reachable.util.js';
33
35
  import {lockScroll} from '../dom/scroll-lock.util.js';
34
36
  import {injectTourStyles} from './styles.js';
35
37
 
@@ -154,7 +156,9 @@ function Layer({
154
156
  const trigger = document.activeElement;
155
157
  openModal(dialog);
156
158
  const unlock = lockScroll(dialog);
159
+ const stopKeepingReachable = keepReachable(dialog);
157
160
  return (): void => {
161
+ stopKeepingReachable();
158
162
  unlock();
159
163
  reopening.current = true;
160
164
  closeModal(dialog);
@@ -162,6 +166,29 @@ function Layer({
162
166
  };
163
167
  }, []);
164
168
 
169
+ // Escape belongs to the topmost modal, the tour. Modal libraries underneath listen for it on the
170
+ // document before it reaches the tour (Radix, in the capture phase): take it on the window first.
171
+ useEffect(() => {
172
+ if (!isInteractive) {
173
+ return undefined;
174
+ }
175
+
176
+ const onEscape = (event: KeyboardEvent): void => {
177
+ if (event.key !== 'Escape' || event.isComposing || event.altKey || event.ctrlKey || event.metaKey) {
178
+ return;
179
+ }
180
+
181
+ event.stopPropagation();
182
+ event.preventDefault();
183
+ onIntent('escape');
184
+ };
185
+
186
+ globalThis.addEventListener('keydown', onEscape, {capture: true});
187
+ return (): void => {
188
+ globalThis.removeEventListener('keydown', onEscape, {capture: true});
189
+ };
190
+ }, [isInteractive, onIntent]);
191
+
165
192
  // If the app opened its own modal (in onBeforeStep), move the tour back on top of it.
166
193
  useEffect(() => {
167
194
  const dialog = layerRef.current;
@@ -229,12 +256,6 @@ function Layer({
229
256
  return;
230
257
  }
231
258
 
232
- if (key === 'Escape') {
233
- event.preventDefault();
234
- onIntent('escape');
235
- return;
236
- }
237
-
238
259
  if ((key === 'ArrowRight' || key === 'ArrowLeft') && !handlesKey(event.target, key)) {
239
260
  event.preventDefault();
240
261
  // In right-to-left pages, the arrow pointing to the start goes forward.
@@ -453,6 +474,15 @@ function TooltipFrame({children, layout, entrance, isBusy, isHidden, onSize}: To
453
474
  );
454
475
  }
455
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
+
456
486
  function MeasureFrame({children, maxWidth, maxHeight, onSize}: MeasureFrameProps): ReactNode {
457
487
  const ref = useRef<HTMLDivElement | null>(null);
458
488
  useIsomorphicLayoutEffect(() => {
@@ -591,4 +621,4 @@ const card: CardParts = {
591
621
  };
592
622
 
593
623
  /** How the web draws the tour. */
594
- export const webRenderer: TourRenderer = {Layer, Backdrop, Spotlight, TooltipFrame, MeasureFrame, card};
624
+ export const webRenderer: TourRenderer = {Layer, Backdrop, Spotlight, TooltipFrame, MeasureFrame, Waiting, card};
@@ -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,17 @@ 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
+ // Preparing a step takes a while: the highlight closes and a spinner shows that the tour is waiting.
98
+ const isPreparing = phase === 'preparing' && !isClosing;
99
+ const isWaiting = useDelayedFlag(isPreparing, motion.waitingDelayMs);
97
100
  // Once on screen, the overlay stays until the tour ends (also when another tour takes over).
98
101
  const [hasBeenVisible, setHasBeenVisible] = useState(false);
99
102
  const isShowingSomething = shown !== undefined || isWaitingDimmed;
@@ -107,8 +110,8 @@ function OverlayContent({
107
110
  useEffect(() => {
108
111
  visibleSince.current = isVisible ? Date.now() : undefined;
109
112
  }, [isVisible]);
110
- const spotlight = useSpotlight(view, isVisible, isReducedMotion);
111
- const announcement = useAnnouncement(shown, totalSteps, i18n, isClosing);
113
+ const spotlight = useSpotlight(view, isVisible, isReducedMotion, isWaiting);
114
+ const announcement = useAnnouncement(view, i18n, isClosing, isWaiting);
112
115
 
113
116
  // Fade out, then unmount.
114
117
  useEffect(() => {
@@ -155,10 +158,11 @@ function OverlayContent({
155
158
 
156
159
  const onBackdropPress = (point: Point): void => {
157
160
  // 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).
161
+ // tour), nor right after a step change began (a second tap on Next falls through the busy
162
+ // tooltip). Once the tour shows that it is waiting, a press leaves it.
159
163
  const since = visibleSince.current;
160
164
  const hasFadedIn = since !== undefined && Date.now() - since >= motion.fadeInMs;
161
- const isChangingStep = shown !== undefined && phase !== 'showing';
165
+ const isChangingStep = shown !== undefined && phase !== 'showing' && !isWaiting;
162
166
  if (!isOverlayDismissible || isClosing || !hasFadedIn || isChangingStep) {
163
167
  return;
164
168
  }
@@ -170,9 +174,6 @@ function OverlayContent({
170
174
  };
171
175
 
172
176
  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
177
 
177
178
  return (
178
179
  <RendererContext.Provider value={context}>
@@ -184,7 +185,7 @@ function OverlayContent({
184
185
  theme={theme}
185
186
  titleId={shown ? titleId : undefined}
186
187
  descriptionId={shown ? descriptionId : undefined}
187
- fallbackLabel={(shown ? textOf(shown.step.title) : '') || counter}
188
+ fallbackLabel={fallbackLabelOf(shown, totalSteps, i18n)}
188
189
  announcement={announcement}
189
190
  stepKey={shown?.transitionId}
190
191
  onIntent={onIntent}
@@ -199,8 +200,8 @@ function OverlayContent({
199
200
  Tooltip={Tooltip}
200
201
  shown={shown}
201
202
  totalSteps={totalSteps}
202
- isBusy={phase === 'preparing' && !isClosing}
203
- isHidden={phase === 'revealing'}
203
+ isBusy={isPreparing}
204
+ isHidden={phase === 'revealing' || isWaiting}
204
205
  isDelayed={spotlight.isMorphing}
205
206
  isReducedMotion={isReducedMotion}
206
207
  titleId={titleId}
@@ -208,6 +209,7 @@ function OverlayContent({
208
209
  onIntent={onIntent}
209
210
  />
210
211
  ) : null}
212
+ {isWaiting ? <Waiting label={i18n.loadingAriaLabel} theme={theme} /> : null}
211
213
  {measure && !isClosing ? (
212
214
  <MeasuredTooltip
213
215
  key={measure.id}
@@ -365,20 +367,46 @@ type SpotlightState = {
365
367
  readonly isMorphing: boolean;
366
368
  };
367
369
 
370
+ /** What the spotlight marks, and the key under which a move there is animated. */
371
+ function spotlightTarget(
372
+ view: TourViewState,
373
+ isWaiting: boolean,
374
+ ): {readonly hole: Box | undefined; readonly key: string} {
375
+ const {shown, phase, revealHighlight} = view;
376
+ const shownKey = shown?.transitionId ?? 'none';
377
+ // While the tour waits for a step, nothing is highlighted: the hole closes.
378
+ if (isWaiting) {
379
+ return {hole: undefined, key: `waiting:${shownKey}`};
380
+ }
381
+
382
+ if (phase === 'revealing') {
383
+ return {
384
+ hole: revealHighlight ? inflate(revealHighlight, tourLayout.highlightPadding) : undefined,
385
+ key: `reveal:${shownKey}`,
386
+ };
387
+ }
388
+
389
+ const hole = shown?.highlight ? inflate(shown.highlight, tourLayout.highlightPadding) : undefined;
390
+ return {hole, key: `${shownKey}:${hole ? 'open' : 'closed'}`};
391
+ }
392
+
368
393
  /**
369
394
  * Where the spotlight's hole is and how it gets there. While the next target is scrolled into
370
395
  * view, the hole goes with it (frame by frame, or straight to where it will end up); otherwise it
371
396
  * marks the step on screen. Moving to another step is animated; following a target that moves on
372
397
  * 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.
398
+ * still on its way. While the tour waits for a step, the hole closes. Nothing animates before the
399
+ * overlay has appeared.
374
400
  */
375
- function useSpotlight(view: TourViewState, isVisible: boolean, isReducedMotion: boolean): SpotlightState {
376
- const {shown, phase, revealHighlight, followsScroll} = view;
401
+ function useSpotlight(
402
+ view: TourViewState,
403
+ isVisible: boolean,
404
+ isReducedMotion: boolean,
405
+ isWaiting: boolean,
406
+ ): SpotlightState {
407
+ const {phase, followsScroll} = view;
377
408
  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'}`;
409
+ const {hole, key} = spotlightTarget(view, isWaiting);
382
410
 
383
411
  // The key at which the overlay appeared: it appears in place, later changes animate.
384
412
  const [appearedAtKey, setAppearedAtKey] = useState<string | undefined>(undefined);
@@ -409,26 +437,37 @@ function useSpotlight(view: TourViewState, isVisible: boolean, isReducedMotion:
409
437
  }
410
438
 
411
439
  /** "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 {
440
+ function useAnnouncement(view: TourViewState, i18n: ResolvedI18n, isClosing: boolean, isWaiting: boolean): string {
441
+ const {shown, phase} = view;
418
442
  const [firstKey, setFirstKey] = useState<number | undefined>(undefined);
419
443
  if (shown && firstKey === undefined) {
420
444
  setFirstKey(shown.transitionId);
421
445
  }
422
446
 
423
- if (!shown || isClosing || shown.transitionId === firstKey) {
447
+ // While the tour waits for a step, that is what is said; a step once it is on screen.
448
+ if (isWaiting) {
449
+ return i18n.loadingAriaLabel;
450
+ }
451
+
452
+ if (!shown || isClosing || phase !== 'showing' || shown.transitionId === firstKey) {
424
453
  return '';
425
454
  }
426
455
 
427
- const counter = formatTemplate(i18n.stepCounterTemplate, {current: shown.stepIndex + 1, total: totalSteps});
456
+ const counter = formatTemplate(i18n.stepCounterTemplate, {current: shown.stepIndex + 1, total: view.steps.length});
428
457
  const title = textOf(shown.step.title);
429
458
  return title ? `${counter}: ${title}` : counter;
430
459
  }
431
460
 
461
+ /** Name of the tour dialog for a custom tooltip without a title element: the step's title, or its counter. */
462
+ function fallbackLabelOf(shown: ShownStep | undefined, totalSteps: number, i18n: ResolvedI18n): string {
463
+ if (!shown) {
464
+ return '';
465
+ }
466
+
467
+ const counter = formatTemplate(i18n.stepCounterTemplate, {current: shown.stepIndex + 1, total: totalSteps});
468
+ return textOf(shown.step.title) || counter;
469
+ }
470
+
432
471
  /** The plain text of React content (strings, numbers, arrays and the children of elements). */
433
472
  export function textOf(node: ReactNode): string {
434
473
  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 {