panelui-native 0.54.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.
- package/README.md +1 -0
- package/lib/module/components/combobox/index.js +61 -7
- package/lib/module/components/combobox/index.js.map +1 -1
- package/lib/module/components/hex-chart/index.js +765 -0
- package/lib/module/components/hex-chart/index.js.map +1 -0
- package/lib/module/components/sortable/index.js +74 -12
- package/lib/module/components/sortable/index.js.map +1 -1
- package/lib/module/components/steps/index.js +95 -12
- package/lib/module/components/steps/index.js.map +1 -1
- package/lib/module/components/tag-input/index.js +435 -0
- package/lib/module/components/tag-input/index.js.map +1 -0
- package/lib/module/components/tour/index.js +661 -0
- package/lib/module/components/tour/index.js.map +1 -0
- package/lib/module/index.js +3 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/chart.js +209 -0
- package/lib/module/utils/chart.js.map +1 -1
- package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
- package/lib/typescript/src/components/hex-chart/index.d.ts +277 -0
- package/lib/typescript/src/components/hex-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/sortable/index.d.ts.map +1 -1
- package/lib/typescript/src/components/steps/index.d.ts +19 -0
- package/lib/typescript/src/components/steps/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tag-input/index.d.ts +240 -0
- package/lib/typescript/src/components/tag-input/index.d.ts.map +1 -0
- package/lib/typescript/src/components/tour/index.d.ts +168 -0
- package/lib/typescript/src/components/tour/index.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +3 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/chart.d.ts +98 -0
- package/lib/typescript/src/utils/chart.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/hex-chart/index.tsx +1009 -0
- package/src/components/steps/index.tsx +103 -8
- package/src/components/tour/index.tsx +933 -0
- package/src/index.ts +22 -0
- 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
|
+
});
|