1616
1717// https://tobyzerner.github.io/placement.js/dist/index.js
1818
19+ /**
20+ * Positions an element (tooltip/popover) relative to a reference element.
21+ * Automatically flips to the opposite side if there's insufficient space.
22+ *
23+ * @param element - The HTMLElement to position
24+ * @param reference - Reference element/Range or bounding rect
25+ * @param side - Preferred side: 'top', 'bottom', 'left', 'right' (default: 'bottom')
26+ * @param align - Alignment: 'start', 'center', 'end' (default: 'center')
27+ * @param options - Optional bounds for constraining the element
28+ * - bound: Custom boundary rect/element
29+ * - followCursor: { x, y } - If provided, tooltip follows cursor with smart positioning
30+ */
1931export const placement = ( function ( ) {
20- const e = {
32+ const AXIS_PROPS = {
2133 size : [ 'height' , 'width' ] ,
2234 clientSize : [ 'clientHeight' , 'clientWidth' ] ,
2335 offsetSize : [ 'offsetHeight' , 'offsetWidth' ] ,
@@ -28,87 +40,241 @@ export const placement = (function () {
2840 marginAfter : [ 'marginBottom' , 'marginRight' ] ,
2941 scrollOffset : [ 'pageYOffset' , 'pageXOffset' ] ,
3042 } ;
31- function t ( e ) {
32- return { top : e . top , bottom : e . bottom , left : e . left , right : e . right } ;
43+
44+ function extractRect ( source ) {
45+ return {
46+ top : source . top ,
47+ bottom : source . bottom ,
48+ left : source . left ,
49+ right : source . right ,
50+ } ;
3351 }
34- return function ( o , r , f , a , i ) {
35- void 0 === f && ( f = 'bottom' ) ,
36- void 0 === a && ( a = 'center' ) ,
37- void 0 === i && ( i = { } ) ,
38- ( r instanceof Element || r instanceof Range ) &&
39- ( r = t ( r . getBoundingClientRect ( ) ) ) ;
40- const n = {
41- top : r . bottom ,
42- bottom : r . top ,
43- left : r . right ,
44- right : r . left ,
45- ...r ,
52+
53+ return function ( element , reference , side , align , options ) {
54+ // Default parameters
55+ void 0 === side && ( side = 'bottom' ) ;
56+ void 0 === align && ( align = 'center' ) ;
57+ void 0 === options && ( options = { } ) ;
58+
59+ // Handle cursor following mode
60+ if ( options . followCursor ) {
61+ const cursorX = options . followCursor . x ;
62+ const cursorY = options . followCursor . y ;
63+ const offset = options . followCursor . offset || 10 ; // Default 10px offset from cursor
64+
65+ element . style . position = 'absolute' ;
66+ element . style . maxWidth = '' ;
67+ element . style . maxHeight = '' ;
68+
69+ const elementWidth = element . offsetWidth ;
70+ const elementHeight = element . offsetHeight ;
71+
72+ // Use viewport bounds for cursor following (not chart bounds)
73+ const viewportBounds = {
74+ top : 0 ,
75+ left : 0 ,
76+ bottom : window . innerHeight ,
77+ right : window . innerWidth ,
78+ } ;
79+
80+ // Vertical positioning: follow cursor Y with offset, clamped to viewport
81+ const topPosition = cursorY + offset ;
82+ const clampedTop = Math . max (
83+ viewportBounds . top ,
84+ Math . min ( topPosition , viewportBounds . bottom - elementHeight ) ,
85+ ) ;
86+ element . style . top = `${ clampedTop } px` ;
87+ element . style . bottom = 'auto' ;
88+
89+ // Horizontal positioning: auto-detect left or right based on available space
90+ const spaceOnRight = viewportBounds . right - cursorX ;
91+ const spaceOnLeft = cursorX - viewportBounds . left ;
92+
93+ if ( spaceOnRight >= elementWidth + offset ) {
94+ // Enough space on the right
95+ element . style . left = `${ cursorX + offset } px` ;
96+ element . style . right = 'auto' ;
97+ element . dataset . side = 'right' ;
98+ } else if ( spaceOnLeft >= elementWidth + offset ) {
99+ // Not enough space on right, use left
100+ element . style . left = `${ cursorX - elementWidth - offset } px` ;
101+ element . style . right = 'auto' ;
102+ element . dataset . side = 'left' ;
103+ } else if ( spaceOnRight > spaceOnLeft ) {
104+ // Not enough space on either side, pick the side with more space
105+ const leftPos = cursorX + offset ;
106+ const clampedLeft = Math . max (
107+ viewportBounds . left ,
108+ Math . min ( leftPos , viewportBounds . right - elementWidth ) ,
109+ ) ;
110+ element . style . left = `${ clampedLeft } px` ;
111+ element . style . right = 'auto' ;
112+ element . dataset . side = 'right' ;
113+ } else {
114+ const leftPos = cursorX - elementWidth - offset ;
115+ const clampedLeft = Math . max (
116+ viewportBounds . left ,
117+ Math . min ( leftPos , viewportBounds . right - elementWidth ) ,
118+ ) ;
119+ element . style . left = `${ clampedLeft } px` ;
120+ element . style . right = 'auto' ;
121+ element . dataset . side = 'left' ;
122+ }
123+
124+ element . dataset . align = 'cursor' ;
125+ return ; // Exit early, don't run normal positioning logic
126+ }
127+
128+ // Normalize reference to rect object
129+ ( reference instanceof Element || reference instanceof Range ) &&
130+ ( reference = extractRect ( reference . getBoundingClientRect ( ) ) ) ;
131+
132+ // Create anchor rect with swapped opposite edges for positioning
133+ const anchorRect = {
134+ top : reference . bottom ,
135+ bottom : reference . top ,
136+ left : reference . right ,
137+ right : reference . left ,
138+ ...reference ,
46139 } ;
47- const s = {
140+
141+ // Viewport bounds (can be overridden via options.bound)
142+ const bounds = {
48143 top : 0 ,
49144 left : 0 ,
50145 bottom : window . innerHeight ,
51146 right : window . innerWidth ,
52147 } ;
53- i . bound &&
54- ( ( i . bound instanceof Element || i . bound instanceof Range ) &&
55- ( i . bound = t ( i . bound . getBoundingClientRect ( ) ) ) ,
56- Object . assign ( s , i . bound ) ) ;
57- const l = getComputedStyle ( o ) ;
58- const m = { } ;
59- const b = { } ;
60- for ( const g in e )
61- ( m [ g ] = e [ g ] [ f === 'top' || f === 'bottom' ? 0 : 1 ] ) ,
62- ( b [ g ] = e [ g ] [ f === 'top' || f === 'bottom' ? 1 : 0 ] ) ;
63- ( o . style . position = 'absolute' ) ,
64- ( o . style . maxWidth = '' ) ,
65- ( o . style . maxHeight = '' ) ;
66- const d = parseInt ( l [ b . marginBefore ] ) ;
67- const c = parseInt ( l [ b . marginAfter ] ) ;
68- const u = d + c ;
69- const p = s [ b . after ] - s [ b . before ] - u ;
70- const h = parseInt ( l [ b . maxSize ] ) ;
71- ( ! h || p < h ) && ( o . style [ b . maxSize ] = `${ p } px` ) ;
72- const x = parseInt ( l [ m . marginBefore ] ) + parseInt ( l [ m . marginAfter ] ) ;
73- const y = n [ m . before ] - s [ m . before ] - x ;
74- const z = s [ m . after ] - n [ m . after ] - x ;
75- ( ( f === m . before && o [ m . offsetSize ] > y ) ||
76- ( f === m . after && o [ m . offsetSize ] > z ) ) &&
77- ( f = y > z ? m . before : m . after ) ;
78- const S = f === m . before ? y : z ;
79- const v = parseInt ( l [ m . maxSize ] ) ;
80- ( ! v || S < v ) && ( o . style [ m . maxSize ] = `${ S } px` ) ;
81- const w = window [ m . scrollOffset ] ;
82- const O = function ( e ) {
83- return Math . max ( s [ m . before ] , Math . min ( e , s [ m . after ] - o [ m . offsetSize ] - x ) ) ;
148+
149+ options . bound &&
150+ ( ( options . bound instanceof Element || options . bound instanceof Range ) &&
151+ ( options . bound = extractRect ( options . bound . getBoundingClientRect ( ) ) ) ,
152+ Object . assign ( bounds , options . bound ) ) ;
153+
154+ const styles = getComputedStyle ( element ) ;
155+ const isVertical = side === 'top' || side === 'bottom' ;
156+
157+ // Build axis property maps based on orientation
158+ const mainAxis = { } ; // Properties for the main positioning axis
159+ const crossAxis = { } ; // Properties for the perpendicular axis
160+
161+ for ( const prop in AXIS_PROPS ) {
162+ mainAxis [ prop ] = AXIS_PROPS [ prop ] [ isVertical ? 0 : 1 ] ;
163+ crossAxis [ prop ] = AXIS_PROPS [ prop ] [ isVertical ? 1 : 0 ] ;
164+ }
165+
166+ // Reset element positioning
167+ element . style . position = 'absolute' ;
168+ element . style . maxWidth = '' ;
169+ element . style . maxHeight = '' ;
170+
171+ // Cross-axis: calculate and apply max size constraint
172+ const crossMarginBefore = parseInt ( styles [ crossAxis . marginBefore ] ) ;
173+ const crossMarginAfter = parseInt ( styles [ crossAxis . marginAfter ] ) ;
174+ const crossMarginTotal = crossMarginBefore + crossMarginAfter ;
175+ const crossAvailableSpace =
176+ bounds [ crossAxis . after ] - bounds [ crossAxis . before ] - crossMarginTotal ;
177+ const crossMaxSize = parseInt ( styles [ crossAxis . maxSize ] ) ;
178+
179+ ( ! crossMaxSize || crossAvailableSpace < crossMaxSize ) &&
180+ ( element . style [ crossAxis . maxSize ] = `${ crossAvailableSpace } px` ) ;
181+
182+ // Main-axis: calculate space on both sides
183+ const mainMarginTotal =
184+ parseInt ( styles [ mainAxis . marginBefore ] ) +
185+ parseInt ( styles [ mainAxis . marginAfter ] ) ;
186+ const spaceBefore =
187+ anchorRect [ mainAxis . before ] - bounds [ mainAxis . before ] - mainMarginTotal ;
188+ const spaceAfter =
189+ bounds [ mainAxis . after ] - anchorRect [ mainAxis . after ] - mainMarginTotal ;
190+
191+ // Auto-flip to the side with more space if needed
192+ ( ( side === mainAxis . before && element [ mainAxis . offsetSize ] > spaceBefore ) ||
193+ ( side === mainAxis . after && element [ mainAxis . offsetSize ] > spaceAfter ) ) &&
194+ ( side = spaceBefore > spaceAfter ? mainAxis . before : mainAxis . after ) ;
195+
196+ // Apply main-axis max size constraint
197+ const mainAvailableSpace =
198+ side === mainAxis . before ? spaceBefore : spaceAfter ;
199+ const mainMaxSize = parseInt ( styles [ mainAxis . maxSize ] ) ;
200+
201+ ( ! mainMaxSize || mainAvailableSpace < mainMaxSize ) &&
202+ ( element . style [ mainAxis . maxSize ] = `${ mainAvailableSpace } px` ) ;
203+
204+ // Position on main axis
205+ const mainScrollOffset = window [ mainAxis . scrollOffset ] ;
206+ const clampMainPosition = function ( pos ) {
207+ return Math . max (
208+ bounds [ mainAxis . before ] ,
209+ Math . min (
210+ pos ,
211+ bounds [ mainAxis . after ] - element [ mainAxis . offsetSize ] - mainMarginTotal ,
212+ ) ,
213+ ) ;
84214 } ;
85- f === m . before
86- ? ( ( o . style [ m . before ] = `${ w + O ( n [ m . before ] - o [ m . offsetSize ] - x ) } px` ) ,
87- ( o . style [ m . after ] = 'auto' ) )
88- : ( ( o . style [ m . before ] = `${ w + O ( n [ m . after ] ) } px` ) ,
89- ( o . style [ m . after ] = 'auto' ) ) ;
90- const B = window [ b . scrollOffset ] ;
91- const I = function ( e ) {
92- return Math . max ( s [ b . before ] , Math . min ( e , s [ b . after ] - o [ b . offsetSize ] - u ) ) ;
215+
216+ side === mainAxis . before
217+ ? ( ( element . style [ mainAxis . before ] = `${
218+ mainScrollOffset +
219+ clampMainPosition (
220+ anchorRect [ mainAxis . before ] -
221+ element [ mainAxis . offsetSize ] -
222+ mainMarginTotal ,
223+ )
224+ } px`) ,
225+ ( element . style [ mainAxis . after ] = 'auto' ) )
226+ : ( ( element . style [ mainAxis . before ] = `${
227+ mainScrollOffset + clampMainPosition ( anchorRect [ mainAxis . after ] )
228+ } px`) ,
229+ ( element . style [ mainAxis . after ] = 'auto' ) ) ;
230+
231+ // Position on cross axis based on alignment
232+ const crossScrollOffset = window [ crossAxis . scrollOffset ] ;
233+ const clampCrossPosition = function ( pos ) {
234+ return Math . max (
235+ bounds [ crossAxis . before ] ,
236+ Math . min (
237+ pos ,
238+ bounds [ crossAxis . after ] - element [ crossAxis . offsetSize ] - crossMarginTotal ,
239+ ) ,
240+ ) ;
93241 } ;
94- switch ( a ) {
242+
243+ switch ( align ) {
95244 case 'start' :
96- ( o . style [ b . before ] = `${ B + I ( n [ b . before ] - d ) } px` ) ,
97- ( o . style [ b . after ] = 'auto' ) ;
245+ ( element . style [ crossAxis . before ] = `${
246+ crossScrollOffset +
247+ clampCrossPosition ( anchorRect [ crossAxis . before ] - crossMarginBefore )
248+ } px`) ,
249+ ( element . style [ crossAxis . after ] = 'auto' ) ;
98250 break ;
99251 case 'end' :
100- ( o . style [ b . before ] = 'auto' ) ,
101- ( o . style [ b . after ] = `${
102- B + I ( document . documentElement [ b . clientSize ] - n [ b . after ] - c )
252+ ( element . style [ crossAxis . before ] = 'auto' ) ,
253+ ( element . style [ crossAxis . after ] = `${
254+ crossScrollOffset +
255+ clampCrossPosition (
256+ document . documentElement [ crossAxis . clientSize ] -
257+ anchorRect [ crossAxis . after ] -
258+ crossMarginAfter ,
259+ )
103260 } px`) ;
104261 break ;
105262 default :
106- var H = n [ b . after ] - n [ b . before ] ;
107- ( o . style [ b . before ] = `${
108- B + I ( n [ b . before ] + H / 2 - o [ b . offsetSize ] / 2 - d )
263+ // 'center'
264+ var crossSize = anchorRect [ crossAxis . after ] - anchorRect [ crossAxis . before ] ;
265+ ( element . style [ crossAxis . before ] = `${
266+ crossScrollOffset +
267+ clampCrossPosition (
268+ anchorRect [ crossAxis . before ] +
269+ crossSize / 2 -
270+ element [ crossAxis . offsetSize ] / 2 -
271+ crossMarginBefore ,
272+ )
109273 } px`) ,
110- ( o . style [ b . after ] = 'auto' ) ;
274+ ( element . style [ crossAxis . after ] = 'auto' ) ;
111275 }
112- ( o . dataset . side = f ) , ( o . dataset . align = a ) ;
276+
277+ // Store final placement as data attributes
278+ ( element . dataset . side = side ) , ( element . dataset . align = align ) ;
113279 } ;
114280} ) ( ) ;
0 commit comments