@@ -10,14 +10,16 @@ import {
1010 type ReactElement ,
1111 type ReactNode ,
1212 type Ref ,
13+ type RefObject ,
1314} from "react" ;
1415import { classNames } from "../../internal/classNames" ;
16+ import { TooltipTriggerContext } from "../../internal/tooltipTriggerContext" ;
1517import { Portal } from "../../overlay/Portal" ;
1618import { useDesignSystem } from "../../overlay/useDesignSystem" ;
1719import styles from "./Tooltip.module.css" ;
1820
1921export type TooltipPlacement = "top" | "bottom" | "left" | "right" ;
20- export type TooltipTrigger = "hover" | "click" | "focus" ;
22+ export type TooltipTrigger = "hover" | "click" | "focus" | "hover-focus" ;
2123
2224const DEFAULT_TOOLTIP_DELAY_MS = 450 ;
2325const INTERACTIVE_HIDE_DELAY_MS = 400 ;
@@ -27,6 +29,7 @@ const INTERACTIVE_HIDE_DELAY_MS = 400;
2729 */
2830const WARM_WINDOW_MS = 300 ;
2931let tooltipWarmUntil = 0 ;
32+ const activeTooltips = new WeakMap < Document , { id : string ; hide : ( ) => void } > ( ) ;
3033
3134/** Cursor offset when followCursor: right and down so the tooltip never covers the cursor. */
3235const CURSOR_OFFSET_X = 12 ;
@@ -36,7 +39,7 @@ const VIEWPORT_PADDING = 8;
3639
3740export interface TooltipProps {
3841 /** Single focusable trigger element the tooltip describes. */
39- children : ReactElement ;
42+ children ? : ReactElement ;
4043 className ?: string ;
4144 content : ReactNode ;
4245 /** Open delay in milliseconds. Falls back to the provider value, then 450ms. */
@@ -49,6 +52,12 @@ export interface TooltipProps {
4952 /** Preferred side of the trigger; flips to the opposite side when space runs out. */
5053 placement ?: TooltipPlacement ;
5154 trigger ?: TooltipTrigger ;
55+ /** Bind to an existing control without adding a wrapper or another tab stop. */
56+ triggerRef ?: RefObject < HTMLElement | null > ;
57+ /** Reveal a virtually focused option, for example in an aria-activedescendant listbox. */
58+ active ?: boolean ;
59+ /** Refresh lazy content or decline opening when the trigger no longer needs a tooltip. */
60+ onBeforeShow ?: ( ) => boolean ;
5261}
5362
5463function assignRef < T > ( ref : Ref < T > | undefined , value : T | null ) : void {
@@ -166,6 +175,9 @@ export function Tooltip({
166175 interactive = false ,
167176 placement = "top" ,
168177 trigger = "hover" ,
178+ triggerRef : externalTriggerRef ,
179+ active = false ,
180+ onBeforeShow,
169181} : TooltipProps ) {
170182 const designSystem = useDesignSystem ( ) ;
171183 const resolvedDelayMs = delay ?? designSystem . tooltipDelay ?? DEFAULT_TOOLTIP_DELAY_MS ;
@@ -181,13 +193,15 @@ export function Tooltip({
181193 ready : false ,
182194 } ) ;
183195 const [ mousePosition , setMousePosition ] = useState < { x : number ; y : number } | null > ( null ) ;
184- const triggerRef = useRef < HTMLElement | null > ( null ) ;
196+ const internalTriggerRef = useRef < HTMLElement | null > ( null ) ;
197+ const triggerRef = externalTriggerRef ?? internalTriggerRef ;
185198 const tooltipRef = useRef < HTMLDivElement | null > ( null ) ;
186199 const showTimeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
187200 const hideTimeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
188201 const latestMousePositionRef = useRef < { x : number ; y : number } | null > ( null ) ;
189202 const recalcFrameRef = useRef < number | null > ( null ) ;
190203 const instantRef = useRef ( false ) ;
204+ const hideCurrentRef = useRef < ( ) => void > ( ( ) => { } ) ;
191205
192206 const calculatePosition = useCallback ( ( ) => {
193207 if ( ! tooltipRef . current ) return ;
@@ -226,8 +240,11 @@ export function Tooltip({
226240 } ) ;
227241 } , [ calculatePosition ] ) ;
228242
229- const showTooltip = useCallback ( ( event ?: ReactMouseEvent ) => {
243+ const showTooltip = useCallback ( ( event ?: Pick < MouseEvent , "clientX" | "clientY" > ) => {
230244 if ( disabled ) return ;
245+ const element = triggerRef . current ;
246+ if ( ! element || element . closest ( '[hidden], [aria-hidden="true"]' ) ) return ;
247+ if ( onBeforeShow && ! onBeforeShow ( ) ) return ;
231248 if ( showTimeoutRef . current ) clearTimeout ( showTimeoutRef . current ) ;
232249 if ( hideTimeoutRef . current ) {
233250 clearTimeout ( hideTimeoutRef . current ) ;
@@ -236,19 +253,24 @@ export function Tooltip({
236253 if ( followCursor && event ) {
237254 latestMousePositionRef . current = { x : event . clientX , y : event . clientY } ;
238255 }
239- const openDelay = trigger === "hover" && Date . now ( ) < tooltipWarmUntil
256+ const openDelay = ( trigger === "hover" || trigger === "hover-focus" ) && Date . now ( ) < tooltipWarmUntil
240257 ? 0
241258 : resolvedDelayMs ;
242259 instantRef . current = openDelay === 0 ;
243260 showTimeoutRef . current = setTimeout ( ( ) => {
244261 showTimeoutRef . current = null ;
262+ if ( ! element . isConnected || element . closest ( '[hidden], [aria-hidden="true"]' ) ) return ;
263+ if ( onBeforeShow && ! onBeforeShow ( ) ) return ;
264+ const previous = activeTooltips . get ( element . ownerDocument ) ;
265+ if ( previous && previous . id !== tooltipId ) previous . hide ( ) ;
266+ activeTooltips . set ( element . ownerDocument , { id : tooltipId , hide : ( ) => hideCurrentRef . current ( ) } ) ;
245267 if ( followCursor ) {
246268 setMousePosition ( latestMousePositionRef . current ) ;
247269 }
248270 setLayout ( ( prev ) => ( prev . ready ? { ...prev , ready : false } : prev ) ) ;
249271 setVisible ( true ) ;
250272 } , openDelay ) ;
251- } , [ disabled , followCursor , resolvedDelayMs , trigger ] ) ;
273+ } , [ disabled , followCursor , onBeforeShow , resolvedDelayMs , tooltipId , trigger , triggerRef ] ) ;
252274
253275 const hideTooltip = useCallback ( ( ) => {
254276 if ( showTimeoutRef . current ) {
@@ -262,16 +284,20 @@ export function Tooltip({
262284 if ( visible ) {
263285 tooltipWarmUntil = Date . now ( ) + WARM_WINDOW_MS ;
264286 }
287+ const ownerDocument = triggerRef . current ?. ownerDocument ;
288+ if ( ownerDocument && activeTooltips . get ( ownerDocument ) ?. id === tooltipId ) activeTooltips . delete ( ownerDocument ) ;
265289 setVisible ( false ) ;
266290 setLayout ( ( prev ) => ( prev . ready ? { ...prev , ready : false } : prev ) ) ;
267291 if ( followCursor ) {
268292 latestMousePositionRef . current = null ;
269293 setMousePosition ( null ) ;
270294 }
271- } , [ followCursor , visible ] ) ;
295+ } , [ followCursor , tooltipId , triggerRef , visible ] ) ;
296+
297+ useEffect ( ( ) => { hideCurrentRef . current = hideTooltip ; } , [ hideTooltip ] ) ;
272298
273299 const scheduleHideTooltip = useCallback ( ( ) => {
274- if ( ! interactive ) {
300+ if ( ! interactive || ! visible ) {
275301 hideTooltip ( ) ;
276302 return ;
277303 }
@@ -281,7 +307,7 @@ export function Tooltip({
281307 hideTimeoutRef . current = null ;
282308 hideTooltip ( ) ;
283309 } , INTERACTIVE_HIDE_DELAY_MS ) ;
284- } , [ hideTooltip , interactive ] ) ;
310+ } , [ hideTooltip , interactive , visible ] ) ;
285311
286312 useEffect ( ( ) => {
287313 setLayout ( ( prev ) => ( prev . placement === placement ? prev : { ...prev , placement } ) ) ;
@@ -314,26 +340,89 @@ export function Tooltip({
314340 } ;
315341 } , [ visible , followCursor , scheduleCalculatePosition ] ) ;
316342
317- useEffect ( ( ) => ( ) => {
318- if ( showTimeoutRef . current ) clearTimeout ( showTimeoutRef . current ) ;
319- if ( hideTimeoutRef . current ) clearTimeout ( hideTimeoutRef . current ) ;
320- } , [ ] ) ;
343+ useEffect ( ( ) => {
344+ const ownerDocument = triggerRef . current ?. ownerDocument ;
345+ return ( ) => {
346+ if ( showTimeoutRef . current ) clearTimeout ( showTimeoutRef . current ) ;
347+ if ( hideTimeoutRef . current ) clearTimeout ( hideTimeoutRef . current ) ;
348+ if ( ownerDocument && activeTooltips . get ( ownerDocument ) ?. id === tooltipId ) activeTooltips . delete ( ownerDocument ) ;
349+ } ;
350+ } , [ tooltipId , triggerRef ] ) ;
351+
352+ // Delegated text slots use the owning button/row for hover and keyboard focus.
353+ // Keep the actual label in place so this also works inside portalled listboxes.
354+ useEffect ( ( ) => {
355+ const element = externalTriggerRef ?. current ;
356+ if ( ! element ) return ;
357+ const onEnter = ( event : MouseEvent ) => {
358+ if ( trigger === "hover" || trigger === "hover-focus" ) showTooltip ( event ) ;
359+ } ;
360+ const onLeave = ( ) => {
361+ if ( trigger === "hover-focus" && element . contains ( element . ownerDocument . activeElement ) ) return ;
362+ if ( trigger === "hover" || trigger === "hover-focus" ) scheduleHideTooltip ( ) ;
363+ } ;
364+ const onFocus = ( ) => {
365+ if ( trigger === "focus" || trigger === "hover-focus" ) showTooltip ( ) ;
366+ } ;
367+ const onBlur = ( event : FocusEvent ) => {
368+ if ( event . relatedTarget && element . contains ( event . relatedTarget as Node ) ) return ;
369+ if ( trigger === "focus" || trigger === "hover-focus" ) hideTooltip ( ) ;
370+ } ;
371+ const onClick = ( ) => {
372+ if ( trigger === "click" && ! visible ) showTooltip ( ) ;
373+ else hideTooltip ( ) ;
374+ } ;
375+ element . addEventListener ( "mouseenter" , onEnter ) ;
376+ element . addEventListener ( "mouseleave" , onLeave ) ;
377+ element . addEventListener ( "focusin" , onFocus ) ;
378+ element . addEventListener ( "focusout" , onBlur ) ;
379+ element . addEventListener ( "click" , onClick ) ;
380+ return ( ) => {
381+ element . removeEventListener ( "mouseenter" , onEnter ) ;
382+ element . removeEventListener ( "mouseleave" , onLeave ) ;
383+ element . removeEventListener ( "focusin" , onFocus ) ;
384+ element . removeEventListener ( "focusout" , onBlur ) ;
385+ element . removeEventListener ( "click" , onClick ) ;
386+ } ;
387+ } , [ externalTriggerRef , hideTooltip , scheduleHideTooltip , showTooltip , trigger , visible ] ) ;
388+
389+ // A measured text slot can mount after focus has already reached its owner.
390+ // Only replay focus/virtual activation on a transition, not on visibility updates.
391+ const activationRef = useRef ( false ) ;
392+ useEffect ( ( ) => {
393+ const element = triggerRef . current ;
394+ const activated = ! disabled && ( active || Boolean ( externalTriggerRef
395+ && element ?. contains ( element . ownerDocument . activeElement ) ) ) ;
396+ if ( activated === activationRef . current ) return ;
397+ activationRef . current = activated ;
398+ if ( activated ) showTooltip ( ) ;
399+ else hideTooltip ( ) ;
400+ } , [ active , disabled , externalTriggerRef , hideTooltip , showTooltip , triggerRef ] ) ;
401+
402+ useEffect ( ( ) => {
403+ const ownerDocument = triggerRef . current ?. ownerDocument ;
404+ const onEscape = ( event : KeyboardEvent ) => {
405+ if ( event . key === "Escape" ) hideTooltip ( ) ;
406+ } ;
407+ ownerDocument ?. addEventListener ( "keydown" , onEscape , true ) ;
408+ return ( ) => ownerDocument ?. removeEventListener ( "keydown" , onEscape , true ) ;
409+ } , [ hideTooltip , triggerRef ] ) ;
321410
322- const childProps = children . props as Record < string , unknown > ;
323- const childRef = ( children as ReactElement & { ref ?: Ref < HTMLElement > } ) . ref ;
411+ const childProps = ( children ? .props ?? { } ) as Record < string , unknown > ;
412+ const childRef = ( children as ( ReactElement & { ref ?: Ref < HTMLElement > } ) | undefined ) ? .ref ;
324413
325414 const handleTriggerRef = useCallback ( ( node : HTMLElement | null ) => {
326- triggerRef . current = node ;
415+ internalTriggerRef . current = node ;
327416 assignRef ( childRef , node ) ;
328417 } , [ childRef ] ) ;
329418
330419 const handleMouseEnter = ( event : ReactMouseEvent ) => {
331- if ( trigger === "hover" ) showTooltip ( event ) ;
420+ if ( trigger === "hover" || trigger === "hover-focus" ) showTooltip ( event ) ;
332421 ( childProps . onMouseEnter as ( ( event : ReactMouseEvent ) => void ) | undefined ) ?.( event ) ;
333422 } ;
334423
335424 const handleMouseLeave = ( event : ReactMouseEvent ) => {
336- if ( trigger === "hover" ) scheduleHideTooltip ( ) ;
425+ if ( trigger === "hover" || ( trigger === "hover-focus" && ! event . currentTarget . contains ( event . currentTarget . ownerDocument . activeElement ) ) ) scheduleHideTooltip ( ) ;
337426 ( childProps . onMouseLeave as ( ( event : ReactMouseEvent ) => void ) | undefined ) ?.( event ) ;
338427 } ;
339428
@@ -361,18 +450,31 @@ export function Tooltip({
361450 } ;
362451
363452 const handleFocus = ( event : ReactFocusEvent ) => {
364- if ( trigger === "focus" ) showTooltip ( ) ;
453+ if ( trigger === "focus" || trigger === "hover-focus" ) showTooltip ( ) ;
365454 ( childProps . onFocus as ( ( event : ReactFocusEvent ) => void ) | undefined ) ?.( event ) ;
366455 } ;
367456
368457 const handleBlur = ( event : ReactFocusEvent ) => {
369- if ( trigger === "focus" ) hideTooltip ( ) ;
458+ if ( trigger === "focus" || trigger === "hover-focus" ) hideTooltip ( ) ;
370459 ( childProps . onBlur as ( ( event : ReactFocusEvent ) => void ) | undefined ) ?.( event ) ;
371460 } ;
372461
373462 const isShown = visible && layout . ready ;
374463
375- const triggerElement = cloneElement ( children as ReactElement < Record < string , unknown > > , {
464+ useEffect ( ( ) => {
465+ const element = externalTriggerRef ?. current ;
466+ if ( ! element || ! isShown ) return ;
467+ const descriptions = new Set ( element . getAttribute ( "aria-describedby" ) ?. split ( / \s + / ) . filter ( Boolean ) ) ;
468+ descriptions . add ( tooltipId ) ;
469+ element . setAttribute ( "aria-describedby" , [ ...descriptions ] . join ( " " ) ) ;
470+ return ( ) => {
471+ const remaining = element . getAttribute ( "aria-describedby" ) ?. split ( / \s + / ) . filter ( id => id && id !== tooltipId ) ?? [ ] ;
472+ if ( remaining . length ) element . setAttribute ( "aria-describedby" , remaining . join ( " " ) ) ;
473+ else element . removeAttribute ( "aria-describedby" ) ;
474+ } ;
475+ } , [ externalTriggerRef , isShown , tooltipId ] ) ;
476+
477+ const triggerElement = children ? cloneElement ( children as ReactElement < Record < string , unknown > > , {
376478 ref : handleTriggerRef ,
377479 onMouseEnter : handleMouseEnter ,
378480 onMouseLeave : handleMouseLeave ,
@@ -383,11 +485,13 @@ export function Tooltip({
383485 "aria-describedby" : isShown
384486 ? [ childProps [ "aria-describedby" ] , tooltipId ] . filter ( Boolean ) . join ( " " )
385487 : childProps [ "aria-describedby" ] ,
386- } as Record < string , unknown > ) ;
488+ } as Record < string , unknown > ) : null ;
387489
388490 return (
389491 < >
390- { triggerElement }
492+ < TooltipTriggerContext . Provider value = { ! disabled } >
493+ { triggerElement }
494+ </ TooltipTriggerContext . Provider >
391495 { visible && (
392496 < Portal ownerDocument = { triggerRef . current ?. ownerDocument } >
393497 < div
0 commit comments