Skip to content

Commit 60f6d6d

Browse files
authored
fix(tooltip): dismiss floating tooltip when its trigger is hidden without pointer events (#6354)
* fix(tooltip): dismiss floating tooltip when its trigger is hidden without pointer events * fix(tooltip): catch display: none triggers in the legacy visibility fallback
1 parent ff3b422 commit 60f6d6d

2 files changed

Lines changed: 190 additions & 11 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*
4+
* The floating tooltip hides from pointer/focus events on its trigger — but a keyboard- or
5+
* script-driven UI change (e.g. tiptap's bubble menu setting `visibility: hidden` on Cmd+A) hides
6+
* the trigger with no such event, and browsers don't re-dispatch boundary events until the pointer
7+
* moves. These tests cover the visibility watcher that dismisses a shown tooltip once its trigger
8+
* is hidden or removed, and that it leaves a still-visible trigger's tooltip alone.
9+
*/
10+
import { act, type ReactNode } from 'react'
11+
import { createRoot, type Root } from 'react-dom/client'
12+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
13+
import { Tooltip } from './tooltip'
14+
15+
let root: Root | null = null
16+
let container: HTMLDivElement | null = null
17+
18+
function mount(ui: ReactNode) {
19+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
20+
container = document.createElement('div')
21+
document.body.appendChild(container)
22+
root = createRoot(container)
23+
act(() => root?.render(ui))
24+
}
25+
26+
beforeEach(() => {
27+
vi.useFakeTimers()
28+
})
29+
30+
afterEach(() => {
31+
if (root) act(() => root?.unmount())
32+
container?.remove()
33+
root = null
34+
container = null
35+
vi.useRealTimers()
36+
})
37+
38+
function trigger(): HTMLButtonElement {
39+
const node = container?.querySelector('button')
40+
if (!node) throw new Error('Trigger did not render')
41+
return node
42+
}
43+
44+
function hover(node: HTMLElement) {
45+
act(() => {
46+
node.dispatchEvent(new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 }))
47+
})
48+
}
49+
50+
/** Generously past TRIGGER_VISIBILITY_INTERVAL_MS so at least one watcher tick has run. */
51+
function runWatcherTick() {
52+
act(() => {
53+
vi.advanceTimersByTime(500)
54+
})
55+
}
56+
57+
function tooltipElement(): HTMLElement | null {
58+
return document.querySelector<HTMLElement>('[role="tooltip"]')
59+
}
60+
61+
function tooltipUi(withTrigger: boolean) {
62+
return (
63+
<Tooltip.Root>
64+
{withTrigger && <Tooltip.Trigger>Hover me</Tooltip.Trigger>}
65+
<Tooltip.Content>Delete column</Tooltip.Content>
66+
</Tooltip.Root>
67+
)
68+
}
69+
70+
function mountTooltip() {
71+
mount(tooltipUi(true))
72+
}
73+
74+
describe('floating tooltip trigger visibility watcher', () => {
75+
it('keeps the tooltip shown while the trigger stays visible', () => {
76+
mountTooltip()
77+
hover(trigger())
78+
expect(tooltipElement()).not.toBeNull()
79+
80+
runWatcherTick()
81+
expect(tooltipElement()).not.toBeNull()
82+
})
83+
84+
it('dismisses the tooltip when the trigger is hidden without a pointer event', () => {
85+
mountTooltip()
86+
hover(trigger())
87+
expect(tooltipElement()).not.toBeNull()
88+
89+
trigger().style.visibility = 'hidden'
90+
runWatcherTick()
91+
expect(tooltipElement()).toBeNull()
92+
})
93+
94+
it('dismisses the tooltip when the trigger is display: none without a pointer event', () => {
95+
mountTooltip()
96+
hover(trigger())
97+
expect(tooltipElement()).not.toBeNull()
98+
99+
trigger().style.display = 'none'
100+
runWatcherTick()
101+
expect(tooltipElement()).toBeNull()
102+
})
103+
104+
it('dismisses the tooltip when an ancestor becomes display: none', () => {
105+
mountTooltip()
106+
hover(trigger())
107+
expect(tooltipElement()).not.toBeNull()
108+
109+
if (!container) throw new Error('Container did not mount')
110+
container.style.display = 'none'
111+
runWatcherTick()
112+
expect(tooltipElement()).toBeNull()
113+
})
114+
115+
it('dismisses the tooltip when the trigger unmounts from under a static pointer', () => {
116+
mountTooltip()
117+
hover(trigger())
118+
expect(tooltipElement()).not.toBeNull()
119+
120+
act(() => root?.render(tooltipUi(false)))
121+
runWatcherTick()
122+
expect(tooltipElement()).toBeNull()
123+
})
124+
125+
it('still hides on pointer leave', () => {
126+
mountTooltip()
127+
const node = trigger()
128+
hover(node)
129+
expect(tooltipElement()).not.toBeNull()
130+
131+
act(() => {
132+
node.dispatchEvent(new MouseEvent('pointerout', { bubbles: true }))
133+
})
134+
expect(tooltipElement()).toBeNull()
135+
})
136+
})

packages/emcn/src/components/tooltip/tooltip.tsx

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ const EDGE_GUTTER = 16
1010
const EDGE_THRESHOLD = 360
1111
const MIN_FRAME_MS = 16
1212

13+
/** How often a visible tooltip re-verifies that its trigger is still visibly rendered, in ms. */
14+
const TRIGGER_VISIBILITY_INTERVAL_MS = 150
15+
1316
/**
1417
* Exponential time constant for smoothing the pointer velocity that drives the
1518
* flourish, in ms. The flourish is deliberately never handed to a CSS transition:
@@ -94,20 +97,22 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): {
9497

9598
const lastPointerRef = React.useRef<PointerSnapshot | null>(null)
9699
const velocityRef = React.useRef({ x: 0, magnitude: 0 })
100+
const triggerRef = React.useRef<HTMLElement | null>(null)
97101
const [state, setState] = React.useState<FloatingTooltipState>(HIDDEN_STATE)
98102

99-
const handlers = React.useMemo<FloatingTooltipHandlers>(() => {
100-
const reset = () => {
101-
lastPointerRef.current = null
102-
velocityRef.current.x = 0
103-
velocityRef.current.magnitude = 0
104-
}
103+
const reset = React.useCallback(() => {
104+
lastPointerRef.current = null
105+
velocityRef.current.x = 0
106+
velocityRef.current.magnitude = 0
107+
}, [])
105108

106-
const hide = () => {
107-
reset()
108-
setState((current) => (current.visible ? HIDDEN_STATE : current))
109-
}
109+
const hide = React.useCallback(() => {
110+
reset()
111+
triggerRef.current = null
112+
setState((current) => (current.visible ? HIDDEN_STATE : current))
113+
}, [reset])
110114

115+
const handlers = React.useMemo<FloatingTooltipHandlers>(() => {
111116
const apply = (clientX: number, clientY: number, motion: TooltipMotion) => {
112117
const next = { ...getTooltipPosition(clientX, clientY), ...motion }
113118
setState((current) =>
@@ -145,10 +150,12 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): {
145150
return {
146151
onPointerEnter: (event) => {
147152
if (!canShowRef.current(event.currentTarget)) return
153+
triggerRef.current = event.currentTarget
148154
showFromPointer(event.clientX, event.clientY)
149155
},
150156
onPointerMove: (event) => {
151157
if (!canShowRef.current(event.currentTarget)) return
158+
triggerRef.current = event.currentTarget
152159
const now = performance.now()
153160
const previous = lastPointerRef.current
154161
const delta = previous ? Math.max(now - previous.time, 1) : MIN_FRAME_MS
@@ -178,12 +185,28 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): {
178185
const target = event.currentTarget
179186
if (!canShowRef.current(target)) return
180187
if (!isFocusVisible(target)) return
188+
triggerRef.current = target
181189
const rect = target.getBoundingClientRect()
182190
showFromElement(rect.left + rect.width / 2, rect.bottom)
183191
},
184192
onBlur: hide,
185193
}
186-
}, [])
194+
}, [hide, reset])
195+
196+
/**
197+
* A keyboard- or script-driven UI change can hide the trigger with no pointer or focus event —
198+
* browsers don't re-dispatch boundary events until the pointer next moves (e.g. an editor bubble
199+
* menu set to `visibility: hidden` by Cmd+A while a toolbar tooltip is open) — so while visible,
200+
* the tooltip re-verifies its trigger and dismisses itself once the trigger is gone or hidden.
201+
*/
202+
React.useEffect(() => {
203+
if (!state.visible) return undefined
204+
const intervalId = window.setInterval(() => {
205+
const trigger = triggerRef.current
206+
if (!trigger || !isVisiblyRendered(trigger)) hide()
207+
}, TRIGGER_VISIBILITY_INTERVAL_MS)
208+
return () => window.clearInterval(intervalId)
209+
}, [state.visible, hide])
187210

188211
return { state, handlers }
189212
}
@@ -242,6 +265,26 @@ export function isTextClipped(element: HTMLElement): boolean {
242265
return element.scrollWidth > element.clientWidth + 1
243266
}
244267

268+
/**
269+
* Whether a tooltip trigger is still visibly rendered. `checkVisibility` (where available) catches
270+
* `display: none` and an inherited `visibility: hidden` anywhere up the tree. The fallback for
271+
* engines without it (Safari < 17.4, jsdom) reads the element's computed `visibility` — which
272+
* inherits from hidden ancestors — and then walks the ancestor chain for `display: none`, which
273+
* does not inherit. Computed styles, not layout (`getClientRects`/`offsetParent`), on purpose:
274+
* jsdom does no layout, so a layout-based check would misread every trigger as hidden in tests.
275+
*/
276+
function isVisiblyRendered(element: HTMLElement): boolean {
277+
if (!element.isConnected) return false
278+
if (typeof element.checkVisibility === 'function') {
279+
return element.checkVisibility({ checkVisibilityCSS: true, visibilityProperty: true })
280+
}
281+
if (getComputedStyle(element).visibility === 'hidden') return false
282+
for (let node: HTMLElement | null = element; node; node = node.parentElement) {
283+
if (getComputedStyle(node).display === 'none') return false
284+
}
285+
return true
286+
}
287+
245288
/** Clamps `value` to the inclusive `[min, max]` range. */
246289
export function clamp(value: number, min: number, max: number): number {
247290
return Math.max(min, Math.min(max, value))

0 commit comments

Comments
 (0)