Skip to content

Commit 7ef7be2

Browse files
committed
feat(tooltip): improve tooltip behavior and fix some issues
1 parent e3e3baf commit 7ef7be2

File tree

1 file changed

+60
-31
lines changed

1 file changed

+60
-31
lines changed

packages/ui/src/components/Tooltip/index.tsx

Lines changed: 60 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,8 @@ type TooltipProps = {
101101
className?: string
102102
onBlur: () => void
103103
onFocus: () => void
104-
onMouseEnter: () => void
105-
onMouseLeave: () => void
104+
onPointerEnter: () => void
105+
onPointerLeave: () => void
106106
ref: RefObject<HTMLDivElement>
107107
}) => ReactNode)
108108
maxWidth?: number
@@ -129,24 +129,34 @@ const Tooltip = ({
129129
id,
130130
className,
131131
maxWidth = 232,
132-
visible = false,
132+
visible,
133133
innerRef,
134134
}: TooltipProps) => {
135135
const childrenRef = useRef<HTMLDivElement>(null)
136136
useImperativeHandle(innerRef, () => childrenRef.current)
137137
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)
140143
const [reverseAnimation, setReverseAnimation] = useState(false)
141144
const [positions, setPositions] = useState<PositionsType>({
142145
...DEFAULT_POSITIONS,
143146
})
144147
const uniqueId = useId()
145148
const generatedId = id ?? uniqueId
149+
const isControlled = visible !== undefined
146150

147151
const generatePositions = useCallback(() => {
148152
if (childrenRef.current && tooltipRef.current) {
149-
setPositions(computePositions({ childrenRef, placement, tooltipRef }))
153+
setPositions(
154+
computePositions({
155+
childrenRef,
156+
placement,
157+
tooltipRef,
158+
}),
159+
)
150160
}
151161
}, [placement])
152162

@@ -164,6 +174,7 @@ const Tooltip = ({
164174
const unmountTooltip = useCallback(() => {
165175
setVisibleInDom(false)
166176
setReverseAnimation(false)
177+
timer.current = undefined
167178

168179
window.removeEventListener('scroll', onScrollDetected, true)
169180
}, [onScrollDetected])
@@ -172,19 +183,30 @@ const Tooltip = ({
172183
* When mouse hover or stop hovering children this function display or hide tooltip. A timeout is set to allow animation
173184
* end, then remove tooltip from dom.
174185
*/
175-
const onMouseEvent = useCallback(
186+
const onPointerEvent = useCallback(
176187
(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)
182196
} 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
185200
if (timer.current) {
186201
setReverseAnimation(false)
187202
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
188210
}
189211
setVisibleInDom(isVisible)
190212
}
@@ -204,42 +226,49 @@ const Tooltip = ({
204226
// Adding true as third parameter to event listener will detect nested scrolls.
205227
window.addEventListener('scroll', onScrollDetected, true)
206228
}
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])
215244

216245
/**
217246
* Will render children conditionally if children is a function or not.
218247
*/
219248
const renderChildren = useCallback(() => {
220249
if (typeof children === 'function') {
221250
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) : () => {},
226255
ref: childrenRef,
227256
})
228257
}
229258

230259
return (
231260
<StyledChildrenContainer
232261
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) : () => {}}
237266
ref={childrenRef}
238267
>
239268
{children}
240269
</StyledChildrenContainer>
241270
)
242-
}, [children, generatedId, onMouseEvent])
271+
}, [children, generatedId, isControlled, onPointerEvent])
243272

244273
if (!text) {
245274
if (typeof children === 'function') return null

0 commit comments

Comments
 (0)