Show tooltip immediately if a tooltip is already open (#28870)

* Memoize delayProps
* Refactor Escape key handler
* Use useTooltipContext
* Remove delay: 0 from tooltips
* Only use isTooltipOpen if available
* Only show transition for initial tooltip

GitOrigin-RevId: 74950ea7e705acb8f42dea552b23ce93c66058c7
This commit is contained in:
Alf Eaton
2025-10-09 08:05:49 +00:00
committed by Copybot
parent 3a8d383ac3
commit d3f05fda77
12 changed files with 106 additions and 29 deletions
@@ -3,6 +3,7 @@ import {
useEffect,
forwardRef,
useState,
useMemo,
useCallback,
} from 'react'
import {
@@ -12,6 +13,7 @@ import {
TooltipProps as BSTooltipProps,
} from 'react-bootstrap'
import { callFnsInSequence } from '@/utils/functions'
import { useTooltipContext } from '@/shared/context/tooltip-provider'
const DEFAULT_DELAY_SHOW = 300
// Slightly lower value avoids flickering when an adjacent tooltip is shown before the previous one hides
@@ -34,6 +36,26 @@ const UpdatingTooltip = forwardRef<HTMLDivElement, BSTooltipProps>(
)
UpdatingTooltip.displayName = 'UpdatingTooltip'
const chooseDelayOptions = (
delay?: number | { show: number; hide: number }
): { show: number; hide: number } => {
if (typeof delay === 'object') {
return delay
}
if (typeof delay === 'number') {
return {
show: delay,
hide: Math.max(delay - 10, 0),
}
}
return {
show: DEFAULT_DELAY_SHOW,
hide: DEFAULT_DELAY_HIDE,
}
}
export type TooltipProps = {
description: React.ReactNode
id: string
@@ -53,20 +75,22 @@ function Tooltip({
}: TooltipProps) {
const [show, setShow] = useState(false)
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const tooltipContext = useTooltipContext()
useEffect(() => {
const listener = (e: KeyboardEvent) => {
if (show && e.key === 'Escape') {
setShow(false)
e.stopPropagation()
}
},
[show, setShow]
)
}
useEffect(() => {
document.addEventListener('keydown', handleKeyDown, true)
return () => document.removeEventListener('keydown', handleKeyDown, true)
}, [handleKeyDown])
document.addEventListener('keydown', listener, true)
return () => {
document.removeEventListener('keydown', listener, true)
}
}, [show])
const hideTooltip = (e: React.MouseEvent) => {
if (e.currentTarget instanceof HTMLElement) {
@@ -75,13 +99,22 @@ function Tooltip({
setShow(false)
}
const delay = overlayProps?.delay
let delayShow = DEFAULT_DELAY_SHOW
let delayHide = DEFAULT_DELAY_HIDE
if (delay !== undefined) {
delayShow = typeof delay === 'number' ? delay : delay.show
delayHide = typeof delay === 'number' ? Math.max(delay - 10, 0) : delay.hide
}
const delayProps = useMemo(() => {
const delayOptions = chooseDelayOptions(overlayProps?.delay)
if (tooltipContext?.isTooltipOpen) {
delayOptions.show = 0
delayOptions.hide = 0
}
return delayOptions
}, [overlayProps?.delay, tooltipContext])
const handleToggle = useCallback(
(value: boolean) => {
tooltipContext?.setIsTooltipOpen(value)
setShow(value)
},
[tooltipContext]
)
return (
<OverlayTrigger
@@ -95,10 +128,11 @@ function Tooltip({
</UpdatingTooltip>
}
{...overlayProps}
delay={{ show: delayShow, hide: delayHide }}
delay={delayProps}
placement={overlayProps?.placement || 'top'}
show={show}
onToggle={setShow}
onToggle={handleToggle}
transition={!tooltipContext?.isTooltipOpen}
>
{overlayProps?.trigger === 'click'
? children
@@ -0,0 +1,41 @@
import {
createContext,
Dispatch,
FC,
PropsWithChildren,
SetStateAction,
useContext,
useMemo,
useState,
} from 'react'
import useDebounce from '@/shared/hooks/use-debounce'
const TooltipContext = createContext<
| {
isTooltipOpen: boolean
setIsTooltipOpen: Dispatch<SetStateAction<boolean>>
}
| undefined
>(undefined)
export const TooltipProvider: FC<PropsWithChildren> = ({ children }) => {
const [isTooltipOpen, setIsTooltipOpen] = useState(false)
const debouncedIsTooltipOpen = useDebounce(isTooltipOpen, 100)
const value = useMemo(
() => ({
isTooltipOpen: debouncedIsTooltipOpen,
setIsTooltipOpen,
}),
[debouncedIsTooltipOpen, setIsTooltipOpen]
)
return (
<TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>
)
}
export const useTooltipContext = () => {
return useContext(TooltipContext)
}