panelui-native 0.53.0 → 0.56.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 (40) hide show
  1. package/README.md +4 -1
  2. package/lib/module/components/combobox/index.js +61 -7
  3. package/lib/module/components/combobox/index.js.map +1 -1
  4. package/lib/module/components/hex-chart/index.js +765 -0
  5. package/lib/module/components/hex-chart/index.js.map +1 -0
  6. package/lib/module/components/sortable/index.js +74 -12
  7. package/lib/module/components/sortable/index.js.map +1 -1
  8. package/lib/module/components/steps/index.js +95 -12
  9. package/lib/module/components/steps/index.js.map +1 -1
  10. package/lib/module/components/tag-input/index.js +435 -0
  11. package/lib/module/components/tag-input/index.js.map +1 -0
  12. package/lib/module/components/tour/index.js +661 -0
  13. package/lib/module/components/tour/index.js.map +1 -0
  14. package/lib/module/index.js +3 -0
  15. package/lib/module/index.js.map +1 -1
  16. package/lib/module/utils/chart.js +209 -0
  17. package/lib/module/utils/chart.js.map +1 -1
  18. package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
  19. package/lib/typescript/src/components/hex-chart/index.d.ts +277 -0
  20. package/lib/typescript/src/components/hex-chart/index.d.ts.map +1 -0
  21. package/lib/typescript/src/components/sortable/index.d.ts.map +1 -1
  22. package/lib/typescript/src/components/steps/index.d.ts +19 -0
  23. package/lib/typescript/src/components/steps/index.d.ts.map +1 -1
  24. package/lib/typescript/src/components/tag-input/index.d.ts +240 -0
  25. package/lib/typescript/src/components/tag-input/index.d.ts.map +1 -0
  26. package/lib/typescript/src/components/tour/index.d.ts +168 -0
  27. package/lib/typescript/src/components/tour/index.d.ts.map +1 -0
  28. package/lib/typescript/src/index.d.ts +3 -0
  29. package/lib/typescript/src/index.d.ts.map +1 -1
  30. package/lib/typescript/src/utils/chart.d.ts +98 -0
  31. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/components/combobox/index.tsx +65 -12
  34. package/src/components/hex-chart/index.tsx +1009 -0
  35. package/src/components/sortable/index.tsx +72 -16
  36. package/src/components/steps/index.tsx +103 -8
  37. package/src/components/tag-input/index.tsx +619 -0
  38. package/src/components/tour/index.tsx +933 -0
  39. package/src/index.ts +30 -0
  40. package/src/utils/chart.ts +236 -0
@@ -0,0 +1,933 @@
1
+ /**
2
+ * Tour — the walkthrough that introduces a screen one control at a time.
3
+ *
4
+ * An empty state explains a screen before there is anything on it; a tour
5
+ * explains it once there is. It dims everything, cuts a hole around one control
6
+ * and puts a card beside it, then moves the hole to the next control. What
7
+ * makes that work is the hole: a caption alone has to describe where to look,
8
+ * and "the button at the top right" is a sentence people read twice and still
9
+ * get wrong.
10
+ *
11
+ * ```tsx
12
+ * <Tour open={onboarding} onOpenChange={setOnboarding}>
13
+ * <Tour.Step order={0} title="Your library" description="Everything you save lands here.">
14
+ * <IconButton icon={<BookmarkIcon />} onPress={openLibrary} />
15
+ * </Tour.Step>
16
+ *
17
+ * <Tour.Step order={1} title="Start writing" description="A new note, from anywhere." shape="circle">
18
+ * <Fab icon={<PlusIcon />} onPress={compose} />
19
+ * </Tour.Step>
20
+ * </Tour>
21
+ * ```
22
+ *
23
+ * A step wraps the control it is about, so the two live together in the tree
24
+ * and cannot drift apart — a step whose target has been deleted goes with it
25
+ * rather than pointing at empty space. `order` is what puts the steps in a
26
+ * sequence, and it is the author's numbering rather than the tree's, because a
27
+ * walkthrough usually crosses a header, a list and a tab bar in an order the
28
+ * layout knows nothing about.
29
+ *
30
+ * The target is measured in window coordinates each time its step becomes
31
+ * current, and again when the window changes size — a rect measured in portrait
32
+ * describes nothing after a rotation, and a spotlight in the wrong place is
33
+ * worse than none. A target that has scrolled out of view is the one case this
34
+ * cannot fix by itself: bring it back with `onStepChange`, which fires with the
35
+ * step about to be shown.
36
+ *
37
+ * The hole is one path with an even-odd fill — the screen rectangle and the
38
+ * cutout in a single `d`, animated on the UI thread — rather than four views
39
+ * arranged around a gap. Four views cannot have rounded corners between them,
40
+ * and the corner is most of what makes the hole read as *this control* instead
41
+ * of as a rectangle that happens to contain it.
42
+ */
43
+ import {
44
+ createContext,
45
+ useCallback,
46
+ useContext,
47
+ useEffect,
48
+ useMemo,
49
+ useRef,
50
+ useState,
51
+ type ReactNode,
52
+ type RefObject,
53
+ } from 'react';
54
+ import {
55
+ StyleSheet,
56
+ useWindowDimensions,
57
+ View,
58
+ type LayoutChangeEvent,
59
+ type ViewProps,
60
+ } from 'react-native';
61
+ import Animated, {
62
+ FadeIn,
63
+ FadeOut,
64
+ useAnimatedProps,
65
+ useReducedMotion,
66
+ useSharedValue,
67
+ withSpring,
68
+ } from 'react-native-reanimated';
69
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
70
+ import Svg, { Path } from 'react-native-svg';
71
+ import { ChevronLeftIcon, XIcon } from '../../icons';
72
+ import { useBackHandler } from '../../hooks/use-back-handler';
73
+ import { Portal } from '../../primitives/portal';
74
+ import { Text } from '../../primitives/text';
75
+ import { cn } from '../../utils/cn';
76
+ import { Button } from '../button';
77
+
78
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
79
+
80
+ /** Room left between the cutout and the target inside it. */
81
+ const DEFAULT_PADDING = 8;
82
+ /** Corner radius of a rectangular cutout. */
83
+ const DEFAULT_RADIUS = 12;
84
+ /** Gap between the cutout and the card. */
85
+ const CARD_OFFSET = 12;
86
+ /** Smallest gap allowed between the card and the edge of the safe area. */
87
+ const SCREEN_MARGIN = 16;
88
+ /** Ceiling on the card's width, so it does not run edge to edge on a tablet. */
89
+ const MAX_CARD_WIDTH = 420;
90
+ /** How the spotlight travels from one target to the next. */
91
+ const SPRING = { damping: 20, stiffness: 180, mass: 0.6 };
92
+ /** The dim laid over everything outside the cutout. */
93
+ const DEFAULT_OVERLAY = 'rgba(0, 0, 0, 0.66)';
94
+
95
+ export type TourShape = 'rect' | 'circle';
96
+ export type TourPlacement = 'top' | 'bottom' | 'auto';
97
+
98
+ /** The words on the card's controls, for a tour that is not in English. */
99
+ export interface TourLabels {
100
+ next?: string;
101
+ back?: string;
102
+ done?: string;
103
+ skip?: string;
104
+ close?: string;
105
+ }
106
+
107
+ const DEFAULT_LABELS: Required<TourLabels> = {
108
+ next: 'Next',
109
+ back: 'Back',
110
+ done: 'Done',
111
+ skip: 'Skip',
112
+ close: 'End tour',
113
+ };
114
+
115
+ interface Rect {
116
+ x: number;
117
+ y: number;
118
+ width: number;
119
+ height: number;
120
+ }
121
+
122
+ /**
123
+ * One step as the root sees it: what to draw the hole around, and what to say
124
+ * about it. The ref rather than a measured rect, because a rect taken at
125
+ * registration is stale by the time the step comes up.
126
+ */
127
+ interface TourStepEntry {
128
+ order: number;
129
+ title?: string;
130
+ description?: string;
131
+ shape?: TourShape;
132
+ padding?: number;
133
+ radius?: number;
134
+ placement?: TourPlacement;
135
+ target: RefObject<View | null>;
136
+ }
137
+
138
+ interface TourContextValue {
139
+ register: (entry: TourStepEntry) => void;
140
+ unregister: (entry: TourStepEntry) => void;
141
+ }
142
+
143
+ const TourContext = createContext<TourContextValue | null>(null);
144
+
145
+ function useTour(component: string): TourContextValue {
146
+ const context = useContext(TourContext);
147
+ if (!context) {
148
+ throw new Error(`${component} must be used within a <Tour>`);
149
+ }
150
+ return context;
151
+ }
152
+
153
+ /**
154
+ * The screen with a rounded rectangle taken out of it, as one path.
155
+ *
156
+ * Two subpaths and `fillRule="evenodd"`: the outer one covers the screen, the
157
+ * inner one falls inside it, and even-odd makes the overlap a hole regardless
158
+ * of which way either is wound. That last part is why the inner rectangle is
159
+ * written in the natural direction rather than reversed — the winding is not
160
+ * load-bearing, and a reversed path is the kind of thing that gets tidied up
161
+ * by someone who cannot see why it was backwards.
162
+ */
163
+ function cutoutPath(
164
+ screenWidth: number,
165
+ screenHeight: number,
166
+ x: number,
167
+ y: number,
168
+ width: number,
169
+ height: number,
170
+ radius: number
171
+ ): string {
172
+ 'worklet';
173
+ const r = Math.max(0, Math.min(radius, width / 2, height / 2));
174
+ const right = x + width;
175
+ const bottom = y + height;
176
+
177
+ return (
178
+ `M0 0H${screenWidth}V${screenHeight}H0Z ` +
179
+ `M${x + r} ${y}` +
180
+ `H${right - r}A${r} ${r} 0 0 1 ${right} ${y + r}` +
181
+ `V${bottom - r}A${r} ${r} 0 0 1 ${right - r} ${bottom}` +
182
+ `H${x + r}A${r} ${r} 0 0 1 ${x} ${bottom - r}` +
183
+ `V${y + r}A${r} ${r} 0 0 1 ${x + r} ${y}Z`
184
+ );
185
+ }
186
+
187
+ /**
188
+ * The target's bounds grown into the shape the hole will take.
189
+ *
190
+ * A circle is squared around the target's centre rather than drawn inside its
191
+ * bounds, because the controls that want one — an avatar, a floating action
192
+ * button — are square already, and squaring off the longer side is what keeps
193
+ * a hole round instead of letting it collapse to a slot.
194
+ */
195
+ function spotlightFor(
196
+ rect: Rect,
197
+ shape: TourShape,
198
+ padding: number,
199
+ radius: number
200
+ ): Rect & { radius: number } {
201
+ if (shape === 'circle') {
202
+ const diameter = Math.max(rect.width, rect.height) + padding * 2;
203
+ return {
204
+ x: rect.x + rect.width / 2 - diameter / 2,
205
+ y: rect.y + rect.height / 2 - diameter / 2,
206
+ width: diameter,
207
+ height: diameter,
208
+ radius: diameter / 2,
209
+ };
210
+ }
211
+
212
+ return {
213
+ x: rect.x - padding,
214
+ y: rect.y - padding,
215
+ width: rect.width + padding * 2,
216
+ height: rect.height + padding * 2,
217
+ radius,
218
+ };
219
+ }
220
+
221
+ export interface TourProps {
222
+ children?: ReactNode;
223
+ /** Whether the walkthrough is running. */
224
+ open?: boolean;
225
+ /** Whether it is running when uncontrolled. */
226
+ defaultOpen?: boolean;
227
+ onOpenChange?: (open: boolean) => void;
228
+ /**
229
+ * The current step's `order`, controlled. Note that this is the author's
230
+ * numbering and not a position in the sequence — the two differ as soon as a
231
+ * step is conditional.
232
+ */
233
+ step?: number;
234
+ /** Where an uncontrolled tour starts. Defaults to the lowest `order`. */
235
+ defaultStep?: number;
236
+ /**
237
+ * Fires with the `order` about to be shown, before it is. This is where a
238
+ * target inside a scroller is brought back into view: the step is measured
239
+ * on the next frame, so a `scrollTo` issued here lands first.
240
+ */
241
+ onStepChange?: (step: number) => void;
242
+ /** The last step was acknowledged. */
243
+ onFinish?: () => void;
244
+ /** The tour was ended early — the skip control, the backdrop, or Android back. */
245
+ onSkip?: () => void;
246
+ /** Room left around every target, in pixels. 8 by default. A step may override it. */
247
+ padding?: number;
248
+ /** Corner radius of a rectangular cutout, in pixels. 12 by default. A step may override it. */
249
+ radius?: number;
250
+ /** Shape of every cutout. A step may override it. */
251
+ shape?: TourShape;
252
+ /**
253
+ * Which side of the target the card prefers. `auto` puts it below when below
254
+ * fits and above when it does not, which is the only behaviour that survives
255
+ * a target near an edge.
256
+ */
257
+ placement?: TourPlacement;
258
+ /** Ending the tour by pressing the dimmed area, or Android back. Default true. */
259
+ dismissible?: boolean;
260
+ /** Show "2 of 5" above the step's title. Default true. */
261
+ showProgress?: boolean;
262
+ /** Show the skip control. Default true. */
263
+ showSkip?: boolean;
264
+ /**
265
+ * Leave the spotlit control pressable.
266
+ *
267
+ * Off by default: a tour is usually read rather than used, and a control that
268
+ * reacts under the dim invites people to start doing the thing before they
269
+ * have been told what it does. Turn it on for the walkthrough that asks you
270
+ * to try the step — the target keeps its own `onPress`, so advancing the tour
271
+ * from it is the app's call.
272
+ */
273
+ interactive?: boolean;
274
+ /**
275
+ * The dim laid over everything outside the cutout. Black at 66% by default —
276
+ * dark enough that the hole reads as the only lit thing, light enough that
277
+ * the screen behind it is still recognisable as the screen you were on.
278
+ */
279
+ overlayColor?: string;
280
+ /** The words on the card's controls. */
281
+ labels?: TourLabels;
282
+ /** Extra classes for the card. */
283
+ cardClassName?: string;
284
+ }
285
+
286
+ function TourRoot({
287
+ children,
288
+ open,
289
+ defaultOpen = false,
290
+ onOpenChange,
291
+ step,
292
+ defaultStep,
293
+ onStepChange,
294
+ onFinish,
295
+ onSkip,
296
+ padding = DEFAULT_PADDING,
297
+ radius = DEFAULT_RADIUS,
298
+ shape = 'rect',
299
+ placement = 'auto',
300
+ dismissible = true,
301
+ showProgress = true,
302
+ showSkip = true,
303
+ interactive = false,
304
+ overlayColor = DEFAULT_OVERLAY,
305
+ labels,
306
+ cardClassName,
307
+ }: TourProps) {
308
+ const [steps, setSteps] = useState<TourStepEntry[]>([]);
309
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
310
+ const [internalStep, setInternalStep] = useState<number | null>(defaultStep ?? null);
311
+
312
+ const isOpenControlled = open !== undefined;
313
+ const isStepControlled = step !== undefined;
314
+ const resolvedOpen = isOpenControlled ? open : internalOpen;
315
+
316
+ const words = { ...DEFAULT_LABELS, ...labels };
317
+
318
+ /*
319
+ * Steps sort themselves by `order` rather than arriving in it, because the
320
+ * tree decides when each one mounts and a tour that crosses a header, a list
321
+ * and a tab bar mounts them in whatever order those render.
322
+ */
323
+ const register = useCallback((entry: TourStepEntry) => {
324
+ setSteps((current) =>
325
+ [...current.filter((other) => other.order !== entry.order), entry].sort(
326
+ (a, b) => a.order - b.order
327
+ )
328
+ );
329
+ }, []);
330
+
331
+ const unregister = useCallback((entry: TourStepEntry) => {
332
+ setSteps((current) => current.filter((other) => other !== entry));
333
+ }, []);
334
+
335
+ const context = useMemo(() => ({ register, unregister }), [register, unregister]);
336
+
337
+ const activeOrder = isStepControlled ? step : (internalStep ?? steps[0]?.order ?? null);
338
+ const index = steps.findIndex((entry) => entry.order === activeOrder);
339
+ const active = index >= 0 ? steps[index] : undefined;
340
+ const isFirst = index <= 0;
341
+ const isLast = index === steps.length - 1;
342
+
343
+ const setOpen = useCallback(
344
+ (next: boolean) => {
345
+ if (!isOpenControlled) setInternalOpen(next);
346
+ onOpenChange?.(next);
347
+ },
348
+ [isOpenControlled, onOpenChange]
349
+ );
350
+
351
+ const goTo = useCallback(
352
+ (order: number) => {
353
+ onStepChange?.(order);
354
+ if (!isStepControlled) setInternalStep(order);
355
+ },
356
+ [isStepControlled, onStepChange]
357
+ );
358
+
359
+ // Reopening starts the tour over rather than resuming where it was ended.
360
+ // Somebody who dismissed a walkthrough and asked for it again wants it from
361
+ // the top; resuming a half-read tour is a state nobody asked to be in.
362
+ useEffect(() => {
363
+ if (resolvedOpen && !isStepControlled) setInternalStep(defaultStep ?? null);
364
+ // eslint-disable-next-line react-hooks/exhaustive-deps
365
+ }, [resolvedOpen]);
366
+
367
+ const finish = useCallback(() => {
368
+ setOpen(false);
369
+ onFinish?.();
370
+ }, [setOpen, onFinish]);
371
+
372
+ const skip = useCallback(() => {
373
+ setOpen(false);
374
+ onSkip?.();
375
+ }, [setOpen, onSkip]);
376
+
377
+ const next = useCallback(() => {
378
+ const following = steps[index + 1];
379
+ if (following) goTo(following.order);
380
+ else finish();
381
+ }, [steps, index, goTo, finish]);
382
+
383
+ const back = useCallback(() => {
384
+ const previous = steps[index - 1];
385
+ if (previous) goTo(previous.order);
386
+ }, [steps, index, goTo]);
387
+
388
+ useBackHandler(resolvedOpen && dismissible, skip);
389
+
390
+ return (
391
+ <TourContext.Provider value={context}>
392
+ {children}
393
+ {resolvedOpen && steps.length > 0 ? (
394
+ <Portal>
395
+ <TourOverlay
396
+ active={active}
397
+ index={index}
398
+ total={steps.length}
399
+ isFirst={isFirst}
400
+ isLast={isLast}
401
+ padding={padding}
402
+ radius={radius}
403
+ shape={shape}
404
+ placement={placement}
405
+ dismissible={dismissible}
406
+ showProgress={showProgress}
407
+ showSkip={showSkip}
408
+ interactive={interactive}
409
+ overlayColor={overlayColor}
410
+ words={words}
411
+ cardClassName={cardClassName}
412
+ onNext={next}
413
+ onBack={back}
414
+ onSkip={skip}
415
+ />
416
+ </Portal>
417
+ ) : null}
418
+ </TourContext.Provider>
419
+ );
420
+ }
421
+
422
+ interface TourOverlayProps {
423
+ active: TourStepEntry | undefined;
424
+ index: number;
425
+ total: number;
426
+ isFirst: boolean;
427
+ isLast: boolean;
428
+ padding: number;
429
+ radius: number;
430
+ shape: TourShape;
431
+ placement: TourPlacement;
432
+ dismissible: boolean;
433
+ showProgress: boolean;
434
+ showSkip: boolean;
435
+ interactive: boolean;
436
+ overlayColor: string;
437
+ words: Required<TourLabels>;
438
+ cardClassName?: string;
439
+ onNext: () => void;
440
+ onBack: () => void;
441
+ onSkip: () => void;
442
+ }
443
+
444
+ function TourOverlay({
445
+ active,
446
+ index,
447
+ total,
448
+ isFirst,
449
+ isLast,
450
+ padding,
451
+ radius,
452
+ shape,
453
+ placement,
454
+ dismissible,
455
+ showProgress,
456
+ showSkip,
457
+ interactive,
458
+ overlayColor,
459
+ words,
460
+ cardClassName,
461
+ onNext,
462
+ onBack,
463
+ onSkip,
464
+ }: TourOverlayProps) {
465
+ const { width: screenWidth, height: screenHeight } = useWindowDimensions();
466
+ const insets = useSafeAreaInsets();
467
+ const reducedMotion = useReducedMotion();
468
+ const [spot, setSpot] = useState<(Rect & { radius: number }) | null>(null);
469
+ const [cardHeight, setCardHeight] = useState<number | null>(null);
470
+
471
+ const stepPadding = active?.padding ?? padding;
472
+ const stepRadius = active?.radius ?? radius;
473
+ const stepShape = active?.shape ?? shape;
474
+
475
+ /*
476
+ * Measured when the step becomes current and again whenever the window
477
+ * changes size. The second half is the part that is easy to leave out and
478
+ * impossible to miss once it is wrong: a rect taken in portrait describes
479
+ * nothing after a rotation, and the hole ends up over the wrong half of a
480
+ * screen the target is no longer on.
481
+ */
482
+ useEffect(() => {
483
+ const target = active?.target.current;
484
+ if (!target) {
485
+ setSpot(null);
486
+ return;
487
+ }
488
+
489
+ let cancelled = false;
490
+ // A frame late on purpose: a step whose target was just scrolled back into
491
+ // view is measured where it lands, not where it was leaving.
492
+ const frame = requestAnimationFrame(() => {
493
+ target.measureInWindow((x, y, width, height) => {
494
+ if (cancelled || (width === 0 && height === 0)) return;
495
+ setSpot(
496
+ spotlightFor({ x, y, width, height }, stepShape, stepPadding, stepRadius)
497
+ );
498
+ });
499
+ });
500
+
501
+ return () => {
502
+ cancelled = true;
503
+ cancelAnimationFrame(frame);
504
+ };
505
+ }, [active, stepShape, stepPadding, stepRadius, screenWidth, screenHeight]);
506
+
507
+ /*
508
+ * The hole's geometry lives on the UI thread so travelling between two
509
+ * targets is one spring rather than a state update per frame. `settled`
510
+ * distinguishes the first target — which appears where it belongs — from
511
+ * every later one, which slides there.
512
+ */
513
+ const x = useSharedValue(0);
514
+ const y = useSharedValue(0);
515
+ const width = useSharedValue(0);
516
+ const height = useSharedValue(0);
517
+ const cornerRadius = useSharedValue(0);
518
+ const settled = useSharedValue(false);
519
+
520
+ useEffect(() => {
521
+ // A step with nothing to point at collapses the hole rather than leaving
522
+ // the last one open: the previous target is no longer what is being talked
523
+ // about, and a hole over it says it is.
524
+ if (!spot) {
525
+ width.value = 0;
526
+ height.value = 0;
527
+ settled.value = false;
528
+ return;
529
+ }
530
+
531
+ const animate = settled.value && !reducedMotion;
532
+ const to = (value: typeof x, next: number) => {
533
+ value.value = animate ? withSpring(next, SPRING) : next;
534
+ };
535
+
536
+ to(x, spot.x);
537
+ to(y, spot.y);
538
+ to(width, spot.width);
539
+ to(height, spot.height);
540
+ to(cornerRadius, spot.radius);
541
+ settled.value = true;
542
+ }, [spot, reducedMotion, x, y, width, height, cornerRadius, settled]);
543
+
544
+ const pathProps = useAnimatedProps(() => ({
545
+ d: cutoutPath(
546
+ screenWidth,
547
+ screenHeight,
548
+ x.value,
549
+ y.value,
550
+ width.value,
551
+ height.value,
552
+ cornerRadius.value
553
+ ),
554
+ }));
555
+
556
+ /*
557
+ * A step with no measurable target — a welcome card, or one whose control has
558
+ * gone — gets no hole and a card in the middle of the screen. Dimming the
559
+ * whole screen and saying nothing about where to look is honest; cutting a
560
+ * hole at the origin is not.
561
+ */
562
+ const card = cardFrame({
563
+ spot,
564
+ cardHeight,
565
+ placement: active?.placement ?? placement,
566
+ screenWidth,
567
+ screenHeight,
568
+ insets,
569
+ });
570
+
571
+ const onCardLayout = (event: LayoutChangeEvent) => {
572
+ const measured = event.nativeEvent.layout.height;
573
+ setCardHeight((current) =>
574
+ current !== null && Math.abs(current - measured) < 1 ? current : measured
575
+ );
576
+ };
577
+
578
+ return (
579
+ <View style={StyleSheet.absoluteFill} pointerEvents="box-none">
580
+ {/*
581
+ The dim is one path with a hole in it and takes no touches, so what
582
+ handles them is the layer under it. That layer is a full-screen
583
+ Pressable normally and a ring of four around the cutout when the target
584
+ is meant to stay usable — the hole is the gap between them, which is
585
+ the only way to leave a rectangle of the screen pressable.
586
+ */}
587
+ <TourBackdrop
588
+ interactive={interactive}
589
+ dismissible={dismissible}
590
+ spot={spot}
591
+ screenWidth={screenWidth}
592
+ screenHeight={screenHeight}
593
+ onDismiss={onSkip}
594
+ />
595
+
596
+ <Animated.View
597
+ pointerEvents="none"
598
+ style={StyleSheet.absoluteFill}
599
+ entering={reducedMotion ? undefined : FadeIn.duration(180)}
600
+ exiting={reducedMotion ? undefined : FadeOut.duration(140)}
601
+ >
602
+ <Svg width={screenWidth} height={screenHeight}>
603
+ <AnimatedPath
604
+ animatedProps={pathProps}
605
+ fill={overlayColor}
606
+ fillRule="evenodd"
607
+ />
608
+ </Svg>
609
+ </Animated.View>
610
+
611
+ {/*
612
+ * Two views, and the split is not cosmetic. The entering animation
613
+ * drives opacity, and so does the gate below that hides the card for the
614
+ * frame it is being measured in — put on one view they fight, and
615
+ * Reanimated says so: a layout animation may overwrite a property the
616
+ * style also sets, and which of them wins is not something to rely on.
617
+ * The outer view owns the animation and the placement; the inner one
618
+ * owns the measurement and the gate.
619
+ */}
620
+ <Animated.View
621
+ key={active?.order ?? 'none'}
622
+ entering={reducedMotion ? undefined : FadeIn.duration(200)}
623
+ style={{
624
+ position: 'absolute',
625
+ left: card.left,
626
+ top: card.top,
627
+ width: card.width,
628
+ }}
629
+ >
630
+ <View
631
+ onLayout={onCardLayout}
632
+ accessibilityViewIsModal
633
+ accessibilityLiveRegion="polite"
634
+ style={{
635
+ // Held off the first frame's opacity rather than off the screen:
636
+ // the card has to be laid out to be measured, and its height is
637
+ // what decides whether it goes above the target or below it.
638
+ opacity: cardHeight === null ? 0 : 1,
639
+ }}
640
+ className={cn(
641
+ 'gap-3 rounded-2xl border border-border bg-overlay p-4 shadow-lg',
642
+ cardClassName
643
+ )}
644
+ >
645
+ {dismissible ? (
646
+ <Button
647
+ variant="ghost"
648
+ size="icon"
649
+ accessibilityLabel={words.close}
650
+ onPress={onSkip}
651
+ className="absolute end-1 top-1 h-9 w-9"
652
+ >
653
+ <XIcon size={16} />
654
+ </Button>
655
+ ) : null}
656
+
657
+ <View className="gap-1 pe-8">
658
+ {showProgress && total > 1 && index >= 0 ? (
659
+ <Text
660
+ size="xs"
661
+ muted
662
+ accessibilityLabel={`Step ${index + 1} of ${total}`}
663
+ >{`${index + 1} of ${total}`}</Text>
664
+ ) : null}
665
+ {active?.title ? (
666
+ <Text
667
+ accessibilityRole="header"
668
+ weight="semibold"
669
+ className="text-overlay-foreground"
670
+ >
671
+ {active.title}
672
+ </Text>
673
+ ) : null}
674
+ {active?.description ? (
675
+ <Text size="sm" muted>
676
+ {active.description}
677
+ </Text>
678
+ ) : null}
679
+ </View>
680
+
681
+ <View className="flex-row items-center justify-between gap-2">
682
+ <View className="flex-row items-center gap-1">
683
+ {showSkip && !isLast ? (
684
+ <Button variant="ghost" size="sm" onPress={onSkip}>
685
+ {words.skip}
686
+ </Button>
687
+ ) : null}
688
+ </View>
689
+
690
+ <View className="flex-row items-center gap-2">
691
+ {!isFirst ? (
692
+ <Button
693
+ variant="outline"
694
+ size="sm"
695
+ onPress={onBack}
696
+ startContent={<ChevronLeftIcon size={16} />}
697
+ >
698
+ {words.back}
699
+ </Button>
700
+ ) : null}
701
+ <Button variant="primary" size="sm" onPress={onNext}>
702
+ {isLast ? words.done : words.next}
703
+ </Button>
704
+ </View>
705
+ </View>
706
+ </View>
707
+ </Animated.View>
708
+ </View>
709
+ );
710
+ }
711
+
712
+ /**
713
+ * Where the card goes, given the hole and the card's own height.
714
+ *
715
+ * Below the target when below fits, above it when it does not, and centred on
716
+ * the screen when there is no target at all. The card is as wide as the safe
717
+ * area allows up to a ceiling, because a card narrower than that on a phone
718
+ * only means a shorter line length and one more thing to get wrong.
719
+ */
720
+ function cardFrame({
721
+ spot,
722
+ cardHeight,
723
+ placement,
724
+ screenWidth,
725
+ screenHeight,
726
+ insets,
727
+ }: {
728
+ spot: Rect | null;
729
+ cardHeight: number | null;
730
+ placement: TourPlacement;
731
+ screenWidth: number;
732
+ screenHeight: number;
733
+ insets: { top: number; bottom: number; left: number; right: number };
734
+ }): { left: number; top: number; width: number } {
735
+ const minX = insets.left + SCREEN_MARGIN;
736
+ const maxX = screenWidth - insets.right - SCREEN_MARGIN;
737
+ const width = Math.min(maxX - minX, MAX_CARD_WIDTH);
738
+ const left = minX + (maxX - minX - width) / 2;
739
+
740
+ const minY = insets.top + SCREEN_MARGIN;
741
+ const maxY = screenHeight - insets.bottom - SCREEN_MARGIN;
742
+ const height = cardHeight ?? 0;
743
+
744
+ if (!spot) {
745
+ return { left, top: Math.max(minY, (screenHeight - height) / 2), width };
746
+ }
747
+
748
+ const below = spot.y + spot.height + CARD_OFFSET;
749
+ const above = spot.y - CARD_OFFSET - height;
750
+ const fitsBelow = below + height <= maxY;
751
+ const fitsAbove = above >= minY;
752
+
753
+ const goBelow =
754
+ placement === 'bottom'
755
+ ? fitsBelow || !fitsAbove
756
+ : placement === 'top'
757
+ ? !fitsAbove
758
+ : fitsBelow;
759
+
760
+ // Neither side fits — a target taller than the room around it. Clamping keeps
761
+ // the card on screen and lets it overlap the dim rather than the other way
762
+ // round, which is the lesser of the two failures.
763
+ const top = goBelow ? Math.min(below, maxY - height) : Math.max(above, minY);
764
+
765
+ return { left, top: Math.max(minY, top), width };
766
+ }
767
+
768
+ /**
769
+ * The layer that takes the touches the dim does not.
770
+ *
771
+ * One Pressable over everything, or four around the cutout when the target has
772
+ * to stay usable. Four rather than one with a hole, because a view cannot have
773
+ * a hole — the gap between them is the hole, and it is the only construction
774
+ * that leaves a rectangle of the screen reachable.
775
+ */
776
+ function TourBackdrop({
777
+ interactive,
778
+ dismissible,
779
+ spot,
780
+ screenWidth,
781
+ screenHeight,
782
+ onDismiss,
783
+ }: {
784
+ interactive: boolean;
785
+ dismissible: boolean;
786
+ spot: Rect | null;
787
+ screenWidth: number;
788
+ screenHeight: number;
789
+ onDismiss: () => void;
790
+ }) {
791
+ const blocking = { onStartShouldSetResponder: () => true };
792
+ const press = dismissible
793
+ ? { onStartShouldSetResponder: () => true, onResponderRelease: onDismiss }
794
+ : blocking;
795
+
796
+ if (!interactive || !spot) {
797
+ return <View style={StyleSheet.absoluteFill} {...press} />;
798
+ }
799
+
800
+ const bottom = spot.y + spot.height;
801
+ const right = spot.x + spot.width;
802
+
803
+ return (
804
+ <>
805
+ <View
806
+ style={{
807
+ position: 'absolute',
808
+ left: 0,
809
+ top: 0,
810
+ right: 0,
811
+ height: Math.max(0, spot.y),
812
+ }}
813
+ {...press}
814
+ />
815
+ <View
816
+ style={{
817
+ position: 'absolute',
818
+ left: 0,
819
+ top: bottom,
820
+ right: 0,
821
+ height: Math.max(0, screenHeight - bottom),
822
+ }}
823
+ {...press}
824
+ />
825
+ <View
826
+ style={{
827
+ position: 'absolute',
828
+ left: 0,
829
+ top: spot.y,
830
+ width: Math.max(0, spot.x),
831
+ height: spot.height,
832
+ }}
833
+ {...press}
834
+ />
835
+ <View
836
+ style={{
837
+ position: 'absolute',
838
+ left: right,
839
+ top: spot.y,
840
+ width: Math.max(0, screenWidth - right),
841
+ height: spot.height,
842
+ }}
843
+ {...press}
844
+ />
845
+ </>
846
+ );
847
+ }
848
+
849
+ export interface TourStepProps extends Omit<ViewProps, 'children'> {
850
+ /**
851
+ * Where this step falls in the walkthrough. The author's numbering rather
852
+ * than the tree's, and unique within a tour — two steps sharing an order
853
+ * means one of them replaces the other.
854
+ */
855
+ order: number;
856
+ /** The step's heading. */
857
+ title?: string;
858
+ /** The sentence under it. */
859
+ description?: string;
860
+ /** Shape of this step's cutout, overriding the tour's. */
861
+ shape?: TourShape;
862
+ /** Room around this target, overriding the tour's. */
863
+ padding?: number;
864
+ /** Corner radius of this cutout, overriding the tour's. */
865
+ radius?: number;
866
+ /** Which side of this target the card prefers, overriding the tour's. */
867
+ placement?: TourPlacement;
868
+ className?: string;
869
+ /** The control this step is about. */
870
+ children?: ReactNode;
871
+ }
872
+
873
+ /**
874
+ * Wraps the control a step is about, and is what gets measured.
875
+ *
876
+ * The child is wrapped in a view rather than handed a ref, because the ref has
877
+ * to survive whatever the child is — a button, a card, a tab bar — and only a
878
+ * wrapper we own is guaranteed to be measurable. That wrapper is a plain view
879
+ * with no sizing of its own, so it takes the width its parent gives it: put
880
+ * layout classes on the step rather than on the child, the way you would on any
881
+ * other view in that position.
882
+ *
883
+ * It renders its child and nothing else while the tour is closed, and stays
884
+ * mounted either way — a step is a description of a control that is already on
885
+ * the screen, not something that appears with the walkthrough.
886
+ */
887
+ function TourStep({
888
+ order,
889
+ title,
890
+ description,
891
+ shape,
892
+ padding,
893
+ radius,
894
+ placement,
895
+ className,
896
+ children,
897
+ ...props
898
+ }: TourStepProps) {
899
+ const { register, unregister } = useTour('Tour.Step');
900
+ const target = useRef<View>(null);
901
+
902
+ const entry = useMemo<TourStepEntry>(
903
+ () => ({
904
+ order,
905
+ title,
906
+ description,
907
+ shape,
908
+ padding,
909
+ radius,
910
+ placement,
911
+ target,
912
+ }),
913
+ [order, title, description, shape, padding, radius, placement]
914
+ );
915
+
916
+ useEffect(() => {
917
+ register(entry);
918
+ return () => unregister(entry);
919
+ }, [entry, register, unregister]);
920
+
921
+ return (
922
+ <View ref={target} collapsable={false} className={className} {...props}>
923
+ {children}
924
+ </View>
925
+ );
926
+ }
927
+ TourStep.displayName = 'Tour.Step';
928
+
929
+ TourRoot.displayName = 'Tour';
930
+
931
+ export const Tour = Object.assign(TourRoot, {
932
+ Step: TourStep,
933
+ });