1
1
import * as React from 'react' ;
2
2
import { createPortal } from 'react-dom' ;
3
3
import { debounce } from 'lodash' ;
4
- const { useState, useEffect, useRef, useMemo } = React ;
5
4
6
5
interface TooltipProps {
7
6
content ?: React . ReactNode ;
@@ -13,182 +12,203 @@ interface TooltipState {
13
12
content : React . ReactNode ;
14
13
visible : boolean ;
15
14
zIndex : number ;
15
+ left : number ;
16
+ top : number ;
16
17
}
17
18
18
- export const TooltipComponent : React . FC < TooltipProps > = ( { content = '⏳' , visible = false , fadeTransition = 500 } ) => {
19
- // eslint-disable-next-line no-null/no-null
20
- const tooltipRef = useRef < HTMLDivElement > ( null ) ;
21
- const lastMousePosition = useRef ( { x : 0 , y : 0 } ) ;
22
- const [ state , setState ] = useState < TooltipState > ( {
23
- content,
24
- visible,
25
- zIndex : visible ? 99999 : - 99999
26
- } ) ;
27
- const isMouseOverRef = useRef ( false ) ;
28
-
29
- // Calculate position based on current mouse position and tooltip dimensions
30
- const calculateAndSetPosition = ( ) => {
31
- if ( ! tooltipRef . current ) {
19
+ export class TooltipComponent extends React . Component < TooltipProps , TooltipState > {
20
+ public static defaultProps = {
21
+ content : '⏳' ,
22
+ visible : false ,
23
+ fadeTransition : 500
24
+ } ;
25
+
26
+ private tooltipRef : React . RefObject < HTMLDivElement > ;
27
+ private lastMousePosition : { x : number ; y : number } ;
28
+ private isMouseOver : boolean ;
29
+ private debouncedUpdate : ReturnType < typeof debounce > ;
30
+
31
+ constructor ( props : TooltipProps ) {
32
+ super ( props ) ;
33
+
34
+ this . tooltipRef = React . createRef ( ) ;
35
+ this . lastMousePosition = { x : 0 , y : 0 } ;
36
+ this . isMouseOver = false ;
37
+
38
+ this . state = {
39
+ content : props . content ,
40
+ visible : props . visible || false ,
41
+ zIndex : props . visible || false ? 99999 : - 99999 ,
42
+ left : 0 ,
43
+ top : 0
44
+ } ;
45
+
46
+ this . debouncedUpdate = debounce ( this . updateState , 500 ) ;
47
+ }
48
+
49
+ public componentDidMount ( ) : void {
50
+ // Track mouse position and detect when mouse leaves viewport
51
+ document . addEventListener ( 'mousemove' , this . handleMouseMove ) ;
52
+ document . addEventListener ( 'mouseleave' , this . handleMouseLeave ) ;
53
+
54
+ // Hide tooltip when scrolling (use capture to catch all scroll events)
55
+ window . addEventListener ( 'scroll' , this . handleScroll , { capture : true } ) ;
56
+ document . addEventListener ( 'scroll' , this . handleScroll , { capture : true } ) ;
57
+
58
+ // Handle transition end for z-index updates
59
+ if ( this . tooltipRef . current ) {
60
+ this . tooltipRef . current . addEventListener ( 'transitionend' , this . handleTransitionEnd ) ;
61
+ }
62
+ }
63
+
64
+ public componentDidUpdate ( prevProps : TooltipProps ) : void {
65
+ // Handle content and visibility changes
66
+ if (
67
+ ! this . isMouseOver &&
68
+ ( prevProps . content !== this . props . content || prevProps . visible !== this . props . visible )
69
+ ) {
70
+ this . debouncedUpdate ( {
71
+ content : this . props . content ,
72
+ visible : this . props . visible
73
+ } ) ;
74
+ }
75
+ }
76
+
77
+ public componentWillUnmount ( ) : void {
78
+ // Cleanup event listeners
79
+ document . removeEventListener ( 'mousemove' , this . handleMouseMove ) ;
80
+ document . removeEventListener ( 'mouseleave' , this . handleMouseLeave ) ;
81
+ window . removeEventListener ( 'scroll' , this . handleScroll , { capture : true } ) ;
82
+ document . removeEventListener ( 'scroll' , this . handleScroll , { capture : true } ) ;
83
+
84
+ if ( this . tooltipRef . current ) {
85
+ this . tooltipRef . current . removeEventListener ( 'transitionend' , this . handleTransitionEnd ) ;
86
+ }
87
+
88
+ // Cancel debounced function
89
+ this . debouncedUpdate . cancel ( ) ;
90
+ }
91
+
92
+ public render ( ) : React . ReactPortal {
93
+ const tooltipStyle : React . CSSProperties = {
94
+ position : 'fixed' ,
95
+ pointerEvents : 'auto' ,
96
+ opacity : this . state . visible ? 1 : 0 ,
97
+ transition : `opacity ${ this . props . fadeTransition } ms ease-in-out` ,
98
+ zIndex : this . state . zIndex ,
99
+ backgroundColor : '#337AB7' ,
100
+ fontSize : '13px' ,
101
+ color : '#fff' ,
102
+ padding : '9px 11px' ,
103
+ border : '1px solid transparent' ,
104
+ borderRadius : '3px' ,
105
+ boxShadow : '0 2px 4px rgba(0,0,0,0.2)' ,
106
+ left : `${ this . state . left } px` ,
107
+ top : `${ this . state . top } px`
108
+ } ;
109
+
110
+ return createPortal (
111
+ < div
112
+ ref = { this . tooltipRef }
113
+ className = "trace-compass-tooltip"
114
+ style = { tooltipStyle }
115
+ onMouseEnter = { this . handleTooltipMouseEnter }
116
+ onMouseLeave = { this . handleTooltipMouseLeave }
117
+ >
118
+ { this . state . content }
119
+ </ div > ,
120
+ document . body
121
+ ) ;
122
+ }
123
+
124
+ private calculateAndSetPosition = ( ) : void => {
125
+ if ( ! this . tooltipRef . current ) {
32
126
return ;
33
127
}
34
128
35
129
const viewportWidth = window . innerWidth ;
36
130
const viewportHeight = window . innerHeight ;
37
- const tooltipRect = tooltipRef . current . getBoundingClientRect ( ) ;
131
+ const tooltipRect = this . tooltipRef . current . getBoundingClientRect ( ) ;
38
132
39
- let x = lastMousePosition . current . x + 10 ;
40
- let y = lastMousePosition . current . y + 10 ;
133
+ let left = this . lastMousePosition . x + 10 ;
134
+ let top = this . lastMousePosition . y + 10 ;
41
135
42
- if ( x + tooltipRect . width > viewportWidth ) {
43
- x = lastMousePosition . current . x - tooltipRect . width - 10 ;
136
+ if ( left + tooltipRect . width > viewportWidth ) {
137
+ left = this . lastMousePosition . x - tooltipRect . width - 10 ;
44
138
}
45
139
46
- if ( y + tooltipRect . height > viewportHeight ) {
47
- y = lastMousePosition . current . y - tooltipRect . height - 10 ;
140
+ if ( top + tooltipRect . height > viewportHeight ) {
141
+ top = this . lastMousePosition . y - tooltipRect . height - 10 ;
48
142
}
49
143
50
- tooltipRef . current . style . left = `${ x } px` ;
51
- tooltipRef . current . style . top = `${ y } px` ;
144
+ this . setState ( { left, top } ) ;
52
145
} ;
53
146
54
- const debouncedUpdate = useMemo (
55
- ( ) =>
56
- debounce ( ( newState : Partial < TooltipState > ) => {
57
- setState ( prevState => {
58
- const updatedState = { ...prevState , ...newState } ;
59
-
60
- const weAreHidingTooltip = prevState . visible === true && newState . visible === false ;
61
- const weAreHoveringOverTooltip = isMouseOverRef . current ;
62
-
63
- if ( weAreHoveringOverTooltip ) {
64
- debouncedUpdate . cancel ( ) ;
65
- return prevState ;
66
- } else if ( weAreHidingTooltip ) {
67
- // Don't update the content
68
- updatedState . content = prevState . content ;
69
- // Keep z-index high during fade out
70
- updatedState . zIndex = prevState . zIndex ;
71
- return updatedState ;
72
- } else {
73
- calculateAndSetPosition ( ) ;
74
- // Update z-index immediately when showing
75
- if ( newState . visible ) {
76
- updatedState . zIndex = 99999 ;
77
- }
78
- return updatedState ;
79
- }
80
- } ) ;
81
- } , 500 ) ,
82
- [ ]
83
- ) ;
84
-
85
- // Track mouse position and detect when mouse leaves viewport
86
- useEffect ( ( ) => {
87
- const handleMouseMove = ( e : MouseEvent ) => {
88
- lastMousePosition . current = { x : e . clientX , y : e . clientY } ;
89
- } ;
90
-
91
- const handleMouseLeave = ( e : MouseEvent ) => {
92
- // Check if the mouse has actually left the viewport
93
- if ( e . clientY <= 0 || e . clientY >= window . innerHeight || e . clientX <= 0 || e . clientX >= window . innerWidth ) {
94
- setState ( prev => ( { ...prev , visible : false } ) ) ;
147
+ /**
148
+ * Updates the tooltip state with special handling for different scenarios.
149
+ *
150
+ * This method is debounced to prevent rapid state changes and handles several edge cases:
151
+ * 1. If mouse is over the tooltip, cancels the update to prevent hiding while user is interacting
152
+ * 2. When hiding the tooltip, maintains content and z-index during fade-out animation
153
+ * 3. When showing the tooltip, updates position and immediately sets z-index to visible
154
+ *
155
+ * @param newState - Partial state update to be applied
156
+ */
157
+ private updateState = ( newState : Partial < TooltipState > ) : void => {
158
+ this . setState ( prevState => {
159
+ const updatedState = { ...prevState , ...newState } ;
160
+
161
+ const weAreHidingTooltip = prevState . visible === true && newState . visible === false ;
162
+
163
+ if ( this . isMouseOver ) {
164
+ this . debouncedUpdate . cancel ( ) ;
165
+ return prevState ;
166
+ } else if ( weAreHidingTooltip ) {
167
+ // Don't update the content
168
+ updatedState . content = prevState . content ;
169
+ // Keep z-index high during fade out
170
+ updatedState . zIndex = prevState . zIndex ;
171
+ return updatedState ;
172
+ } else {
173
+ this . calculateAndSetPosition ( ) ;
174
+ // Update z-index immediately when showing
175
+ if ( newState . visible ) {
176
+ updatedState . zIndex = 99999 ;
177
+ }
178
+ return updatedState ;
95
179
}
96
- } ;
97
-
98
- document . addEventListener ( 'mousemove' , handleMouseMove ) ;
99
- document . addEventListener ( 'mouseleave' , handleMouseLeave ) ;
100
-
101
- return ( ) => {
102
- document . removeEventListener ( 'mousemove' , handleMouseMove ) ;
103
- document . removeEventListener ( 'mouseleave' , handleMouseLeave ) ;
104
- } ;
105
- } , [ debouncedUpdate ] ) ;
106
-
107
- // Hide tooltip when scrolling
108
- useEffect ( ( ) => {
109
- const handleScroll = ( ) => {
110
- setState ( prev => ( {
111
- ...prev ,
112
- visible : false
113
- } ) ) ;
114
- } ;
180
+ } ) ;
181
+ } ;
115
182
116
- // Use capture to catch all scroll events before they might be stopped
117
- const options = { capture : true } ;
183
+ private handleMouseMove = ( e : MouseEvent ) : void => {
184
+ this . lastMousePosition = { x : e . clientX , y : e . clientY } ;
185
+ } ;
118
186
119
- window . addEventListener ( 'scroll' , handleScroll , options ) ;
120
- document . addEventListener ( 'scroll' , handleScroll , options ) ;
187
+ private handleMouseLeave = ( e : MouseEvent ) : void => {
188
+ // Check if the mouse has actually left the viewport
189
+ if ( e . clientY <= 0 || e . clientY >= window . innerHeight || e . clientX <= 0 || e . clientX >= window . innerWidth ) {
190
+ this . setState ( { visible : false } ) ;
191
+ }
192
+ } ;
121
193
122
- return ( ) => {
123
- window . removeEventListener ( 'scroll' , handleScroll , options ) ;
124
- document . removeEventListener ( 'scroll' , handleScroll , options ) ;
125
- } ;
126
- } , [ ] ) ;
194
+ private handleScroll = ( ) : void => {
195
+ this . setState ( {
196
+ visible : false
197
+ } ) ;
198
+ } ;
127
199
128
- // Handle content and visibility changes
129
- useEffect ( ( ) => {
130
- if ( ! isMouseOverRef . current ) {
131
- debouncedUpdate ( { content, visible } ) ;
132
- }
133
- } , [ content , visible , debouncedUpdate ] ) ;
134
-
135
- // Cleanup debounced function
136
- useEffect (
137
- ( ) => ( ) => {
138
- debouncedUpdate . cancel ( ) ;
139
- } ,
140
- [ debouncedUpdate ]
141
- ) ;
142
-
143
- // Handle opacity transition end
144
- useEffect ( ( ) => {
145
- const tooltip = tooltipRef . current ;
146
- if ( ! tooltip ) {
147
- return ;
200
+ private handleTransitionEnd = ( e : TransitionEvent ) : void => {
201
+ if ( e . propertyName === 'opacity' && ! this . state . visible ) {
202
+ this . setState ( { zIndex : - 99999 } ) ;
148
203
}
204
+ } ;
149
205
150
- const handleTransitionEnd = ( e : TransitionEvent ) => {
151
- if ( e . propertyName === 'opacity' && ! state . visible ) {
152
- setState ( prev => ( { ...prev , zIndex : - 99999 } ) ) ;
153
- }
154
- } ;
155
-
156
- tooltip . addEventListener ( 'transitionend' , handleTransitionEnd ) ;
157
- return ( ) => {
158
- tooltip . removeEventListener ( 'transitionend' , handleTransitionEnd ) ;
159
- } ;
160
- } , [ state . visible ] ) ;
161
-
162
- const tooltipStyle : React . CSSProperties = {
163
- position : 'fixed' ,
164
- pointerEvents : 'auto' ,
165
- opacity : state . visible ? 1 : 0 ,
166
- transition : `opacity ${ fadeTransition } ms ease-in-out` ,
167
- zIndex : state . zIndex ,
168
- backgroundColor : '#337AB7' ,
169
- fontSize : '13px' ,
170
- color : '#fff' ,
171
- padding : '9px 11px' ,
172
- border : '1px solid transparent' ,
173
- borderRadius : '3px' ,
174
- boxShadow : '0 2px 4px rgba(0,0,0,0.2)'
206
+ private handleTooltipMouseEnter = ( ) : void => {
207
+ this . isMouseOver = true ;
175
208
} ;
176
209
177
- return createPortal (
178
- < div
179
- ref = { tooltipRef }
180
- className = "trace-compass-tooltip"
181
- style = { tooltipStyle }
182
- onMouseEnter = { ( ) => {
183
- isMouseOverRef . current = true ;
184
- } }
185
- onMouseLeave = { ( ) => {
186
- isMouseOverRef . current = false ;
187
- setState ( prev => ( { ...prev , visible : false } ) ) ;
188
- } }
189
- >
190
- { state . content }
191
- </ div > ,
192
- document . body
193
- ) ;
194
- } ;
210
+ private handleTooltipMouseLeave = ( ) : void => {
211
+ this . isMouseOver = false ;
212
+ this . setState ( { visible : false } ) ;
213
+ } ;
214
+ }
0 commit comments