@@ -101,8 +101,8 @@ type TooltipProps = {
101
101
className ?: string
102
102
onBlur : ( ) => void
103
103
onFocus : ( ) => void
104
- onMouseEnter : ( ) => void
105
- onMouseLeave : ( ) => void
104
+ onPointerEnter : ( ) => void
105
+ onPointerLeave : ( ) => void
106
106
ref : RefObject < HTMLDivElement >
107
107
} ) => ReactNode )
108
108
maxWidth ?: number
@@ -129,24 +129,34 @@ const Tooltip = ({
129
129
id,
130
130
className,
131
131
maxWidth = 232 ,
132
- visible = false ,
132
+ visible,
133
133
innerRef,
134
134
} : TooltipProps ) => {
135
135
const childrenRef = useRef < HTMLDivElement > ( null )
136
136
useImperativeHandle ( innerRef , ( ) => childrenRef . current )
137
137
const tooltipRef = useRef < HTMLDivElement > ( null )
138
- const timer = useRef < ReturnType < typeof setInterval > > ( )
139
- const [ visibleInDom , setVisibleInDom ] = useState ( visible )
138
+ const timer = useRef < ReturnType < typeof setTimeout > | undefined > ( )
139
+
140
+ // Debounce timer will be used to prevent the tooltip from flickering when the user moves the mouse out and in the children element.
141
+ const debounceTimer = useRef < ReturnType < typeof setTimeout > | undefined > ( )
142
+ const [ visibleInDom , setVisibleInDom ] = useState ( false )
140
143
const [ reverseAnimation , setReverseAnimation ] = useState ( false )
141
144
const [ positions , setPositions ] = useState < PositionsType > ( {
142
145
...DEFAULT_POSITIONS ,
143
146
} )
144
147
const uniqueId = useId ( )
145
148
const generatedId = id ?? uniqueId
149
+ const isControlled = visible !== undefined
146
150
147
151
const generatePositions = useCallback ( ( ) => {
148
152
if ( childrenRef . current && tooltipRef . current ) {
149
- setPositions ( computePositions ( { childrenRef, placement, tooltipRef } ) )
153
+ setPositions (
154
+ computePositions ( {
155
+ childrenRef,
156
+ placement,
157
+ tooltipRef,
158
+ } ) ,
159
+ )
150
160
}
151
161
} , [ placement ] )
152
162
@@ -164,6 +174,7 @@ const Tooltip = ({
164
174
const unmountTooltip = useCallback ( ( ) => {
165
175
setVisibleInDom ( false )
166
176
setReverseAnimation ( false )
177
+ timer . current = undefined
167
178
168
179
window . removeEventListener ( 'scroll' , onScrollDetected , true )
169
180
} , [ onScrollDetected ] )
@@ -172,19 +183,30 @@ const Tooltip = ({
172
183
* When mouse hover or stop hovering children this function display or hide tooltip. A timeout is set to allow animation
173
184
* end, then remove tooltip from dom.
174
185
*/
175
- const onMouseEvent = useCallback (
186
+ const onPointerEvent = useCallback (
176
187
( isVisible : boolean ) => ( ) => {
177
- // This is when we hide the tooltip, we reverse animation then we set a timeout based on CSS animation duration
178
- // then we remove it from dom
179
- if ( ! isVisible && tooltipRef . current ) {
180
- setReverseAnimation ( true )
181
- timer . current = setTimeout ( ( ) => unmountTooltip ( ) , ANIMATION_DURATION )
188
+ // This condition is for when we want to unmount the tooltip
189
+ // There is debounce in order to avoid tooltip to flicker when we move the mouse from children to tooltip
190
+ // Timer is used to follow the animation duration
191
+ if ( ! isVisible && tooltipRef . current && ! debounceTimer . current ) {
192
+ debounceTimer . current = setTimeout ( ( ) => {
193
+ setReverseAnimation ( true )
194
+ timer . current = setTimeout ( ( ) => unmountTooltip ( ) , ANIMATION_DURATION )
195
+ } , 200 )
182
196
} else {
183
- // If a timeout is already set it means tooltip didn't have time to close completely and be removed from dom,
184
- // so we clear timeout and set back opacity of tooltip to 1, so it can be visible on screen.
197
+ // This condition is for when we want to mount the tooltip
198
+ // If the timer exists it means the tooltip was about to umount, but we hovered the children again,
199
+ // so we clear the timer and the tooltip will not be unmounted
185
200
if ( timer . current ) {
186
201
setReverseAnimation ( false )
187
202
clearTimeout ( timer . current )
203
+ timer . current = undefined
204
+ }
205
+ // And here is when we currently are in a debounce timer, it means tooltip was hovered during
206
+ // that period, and so we can clear debounce timer
207
+ if ( debounceTimer . current ) {
208
+ clearTimeout ( debounceTimer . current )
209
+ debounceTimer . current = undefined
188
210
}
189
211
setVisibleInDom ( isVisible )
190
212
}
@@ -204,42 +226,49 @@ const Tooltip = ({
204
226
// Adding true as third parameter to event listener will detect nested scrolls.
205
227
window . addEventListener ( 'scroll' , onScrollDetected , true )
206
228
}
207
- } , [
208
- visibleInDom ,
209
- positions . tooltipPosition ,
210
- generatePositions ,
211
- placement ,
212
- text ,
213
- onScrollDetected ,
214
- ] )
229
+
230
+ return ( ) => {
231
+ window . removeEventListener ( 'scroll' , onScrollDetected , true )
232
+ }
233
+ } , [ generatePositions , onScrollDetected , visibleInDom ] )
234
+
235
+ /**
236
+ * If tooltip has `visible` prop it means the tooltip is manually controlled through this prop.
237
+ * In this cas we don't want to display tooltip on hover, but only when `visible` is true.
238
+ */
239
+ useEffect ( ( ) => {
240
+ if ( isControlled ) {
241
+ onPointerEvent ( visible ) ( )
242
+ }
243
+ } , [ isControlled , onPointerEvent , visible ] )
215
244
216
245
/**
217
246
* Will render children conditionally if children is a function or not.
218
247
*/
219
248
const renderChildren = useCallback ( ( ) => {
220
249
if ( typeof children === 'function' ) {
221
250
return children ( {
222
- onBlur : onMouseEvent ( false ) ,
223
- onFocus : onMouseEvent ( true ) ,
224
- onMouseEnter : onMouseEvent ( true ) ,
225
- onMouseLeave : onMouseEvent ( false ) ,
251
+ onBlur : ! isControlled ? onPointerEvent ( false ) : ( ) => { } ,
252
+ onFocus : ! isControlled ? onPointerEvent ( true ) : ( ) => { } ,
253
+ onPointerEnter : ! isControlled ? onPointerEvent ( true ) : ( ) => { } ,
254
+ onPointerLeave : ! isControlled ? onPointerEvent ( false ) : ( ) => { } ,
226
255
ref : childrenRef ,
227
256
} )
228
257
}
229
258
230
259
return (
231
260
< StyledChildrenContainer
232
261
aria-describedby = { generatedId }
233
- onBlur = { onMouseEvent ( false ) }
234
- onFocus = { onMouseEvent ( true ) }
235
- onMouseEnter = { onMouseEvent ( true ) }
236
- onMouseLeave = { onMouseEvent ( false ) }
262
+ onBlur = { ! isControlled ? onPointerEvent ( false ) : ( ) => { } }
263
+ onFocus = { ! isControlled ? onPointerEvent ( true ) : ( ) => { } }
264
+ onPointerEnter = { ! isControlled ? onPointerEvent ( true ) : ( ) => { } }
265
+ onPointerLeave = { ! isControlled ? onPointerEvent ( false ) : ( ) => { } }
237
266
ref = { childrenRef }
238
267
>
239
268
{ children }
240
269
</ StyledChildrenContainer >
241
270
)
242
- } , [ children , generatedId , onMouseEvent ] )
271
+ } , [ children , generatedId , isControlled , onPointerEvent ] )
243
272
244
273
if ( ! text ) {
245
274
if ( typeof children === 'function' ) return null
0 commit comments