@momo-kits/animated-tooltip 0.154.2-beta.1 → 0.154.2-beta.2

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,52 @@
1
+ # Tooltip Portal Setup
2
+
3
+ ## Important: Required Setup
4
+
5
+ The Tooltip component now uses a **Portal system** to render above all other content, solving zIndex limitations. This requires a one-time setup in your app.
6
+
7
+ ### Setup Instructions
8
+
9
+ Wrap your app with `TooltipPortalProvider` and add `TooltipPortalHost` at the root level:
10
+
11
+ ```typescript
12
+ import React from 'react';
13
+ import { TooltipPortalProvider, TooltipPortalHost } from '@momo-kits/tooltip';
14
+ import YourApp from './YourApp';
15
+
16
+ function App() {
17
+ return (
18
+ <TooltipPortalProvider>
19
+ <YourApp />
20
+ {/* TooltipPortalHost renders all tooltips at this level */}
21
+ <TooltipPortalHost />
22
+ </TooltipPortalProvider>
23
+ );
24
+ }
25
+
26
+ export default App;
27
+ ```
28
+
29
+ ### Usage
30
+
31
+ After setup, use Tooltip as normal - **no changes required**:
32
+
33
+ ```typescript
34
+ import { Tooltip } from '@momo-kits/tooltip';
35
+
36
+ <Tooltip title="Help" description="This is helpful info" placement="top">
37
+ <Button>Press me</Button>
38
+ </Tooltip>
39
+ ```
40
+
41
+ ## What Changed
42
+
43
+ ### For Developers
44
+ - **No API changes** - Tooltip props remain the same
45
+ - **One-time setup** required (Provider + Host)
46
+ - **Better zIndex handling** - Tooltips now render above everything
47
+
48
+ ### Technical Details
49
+ - Uses `measureInWindow` for absolute positioning
50
+ - Portal renders tooltips at root level (outside component hierarchy)
51
+ - No ref required from consumer - uses internal ref
52
+ - Backward compatible API
@@ -0,0 +1,125 @@
1
+ import React, {
2
+ createContext,
3
+ useContext,
4
+ useCallback,
5
+ ReactNode,
6
+ useMemo,
7
+ useReducer,
8
+ useRef,
9
+ RefObject,
10
+ } from 'react';
11
+ import { View, StyleSheet } from 'react-native';
12
+
13
+ interface TooltipPortalContextValue {
14
+ register: (id: string, content: ReactNode) => void;
15
+ unregister: (id: string, immediate?: boolean) => void;
16
+ portals: Map<string, ReactNode>;
17
+ hostRef: RefObject<View | null>;
18
+ }
19
+
20
+ const TooltipPortalContext = createContext<TooltipPortalContextValue | null>(
21
+ null,
22
+ );
23
+
24
+ export const TooltipPortalProvider: React.FC<{ children: ReactNode }> = ({
25
+ children,
26
+ }) => {
27
+ // Use ref for synchronous updates + reducer for manual re-renders
28
+ const portalsRef = useRef<Map<string, ReactNode>>(new Map());
29
+ const hostRef = useRef<View>(null);
30
+ const [updateCounter, forceUpdate] = useReducer((x: number) => x + 1, 0);
31
+ const pendingUpdateRef = useRef<NodeJS.Timeout | null>(null);
32
+
33
+ const register = useCallback((id: string, content: ReactNode) => {
34
+ portalsRef.current.set(id, content);
35
+ forceUpdate(); // Trigger re-render to show new content
36
+ }, []);
37
+
38
+ const unregister = useCallback((id: string, immediate = false) => {
39
+ const hasItem = portalsRef.current.has(id);
40
+ if (!hasItem) return; // Already removed
41
+
42
+ portalsRef.current.delete(id);
43
+
44
+ // Clear any pending updates
45
+ if (pendingUpdateRef.current) {
46
+ clearTimeout(pendingUpdateRef.current);
47
+ pendingUpdateRef.current = null;
48
+ }
49
+
50
+ if (immediate) {
51
+ forceUpdate();
52
+ } else {
53
+ // Debounce normal updates to avoid excessive re-renders
54
+ pendingUpdateRef.current = setTimeout(() => {
55
+ forceUpdate();
56
+ pendingUpdateRef.current = null;
57
+ }, 0) as unknown as NodeJS.Timeout;
58
+ }
59
+ }, []);
60
+
61
+ const value = useMemo(
62
+ () => ({ register, unregister, portals: portalsRef.current, hostRef }),
63
+ [register, unregister, updateCounter], // Include updateCounter to refresh context
64
+ );
65
+
66
+ return (
67
+ <TooltipPortalContext.Provider value={value}>
68
+ {children}
69
+ </TooltipPortalContext.Provider>
70
+ );
71
+ };
72
+
73
+ export const TooltipPortalHost: React.FC = () => {
74
+ const context = useContext(TooltipPortalContext);
75
+
76
+ if (!context) {
77
+ if (__DEV__) {
78
+ console.warn(
79
+ 'TooltipPortalHost must be used within TooltipPortalProvider',
80
+ );
81
+ }
82
+ return null;
83
+ }
84
+
85
+ const { portals, hostRef } = context;
86
+
87
+ return (
88
+ <View ref={hostRef} style={styles.hostContainer} pointerEvents="box-none">
89
+ {Array.from(portals.entries()).map(([id, content]) => (
90
+ <React.Fragment key={id}>{content}</React.Fragment>
91
+ ))}
92
+ </View>
93
+ );
94
+ };
95
+
96
+ export const useTooltipPortal = () => {
97
+ const context = useContext(TooltipPortalContext);
98
+
99
+ if (!context) {
100
+ if (__DEV__) {
101
+ console.warn(
102
+ 'useTooltipPortal must be used within TooltipPortalProvider. Tooltips may not appear correctly.',
103
+ );
104
+ }
105
+ // Return no-op functions if context is missing
106
+ return {
107
+ register: () => {},
108
+ unregister: () => {},
109
+ hostRef: null,
110
+ };
111
+ }
112
+
113
+ return context;
114
+ };
115
+
116
+ const styles = StyleSheet.create({
117
+ hostContainer: {
118
+ position: 'absolute',
119
+ top: 0,
120
+ left: 0,
121
+ right: 0,
122
+ bottom: 0,
123
+ zIndex: 9999,
124
+ },
125
+ });
package/index.tsx CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  import styles from './styles';
27
27
  import { TooltipPlacement, TooltipProps, TooltipRef } from './types';
28
28
  import { IconButton, PrimaryButton, SecondaryButton } from './TooltipButtons';
29
+ import { useTooltipPortal } from './TooltipPortal';
29
30
 
30
31
  const Tooltip = forwardRef<TooltipRef, TooltipProps>(function Tooltip(
31
32
  {
@@ -53,6 +54,32 @@ const Tooltip = forwardRef<TooltipRef, TooltipProps>(function Tooltip(
53
54
  const arrowSize = 6;
54
55
  const [tooltipSize, setTooltipSize] = useState({ width: 0, height: 0 });
55
56
 
57
+ // Portal integration
58
+ const portal = useTooltipPortal();
59
+ const portalId = useRef(
60
+ `tooltip-${Math.random().toString(36).substr(2, 9)}`,
61
+ ).current;
62
+
63
+ // DEBUG: Check if portal is available
64
+ console.log('[Tooltip] Portal context available:', !!portal, portalId);
65
+
66
+ // Internal ref for anchor - no ref required from consumer
67
+ const anchorRef = useRef<View>(null);
68
+
69
+ // Anchor position from measureInWindow
70
+ const [anchorPosition, setAnchorPosition] = useState({
71
+ x: 0,
72
+ y: 0,
73
+ width: 0,
74
+ height: 0,
75
+ });
76
+
77
+ // Portal host position for relative calculation
78
+ const [hostPosition, setHostPosition] = useState({
79
+ x: 0,
80
+ y: 0,
81
+ });
82
+
56
83
  const componentName = 'Tooltip';
57
84
  const componentId = useMemo(() => {
58
85
  if (accessibilityLabel) {
@@ -69,10 +96,17 @@ const Tooltip = forwardRef<TooltipRef, TooltipProps>(function Tooltip(
69
96
  ).current;
70
97
  const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
71
98
  const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
72
- const [anchorSize, setAnchorSize] = useState({ width: 0, height: 0 });
73
99
 
74
100
  const currentVisible = isControlled ? !!visible : internalVisible;
75
101
 
102
+ // DEBUG: Log visibility state
103
+ console.log('[Tooltip] Visibility state:', {
104
+ isControlled,
105
+ visible,
106
+ internalVisible,
107
+ currentVisible
108
+ });
109
+
76
110
  const clearTimers = () => {
77
111
  if (showTimer.current) {
78
112
  clearTimeout(showTimer.current);
@@ -102,6 +136,100 @@ const Tooltip = forwardRef<TooltipRef, TooltipProps>(function Tooltip(
102
136
  animate(currentVisible);
103
137
  }, [currentVisible, animationDuration]);
104
138
 
139
+ // Preload anchor measurement on mount for faster initial display
140
+ useEffect(() => {
141
+ // Measure immediately on mount to have position ready
142
+ const timer = setTimeout(() => {
143
+ // Measure host position first
144
+ if (portal.hostRef?.current) {
145
+ portal.hostRef.current.measureInWindow((hostX, hostY) => {
146
+ setHostPosition({ x: hostX, y: hostY });
147
+ });
148
+ }
149
+
150
+ anchorRef.current?.measureInWindow((x, y, width, height) => {
151
+ if (x !== 0 || y !== 0 || width !== 0 || height !== 0) {
152
+ setAnchorPosition({ x, y, width, height });
153
+ }
154
+ });
155
+ }, 0);
156
+
157
+ return () => clearTimeout(timer);
158
+ }, []);
159
+
160
+ // Continuously measure anchor position when tooltip is visible
161
+ // This handles scroll events and dynamic layout changes
162
+ useEffect(() => {
163
+ if (!currentVisible) {
164
+ // Reset position immediately when hiding
165
+ setAnchorPosition({ x: 0, y: 0, width: 0, height: 0 });
166
+ setHostPosition({ x: 0, y: 0 });
167
+ return;
168
+ }
169
+
170
+ let rafId: number | null = null;
171
+ let lastPosition = { x: 0, y: 0, width: 0, height: 0 };
172
+ let lastHostPosition = { x: 0, y: 0 };
173
+ let isMounted = true; // Track mounted state
174
+ let isActive = true; // Track active state for early cancellation
175
+
176
+ const measureAnchor = () => {
177
+ // Check flags before any work
178
+ if (!isMounted || !isActive || !anchorRef.current) {
179
+ return;
180
+ }
181
+
182
+ // Measure portal host position first
183
+ if (portal.hostRef?.current) {
184
+ portal.hostRef.current.measureInWindow((hostX, hostY) => {
185
+ if (!isMounted || !isActive) return;
186
+
187
+ if (hostX !== lastHostPosition.x || hostY !== lastHostPosition.y) {
188
+ lastHostPosition = { x: hostX, y: hostY };
189
+ setHostPosition({ x: hostX, y: hostY });
190
+ }
191
+ });
192
+ }
193
+
194
+ anchorRef.current.measureInWindow((x, y, width, height) => {
195
+ // Double-check flags in callback
196
+ if (!isMounted || !isActive) return;
197
+
198
+ // Only update if position changed (to avoid unnecessary re-renders)
199
+ if (
200
+ x !== lastPosition.x ||
201
+ y !== lastPosition.y ||
202
+ width !== lastPosition.width ||
203
+ height !== lastPosition.height
204
+ ) {
205
+ console.log('[Tooltip] Position updated:', { x, y, width, height });
206
+ lastPosition = { x, y, width, height };
207
+ setAnchorPosition({ x, y, width, height });
208
+ }
209
+ });
210
+
211
+ // Continue polling while visible, mounted, and active
212
+ if (currentVisible && isMounted && isActive) {
213
+ rafId = requestAnimationFrame(measureAnchor);
214
+ }
215
+ };
216
+
217
+ // Start polling
218
+ rafId = requestAnimationFrame(measureAnchor);
219
+
220
+ return () => {
221
+ // Set flags immediately to stop all pending work
222
+ isMounted = false;
223
+ isActive = false;
224
+
225
+ // Cancel any pending animation frame
226
+ if (rafId !== null) {
227
+ cancelAnimationFrame(rafId);
228
+ rafId = null;
229
+ }
230
+ };
231
+ }, [currentVisible]);
232
+
105
233
  useEffect(() => {
106
234
  return () => {
107
235
  clearTimers();
@@ -111,7 +239,20 @@ const Tooltip = forwardRef<TooltipRef, TooltipProps>(function Tooltip(
111
239
 
112
240
  const handleShow = () => {
113
241
  clearTimers();
114
- showTimer.current = setTimeout(() => setVisibility(true), showDelay);
242
+ showTimer.current = setTimeout(() => {
243
+ // Measure host position first
244
+ if (portal.hostRef?.current) {
245
+ portal.hostRef.current.measureInWindow((hostX, hostY) => {
246
+ setHostPosition({ x: hostX, y: hostY });
247
+ });
248
+ }
249
+
250
+ // Measure anchor position before showing tooltip
251
+ anchorRef.current?.measureInWindow((x, y, width, height) => {
252
+ setAnchorPosition({ x, y, width, height });
253
+ setVisibility(true);
254
+ });
255
+ }, showDelay);
115
256
  };
116
257
 
117
258
  const handleHide = () => {
@@ -166,32 +307,73 @@ const Tooltip = forwardRef<TooltipRef, TooltipProps>(function Tooltip(
166
307
  }),
167
308
  };
168
309
 
169
- const placementStyle: {
170
- [key: string]: any;
171
- } =
172
- placement === 'top'
173
- ? { bottom: (anchorSize.height || 0) + offset }
174
- : placement === 'bottom'
175
- ? { top: (anchorSize.height || 0) + offset }
176
- : placement === 'left'
177
- ? { right: (anchorSize.width || 0) + offset }
178
- : { left: (anchorSize.width || 0) + offset };
310
+ // Calculate absolute position based on window coordinates
311
+ // Subtract host position to make it relative to the portal host container
312
+ const getTooltipPosition = useMemo((): {
313
+ top?: number;
314
+ left?: number;
315
+ right?: number;
316
+ bottom?: number;
317
+ } => {
318
+ const { x, y, width, height } = anchorPosition;
319
+ const { width: tooltipWidth, height: tooltipHeight } = tooltipSize;
320
+
321
+ let position: { top?: number; left?: number; right?: number } = {};
322
+
323
+ // Vertical positioning (relative to portal host)
324
+ if (placement === 'top') {
325
+ position.top = y - tooltipHeight - offset - hostPosition.y;
326
+ } else if (placement === 'bottom') {
327
+ position.top = y + height + offset - hostPosition.y;
328
+ } else {
329
+ // For left/right placement, calculate vertical center alignment
330
+ if (align === 'start') {
331
+ position.top = y - hostPosition.y;
332
+ } else if (align === 'end') {
333
+ position.top = y + height - tooltipHeight - hostPosition.y;
334
+ } else {
335
+ // center
336
+ position.top = y + height / 2 - tooltipHeight / 2 - hostPosition.y;
337
+ }
338
+ }
339
+
340
+ // Horizontal positioning (relative to portal host)
341
+ if (placement === 'left') {
342
+ position.left = x - tooltipWidth - offset - hostPosition.x;
343
+ } else if (placement === 'right') {
344
+ position.left = x + width + offset - hostPosition.x;
345
+ } else {
346
+ // For top/bottom placement, calculate horizontal alignment
347
+ if (align === 'start') {
348
+ position.left = x - hostPosition.x;
349
+ } else if (align === 'end') {
350
+ position.left = x + width - tooltipWidth - hostPosition.x;
351
+ } else {
352
+ // center
353
+ position.left = x + width / 2 - tooltipWidth / 2 - hostPosition.x;
354
+ }
355
+ }
356
+
357
+ return position;
358
+ }, [anchorPosition, tooltipSize, placement, align, offset, hostPosition]);
359
+
360
+ const placementStyle = getTooltipPosition; // This IS the memoized position object!
361
+
362
+ // DEBUG: Log values to troubleshoot positioning
363
+ if (__DEV__ && currentVisible) {
364
+ console.log('[Tooltip Debug]', {
365
+ anchorPosition,
366
+ tooltipSize,
367
+ placement,
368
+ align,
369
+ placementStyle,
370
+ });
371
+ }
179
372
 
180
373
  const centerVerticalOffset =
181
- (anchorSize.height || 0) / 2 - (tooltipSize.height || 0) / 2;
182
-
183
- const alignStyle: ViewStyle =
184
- placement === 'top' || placement === 'bottom'
185
- ? align === 'start'
186
- ? { left: 0 }
187
- : align === 'end'
188
- ? { right: 0 }
189
- : { alignSelf: 'center' }
190
- : align === 'start'
191
- ? { top: 0 }
192
- : align === 'end'
193
- ? { bottom: 0 }
194
- : { top: centerVerticalOffset };
374
+ (anchorPosition.height || 0) / 2 - (tooltipSize.height || 0) / 2;
375
+
376
+ const alignStyle: ViewStyle = {}; // No longer needed - position calculated in getTooltipPosition
195
377
 
196
378
  const resolvedTextColor = Colors.black_01;
197
379
 
@@ -391,28 +573,67 @@ const Tooltip = forwardRef<TooltipRef, TooltipProps>(function Tooltip(
391
573
  }
392
574
  };
393
575
 
394
- const tooltipNode = (
395
- <Animated.View
396
- pointerEvents="auto"
397
- style={[
398
- {
399
- maxWidth: SCREEN_WIDTH - Spacing.M * 2,
400
- },
401
- containerStyle,
402
- styles.tooltip,
403
- placementStyle,
404
- alignStyle,
405
- {
406
- opacity: animatedValue,
407
- transform: [translate],
408
- },
409
- ]}
410
- onLayout={event => setTooltipSize(event.nativeEvent.layout)}
411
- >
412
- {renderContent()}
413
- <View style={getArrowStyle()} />
414
- </Animated.View>
415
- );
576
+ // Register tooltip with portal when visible
577
+ // Note: content is created fresh each time to ensure up-to-date styles/props
578
+ useEffect(() => {
579
+ let isActive = true; // Track if effect is still active
580
+
581
+ if (!currentVisible) {
582
+ portal.unregister(portalId, true); // Immediate cleanup when hiding
583
+ return;
584
+ }
585
+
586
+ // Only render tooltip if anchor has been measured (not initial 0,0,0,0)
587
+ const hasMeasurement =
588
+ anchorPosition.x !== 0 ||
589
+ anchorPosition.y !== 0 ||
590
+ anchorPosition.width !== 0 ||
591
+ anchorPosition.height !== 0;
592
+
593
+ if (!hasMeasurement) {
594
+ console.log('[Tooltip] Waiting for anchor measurement...');
595
+ return;
596
+ }
597
+
598
+ console.log('[Tooltip] Registering with portal, position:', placementStyle);
599
+
600
+ const content = (
601
+ <Animated.View
602
+ pointerEvents="auto"
603
+ style={[
604
+ {
605
+ maxWidth: SCREEN_WIDTH - Spacing.M * 2,
606
+ },
607
+ containerStyle,
608
+ styles.tooltip,
609
+ placementStyle,
610
+ alignStyle,
611
+ {
612
+ opacity: animatedValue,
613
+ transform: [translate],
614
+ },
615
+ ]}
616
+ onLayout={event => {
617
+ if (isActive) {
618
+ setTooltipSize(event.nativeEvent.layout);
619
+ }
620
+ }}
621
+ >
622
+ {renderContent()}
623
+ <View style={getArrowStyle()} />
624
+ </Animated.View>
625
+ );
626
+
627
+ portal.register(portalId, content);
628
+
629
+ return () => {
630
+ isActive = false; // Mark as inactive immediately
631
+ portal.unregister(portalId, true); // Always use immediate cleanup on unmount
632
+ };
633
+ // Only depend on position-affecting values
634
+ // Content (title, description, buttons) updates via React reconciliation automatically
635
+ // eslint-disable-next-line react-hooks/exhaustive-deps
636
+ }, [currentVisible, anchorPosition, placement, align]);
416
637
 
417
638
  return (
418
639
  <View
@@ -420,13 +641,17 @@ const Tooltip = forwardRef<TooltipRef, TooltipProps>(function Tooltip(
420
641
  style={styles.container}
421
642
  accessibilityLabel={componentId}
422
643
  >
423
- <View onLayout={event => setAnchorSize(event.nativeEvent.layout)}>
644
+ <View ref={anchorRef} style={{ alignSelf: 'flex-start' }}>
424
645
  {children}
425
646
  </View>
426
- {tooltipNode}
427
647
  </View>
428
648
  );
429
649
  });
430
650
 
431
651
  export { Tooltip };
432
652
  export type { TooltipProps, TooltipPlacement, TooltipRef };
653
+ export {
654
+ TooltipPortalProvider,
655
+ TooltipPortalHost,
656
+ useTooltipPortal,
657
+ } from './TooltipPortal';
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
- "name": "@momo-kits/animated-tooltip",
3
- "version": "0.154.2-beta.1",
4
- "private": false,
5
- "main": "index.tsx",
6
- "dependencies": {},
7
- "peerDependencies": {
8
- "@momo-kits/foundation": "latest",
9
- "react": "*",
10
- "react-native": "*"
11
- },
12
- "license": "MoMo",
13
- "publishConfig": {
14
- "registry": "https://registry.npmjs.org/"
15
- }
16
- }
2
+ "name": "@momo-kits/animated-tooltip",
3
+ "version": "0.154.2-beta.2",
4
+ "private": false,
5
+ "main": "index.tsx",
6
+ "dependencies": {},
7
+ "peerDependencies": {
8
+ "@momo-kits/foundation": "latest",
9
+ "react": "*",
10
+ "react-native": "*"
11
+ },
12
+ "license": "MoMo",
13
+ "publishConfig": {
14
+ "registry": "https://registry.npmjs.org/"
15
+ }
16
+ }
package/styles.ts CHANGED
@@ -7,7 +7,6 @@ export default StyleSheet.create({
7
7
  },
8
8
  tooltip: {
9
9
  position: 'absolute',
10
- zIndex: 10,
11
10
  padding: Spacing.M,
12
11
  backgroundColor: Colors.black_17,
13
12
  borderRadius: Radius.S,