Skip to content

Commit 013931f

Browse files
authored
Merge pull request #1271 from ReactTooltip/fix/tooltip-target-issue-1270
fix: tooltip target when delayShow attribute exist
2 parents f93a090 + 4508358 commit 013931f

3 files changed

Lines changed: 147 additions & 6 deletions

File tree

src/components/Tooltip/Tooltip.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,13 +273,16 @@ const Tooltip = ({
273273
})
274274
}, [])
275275

276+
const renderedRef = useRef(rendered)
277+
renderedRef.current = rendered
278+
276279
const handleShowTooltipDelayed = useCallback(
277280
(delay = delayShow) => {
278281
if (tooltipShowDelayTimerRef.current) {
279282
clearTimeout(tooltipShowDelayTimerRef.current)
280283
}
281284

282-
if (rendered) {
285+
if (renderedRef.current) {
283286
// if the tooltip is already rendered, ignore delay
284287
handleShow(true)
285288
return
@@ -289,7 +292,7 @@ const Tooltip = ({
289292
handleShow(true)
290293
}, delay)
291294
},
292-
[delayShow, handleShow, rendered],
295+
[delayShow, handleShow],
293296
)
294297

295298
const handleHideTooltipDelayed = useCallback(

src/components/Tooltip/use-tooltip-events.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -325,11 +325,13 @@ const useTooltipEvents = ({
325325

326326
const addDelegatedHoverCloseListener = () => {
327327
addDelegatedListener('mouseout', (event) => {
328-
if (!activeAnchorContainsTarget(event)) {
328+
const targetAnchor = resolveAnchorElementRef.current(event.target)
329+
if (!targetAnchor && !activeAnchorContainsTarget(event)) {
329330
return
330331
}
331332
const relatedTarget = (event as MouseEvent).relatedTarget as HTMLElement | null
332-
if (activeAnchorRef.current?.contains(relatedTarget)) {
333+
const containerAnchor = targetAnchor || activeAnchorRef.current
334+
if (containerAnchor?.contains(relatedTarget)) {
333335
return
334336
}
335337
debouncedHandleHideTooltip()
@@ -355,11 +357,13 @@ const useTooltipEvents = ({
355357
}
356358
if (actualCloseEvents.blur) {
357359
addDelegatedListener('focusout', (event) => {
358-
if (!activeAnchorContainsTarget(event)) {
360+
const targetAnchor = resolveAnchorElementRef.current(event.target)
361+
if (!targetAnchor && !activeAnchorContainsTarget(event)) {
359362
return
360363
}
361364
const relatedTarget = (event as FocusEvent).relatedTarget as HTMLElement | null
362-
if (activeAnchorRef.current?.contains(relatedTarget)) {
365+
const containerAnchor = targetAnchor || activeAnchorRef.current
366+
if (containerAnchor?.contains(relatedTarget)) {
363367
return
364368
}
365369
debouncedHandleHideTooltip()

src/test/tooltip-close-and-delay-behavior.spec.js

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,4 +497,138 @@ describe('tooltip close and delay behavior', () => {
497497
const tooltip = document.getElementById('deferred-anchor-test')
498498
expect(tooltip).toBeInTheDocument()
499499
})
500+
501+
test('hides tooltip when quickly moving between anchors and then away with delayShow', () => {
502+
render(
503+
<>
504+
<span data-tooltip-id="stuck-tooltip-test">Anchor A</span>
505+
<span data-tooltip-id="stuck-tooltip-test">Anchor B</span>
506+
<TooltipController id="stuck-tooltip-test" content="Stuck Tooltip Test" delayShow={200} />
507+
</>,
508+
)
509+
510+
const anchorA = screen.getByText('Anchor A')
511+
const anchorB = screen.getByText('Anchor B')
512+
513+
// Hover anchor A, then quickly move to anchor B before delayShow fires
514+
hoverAnchor(anchorA)
515+
advanceTimers(50)
516+
unhoverAnchor(anchorA)
517+
hoverAnchor(anchorB)
518+
advanceTimers(50)
519+
520+
// Move away from anchor B before the deferred delayShow fires
521+
unhoverAnchor(anchorB)
522+
523+
// Advance past the delayShow — tooltip should NOT appear
524+
advanceTimers(300)
525+
526+
expect(document.getElementById('stuck-tooltip-test')).not.toBeInTheDocument()
527+
})
528+
529+
test('switches data-tooltip-content when moving between anchors with delayShow', async () => {
530+
render(
531+
<>
532+
<h1 data-tooltip-id="content-switch-test" data-tooltip-content="first item">
533+
First heading
534+
</h1>
535+
<h2 data-tooltip-id="content-switch-test" data-tooltip-content="second item">
536+
Second heading
537+
</h2>
538+
<TooltipController id="content-switch-test" place="bottom" delayShow={200} />
539+
</>,
540+
)
541+
542+
const h1 = screen.getByText('First heading')
543+
const h2 = screen.getByText('Second heading')
544+
545+
// Hover h1 and wait for tooltip to show
546+
hoverAnchor(h1, 250)
547+
await waitForTooltip('content-switch-test')
548+
549+
const tooltip = document.getElementById('content-switch-test')
550+
expect(tooltip.textContent).toBe('first item')
551+
552+
// Move from h1 to h2
553+
unhoverAnchor(h1)
554+
hoverAnchor(h2)
555+
556+
// During delay, content should still reflect h1
557+
advanceTimers(50)
558+
expect(tooltip.textContent).toBe('first item')
559+
560+
// After delay, content should switch to h2
561+
advanceTimers(200)
562+
expect(tooltip.textContent).toBe('second item')
563+
})
564+
565+
test('switches content when quickly moving between anchors before first delayShow fires', () => {
566+
render(
567+
<>
568+
<span data-tooltip-id="quick-switch-test" data-tooltip-content="first item">
569+
Anchor A
570+
</span>
571+
<span data-tooltip-id="quick-switch-test" data-tooltip-content="second item">
572+
Anchor B
573+
</span>
574+
<TooltipController id="quick-switch-test" delayShow={200} />
575+
</>,
576+
)
577+
578+
const anchorA = screen.getByText('Anchor A')
579+
const anchorB = screen.getByText('Anchor B')
580+
581+
// Hover A briefly, then move to B before delayShow fires
582+
hoverAnchor(anchorA)
583+
advanceTimers(50)
584+
unhoverAnchor(anchorA)
585+
hoverAnchor(anchorB)
586+
587+
// Advance past the deferred delayShow
588+
advanceTimers(250)
589+
590+
const tooltip = document.getElementById('quick-switch-test')
591+
expect(tooltip).toBeInTheDocument()
592+
expect(tooltip.textContent).toBe('second item')
593+
})
594+
595+
test('deferred anchor switch survives when closing transition completes before delay fires', async () => {
596+
render(
597+
<>
598+
<span data-tooltip-id="transition-race-test" data-tooltip-content="first item">
599+
Anchor A
600+
</span>
601+
<span data-tooltip-id="transition-race-test" data-tooltip-content="second item">
602+
Anchor B
603+
</span>
604+
<TooltipController id="transition-race-test" delayShow={200} />
605+
</>,
606+
)
607+
608+
const anchorA = screen.getByText('Anchor A')
609+
const anchorB = screen.getByText('Anchor B')
610+
611+
// Show tooltip at A
612+
hoverAnchor(anchorA, 250)
613+
await waitForTooltip('transition-race-test')
614+
const tooltip = document.getElementById('transition-race-test')
615+
expect(tooltip.textContent).toBe('first item')
616+
617+
// Move from A to B — triggers deferred anchor switch
618+
unhoverAnchor(anchorA)
619+
hoverAnchor(anchorB)
620+
621+
// Simulate the closing transition completing before the deferred timer fires.
622+
// In a real browser, the opacity transition ends after ~150ms (before the 200ms delay).
623+
advanceTimers(25)
624+
const transitionEndEvent = new Event('transitionend', { bubbles: true })
625+
Object.defineProperty(transitionEndEvent, 'propertyName', { value: 'opacity' })
626+
fireEvent(tooltip, transitionEndEvent)
627+
628+
// After the deferred delay completes, content should switch to anchor B
629+
advanceTimers(250)
630+
const updatedTooltip = document.getElementById('transition-race-test')
631+
expect(updatedTooltip).toBeInTheDocument()
632+
expect(updatedTooltip.textContent).toBe('second item')
633+
})
500634
})

0 commit comments

Comments
 (0)