@korsolutions/guidon 1.0.1 → 1.0.3

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 (143) hide show
  1. package/dist/commonjs/babel.config.js +15 -0
  2. package/dist/commonjs/babel.config.js.map +1 -0
  3. package/dist/commonjs/bob.config.js +11 -0
  4. package/dist/commonjs/bob.config.js.map +1 -0
  5. package/dist/commonjs/components/GuidonOverlay.js +206 -0
  6. package/dist/commonjs/components/GuidonOverlay.js.map +1 -0
  7. package/dist/commonjs/components/GuidonProvider.js +157 -0
  8. package/dist/commonjs/components/GuidonProvider.js.map +1 -0
  9. package/dist/commonjs/components/GuidonTarget.js +110 -0
  10. package/dist/commonjs/components/GuidonTarget.js.map +1 -0
  11. package/dist/commonjs/components/GuidonTooltip.js +422 -0
  12. package/dist/commonjs/components/GuidonTooltip.js.map +1 -0
  13. package/dist/commonjs/components/index.js +40 -0
  14. package/dist/commonjs/components/index.js.map +1 -0
  15. package/dist/commonjs/hooks/index.js +13 -0
  16. package/dist/commonjs/hooks/index.js.map +1 -0
  17. package/dist/commonjs/hooks/useGuidonRef.js +132 -0
  18. package/dist/commonjs/hooks/useGuidonRef.js.map +1 -0
  19. package/dist/commonjs/index.js +143 -0
  20. package/dist/commonjs/index.js.map +1 -0
  21. package/dist/commonjs/package.json +1 -0
  22. package/dist/commonjs/persistence/adapters.js +213 -0
  23. package/dist/commonjs/persistence/adapters.js.map +1 -0
  24. package/dist/commonjs/persistence/hooks.js +153 -0
  25. package/dist/commonjs/persistence/hooks.js.map +1 -0
  26. package/dist/commonjs/persistence/index.js +28 -0
  27. package/dist/commonjs/persistence/index.js.map +1 -0
  28. package/dist/commonjs/store.js +314 -0
  29. package/dist/commonjs/store.js.map +1 -0
  30. package/dist/commonjs/tsconfig.json +32 -0
  31. package/dist/commonjs/types.js +6 -0
  32. package/dist/commonjs/types.js.map +1 -0
  33. package/dist/module/babel.config.js +15 -0
  34. package/dist/module/babel.config.js.map +1 -0
  35. package/dist/module/bob.config.js +11 -0
  36. package/dist/module/bob.config.js.map +1 -0
  37. package/dist/module/components/GuidonOverlay.js +201 -0
  38. package/dist/module/components/GuidonOverlay.js.map +1 -0
  39. package/dist/module/components/GuidonProvider.js +152 -0
  40. package/dist/module/components/GuidonProvider.js.map +1 -0
  41. package/dist/module/components/GuidonTarget.js +106 -0
  42. package/dist/module/components/GuidonTarget.js.map +1 -0
  43. package/dist/module/components/GuidonTooltip.js +417 -0
  44. package/dist/module/components/GuidonTooltip.js.map +1 -0
  45. package/dist/module/components/index.js +7 -0
  46. package/dist/module/components/index.js.map +1 -0
  47. package/dist/module/hooks/index.js +4 -0
  48. package/dist/module/hooks/index.js.map +1 -0
  49. package/dist/module/hooks/useGuidonRef.js +129 -0
  50. package/dist/module/hooks/useGuidonRef.js.map +1 -0
  51. package/dist/module/index.js +17 -0
  52. package/dist/module/index.js.map +1 -0
  53. package/dist/module/package.json +1 -0
  54. package/dist/module/persistence/adapters.js +203 -0
  55. package/dist/module/persistence/adapters.js.map +1 -0
  56. package/dist/module/persistence/hooks.js +148 -0
  57. package/dist/module/persistence/hooks.js.map +1 -0
  58. package/dist/module/persistence/index.js +5 -0
  59. package/dist/module/persistence/index.js.map +1 -0
  60. package/dist/module/store.js +304 -0
  61. package/dist/module/store.js.map +1 -0
  62. package/dist/module/tsconfig.json +32 -0
  63. package/dist/module/types.js +4 -0
  64. package/dist/module/types.js.map +1 -0
  65. package/dist/typescript/commonjs/components/GuidonOverlay.d.ts +9 -0
  66. package/dist/typescript/commonjs/components/GuidonOverlay.d.ts.map +1 -0
  67. package/dist/typescript/commonjs/components/GuidonProvider.d.ts +14 -0
  68. package/dist/typescript/commonjs/components/GuidonProvider.d.ts.map +1 -0
  69. package/dist/typescript/commonjs/components/GuidonTarget.d.ts +7 -0
  70. package/dist/typescript/commonjs/components/GuidonTarget.d.ts.map +1 -0
  71. package/dist/typescript/commonjs/components/GuidonTooltip.d.ts +24 -0
  72. package/dist/typescript/commonjs/components/GuidonTooltip.d.ts.map +1 -0
  73. package/dist/typescript/commonjs/components/index.d.ts +5 -0
  74. package/dist/typescript/commonjs/components/index.d.ts.map +1 -0
  75. package/dist/typescript/commonjs/hooks/index.d.ts +2 -0
  76. package/dist/typescript/commonjs/hooks/index.d.ts.map +1 -0
  77. package/dist/typescript/commonjs/hooks/useGuidonRef.d.ts +35 -0
  78. package/dist/typescript/commonjs/hooks/useGuidonRef.d.ts.map +1 -0
  79. package/dist/typescript/commonjs/index.d.ts +7 -0
  80. package/dist/typescript/commonjs/index.d.ts.map +1 -0
  81. package/dist/typescript/commonjs/package.json +1 -0
  82. package/dist/typescript/commonjs/persistence/adapters.d.ts +57 -0
  83. package/dist/typescript/commonjs/persistence/adapters.d.ts.map +1 -0
  84. package/dist/typescript/commonjs/persistence/hooks.d.ts +29 -0
  85. package/dist/typescript/commonjs/persistence/hooks.d.ts.map +1 -0
  86. package/dist/typescript/commonjs/persistence/index.d.ts +3 -0
  87. package/dist/typescript/commonjs/persistence/index.d.ts.map +1 -0
  88. package/dist/typescript/commonjs/store.d.ts +89 -0
  89. package/dist/typescript/commonjs/store.d.ts.map +1 -0
  90. package/dist/{index-D_JFvCIg.d.mts → typescript/commonjs/types.d.ts} +40 -104
  91. package/dist/typescript/commonjs/types.d.ts.map +1 -0
  92. package/dist/typescript/module/components/GuidonOverlay.d.ts +9 -0
  93. package/dist/typescript/module/components/GuidonOverlay.d.ts.map +1 -0
  94. package/dist/typescript/module/components/GuidonProvider.d.ts +14 -0
  95. package/dist/typescript/module/components/GuidonProvider.d.ts.map +1 -0
  96. package/dist/typescript/module/components/GuidonTarget.d.ts +7 -0
  97. package/dist/typescript/module/components/GuidonTarget.d.ts.map +1 -0
  98. package/dist/typescript/module/components/GuidonTooltip.d.ts +24 -0
  99. package/dist/typescript/module/components/GuidonTooltip.d.ts.map +1 -0
  100. package/dist/typescript/module/components/index.d.ts +5 -0
  101. package/dist/typescript/module/components/index.d.ts.map +1 -0
  102. package/dist/typescript/module/hooks/index.d.ts +2 -0
  103. package/dist/typescript/module/hooks/index.d.ts.map +1 -0
  104. package/dist/typescript/module/hooks/useGuidonRef.d.ts +35 -0
  105. package/dist/typescript/module/hooks/useGuidonRef.d.ts.map +1 -0
  106. package/dist/typescript/module/index.d.ts +7 -0
  107. package/dist/typescript/module/index.d.ts.map +1 -0
  108. package/dist/typescript/module/package.json +1 -0
  109. package/dist/typescript/module/persistence/adapters.d.ts +57 -0
  110. package/dist/typescript/module/persistence/adapters.d.ts.map +1 -0
  111. package/dist/typescript/module/persistence/hooks.d.ts +29 -0
  112. package/dist/typescript/module/persistence/hooks.d.ts.map +1 -0
  113. package/dist/typescript/module/persistence/index.d.ts +3 -0
  114. package/dist/typescript/module/persistence/index.d.ts.map +1 -0
  115. package/dist/typescript/module/store.d.ts +89 -0
  116. package/dist/typescript/module/store.d.ts.map +1 -0
  117. package/dist/{index-D_JFvCIg.d.ts → typescript/module/types.d.ts} +40 -104
  118. package/dist/typescript/module/types.d.ts.map +1 -0
  119. package/package.json +25 -13
  120. package/src/babel.config.js +18 -0
  121. package/src/bob.config.js +14 -0
  122. package/src/components/GuidonOverlay.tsx +60 -4
  123. package/src/components/GuidonProvider.tsx +29 -1
  124. package/src/components/GuidonTarget.tsx +41 -25
  125. package/src/components/GuidonTooltip.tsx +143 -9
  126. package/src/hooks/index.ts +1 -0
  127. package/src/hooks/useGuidonRef.ts +154 -0
  128. package/src/index.ts +6 -0
  129. package/src/store.ts +68 -15
  130. package/src/tsconfig.json +32 -0
  131. package/src/types.ts +32 -2
  132. package/dist/index.d.mts +0 -128
  133. package/dist/index.d.ts +0 -128
  134. package/dist/index.js +0 -1097
  135. package/dist/index.js.map +0 -1
  136. package/dist/index.mjs +0 -1072
  137. package/dist/index.mjs.map +0 -1
  138. package/dist/persistence/index.d.mts +0 -2
  139. package/dist/persistence/index.d.ts +0 -2
  140. package/dist/persistence/index.js +0 -300
  141. package/dist/persistence/index.js.map +0 -1
  142. package/dist/persistence/index.mjs +0 -291
  143. package/dist/persistence/index.mjs.map +0 -1
@@ -1,7 +1,12 @@
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';
1
+ import { useCallback, useEffect, useRef, type ComponentRef } from "react";
2
+ import { View, Platform, type LayoutChangeEvent } from "react-native";
3
+ import { useGuidonStore } from "../store";
4
+ import type {
5
+ GuidonTargetProps,
6
+ TargetMeasurements,
7
+ GuidonStore,
8
+ GuidonStep,
9
+ } from "../types";
5
10
 
6
11
  /**
7
12
  * Wrapper component that marks an element as a walkthrough target
@@ -12,23 +17,28 @@ export function GuidonTarget({
12
17
  targetId,
13
18
  active = true,
14
19
  }: GuidonTargetProps) {
15
- const viewRef = useRef<View>(null);
16
- const registerTarget = useGuidonStore((state: GuidonStore) => state.registerTarget);
17
- const unregisterTarget = useGuidonStore((state: GuidonStore) => state.unregisterTarget);
20
+ const viewRef = useRef<ComponentRef<typeof View>>(null);
21
+ const registerTarget = useGuidonStore(
22
+ (state: GuidonStore) => state.registerTarget,
23
+ );
24
+ const unregisterTarget = useGuidonStore(
25
+ (state: GuidonStore) => state.unregisterTarget,
26
+ );
18
27
  const isActive = useGuidonStore((state: GuidonStore) => state.isActive);
19
28
  const config = useGuidonStore((state: GuidonStore) => state.config);
20
29
 
21
30
  // Check if this target is needed for the current walkthrough
22
31
  const isTargetNeeded =
23
- isActive && config?.steps.some((step: GuidonStep) => step.targetId === targetId);
32
+ isActive &&
33
+ config?.steps.some((step: GuidonStep) => step.targetId === targetId);
24
34
 
25
35
  const measureElement = useCallback(() => {
26
36
  if (!viewRef.current || !active || !isTargetNeeded) return;
27
37
 
28
- if (Platform.OS === 'web') {
38
+ if (Platform.OS === "web") {
29
39
  // Web measurement using getBoundingClientRect
30
40
  const element = viewRef.current as unknown as HTMLElement;
31
- if (element && typeof element.getBoundingClientRect === 'function') {
41
+ if (element && typeof element.getBoundingClientRect === "function") {
32
42
  const rect = element.getBoundingClientRect();
33
43
  const measurements: TargetMeasurements = {
34
44
  x: rect.left + window.scrollX,
@@ -40,7 +50,13 @@ export function GuidonTarget({
40
50
  }
41
51
  } else {
42
52
  // Native measurement using measureInWindow
43
- viewRef.current.measureInWindow((x, y, width, height) => {
53
+ // Cast to access the native measureInWindow method
54
+ const nativeRef = viewRef.current as unknown as {
55
+ measureInWindow: (
56
+ callback: (x: number, y: number, width: number, height: number) => void
57
+ ) => void;
58
+ };
59
+ nativeRef.measureInWindow((x, y, width, height) => {
44
60
  if (width > 0 && height > 0) {
45
61
  const measurements: TargetMeasurements = { x, y, width, height };
46
62
  registerTarget(targetId, measurements);
@@ -57,34 +73,34 @@ export function GuidonTarget({
57
73
  measureElement();
58
74
  });
59
75
  },
60
- [measureElement]
76
+ [measureElement],
61
77
  );
62
78
 
63
79
  // Re-measure when walkthrough becomes active or when this target becomes relevant
64
80
  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
- }
81
+ if (!isTargetNeeded) return;
82
+
83
+ // Delay to ensure the element is rendered
84
+ const timer = setTimeout(() => {
85
+ measureElement();
86
+ }, 100);
87
+ return () => clearTimeout(timer);
72
88
  }, [isTargetNeeded, measureElement]);
73
89
 
74
90
  // Re-measure on scroll (web only)
75
91
  useEffect(() => {
76
- if (Platform.OS !== 'web' || !isTargetNeeded) return;
92
+ if (Platform.OS !== "web" || !isTargetNeeded) return;
77
93
 
78
94
  const handleScroll = () => {
79
95
  measureElement();
80
96
  };
81
97
 
82
- window.addEventListener('scroll', handleScroll, true);
83
- window.addEventListener('resize', handleScroll);
98
+ window.addEventListener("scroll", handleScroll, true);
99
+ window.addEventListener("resize", handleScroll);
84
100
 
85
101
  return () => {
86
- window.removeEventListener('scroll', handleScroll, true);
87
- window.removeEventListener('resize', handleScroll);
102
+ window.removeEventListener("scroll", handleScroll, true);
103
+ window.removeEventListener("resize", handleScroll);
88
104
  };
89
105
  }, [isTargetNeeded, measureElement]);
90
106
 
@@ -100,7 +116,7 @@ export function GuidonTarget({
100
116
  ref={viewRef}
101
117
  onLayout={handleLayout}
102
118
  collapsable={false}
103
- // style={{ alignSelf: 'flex-start' }}
119
+ style={{ alignSelf: "flex-start" }}
104
120
  >
105
121
  {children}
106
122
  </View>
@@ -6,6 +6,7 @@ import {
6
6
  Dimensions,
7
7
  TouchableOpacity,
8
8
  Platform,
9
+ ActivityIndicator,
9
10
  } from 'react-native';
10
11
  import Animated, {
11
12
  useAnimatedStyle,
@@ -14,8 +15,8 @@ import Animated, {
14
15
  Easing,
15
16
  } from 'react-native-reanimated';
16
17
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
17
- import { useGuidonStore } from '../store';
18
- import type { GuidonTheme, TooltipPosition, TargetMeasurements, GuidonStore, GuidonStep } from '../types';
18
+ import { useGuidonStore, useWaitingState, useIsFloatingStep } from '../store';
19
+ import type { GuidonTheme, TooltipPosition, TargetMeasurements, GuidonStore, GuidonStep, FloatingPosition } from '../types';
19
20
 
20
21
  const DEFAULT_THEME: Required<
21
22
  Pick<
@@ -42,6 +43,67 @@ const DEFAULT_THEME: Required<
42
43
 
43
44
  const TOOLTIP_MARGIN = 16;
44
45
  const TOOLTIP_WIDTH = 300;
46
+ const ESTIMATED_TOOLTIP_HEIGHT = 200;
47
+
48
+ /**
49
+ * Calculate position for floating tooltips (steps without a target element)
50
+ */
51
+ function calculateFloatingPosition(
52
+ floatingPosition: FloatingPosition,
53
+ screenWidth: number,
54
+ screenHeight: number,
55
+ tooltipWidth: number,
56
+ insets: { top: number; bottom: number; left: number; right: number }
57
+ ): { top: number; left: number } {
58
+ const MARGIN = 32;
59
+
60
+ const centerX = (screenWidth - tooltipWidth) / 2;
61
+ const centerY = (screenHeight - ESTIMATED_TOOLTIP_HEIGHT) / 2;
62
+
63
+ switch (floatingPosition) {
64
+ case 'center':
65
+ return { top: centerY, left: centerX };
66
+
67
+ case 'top':
68
+ return {
69
+ top: insets.top + MARGIN,
70
+ left: centerX,
71
+ };
72
+
73
+ case 'bottom':
74
+ return {
75
+ top: screenHeight - ESTIMATED_TOOLTIP_HEIGHT - insets.bottom - MARGIN,
76
+ left: centerX,
77
+ };
78
+
79
+ case 'top-left':
80
+ return {
81
+ top: insets.top + MARGIN,
82
+ left: MARGIN,
83
+ };
84
+
85
+ case 'top-right':
86
+ return {
87
+ top: insets.top + MARGIN,
88
+ left: screenWidth - tooltipWidth - MARGIN,
89
+ };
90
+
91
+ case 'bottom-left':
92
+ return {
93
+ top: screenHeight - ESTIMATED_TOOLTIP_HEIGHT - insets.bottom - MARGIN,
94
+ left: MARGIN,
95
+ };
96
+
97
+ case 'bottom-right':
98
+ return {
99
+ top: screenHeight - ESTIMATED_TOOLTIP_HEIGHT - insets.bottom - MARGIN,
100
+ left: screenWidth - tooltipWidth - MARGIN,
101
+ };
102
+
103
+ default:
104
+ return { top: centerY, left: centerX };
105
+ }
106
+ }
45
107
 
46
108
  interface GuidonTooltipProps {
47
109
  theme?: GuidonTheme;
@@ -60,6 +122,7 @@ interface GuidonTooltipProps {
60
122
  skip?: string;
61
123
  finish?: string;
62
124
  stepOf?: (current: number, total: number) => string;
125
+ waitingDefault?: string;
63
126
  };
64
127
  }
65
128
 
@@ -80,6 +143,11 @@ export function GuidonTooltip({
80
143
  const previous = useGuidonStore((state: GuidonStore) => state.previous);
81
144
  const skip = useGuidonStore((state: GuidonStore) => state.skip);
82
145
 
146
+ // Check for floating or waiting states
147
+ const isFloatingStep = useIsFloatingStep();
148
+ const waitingState = useWaitingState();
149
+ const isWaiting = waitingState?.isWaiting ?? false;
150
+
83
151
  const mergedTheme = { ...DEFAULT_THEME, ...theme };
84
152
  const mergedLabels = {
85
153
  next: labels.next ?? 'Next',
@@ -87,6 +155,7 @@ export function GuidonTooltip({
87
155
  skip: labels.skip ?? 'Skip',
88
156
  finish: labels.finish ?? 'Finish',
89
157
  stepOf: labels.stepOf ?? ((c: number, t: number) => `${c} of ${t}`),
158
+ waitingDefault: labels.waitingDefault ?? 'Navigate to continue...',
90
159
  };
91
160
 
92
161
  const currentStep = config?.steps[currentStepIndex];
@@ -100,11 +169,23 @@ export function GuidonTooltip({
100
169
 
101
170
  // Calculate tooltip position
102
171
  const tooltipPosition = useMemo(() => {
172
+ // Handle floating steps (no target element)
173
+ if (isFloatingStep) {
174
+ const floatingPos = currentStep?.floatingPosition ?? 'center';
175
+ return calculateFloatingPosition(
176
+ floatingPos,
177
+ screenWidth,
178
+ screenHeight,
179
+ TOOLTIP_WIDTH,
180
+ insets
181
+ );
182
+ }
183
+
184
+ // Handle waiting state or no measurements - center the tooltip
103
185
  if (!measurements) {
104
- return { top: screenHeight / 2, left: screenWidth / 2 - TOOLTIP_WIDTH / 2 };
186
+ return { top: screenHeight / 2 - ESTIMATED_TOOLTIP_HEIGHT / 2, left: screenWidth / 2 - TOOLTIP_WIDTH / 2 };
105
187
  }
106
188
 
107
- const targetCenterY = measurements.y + measurements.height / 2;
108
189
  const targetBottom = measurements.y + measurements.height + mergedTheme.spotlightPadding;
109
190
  const targetTop = measurements.y - mergedTheme.spotlightPadding;
110
191
 
@@ -138,27 +219,68 @@ export function GuidonTooltip({
138
219
  top = Math.max(insets.top + TOOLTIP_MARGIN, Math.min(top, screenHeight - 200 - insets.bottom));
139
220
 
140
221
  return { top, left, position };
141
- }, [measurements, screenWidth, screenHeight, insets, currentStep?.tooltipPosition, mergedTheme.spotlightPadding]);
222
+ }, [measurements, screenWidth, screenHeight, insets, currentStep?.tooltipPosition, currentStep?.floatingPosition, mergedTheme.spotlightPadding, isFloatingStep]);
223
+
224
+ // Determine if tooltip should be visible
225
+ // Show for: normal steps with measurements, floating steps, or waiting states
226
+ const shouldShowTooltip = isActive && currentStep && (measurements || isFloatingStep || isWaiting);
142
227
 
143
228
  // Animated styles
144
229
  const animatedStyle = useAnimatedStyle(() => {
145
230
  return {
146
- opacity: withTiming(isActive && measurements ? 1 : 0, {
231
+ opacity: withTiming(shouldShowTooltip ? 1 : 0, {
147
232
  duration: animationDuration,
148
233
  easing: Easing.inOut(Easing.ease),
149
234
  }),
150
235
  transform: [
151
236
  {
152
- translateY: withSpring(isActive && measurements ? 0 : 20, {
237
+ translateY: withSpring(shouldShowTooltip ? 0 : 20, {
153
238
  damping: 15,
154
239
  stiffness: 150,
155
240
  }),
156
241
  },
157
242
  ],
158
243
  };
159
- }, [isActive, measurements, animationDuration]);
244
+ }, [shouldShowTooltip, animationDuration]);
245
+
246
+ if (!isActive || !currentStep) {
247
+ return null;
248
+ }
249
+
250
+ // Show waiting indicator when target element isn't mounted yet
251
+ if (isWaiting) {
252
+ return (
253
+ <Animated.View
254
+ style={[
255
+ styles.tooltipContainer,
256
+ {
257
+ top: tooltipPosition.top,
258
+ left: tooltipPosition.left,
259
+ width: TOOLTIP_WIDTH,
260
+ backgroundColor: mergedTheme.tooltipBackgroundColor,
261
+ borderColor: mergedTheme.tooltipBorderColor,
262
+ borderRadius: mergedTheme.tooltipBorderRadius,
263
+ },
264
+ animatedStyle,
265
+ ]}
266
+ >
267
+ <View style={styles.waitingContainer}>
268
+ <ActivityIndicator color={mergedTheme.primaryColor} />
269
+ <Text style={[styles.waitingText, { color: mergedTheme.descriptionColor }]}>
270
+ {waitingState?.message || mergedLabels.waitingDefault}
271
+ </Text>
272
+ </View>
273
+ <TouchableOpacity onPress={skip} style={styles.skipButton}>
274
+ <Text style={[styles.skipText, { color: mergedTheme.mutedColor }]}>
275
+ {mergedLabels.skip}
276
+ </Text>
277
+ </TouchableOpacity>
278
+ </Animated.View>
279
+ );
280
+ }
160
281
 
161
- if (!isActive || !currentStep || !measurements) {
282
+ // For non-floating steps, we need measurements
283
+ if (!isFloatingStep && !measurements) {
162
284
  return null;
163
285
  }
164
286
 
@@ -362,4 +484,16 @@ const styles = StyleSheet.create({
362
484
  fontSize: 14,
363
485
  fontWeight: '600',
364
486
  },
487
+ waitingContainer: {
488
+ flexDirection: 'row',
489
+ alignItems: 'center',
490
+ justifyContent: 'center',
491
+ gap: 12,
492
+ paddingVertical: 8,
493
+ marginBottom: 12,
494
+ },
495
+ waitingText: {
496
+ fontSize: 14,
497
+ lineHeight: 20,
498
+ },
365
499
  });
@@ -0,0 +1 @@
1
+ export { useGuidonRef } from './useGuidonRef';
@@ -0,0 +1,154 @@
1
+ import { useCallback, useEffect, useRef, type RefObject } from 'react';
2
+ import { Platform } from 'react-native';
3
+ import { useGuidonStore } from '../store';
4
+ import type { TargetMeasurements, GuidonStore, GuidonStep } from '../types';
5
+
6
+ /**
7
+ * Element type that can be measured
8
+ * Supports both web elements and React Native Views
9
+ */
10
+ type MeasurableElement = {
11
+ getBoundingClientRect?: () => DOMRect;
12
+ measureInWindow?: (
13
+ callback: (x: number, y: number, width: number, height: number) => void
14
+ ) => void;
15
+ };
16
+
17
+ /**
18
+ * Hook that returns a ref to attach to an element for guidon targeting.
19
+ * Alternative to the GuidonTarget wrapper component.
20
+ *
21
+ * @param targetId - The target ID that matches a step's targetId
22
+ * @returns A ref to attach to the target element
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * function MyComponent() {
27
+ * const buttonRef = useGuidonRef<View>('my-button');
28
+ * return <Button ref={buttonRef}>Click me</Button>;
29
+ * }
30
+ * ```
31
+ *
32
+ * @example Web usage
33
+ * ```tsx
34
+ * function MyComponent() {
35
+ * const divRef = useGuidonRef<HTMLDivElement>('my-div');
36
+ * return <div ref={divRef}>Highlight me</div>;
37
+ * }
38
+ * ```
39
+ */
40
+ export function useGuidonRef<T extends MeasurableElement>(
41
+ targetId: string
42
+ ): RefObject<T | null> {
43
+ const ref = useRef<T>(null);
44
+ const measurementFrameRef = useRef<number | null>(null);
45
+
46
+ const registerTarget = useGuidonStore(
47
+ (state: GuidonStore) => state.registerTarget
48
+ );
49
+ const unregisterTarget = useGuidonStore(
50
+ (state: GuidonStore) => state.unregisterTarget
51
+ );
52
+ const isActive = useGuidonStore((state: GuidonStore) => state.isActive);
53
+ const config = useGuidonStore((state: GuidonStore) => state.config);
54
+ const currentStepIndex = useGuidonStore(
55
+ (state: GuidonStore) => state.currentStepIndex
56
+ );
57
+
58
+ // Check if this target is needed anywhere in the walkthrough
59
+ const isTargetNeeded =
60
+ isActive &&
61
+ config?.steps.some((step: GuidonStep) => step.targetId === targetId);
62
+
63
+ // Check if this specific target is needed for the current step
64
+ const isCurrentTarget =
65
+ config?.steps[currentStepIndex]?.targetId === targetId;
66
+
67
+ const measureElement = useCallback(() => {
68
+ if (!ref.current || !isTargetNeeded) return;
69
+
70
+ if (Platform.OS === 'web') {
71
+ // Web measurement using getBoundingClientRect
72
+ const element = ref.current as unknown as HTMLElement;
73
+ if (element && typeof element.getBoundingClientRect === 'function') {
74
+ const rect = element.getBoundingClientRect();
75
+ const measurements: TargetMeasurements = {
76
+ x: rect.left + window.scrollX,
77
+ y: rect.top + window.scrollY,
78
+ width: rect.width,
79
+ height: rect.height,
80
+ };
81
+ registerTarget(targetId, measurements);
82
+ }
83
+ } else {
84
+ // Native measurement using measureInWindow
85
+ const nativeRef = ref.current as unknown as {
86
+ measureInWindow: (
87
+ cb: (x: number, y: number, w: number, h: number) => void
88
+ ) => void;
89
+ };
90
+
91
+ if (nativeRef && typeof nativeRef.measureInWindow === 'function') {
92
+ nativeRef.measureInWindow((x, y, width, height) => {
93
+ if (width > 0 && height > 0) {
94
+ const measurements: TargetMeasurements = { x, y, width, height };
95
+ registerTarget(targetId, measurements);
96
+ }
97
+ });
98
+ }
99
+ }
100
+ }, [targetId, isTargetNeeded, registerTarget]);
101
+
102
+ // Measure when guidon becomes active or when this target becomes relevant
103
+ useEffect(() => {
104
+ if (!isTargetNeeded) return;
105
+
106
+ // Use requestAnimationFrame to ensure layout is complete
107
+ measurementFrameRef.current = requestAnimationFrame(() => {
108
+ measureElement();
109
+ });
110
+
111
+ return () => {
112
+ if (measurementFrameRef.current) {
113
+ cancelAnimationFrame(measurementFrameRef.current);
114
+ }
115
+ };
116
+ }, [isTargetNeeded, measureElement]);
117
+
118
+ // Re-measure when this target becomes the current step
119
+ useEffect(() => {
120
+ if (!isCurrentTarget) return;
121
+
122
+ const timer = setTimeout(() => {
123
+ measureElement();
124
+ }, 50);
125
+
126
+ return () => clearTimeout(timer);
127
+ }, [isCurrentTarget, measureElement]);
128
+
129
+ // Web: handle scroll and resize
130
+ useEffect(() => {
131
+ if (Platform.OS !== 'web' || !isTargetNeeded) return;
132
+
133
+ const handleScrollOrResize = () => {
134
+ measureElement();
135
+ };
136
+
137
+ window.addEventListener('scroll', handleScrollOrResize, true);
138
+ window.addEventListener('resize', handleScrollOrResize);
139
+
140
+ return () => {
141
+ window.removeEventListener('scroll', handleScrollOrResize, true);
142
+ window.removeEventListener('resize', handleScrollOrResize);
143
+ };
144
+ }, [isTargetNeeded, measureElement]);
145
+
146
+ // Unregister on unmount
147
+ useEffect(() => {
148
+ return () => {
149
+ unregisterTarget(targetId);
150
+ };
151
+ }, [targetId, unregisterTarget]);
152
+
153
+ return ref;
154
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,9 @@ export {
7
7
  useGuidonContext,
8
8
  } from "./components";
9
9
 
10
+ // Hooks
11
+ export { useGuidonRef } from "./hooks";
12
+
10
13
  // Store and API
11
14
  export {
12
15
  useGuidonStore,
@@ -15,6 +18,8 @@ export {
15
18
  useGuidonStep,
16
19
  useGuidonProgress,
17
20
  useTargetMeasurements,
21
+ useWaitingState,
22
+ useIsFloatingStep,
18
23
  } from "./store";
19
24
 
20
25
  // Types
@@ -29,6 +34,7 @@ export type {
29
34
  GuidonTooltipLabels,
30
35
  GuidonTooltipRenderProps,
31
36
  TooltipPosition,
37
+ FloatingPosition,
32
38
  TargetMeasurements,
33
39
  GuidonState,
34
40
  GuidonActions,
package/src/store.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { create } from "zustand";
2
+ import { useShallow } from "zustand/react/shallow";
2
3
  import type {
3
4
  GuidonConfig,
4
5
  GuidonStore,
@@ -13,6 +14,8 @@ const initialState = {
13
14
  targetMeasurements: {},
14
15
  isLoading: false,
15
16
  error: null,
17
+ waitingForTarget: false,
18
+ waitingTargetId: null,
16
19
  };
17
20
 
18
21
  export const useGuidonStore = create<GuidonStore>((set, get) => ({
@@ -30,8 +33,10 @@ export const useGuidonStore = create<GuidonStore>((set, get) => ({
30
33
 
31
34
  // Call onStepEnter for the first step
32
35
  const firstStep = config.steps[0];
33
- firstStep.onStepEnter?.();
34
- config.onStepChange?.(0, firstStep);
36
+ firstStep?.onStepEnter?.();
37
+ if (firstStep) {
38
+ config.onStepChange?.(0, firstStep);
39
+ }
35
40
  },
36
41
 
37
42
  next: () => {
@@ -47,8 +52,10 @@ export const useGuidonStore = create<GuidonStore>((set, get) => ({
47
52
 
48
53
  set({ currentStepIndex: nextIndex });
49
54
 
50
- nextStep.onStepEnter?.();
51
- config.onStepChange?.(nextIndex, nextStep);
55
+ nextStep?.onStepEnter?.();
56
+ if (nextStep) {
57
+ config.onStepChange?.(nextIndex, nextStep);
58
+ }
52
59
  } else {
53
60
  // Last step completed
54
61
  get().complete();
@@ -67,8 +74,10 @@ export const useGuidonStore = create<GuidonStore>((set, get) => ({
67
74
 
68
75
  set({ currentStepIndex: prevIndex });
69
76
 
70
- prevStep.onStepEnter?.();
71
- config.onStepChange?.(prevIndex, prevStep);
77
+ prevStep?.onStepEnter?.();
78
+ if (prevStep) {
79
+ config.onStepChange?.(prevIndex, prevStep);
80
+ }
72
81
  },
73
82
 
74
83
  goToStep: (index: number) => {
@@ -83,8 +92,10 @@ export const useGuidonStore = create<GuidonStore>((set, get) => ({
83
92
 
84
93
  set({ currentStepIndex: index });
85
94
 
86
- targetStep.onStepEnter?.();
87
- config.onStepChange?.(index, targetStep);
95
+ targetStep?.onStepEnter?.();
96
+ if (targetStep) {
97
+ config.onStepChange?.(index, targetStep);
98
+ }
88
99
  },
89
100
 
90
101
  skip: () => {
@@ -114,11 +125,17 @@ export const useGuidonStore = create<GuidonStore>((set, get) => ({
114
125
  },
115
126
 
116
127
  registerTarget: (targetId: string, measurements: TargetMeasurements) => {
128
+ const { waitingTargetId } = get();
117
129
  set((state: GuidonStore) => ({
118
130
  targetMeasurements: {
119
131
  ...state.targetMeasurements,
120
132
  [targetId]: measurements,
121
133
  },
134
+ // Clear waiting state if this was the target we were waiting for
135
+ waitingForTarget:
136
+ waitingTargetId === targetId ? false : state.waitingForTarget,
137
+ waitingTargetId:
138
+ waitingTargetId === targetId ? null : state.waitingTargetId,
122
139
  }));
123
140
  },
124
141
 
@@ -254,15 +271,51 @@ export const useGuidonStep = () =>
254
271
  });
255
272
 
256
273
  export const useGuidonProgress = () =>
257
- useGuidonStore((state: GuidonStore) => ({
258
- currentStep: state.currentStepIndex + 1,
259
- totalSteps: state.config?.steps.length ?? 0,
260
- percentage: state.config
261
- ? ((state.currentStepIndex + 1) / state.config.steps.length) * 100
262
- : 0,
263
- }));
274
+ useGuidonStore(
275
+ useShallow((state: GuidonStore) => ({
276
+ currentStep: state.currentStepIndex + 1,
277
+ totalSteps: state.config?.steps.length ?? 0,
278
+ percentage: state.config
279
+ ? ((state.currentStepIndex + 1) / state.config.steps.length) * 100
280
+ : 0,
281
+ }))
282
+ );
264
283
 
265
284
  export const useTargetMeasurements = (targetId: string) =>
266
285
  useGuidonStore(
267
286
  (state: GuidonStore) => state.targetMeasurements[targetId],
268
287
  );
288
+
289
+ /**
290
+ * Hook to check if the guidon is waiting for a target element to mount
291
+ * Returns null if not active, not waiting, or if it's a floating step
292
+ */
293
+ export const useWaitingState = () =>
294
+ useGuidonStore(
295
+ useShallow((state: GuidonStore) => {
296
+ if (!state.isActive || !state.config) return null;
297
+
298
+ const currentStep = state.config.steps[state.currentStepIndex];
299
+ const targetId = currentStep?.targetId;
300
+
301
+ // Floating step (no targetId) - not waiting
302
+ if (!targetId) return null;
303
+
304
+ const hasMeasurements = !!state.targetMeasurements[targetId];
305
+
306
+ return {
307
+ isWaiting: !hasMeasurements,
308
+ targetId: hasMeasurements ? null : targetId,
309
+ message: currentStep?.waitingMessage ?? null,
310
+ };
311
+ })
312
+ );
313
+
314
+ /**
315
+ * Hook to check if the current step is a floating step (no target element)
316
+ */
317
+ export const useIsFloatingStep = () =>
318
+ useGuidonStore((state: GuidonStore) => {
319
+ if (!state.config || !state.isActive) return false;
320
+ return !state.config.steps[state.currentStepIndex]?.targetId;
321
+ });