@@ -10,6 +10,9 @@ const EDGE_GUTTER = 16
1010const EDGE_THRESHOLD = 360
1111const 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. */
246289export function clamp ( value : number , min : number , max : number ) : number {
247290 return Math . max ( min , Math . min ( max , value ) )
0 commit comments