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