22 type ReactElement ,
33 cloneElement ,
44 isValidElement ,
5- useMemo , useRef ,
5+ useLayoutEffect ,
6+ useMemo ,
7+ useRef ,
68 useState ,
79} from 'react'
810import { createPortal } from 'react-dom'
@@ -16,8 +18,6 @@ export type VisualOnlyTooltipProps = {
1618 tooltipPosition ?: 'top' | 'bottom'
1719}
1820
19- const TOOLTIP_VERTICAL_OFFSET_PX = 8
20-
2121/**
2222 * Wrapper component that displays a tooltip visually only (not announced by screen readers).
2323 *
@@ -37,10 +37,15 @@ export const VisualOnlyTooltip = ({
3737 const [ isVisible , setIsVisible ] = useState ( false )
3838 const { getContainer } = useUNSAFE_PortalContext ( )
3939 const wrapperRef = useRef < HTMLDivElement > ( null )
40+ const tooltipRef = useRef < HTMLDivElement > ( null )
4041 const [ position , setPosition ] = useState < {
4142 top : number
4243 left : number
4344 } | null > ( null )
45+ const [ computedStyle , setComputedStyle ] = useState < {
46+ left : number
47+ arrowLeft : number
48+ } | null > ( null )
4449
4550 const isBottom = tooltipPosition === 'bottom'
4651
@@ -57,9 +62,23 @@ export const VisualOnlyTooltip = ({
5762 const hideTooltip = ( ) => {
5863 setIsVisible ( false )
5964 setPosition ( null )
65+ setComputedStyle ( null )
6066 }
6167
62- const tooltipData = isVisible && position ? { isVisible, position } : null
68+ useLayoutEffect ( ( ) => {
69+ if ( ! tooltipRef . current || ! isVisible || ! position ) return
70+ const tooltipWidth = tooltipRef . current . getBoundingClientRect ( ) . width
71+ const doc = tooltipRef . current . ownerDocument
72+ const viewportWidth = doc . defaultView ?. innerWidth ?? window . innerWidth
73+ const padding = 8
74+ const desiredLeft = position . left - tooltipWidth / 2
75+ const maxLeft = viewportWidth - padding - tooltipWidth
76+ if ( desiredLeft <= maxLeft ) {
77+ setComputedStyle ( null )
78+ return
79+ }
80+ setComputedStyle ( { left : maxLeft , arrowLeft : position . left - maxLeft } )
81+ } , [ isVisible , position ] )
6382
6483 const portalContainer = useMemo ( ( ) => {
6584 if ( getContainer ) return getContainer ( )
@@ -82,12 +101,14 @@ export const VisualOnlyTooltip = ({
82101 >
83102 { wrappedChild }
84103 </ div >
85- { tooltipData &&
104+ { isVisible &&
105+ position &&
86106 portalContainer &&
87107 createPortal (
88108 < div
89109 aria-hidden = "true"
90110 role = "presentation"
111+ ref = { tooltipRef }
91112 className = { css ( {
92113 position : 'fixed' ,
93114 padding : '2px 8px' ,
@@ -97,12 +118,12 @@ export const VisualOnlyTooltip = ({
97118 fontSize : 14 ,
98119 whiteSpace : 'nowrap' ,
99120 pointerEvents : 'none' ,
100- zIndex : 9999 ,
121+ zIndex : 100001 ,
101122 boxShadow : '0 8px 20px rgba(0 0 0 / 0.1)' ,
102123 '&::after' : {
103124 content : '""' ,
104125 position : 'absolute' ,
105- left : '50%' ,
126+ left : 'var(--tooltip-arrow-left, 50%) ' ,
106127 transform : 'translateX(-50%)' ,
107128 border : '4px solid transparent' ,
108129 ...( isBottom
@@ -117,11 +138,22 @@ export const VisualOnlyTooltip = ({
117138 } ,
118139 } ) }
119140 style = { {
120- top : `${ tooltipData . position . top } px` ,
121- left : `${ tooltipData . position . left } px` ,
122- transform : isBottom
123- ? 'translate(-50%, 0)'
124- : 'translate(-50%, -100%)' ,
141+ top : `${ position . top } px` ,
142+ left : computedStyle
143+ ? `${ computedStyle . left } px`
144+ : `${ position . left } px` ,
145+ transform : computedStyle
146+ ? isBottom
147+ ? 'translateY(0)'
148+ : 'translateY(-100%)'
149+ : isBottom
150+ ? 'translate(-50%, 0)'
151+ : 'translate(-50%, -100%)' ,
152+ ...( computedStyle
153+ ? {
154+ '--tooltip-arrow-left' : `${ computedStyle . arrowLeft } px` ,
155+ }
156+ : null ) ,
125157 } }
126158 >
127159 { tooltip }
0 commit comments