Skip to content

Commit

Permalink
feat(tooltip): improve tooltip behavior and fix some issues (#2209)
Browse files Browse the repository at this point in the history
* feat(tooltip): improve tooltip behavior and fix some issues

* fix: feedback tom
  • Loading branch information
matthprost authored Jan 26, 2023
1 parent 6329ac3 commit 7c6ea37
Showing 1 changed file with 66 additions and 31 deletions.
97 changes: 66 additions & 31 deletions packages/ui/src/components/Tooltip/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { ARROW_WIDTH, DEFAULT_POSITIONS, computePositions } from './helpers'

const ANIMATION_DURATION = 230 // in ms

function noop() {}

const animation = (positions: PositionsType) => keyframes`
0% {
opacity: 0;
Expand Down Expand Up @@ -101,8 +103,8 @@ type TooltipProps = {
className?: string
onBlur: () => void
onFocus: () => void
onMouseEnter: () => void
onMouseLeave: () => void
onPointerEnter: () => void
onPointerLeave: () => void
ref: RefObject<HTMLDivElement>
}) => ReactNode)
maxWidth?: number
Expand All @@ -129,24 +131,34 @@ export const Tooltip = ({
id,
className,
maxWidth = 232,
visible = false,
visible,
innerRef,
}: TooltipProps) => {
const childrenRef = useRef<HTMLDivElement>(null)
useImperativeHandle(innerRef, () => childrenRef.current)
const tooltipRef = useRef<HTMLDivElement>(null)
const timer = useRef<ReturnType<typeof setInterval>>()
const [visibleInDom, setVisibleInDom] = useState(visible)
const timer = useRef<ReturnType<typeof setTimeout> | undefined>()

// Debounce timer will be used to prevent the tooltip from flickering when the user moves the mouse out and in the children element.
const debounceTimer = useRef<ReturnType<typeof setTimeout> | undefined>()
const [visibleInDom, setVisibleInDom] = useState(false)
const [reverseAnimation, setReverseAnimation] = useState(false)
const [positions, setPositions] = useState<PositionsType>({
...DEFAULT_POSITIONS,
})
const uniqueId = useId()
const generatedId = id ?? uniqueId
const isControlled = visible !== undefined

const generatePositions = useCallback(() => {
if (childrenRef.current && tooltipRef.current) {
setPositions(computePositions({ childrenRef, placement, tooltipRef }))
setPositions(
computePositions({
childrenRef,
placement,
tooltipRef,
}),
)
}
}, [placement])

Expand All @@ -164,6 +176,7 @@ export const Tooltip = ({
const unmountTooltip = useCallback(() => {
setVisibleInDom(false)
setReverseAnimation(false)
timer.current = undefined

window.removeEventListener('scroll', onScrollDetected, true)
}, [onScrollDetected])
Expand All @@ -172,19 +185,30 @@ export const Tooltip = ({
* When mouse hover or stop hovering children this function display or hide tooltip. A timeout is set to allow animation
* end, then remove tooltip from dom.
*/
const onMouseEvent = useCallback(
const onPointerEvent = useCallback(
(isVisible: boolean) => () => {
// This is when we hide the tooltip, we reverse animation then we set a timeout based on CSS animation duration
// then we remove it from dom
if (!isVisible && tooltipRef.current) {
setReverseAnimation(true)
timer.current = setTimeout(() => unmountTooltip(), ANIMATION_DURATION)
// This condition is for when we want to unmount the tooltip
// There is debounce in order to avoid tooltip to flicker when we move the mouse from children to tooltip
// Timer is used to follow the animation duration
if (!isVisible && tooltipRef.current && !debounceTimer.current) {
debounceTimer.current = setTimeout(() => {
setReverseAnimation(true)
timer.current = setTimeout(() => unmountTooltip(), ANIMATION_DURATION)
}, 200)
} else {
// If a timeout is already set it means tooltip didn't have time to close completely and be removed from dom,
// so we clear timeout and set back opacity of tooltip to 1, so it can be visible on screen.
// This condition is for when we want to mount the tooltip
// If the timer exists it means the tooltip was about to umount, but we hovered the children again,
// so we clear the timer and the tooltip will not be unmounted
if (timer.current) {
setReverseAnimation(false)
clearTimeout(timer.current)
timer.current = undefined
}
// And here is when we currently are in a debounce timer, it means tooltip was hovered during
// that period, and so we can clear debounce timer
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
debounceTimer.current = undefined
}
setVisibleInDom(isVisible)
}
Expand All @@ -204,42 +228,53 @@ export const Tooltip = ({
// Adding true as third parameter to event listener will detect nested scrolls.
window.addEventListener('scroll', onScrollDetected, true)
}
}, [
visibleInDom,
positions.tooltipPosition,
generatePositions,
placement,
text,
onScrollDetected,
])

return () => {
window.removeEventListener('scroll', onScrollDetected, true)
if (timer.current) {
clearTimeout(timer.current)
timer.current = undefined
}
}
}, [generatePositions, onScrollDetected, visibleInDom])

/**
* If tooltip has `visible` prop it means the tooltip is manually controlled through this prop.
* In this cas we don't want to display tooltip on hover, but only when `visible` is true.
*/
useEffect(() => {
if (isControlled) {
onPointerEvent(visible)()
}
}, [isControlled, onPointerEvent, visible])

/**
* Will render children conditionally if children is a function or not.
*/
const renderChildren = useCallback(() => {
if (typeof children === 'function') {
return children({
onBlur: onMouseEvent(false),
onFocus: onMouseEvent(true),
onMouseEnter: onMouseEvent(true),
onMouseLeave: onMouseEvent(false),
onBlur: !isControlled ? onPointerEvent(false) : noop,
onFocus: !isControlled ? onPointerEvent(true) : noop,
onPointerEnter: !isControlled ? onPointerEvent(true) : noop,
onPointerLeave: !isControlled ? onPointerEvent(false) : noop,
ref: childrenRef,
})
}

return (
<StyledChildrenContainer
aria-describedby={generatedId}
onBlur={onMouseEvent(false)}
onFocus={onMouseEvent(true)}
onMouseEnter={onMouseEvent(true)}
onMouseLeave={onMouseEvent(false)}
onBlur={!isControlled ? onPointerEvent(false) : noop}
onFocus={!isControlled ? onPointerEvent(true) : noop}
onPointerEnter={!isControlled ? onPointerEvent(true) : noop}
onPointerLeave={!isControlled ? onPointerEvent(false) : noop}
ref={childrenRef}
>
{children}
</StyledChildrenContainer>
)
}, [children, generatedId, onMouseEvent])
}, [children, generatedId, isControlled, onPointerEvent])

if (!text) {
if (typeof children === 'function') return null
Expand Down

0 comments on commit 7c6ea37

Please sign in to comment.