@korsolutions/guidon 1.0.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.
@@ -0,0 +1,158 @@
1
+ import { useEffect, useCallback, useRef, createContext, useContext } from 'react';
2
+ import { useGuidonStore } from '../store';
3
+ import { useGuidonPersistence } from '../persistence/hooks';
4
+ import { GuidonOverlay } from './GuidonOverlay';
5
+ import { GuidonTooltip } from './GuidonTooltip';
6
+ import type {
7
+ GuidonProviderProps,
8
+ GuidonConfig,
9
+ GuidonStore,
10
+ } from '../types';
11
+
12
+ interface GuidonContextValue {
13
+ start: () => void;
14
+ skip: () => void;
15
+ reset: () => void;
16
+ replay: () => Promise<void>;
17
+ isActive: boolean;
18
+ isCompleted: boolean;
19
+ isLoading: boolean;
20
+ }
21
+
22
+ const GuidonContext = createContext<GuidonContextValue | null>(null);
23
+
24
+ export function useGuidonContext() {
25
+ const context = useContext(GuidonContext);
26
+ if (!context) {
27
+ throw new Error('useGuidonContext must be used within a GuidonProvider');
28
+ }
29
+ return context;
30
+ }
31
+
32
+ export function GuidonProvider({
33
+ children,
34
+ config,
35
+ autoStart = true,
36
+ shouldStart,
37
+ persistenceAdapter,
38
+ portalComponent: Portal,
39
+ renderTooltip,
40
+ tooltipLabels,
41
+ onBackdropPress,
42
+ }: GuidonProviderProps) {
43
+ const hasInitialized = useRef(false);
44
+ const isActive = useGuidonStore((state: GuidonStore) => state.isActive);
45
+ const storeIsCompleted = useGuidonStore((state: GuidonStore) => state.isCompleted);
46
+ const currentStepIndex = useGuidonStore((state: GuidonStore) => state.currentStepIndex);
47
+ const configure = useGuidonStore((state: GuidonStore) => state.configure);
48
+ const start = useGuidonStore((state: GuidonStore) => state.start);
49
+ const skip = useGuidonStore((state: GuidonStore) => state.skip);
50
+ const reset = useGuidonStore((state: GuidonStore) => state.reset);
51
+
52
+ const {
53
+ isLoading,
54
+ isCompleted: persistedCompleted,
55
+ markCompleted,
56
+ markStepViewed,
57
+ clearProgress,
58
+ } = useGuidonPersistence(persistenceAdapter, config.id);
59
+
60
+ const isCompleted = storeIsCompleted || persistedCompleted;
61
+
62
+ useEffect(() => {
63
+ const enhancedConfig: GuidonConfig = {
64
+ ...config,
65
+ onComplete: async () => {
66
+ config.onComplete?.();
67
+ if (persistenceAdapter) {
68
+ await markCompleted();
69
+ }
70
+ },
71
+ onSkip: async () => {
72
+ config.onSkip?.();
73
+ if (persistenceAdapter) {
74
+ await markStepViewed(currentStepIndex);
75
+ }
76
+ },
77
+ onStepChange: async (stepIndex, step) => {
78
+ config.onStepChange?.(stepIndex, step);
79
+ if (persistenceAdapter) {
80
+ await markStepViewed(stepIndex);
81
+ }
82
+ },
83
+ };
84
+
85
+ configure(enhancedConfig);
86
+ }, [config, configure, persistenceAdapter, markCompleted, markStepViewed, currentStepIndex]);
87
+
88
+ useEffect(() => {
89
+ if (!autoStart || hasInitialized.current || isLoading) return;
90
+
91
+ const checkAndStart = async () => {
92
+ hasInitialized.current = true;
93
+
94
+ if (persistedCompleted) return;
95
+
96
+ if (shouldStart) {
97
+ const should = await shouldStart();
98
+ if (!should) return;
99
+ }
100
+
101
+ setTimeout(() => {
102
+ start();
103
+ }, 500);
104
+ };
105
+
106
+ checkAndStart();
107
+ }, [autoStart, isLoading, persistedCompleted, shouldStart, start]);
108
+
109
+ const replay = useCallback(async () => {
110
+ if (persistenceAdapter) {
111
+ await clearProgress();
112
+ }
113
+ reset();
114
+ hasInitialized.current = false;
115
+ setTimeout(() => {
116
+ start();
117
+ }, 100);
118
+ }, [persistenceAdapter, clearProgress, reset, start]);
119
+
120
+ const manualStart = useCallback(() => {
121
+ if (!isActive && !isLoading) {
122
+ start();
123
+ }
124
+ }, [isActive, isLoading, start]);
125
+
126
+ const contextValue: GuidonContextValue = {
127
+ start: manualStart,
128
+ skip,
129
+ reset,
130
+ replay,
131
+ isActive,
132
+ isCompleted,
133
+ isLoading,
134
+ };
135
+
136
+ const overlayContent = (
137
+ <>
138
+ <GuidonOverlay
139
+ theme={config.theme}
140
+ animationDuration={config.animationDuration}
141
+ onBackdropPress={onBackdropPress}
142
+ />
143
+ <GuidonTooltip
144
+ theme={config.theme}
145
+ animationDuration={config.animationDuration}
146
+ renderCustomTooltip={renderTooltip}
147
+ labels={tooltipLabels}
148
+ />
149
+ </>
150
+ );
151
+
152
+ return (
153
+ <GuidonContext.Provider value={contextValue}>
154
+ {children}
155
+ {Portal ? <Portal>{overlayContent}</Portal> : overlayContent}
156
+ </GuidonContext.Provider>
157
+ );
158
+ }
@@ -0,0 +1,108 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ import { View, Platform, type LayoutChangeEvent } from 'react-native';
3
+ import { useGuidonStore } from '../store';
4
+ import type { GuidonTargetProps, TargetMeasurements, GuidonStore, GuidonStep } from '../types';
5
+
6
+ /**
7
+ * Wrapper component that marks an element as a walkthrough target
8
+ * Automatically measures and reports its position to the walkthrough store
9
+ */
10
+ export function GuidonTarget({
11
+ children,
12
+ targetId,
13
+ active = true,
14
+ }: GuidonTargetProps) {
15
+ const viewRef = useRef<View>(null);
16
+ const registerTarget = useGuidonStore((state: GuidonStore) => state.registerTarget);
17
+ const unregisterTarget = useGuidonStore((state: GuidonStore) => state.unregisterTarget);
18
+ const isActive = useGuidonStore((state: GuidonStore) => state.isActive);
19
+ const config = useGuidonStore((state: GuidonStore) => state.config);
20
+
21
+ // Check if this target is needed for the current walkthrough
22
+ const isTargetNeeded =
23
+ isActive && config?.steps.some((step: GuidonStep) => step.targetId === targetId);
24
+
25
+ const measureElement = useCallback(() => {
26
+ if (!viewRef.current || !active || !isTargetNeeded) return;
27
+
28
+ if (Platform.OS === 'web') {
29
+ // Web measurement using getBoundingClientRect
30
+ const element = viewRef.current as unknown as HTMLElement;
31
+ if (element && typeof element.getBoundingClientRect === 'function') {
32
+ const rect = element.getBoundingClientRect();
33
+ const measurements: TargetMeasurements = {
34
+ x: rect.left + window.scrollX,
35
+ y: rect.top + window.scrollY,
36
+ width: rect.width,
37
+ height: rect.height,
38
+ };
39
+ registerTarget(targetId, measurements);
40
+ }
41
+ } else {
42
+ // Native measurement using measureInWindow
43
+ viewRef.current.measureInWindow((x, y, width, height) => {
44
+ if (width > 0 && height > 0) {
45
+ const measurements: TargetMeasurements = { x, y, width, height };
46
+ registerTarget(targetId, measurements);
47
+ }
48
+ });
49
+ }
50
+ }, [targetId, active, isTargetNeeded, registerTarget]);
51
+
52
+ // Measure on layout change
53
+ const handleLayout = useCallback(
54
+ (_event: LayoutChangeEvent) => {
55
+ // Small delay to ensure layout is complete
56
+ requestAnimationFrame(() => {
57
+ measureElement();
58
+ });
59
+ },
60
+ [measureElement]
61
+ );
62
+
63
+ // Re-measure when walkthrough becomes active or when this target becomes relevant
64
+ useEffect(() => {
65
+ if (isTargetNeeded) {
66
+ // Delay to ensure the element is rendered
67
+ const timer = setTimeout(() => {
68
+ measureElement();
69
+ }, 100);
70
+ return () => clearTimeout(timer);
71
+ }
72
+ }, [isTargetNeeded, measureElement]);
73
+
74
+ // Re-measure on scroll (web only)
75
+ useEffect(() => {
76
+ if (Platform.OS !== 'web' || !isTargetNeeded) return;
77
+
78
+ const handleScroll = () => {
79
+ measureElement();
80
+ };
81
+
82
+ window.addEventListener('scroll', handleScroll, true);
83
+ window.addEventListener('resize', handleScroll);
84
+
85
+ return () => {
86
+ window.removeEventListener('scroll', handleScroll, true);
87
+ window.removeEventListener('resize', handleScroll);
88
+ };
89
+ }, [isTargetNeeded, measureElement]);
90
+
91
+ // Unregister on unmount
92
+ useEffect(() => {
93
+ return () => {
94
+ unregisterTarget(targetId);
95
+ };
96
+ }, [targetId, unregisterTarget]);
97
+
98
+ return (
99
+ <View
100
+ ref={viewRef}
101
+ onLayout={handleLayout}
102
+ collapsable={false}
103
+ style={{ alignSelf: 'flex-start' }}
104
+ >
105
+ {children}
106
+ </View>
107
+ );
108
+ }
@@ -0,0 +1,365 @@
1
+ import { useMemo } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ StyleSheet,
6
+ Dimensions,
7
+ TouchableOpacity,
8
+ Platform,
9
+ } from 'react-native';
10
+ import Animated, {
11
+ useAnimatedStyle,
12
+ withTiming,
13
+ withSpring,
14
+ Easing,
15
+ } from 'react-native-reanimated';
16
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
17
+ import { useGuidonStore } from '../store';
18
+ import type { GuidonTheme, TooltipPosition, TargetMeasurements, GuidonStore, GuidonStep } from '../types';
19
+
20
+ const DEFAULT_THEME: Required<
21
+ Pick<
22
+ GuidonTheme,
23
+ | 'tooltipBackgroundColor'
24
+ | 'tooltipBorderColor'
25
+ | 'tooltipBorderRadius'
26
+ | 'titleColor'
27
+ | 'descriptionColor'
28
+ | 'primaryColor'
29
+ | 'mutedColor'
30
+ | 'spotlightPadding'
31
+ >
32
+ > = {
33
+ tooltipBackgroundColor: '#ffffff',
34
+ tooltipBorderColor: '#e5e5e5',
35
+ tooltipBorderRadius: 12,
36
+ titleColor: '#1a1a1a',
37
+ descriptionColor: '#666666',
38
+ primaryColor: '#007AFF',
39
+ mutedColor: '#999999',
40
+ spotlightPadding: 8,
41
+ };
42
+
43
+ const TOOLTIP_MARGIN = 16;
44
+ const TOOLTIP_WIDTH = 300;
45
+
46
+ interface GuidonTooltipProps {
47
+ theme?: GuidonTheme;
48
+ animationDuration?: number;
49
+ renderCustomTooltip?: (props: {
50
+ step: GuidonStep;
51
+ currentIndex: number;
52
+ totalSteps: number;
53
+ onNext: () => void;
54
+ onPrevious: () => void;
55
+ onSkip: () => void;
56
+ }) => React.ReactNode;
57
+ labels?: {
58
+ next?: string;
59
+ previous?: string;
60
+ skip?: string;
61
+ finish?: string;
62
+ stepOf?: (current: number, total: number) => string;
63
+ };
64
+ }
65
+
66
+ export function GuidonTooltip({
67
+ theme = {},
68
+ animationDuration = 300,
69
+ renderCustomTooltip,
70
+ labels = {},
71
+ }: GuidonTooltipProps) {
72
+ const insets = useSafeAreaInsets();
73
+ const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
74
+
75
+ const isActive = useGuidonStore((state: GuidonStore) => state.isActive);
76
+ const config = useGuidonStore((state: GuidonStore) => state.config);
77
+ const currentStepIndex = useGuidonStore((state: GuidonStore) => state.currentStepIndex);
78
+ const targetMeasurements = useGuidonStore((state: GuidonStore) => state.targetMeasurements);
79
+ const next = useGuidonStore((state: GuidonStore) => state.next);
80
+ const previous = useGuidonStore((state: GuidonStore) => state.previous);
81
+ const skip = useGuidonStore((state: GuidonStore) => state.skip);
82
+
83
+ const mergedTheme = { ...DEFAULT_THEME, ...theme };
84
+ const mergedLabels = {
85
+ next: labels.next ?? 'Next',
86
+ previous: labels.previous ?? 'Back',
87
+ skip: labels.skip ?? 'Skip',
88
+ finish: labels.finish ?? 'Finish',
89
+ stepOf: labels.stepOf ?? ((c: number, t: number) => `${c} of ${t}`),
90
+ };
91
+
92
+ const currentStep = config?.steps[currentStepIndex];
93
+ const totalSteps = config?.steps.length ?? 0;
94
+ const isFirstStep = currentStepIndex === 0;
95
+ const isLastStep = currentStepIndex === totalSteps - 1;
96
+
97
+ const measurements: TargetMeasurements | undefined = currentStep?.targetId
98
+ ? targetMeasurements[currentStep.targetId]
99
+ : undefined;
100
+
101
+ // Calculate tooltip position
102
+ const tooltipPosition = useMemo(() => {
103
+ if (!measurements) {
104
+ return { top: screenHeight / 2, left: screenWidth / 2 - TOOLTIP_WIDTH / 2 };
105
+ }
106
+
107
+ const targetCenterY = measurements.y + measurements.height / 2;
108
+ const targetBottom = measurements.y + measurements.height + mergedTheme.spotlightPadding;
109
+ const targetTop = measurements.y - mergedTheme.spotlightPadding;
110
+
111
+ // Determine preferred position
112
+ let position: TooltipPosition = currentStep?.tooltipPosition ?? 'auto';
113
+
114
+ if (position === 'auto') {
115
+ // Auto-detect best position
116
+ const spaceAbove = targetTop - insets.top;
117
+ const spaceBelow = screenHeight - targetBottom - insets.bottom;
118
+
119
+ position = spaceBelow >= 200 ? 'bottom' : spaceAbove >= 200 ? 'top' : 'bottom';
120
+ }
121
+
122
+ let top: number;
123
+ let left: number = Math.max(
124
+ TOOLTIP_MARGIN,
125
+ Math.min(
126
+ measurements.x + measurements.width / 2 - TOOLTIP_WIDTH / 2,
127
+ screenWidth - TOOLTIP_WIDTH - TOOLTIP_MARGIN
128
+ )
129
+ );
130
+
131
+ if (position === 'top') {
132
+ top = targetTop - TOOLTIP_MARGIN - 150; // Approximate tooltip height
133
+ } else {
134
+ top = targetBottom + TOOLTIP_MARGIN;
135
+ }
136
+
137
+ // Ensure tooltip is within screen bounds
138
+ top = Math.max(insets.top + TOOLTIP_MARGIN, Math.min(top, screenHeight - 200 - insets.bottom));
139
+
140
+ return { top, left, position };
141
+ }, [measurements, screenWidth, screenHeight, insets, currentStep?.tooltipPosition, mergedTheme.spotlightPadding]);
142
+
143
+ // Animated styles
144
+ const animatedStyle = useAnimatedStyle(() => {
145
+ return {
146
+ opacity: withTiming(isActive && measurements ? 1 : 0, {
147
+ duration: animationDuration,
148
+ easing: Easing.inOut(Easing.ease),
149
+ }),
150
+ transform: [
151
+ {
152
+ translateY: withSpring(isActive && measurements ? 0 : 20, {
153
+ damping: 15,
154
+ stiffness: 150,
155
+ }),
156
+ },
157
+ ],
158
+ };
159
+ }, [isActive, measurements, animationDuration]);
160
+
161
+ if (!isActive || !currentStep || !measurements) {
162
+ return null;
163
+ }
164
+
165
+ // Render custom tooltip if provided
166
+ if (renderCustomTooltip) {
167
+ return (
168
+ <Animated.View
169
+ style={[
170
+ styles.tooltipContainer,
171
+ {
172
+ top: tooltipPosition.top,
173
+ left: tooltipPosition.left,
174
+ width: TOOLTIP_WIDTH,
175
+ },
176
+ animatedStyle,
177
+ ]}
178
+ >
179
+ {renderCustomTooltip({
180
+ step: currentStep,
181
+ currentIndex: currentStepIndex,
182
+ totalSteps,
183
+ onNext: next,
184
+ onPrevious: previous,
185
+ onSkip: skip,
186
+ })}
187
+ </Animated.View>
188
+ );
189
+ }
190
+
191
+ // Default tooltip UI
192
+ return (
193
+ <Animated.View
194
+ style={[
195
+ styles.tooltipContainer,
196
+ {
197
+ top: tooltipPosition.top,
198
+ left: tooltipPosition.left,
199
+ width: TOOLTIP_WIDTH,
200
+ backgroundColor: mergedTheme.tooltipBackgroundColor,
201
+ borderColor: mergedTheme.tooltipBorderColor,
202
+ borderRadius: mergedTheme.tooltipBorderRadius,
203
+ },
204
+ animatedStyle,
205
+ ]}
206
+ >
207
+ {/* Progress indicator */}
208
+ <View style={styles.progressContainer}>
209
+ <Text style={[styles.progressText, { color: mergedTheme.mutedColor }]}>
210
+ {mergedLabels.stepOf(currentStepIndex + 1, totalSteps)}
211
+ </Text>
212
+ <View style={styles.progressDots}>
213
+ {Array.from({ length: totalSteps }).map((_, i) => (
214
+ <View
215
+ key={i}
216
+ style={[
217
+ styles.progressDot,
218
+ {
219
+ backgroundColor:
220
+ i === currentStepIndex
221
+ ? mergedTheme.primaryColor
222
+ : mergedTheme.mutedColor,
223
+ opacity: i === currentStepIndex ? 1 : 0.3,
224
+ },
225
+ ]}
226
+ />
227
+ ))}
228
+ </View>
229
+ </View>
230
+
231
+ {/* Content */}
232
+ <View style={styles.content}>
233
+ <Text style={[styles.title, { color: mergedTheme.titleColor }]}>
234
+ {currentStep.title}
235
+ </Text>
236
+ <Text style={[styles.description, { color: mergedTheme.descriptionColor }]}>
237
+ {currentStep.description}
238
+ </Text>
239
+ {currentStep.customContent}
240
+ </View>
241
+
242
+ {/* Navigation buttons */}
243
+ <View style={styles.buttonContainer}>
244
+ <TouchableOpacity onPress={skip} style={styles.skipButton}>
245
+ <Text style={[styles.skipText, { color: mergedTheme.mutedColor }]}>
246
+ {mergedLabels.skip}
247
+ </Text>
248
+ </TouchableOpacity>
249
+
250
+ <View style={styles.navButtons}>
251
+ {!isFirstStep && (
252
+ <TouchableOpacity
253
+ onPress={previous}
254
+ style={[styles.navButton, styles.backButton]}
255
+ >
256
+ <Text style={[styles.backButtonText, { color: mergedTheme.primaryColor }]}>
257
+ {mergedLabels.previous}
258
+ </Text>
259
+ </TouchableOpacity>
260
+ )}
261
+ <TouchableOpacity
262
+ onPress={next}
263
+ style={[
264
+ styles.navButton,
265
+ styles.nextButton,
266
+ { backgroundColor: mergedTheme.primaryColor },
267
+ ]}
268
+ >
269
+ <Text style={styles.nextButtonText}>
270
+ {isLastStep ? mergedLabels.finish : mergedLabels.next}
271
+ </Text>
272
+ </TouchableOpacity>
273
+ </View>
274
+ </View>
275
+ </Animated.View>
276
+ );
277
+ }
278
+
279
+ const styles = StyleSheet.create({
280
+ tooltipContainer: {
281
+ position: 'absolute',
282
+ zIndex: 1000,
283
+ borderWidth: 1,
284
+ padding: 16,
285
+ ...Platform.select({
286
+ ios: {
287
+ shadowColor: '#000',
288
+ shadowOffset: { width: 0, height: 4 },
289
+ shadowOpacity: 0.15,
290
+ shadowRadius: 12,
291
+ },
292
+ android: {
293
+ elevation: 8,
294
+ },
295
+ web: {
296
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
297
+ } as any,
298
+ }),
299
+ },
300
+ progressContainer: {
301
+ flexDirection: 'row',
302
+ alignItems: 'center',
303
+ justifyContent: 'space-between',
304
+ marginBottom: 12,
305
+ },
306
+ progressText: {
307
+ fontSize: 12,
308
+ fontWeight: '500',
309
+ },
310
+ progressDots: {
311
+ flexDirection: 'row',
312
+ gap: 4,
313
+ },
314
+ progressDot: {
315
+ width: 6,
316
+ height: 6,
317
+ borderRadius: 3,
318
+ },
319
+ content: {
320
+ marginBottom: 16,
321
+ },
322
+ title: {
323
+ fontSize: 18,
324
+ fontWeight: '600',
325
+ marginBottom: 8,
326
+ },
327
+ description: {
328
+ fontSize: 14,
329
+ lineHeight: 20,
330
+ },
331
+ buttonContainer: {
332
+ flexDirection: 'row',
333
+ alignItems: 'center',
334
+ justifyContent: 'space-between',
335
+ },
336
+ skipButton: {
337
+ paddingVertical: 8,
338
+ paddingHorizontal: 4,
339
+ },
340
+ skipText: {
341
+ fontSize: 14,
342
+ },
343
+ navButtons: {
344
+ flexDirection: 'row',
345
+ gap: 8,
346
+ },
347
+ navButton: {
348
+ paddingVertical: 10,
349
+ paddingHorizontal: 16,
350
+ borderRadius: 8,
351
+ },
352
+ backButton: {
353
+ backgroundColor: 'transparent',
354
+ },
355
+ backButtonText: {
356
+ fontSize: 14,
357
+ fontWeight: '600',
358
+ },
359
+ nextButton: {},
360
+ nextButtonText: {
361
+ color: '#ffffff',
362
+ fontSize: 14,
363
+ fontWeight: '600',
364
+ },
365
+ });
@@ -0,0 +1,4 @@
1
+ export { GuidonTarget } from './GuidonTarget';
2
+ export { GuidonOverlay } from './GuidonOverlay';
3
+ export { GuidonTooltip } from './GuidonTooltip';
4
+ export { GuidonProvider, useGuidonContext } from './GuidonProvider';
package/src/index.ts ADDED
@@ -0,0 +1,51 @@
1
+ // Components
2
+ export {
3
+ GuidonTarget,
4
+ GuidonOverlay,
5
+ GuidonTooltip,
6
+ GuidonProvider,
7
+ useGuidonContext,
8
+ } from "./components";
9
+
10
+ // Store and API
11
+ export {
12
+ useGuidonStore,
13
+ Guidon,
14
+ useGuidonActive,
15
+ useGuidonStep,
16
+ useGuidonProgress,
17
+ useTargetMeasurements,
18
+ } from "./store";
19
+
20
+ // Types
21
+ export type {
22
+ GuidonStep,
23
+ GuidonConfig,
24
+ GuidonTheme,
25
+ GuidonProgress,
26
+ GuidonPersistenceAdapter,
27
+ GuidonProviderProps,
28
+ GuidonTargetProps,
29
+ GuidonTooltipLabels,
30
+ GuidonTooltipRenderProps,
31
+ TooltipPosition,
32
+ TargetMeasurements,
33
+ GuidonState,
34
+ GuidonActions,
35
+ GuidonStore,
36
+ } from "./types";
37
+
38
+ // Persistence adapters
39
+ export {
40
+ createNoopAdapter,
41
+ createMemoryAdapter,
42
+ createLocalStorageAdapter,
43
+ createAsyncStorageAdapter,
44
+ createApiAdapter,
45
+ createCompositeAdapter,
46
+ } from "./persistence/adapters";
47
+
48
+ export {
49
+ useGuidonPersistence,
50
+ useShouldShowGuidon,
51
+ } from "./persistence/hooks";