1616 * specific language governing permissions and limitations
1717 * under the License.
1818 */
19- import { Portal } from "@chakra-ui/react" ;
20- import type { CSSProperties , ReactElement , ReactNode } from "react" ;
21- import { cloneElement , useCallback , useEffect , useMemo , useRef , useState } from "react" ;
22-
23- export type TooltipPlacement =
24- | "bottom-end"
25- | "bottom-start"
26- | "bottom"
27- | "left"
28- | "right"
29- | "top-end"
30- | "top-start"
31- | "top" ;
19+ import { Box , Portal } from "@chakra-ui/react" ;
20+ import type { ReactElement , ReactNode } from "react" ;
21+ import { cloneElement , useCallback , useEffect , useRef , useState } from "react" ;
3222
3323type Props = {
3424 readonly children : ReactElement ;
3525 readonly content : ReactNode ;
36- readonly placement ?: TooltipPlacement ;
3726} ;
3827
39- const calculatePosition = (
40- rect : DOMRect ,
41- placement : TooltipPlacement ,
42- offset : number ,
43- ) : { left : string ; top : string ; transform : string } => {
44- const { bottom, height, left, right, top, width } = rect ;
45- const { scrollX, scrollY } = globalThis ;
46-
47- switch ( placement ) {
48- case "bottom" :
49- return {
50- left : `${ left + scrollX + width / 2 } px` ,
51- top : `${ bottom + scrollY + offset } px` ,
52- transform : "translateX(-50%)" ,
53- } ;
54-
55- case "bottom-end" :
56- return {
57- left : `${ right + scrollX } px` ,
58- top : `${ bottom + scrollY + offset } px` ,
59- transform : "translateX(-100%)" ,
60- } ;
61-
62- case "bottom-start" :
63- return {
64- left : `${ left + scrollX } px` ,
65- top : `${ bottom + scrollY + offset } px` ,
66- transform : "none" ,
67- } ;
68-
69- case "left" :
70- return {
71- left : `${ left + scrollX - offset } px` ,
72- top : `${ top + scrollY + height / 2 } px` ,
73- transform : "translate(-100%, -50%)" ,
74- } ;
75-
76- case "right" :
77- return {
78- left : `${ right + scrollX + offset } px` ,
79- top : `${ top + scrollY + height / 2 } px` ,
80- transform : "translateY(-50%)" ,
81- } ;
82-
83- case "top" :
84- return {
85- left : `${ left + scrollX + width / 2 } px` ,
86- top : `${ top + scrollY - offset } px` ,
87- transform : "translate(-50%, -100%)" ,
88- } ;
89-
90- case "top-end" :
91- return {
92- left : `${ right + scrollX } px` ,
93- top : `${ top + scrollY - offset } px` ,
94- transform : "translate(-100%, -100%)" ,
95- } ;
96-
97- case "top-start" :
98- return {
99- left : `${ left + scrollX } px` ,
100- top : `${ top + scrollY - offset } px` ,
101- transform : "translateY(-100%)" ,
102- } ;
103-
104- default :
105- return {
106- left : `${ left + scrollX + width / 2 } px` ,
107- top : `${ top + scrollY - offset } px` ,
108- transform : "translate(-50%, -100%)" ,
109- } ;
110- }
111- } ;
112-
113- const getArrowStyle = ( placement : TooltipPlacement ) : CSSProperties => {
114- const baseStyle : CSSProperties = {
115- content : '""' ,
116- height : 0 ,
117- position : "absolute" ,
118- width : 0 ,
119- } ;
120-
121- switch ( placement ) {
122- case "bottom" :
123- case "bottom-end" :
124- case "bottom-start" :
125- return {
126- ...baseStyle ,
127- borderBottom : "4px solid var(--chakra-colors-bg-inverted)" ,
128- borderLeft : "4px solid transparent" ,
129- borderRight : "4px solid transparent" ,
130- left : placement === "bottom" ? "50%" : placement === "bottom-start" ? "12px" : undefined ,
131- right : placement === "bottom-end" ? "12px" : undefined ,
132- top : "-4px" ,
133- transform : placement === "bottom" ? "translateX(-50%)" : undefined ,
134- } ;
135-
136- case "left" :
137- return {
138- ...baseStyle ,
139- borderBottom : "4px solid transparent" ,
140- borderLeft : "4px solid var(--chakra-colors-bg-inverted)" ,
141- borderTop : "4px solid transparent" ,
142- right : "-4px" ,
143- top : "50%" ,
144- transform : "translateY(-50%)" ,
145- } ;
146-
147- case "right" :
148- return {
149- ...baseStyle ,
150- borderBottom : "4px solid transparent" ,
151- borderRight : "4px solid var(--chakra-colors-bg-inverted)" ,
152- borderTop : "4px solid transparent" ,
153- left : "-4px" ,
154- top : "50%" ,
155- transform : "translateY(-50%)" ,
156- } ;
28+ const offset = 8 ;
29+ const zIndex = 1500 ;
30+ // Estimated tooltip height for viewport boundary detection
31+ const estimatedTooltipHeight = 100 ;
15732
158- case "top" :
159- case "top-end" :
160- case "top-start" :
161- return {
162- ...baseStyle ,
163- borderLeft : "4px solid transparent" ,
164- borderRight : "4px solid transparent" ,
165- borderTop : "4px solid var(--chakra-colors-bg-inverted)" ,
166- bottom : "-4px" ,
167- left : placement === "top" ? "50%" : placement === "top-start" ? "12px" : undefined ,
168- right : placement === "top-end" ? "12px" : undefined ,
169- transform : placement === "top" ? "translateX(-50%)" : undefined ,
170- } ;
171-
172- default :
173- return baseStyle ;
174- }
175- } ;
176-
177- export const BasicTooltip = ( { children, content, placement = "bottom" } : Props ) : ReactElement => {
33+ export const BasicTooltip = ( { children, content } : Props ) : ReactElement => {
17834 const triggerRef = useRef < HTMLElement > ( null ) ;
17935 const [ isOpen , setIsOpen ] = useState ( false ) ;
36+ const [ showOnTop , setShowOnTop ] = useState ( false ) ;
18037 const timeoutRef = useRef < NodeJS . Timeout > ( ) ;
18138
182- const offset = 8 ;
183- const zIndex = 1500 ;
184-
18539 const handleMouseEnter = useCallback ( ( ) => {
18640 if ( timeoutRef . current ) {
18741 clearTimeout ( timeoutRef . current ) ;
18842 }
18943 timeoutRef . current = setTimeout ( ( ) => {
44+ // Check if tooltip would overflow viewport bottom
45+ if ( triggerRef . current ) {
46+ const triggerRect = triggerRef . current . getBoundingClientRect ( ) ;
47+ const wouldOverflow = triggerRect . bottom + offset + estimatedTooltipHeight > globalThis . innerHeight ;
48+
49+ setShowOnTop ( wouldOverflow ) ;
50+ }
19051 setIsOpen ( true ) ;
19152 } , 500 ) ;
19253 } , [ ] ) ;
@@ -209,49 +70,54 @@ export const BasicTooltip = ({ children, content, placement = "bottom" }: Props)
20970 [ ] ,
21071 ) ;
21172
212- const tooltipStyle = useMemo ( ( ) => {
213- if ( ! isOpen || ! triggerRef . current ) {
214- return { display : "none" } ;
215- }
216-
217- const rect = triggerRef . current . getBoundingClientRect ( ) ;
218- const position = calculatePosition ( rect , placement , offset ) ;
219-
220- return {
221- ...position ,
222- backgroundColor : "var(--chakra-colors-bg-inverted)" ,
223- borderRadius : "4px" ,
224- boxShadow : "0 2px 8px rgba(0, 0, 0, 0.15)" ,
225- color : "var(--chakra-colors-fg-inverted)" ,
226- fontSize : "14px" ,
227- padding : "8px 12px" ,
228- pointerEvents : "none" as const ,
229- position : "absolute" as const ,
230- whiteSpace : "nowrap" as const ,
231- zIndex,
232- } ;
233- } , [ isOpen , placement , offset , zIndex ] ) ;
234-
235- const arrowStyle = useMemo ( ( ) => getArrowStyle ( placement ) , [ placement ] ) ;
236-
23773 // Clone children and attach event handlers + ref
23874 const trigger = cloneElement ( children , {
23975 onMouseEnter : handleMouseEnter ,
24076 onMouseLeave : handleMouseLeave ,
24177 ref : triggerRef ,
24278 } ) ;
24379
80+ if ( ! isOpen || ! triggerRef . current ) {
81+ return trigger ;
82+ }
83+
84+ const rect = triggerRef . current . getBoundingClientRect ( ) ;
85+ const { scrollX, scrollY } = globalThis ;
86+
24487 return (
24588 < >
24689 { trigger }
247- { Boolean ( isOpen ) && (
248- < Portal >
249- < div style = { tooltipStyle } >
250- < div style = { arrowStyle } />
251- { content ?? undefined }
252- </ div >
253- </ Portal >
254- ) }
90+ < Portal >
91+ < Box
92+ bg = "bg.inverted"
93+ borderRadius = "4px"
94+ boxShadow = "0 2px 8px rgba(0, 0, 0, 0.15)"
95+ color = "fg.inverted"
96+ fontSize = "14px"
97+ left = { `${ rect . left + scrollX + rect . width / 2 } px` }
98+ padding = "8px 12px"
99+ pointerEvents = "none"
100+ position = "absolute"
101+ top = { showOnTop ? `${ rect . top + scrollY - offset } px` : `${ rect . bottom + scrollY + offset } px` }
102+ transform = { showOnTop ? "translate(-50%, -100%)" : "translateX(-50%)" }
103+ whiteSpace = "nowrap"
104+ zIndex = { zIndex }
105+ >
106+ < Box
107+ borderLeft = "4px solid transparent"
108+ borderRight = "4px solid transparent"
109+ height = { 0 }
110+ left = "50%"
111+ position = "absolute"
112+ transform = "translateX(-50%)"
113+ width = { 0 }
114+ { ...( showOnTop
115+ ? { borderTop : "4px solid var(--chakra-colors-bg-inverted)" , bottom : "-4px" }
116+ : { borderBottom : "4px solid var(--chakra-colors-bg-inverted)" , top : "-4px" } ) }
117+ />
118+ { content }
119+ </ Box >
120+ </ Portal >
255121 </ >
256122 ) ;
257123} ;
0 commit comments