1+ import { h , cloneElement } from 'preact' ;
2+ import { useState , useEffect , useRef } from 'preact/hooks' ;
3+ import {
4+ computePosition ,
5+ flip ,
6+ shift ,
7+ offset ,
8+ arrow
9+ } from '@floating-ui/dom' ;
10+ import { visualBuilderStyles } from '../visualBuilder.style' ;
11+ import classNames from 'classnames' ;
12+ import { ContentTypeIcon } from './icons' ;
13+ import { FieldTypeIconsMap } from '../generators/generateCustomCursor' ;
14+ interface TooltipProps {
15+ children : JSX . Element ;
16+ content : JSX . Element ;
17+ placement ?: 'top-start' | 'bottom-start' | 'left-start' | 'right-start' ;
18+ }
19+
20+ /**
21+ * A lightweight, reusable tooltip component for Preact powered by Floating UI.
22+ *
23+ * @param {object } props - The component props.
24+ * @param {preact.ComponentChildren } props.children - The single child element that triggers the tooltip.
25+ * @param {string | preact.VNode } props.content - The content to display inside the tooltip.
26+ * @param {'top'|'bottom'|'left'|'right' } [props.placement='top'] - The desired placement of the tooltip.
27+ */
28+ const Tooltip = ( { children, content, placement = 'top-start' } : TooltipProps ) => {
29+ const [ isVisible , setIsVisible ] = useState ( false ) ;
30+ // Create refs for the trigger and the floating tooltip elements
31+ const triggerRef = useRef ( null ) ;
32+ const tooltipRef = useRef ( null ) ;
33+ const arrowRef = useRef ( null ) ;
34+
35+ const showTooltip = ( ) => setIsVisible ( true ) ;
36+ const hideTooltip = ( ) => setIsVisible ( false ) ;
37+
38+ // This effect calculates the tooltip's position whenever it becomes visible
39+ // or if its content or placement changes.
40+ useEffect ( ( ) => {
41+ if ( ! isVisible || ! triggerRef . current || ! tooltipRef . current ) {
42+ return ;
43+ }
44+
45+ const trigger = triggerRef . current as HTMLElement ;
46+ const tooltip = tooltipRef . current as HTMLElement ;
47+
48+ computePosition ( trigger , tooltip , {
49+ placement,
50+ // Middleware runs in order to modify the position
51+ middleware : [
52+ offset ( 8 ) , // Add 8px of space between the trigger and tooltip
53+ flip ( ) , // Flip to the opposite side if it overflows
54+ shift ( { padding : 5 } ) , // Shift to keep it in view
55+ ...( arrowRef . current ? [ arrow ( { element : arrowRef . current as HTMLElement } ) ] : [ ] ) , // Handle arrow positioning
56+ ] ,
57+ } ) . then ( ( { x, y, placement, middlewareData } ) => {
58+ // Apply the calculated coordinates to the tooltip element
59+ Object . assign ( tooltip . style , {
60+ left : `${ x } px` ,
61+ top : `${ y } px` ,
62+ } ) ;
63+
64+ // Position the arrow element
65+ if ( middlewareData . arrow && arrowRef . current ) {
66+ const { x : arrowX , y : arrowY } = middlewareData . arrow ;
67+ const side = placement . split ( '-' ) [ 0 ] ;
68+ const staticSide = {
69+ top : 'bottom' ,
70+ right : 'left' ,
71+ bottom : 'top' ,
72+ left : 'right' ,
73+ } [ side ] as string ;
74+
75+ const arrowElement = arrowRef . current as HTMLElement ;
76+
77+ // Reset all positioning properties
78+ Object . assign ( arrowElement . style , {
79+ left : '' ,
80+ top : '' ,
81+ right : '' ,
82+ bottom : '' ,
83+ } ) ;
84+
85+ // For placements like top-start, bottom-start, etc., we want the arrow
86+ // to be centered on the tooltip rather than pointing at the trigger center
87+ if ( placement . includes ( '-start' ) || placement . includes ( '-end' ) ) {
88+ const tooltipRect = tooltip . getBoundingClientRect ( ) ;
89+
90+ if ( side === 'top' || side === 'bottom' ) {
91+ // For top/bottom placements, center the arrow horizontally
92+ arrowElement . style . left = `${ 14 } px` ; // 4px = half arrow width
93+ if ( arrowY != null ) {
94+ arrowElement . style . top = `${ arrowY } px` ;
95+ }
96+ } else {
97+ // For left/right placements, center the arrow vertically
98+ arrowElement . style . top = `${ tooltipRect . height / 2 - 4 } px` ; // 4px = half arrow height
99+ if ( arrowX != null ) {
100+ arrowElement . style . left = `${ arrowX } px` ;
101+ }
102+ }
103+ } else {
104+ // For regular placements (top, bottom, left, right), use floating-ui's positioning
105+ if ( arrowX != null ) {
106+ arrowElement . style . left = `${ arrowX } px` ;
107+ }
108+ if ( arrowY != null ) {
109+ arrowElement . style . top = `${ arrowY } px` ;
110+ }
111+ }
112+
113+ // Position arrow to overlap the tooltip's border
114+ ( arrowElement . style as any ) [ staticSide ] = '-4px' ;
115+ }
116+ } ) ;
117+
118+ } , [ isVisible , placement , content ] ) ;
119+
120+ // We need to clone the child element to attach our ref and event listeners.
121+ // This ensures we don't wrap the child in an extra <div>.
122+ const triggerWithListeners = cloneElement ( children , {
123+ ref : triggerRef ,
124+ onMouseEnter : showTooltip ,
125+ onMouseLeave : hideTooltip ,
126+ onFocus : showTooltip ,
127+ onBlur : hideTooltip ,
128+ 'aria-describedby' : 'lightweight-tooltip' // for accessibility
129+ } ) ;
130+
131+ return (
132+ < >
133+ { triggerWithListeners }
134+ { isVisible && (
135+ < div
136+ ref = { tooltipRef }
137+ role = "tooltip"
138+ id = "lightweight-tooltip"
139+ className = { classNames ( "tooltip-container" , visualBuilderStyles ( ) [ "tooltip-container" ] ) }
140+ >
141+ { content }
142+ < div ref = { arrowRef } className = { classNames ( "tooltip-arrow" , visualBuilderStyles ( ) [ "tooltip-arrow" ] ) } > </ div >
143+ </ div >
144+ ) }
145+ </ >
146+ ) ;
147+ } ;
148+
149+ function ToolbarTooltipContent ( { contentTypeName, referenceFieldName} : { contentTypeName : string , referenceFieldName : string } ) {
150+ return (
151+ < div className = { classNames ( "toolbar-tooltip-content" , visualBuilderStyles ( ) [ "toolbar-tooltip-content" ] ) } >
152+ {
153+ contentTypeName && (
154+ < div className = { classNames ( "toolbar-tooltip-content-item" , visualBuilderStyles ( ) [ "toolbar-tooltip-content-item" ] ) } >
155+ < ContentTypeIcon />
156+ < p > { contentTypeName } </ p >
157+ </ div >
158+ )
159+ }
160+ {
161+ referenceFieldName && (
162+ < div className = { classNames ( "toolbar-tooltip-content-item" , visualBuilderStyles ( ) [ "toolbar-tooltip-content-item" ] ) } >
163+ < div dangerouslySetInnerHTML = { { __html : FieldTypeIconsMap . reference } } className = { classNames ( "visual-builder__field-icon" , visualBuilderStyles ( ) [ "visual-builder__field-icon" ] ) } />
164+ < p > { referenceFieldName } </ p >
165+ </ div >
166+ )
167+ }
168+ </ div >
169+ )
170+ }
171+
172+ export function ToolbarTooltip ( { children, data, disabled = false } : { children : JSX . Element , data : { contentTypeName : string , referenceFieldName : string } , disabled ?: boolean } ) {
173+ if ( disabled ) {
174+ return children ;
175+ }
176+ const { contentTypeName, referenceFieldName } = data ;
177+ return (
178+ < Tooltip content = { < ToolbarTooltipContent contentTypeName = { contentTypeName } referenceFieldName = { referenceFieldName } /> } >
179+ { children }
180+ </ Tooltip >
181+ )
182+ }
183+
184+ export default Tooltip ;
0 commit comments