1+ import { computePosition , flip , offset , autoUpdate } from '@floating-ui/dom' ;
2+
13const KEYS = {
24 ARROW_UP : 'ArrowUp' ,
35 ARROW_DOWN : 'ArrowDown' ,
@@ -12,6 +14,7 @@ const KEYS = {
1214
1315const SELECTORS = {
1416 BUTTON : '[aria-haspopup="menu"]' ,
17+ MENU_WRAPPER : '[data-prima-ref="menu-wrapper"]' ,
1518 MENU : '[role="menu"]' ,
1619 MENUITEM : '[role="menuitem"]' ,
1720 ENABLED_MENUITEM : '[role="menuitem"]:not([aria-disabled="true"])' ,
@@ -40,11 +43,15 @@ export default {
4043
4144 setupElements ( ) {
4245 const button = this . el . querySelector ( SELECTORS . BUTTON )
46+ const menuWrapper = this . el . querySelector ( SELECTORS . MENU_WRAPPER )
4347 const menu = this . el . querySelector ( SELECTORS . MENU )
4448 const items = this . el . querySelectorAll ( SELECTORS . MENUITEM )
4549
50+ const referenceSelector = menuWrapper ?. getAttribute ( 'data-reference' )
51+ const referenceElement = referenceSelector ? document . querySelector ( referenceSelector ) : button
52+
4653 this . setupAriaRelationships ( button , menu )
47- this . refs = { button, menu, items }
54+ this . refs = { button, menuWrapper , menu, items, referenceElement }
4855 } ,
4956
5057 setupEventListeners ( ) {
@@ -64,6 +71,8 @@ export default {
6471 } ,
6572
6673 cleanup ( ) {
74+ this . cleanupAutoUpdate ( )
75+
6776 if ( this . listeners ) {
6877 this . listeners . forEach ( ( [ element , event , handler ] ) => {
6978 element . removeEventListener ( event , handler )
@@ -72,6 +81,13 @@ export default {
7281 }
7382 } ,
7483
84+ cleanupAutoUpdate ( ) {
85+ if ( this . autoUpdateCleanup ) {
86+ this . autoUpdateCleanup ( )
87+ this . autoUpdateCleanup = null
88+ }
89+ } ,
90+
7591 handleKeydown ( e ) {
7692 const keyHandlers = {
7793 [ KEYS . ARROW_UP ] : ( ) => this . navigateUp ( e ) ,
@@ -207,12 +223,19 @@ export default {
207223
208224 handleShowStart ( ) {
209225 this . refs . button . setAttribute ( 'aria-expanded' , 'true' )
226+
227+ // Setup autoUpdate to reposition on scroll/resize
228+ this . autoUpdateCleanup = autoUpdate ( this . refs . referenceElement , this . refs . menuWrapper , ( ) => {
229+ this . positionMenu ( )
230+ } )
210231 } ,
211232
212233 handleHideEnd ( ) {
213234 this . clearFocus ( )
214235 this . refs . menu . removeAttribute ( 'aria-activedescendant' )
215236 this . refs . button . setAttribute ( 'aria-expanded' , 'false' )
237+ this . refs . menuWrapper . style . display = 'none'
238+ this . cleanupAutoUpdate ( )
216239 } ,
217240
218241 getAllMenuItems ( ) {
@@ -224,8 +247,8 @@ export default {
224247 } ,
225248
226249 isMenuVisible ( ) {
227- const menu = this . refs . menu
228- return menu && menu . style . display !== 'none' && menu . offsetParent !== null
250+ const wrapper = this . refs . menuWrapper
251+ return wrapper && wrapper . style . display !== 'none' && wrapper . offsetParent !== null
229252 } ,
230253
231254 getCurrentFocusIndex ( items ) {
@@ -248,15 +271,30 @@ export default {
248271
249272 hideMenu ( ) {
250273 liveSocket . execJS ( this . refs . menu , this . refs . menu . getAttribute ( 'js-hide' ) )
274+ this . refs . menuWrapper . style . display = 'none'
251275 } ,
252276
253277 toggleMenu ( ) {
254- liveSocket . execJS ( this . refs . menu , this . refs . menu . getAttribute ( 'js-toggle' ) )
278+ if ( this . isMenuVisible ( ) ) {
279+ liveSocket . execJS ( this . refs . menu , this . refs . menu . getAttribute ( 'js-hide' ) )
280+ this . refs . menuWrapper . style . display = 'none'
281+ } else {
282+ // Wrapper pattern: Show wrapper first (display:block) so Floating UI can measure it,
283+ // then position it, then trigger inner menu transition. This prevents the menu from
284+ // briefly appearing at wrong position before jumping to correct position.
285+ this . refs . menuWrapper . style . display = 'block'
286+ this . positionMenu ( )
287+ liveSocket . execJS ( this . refs . menu , this . refs . menu . getAttribute ( 'js-show' ) )
288+ }
255289 } ,
256290
257291 showMenuAndFocusFirst ( ) {
258- // Use toggle to show the menu (same as clicking the button)
259- liveSocket . execJS ( this . refs . menu , this . refs . menu . getAttribute ( 'js-toggle' ) )
292+ // Show wrapper and position it
293+ this . refs . menuWrapper . style . display = 'block'
294+ this . positionMenu ( )
295+
296+ // Use show to display the menu
297+ liveSocket . execJS ( this . refs . menu , this . refs . menu . getAttribute ( 'js-show' ) )
260298
261299 // Focus the first enabled item after the menu appears
262300 const items = this . getEnabledMenuItems ( )
@@ -266,8 +304,12 @@ export default {
266304 } ,
267305
268306 showMenuAndFocusLast ( ) {
269- // Use toggle to show the menu (same as clicking the button)
270- liveSocket . execJS ( this . refs . menu , this . refs . menu . getAttribute ( 'js-toggle' ) )
307+ // Show wrapper and position it
308+ this . refs . menuWrapper . style . display = 'block'
309+ this . positionMenu ( )
310+
311+ // Use show to display the menu
312+ liveSocket . execJS ( this . refs . menu , this . refs . menu . getAttribute ( 'js-show' ) )
271313
272314 // Focus the last enabled item after the menu appears
273315 const items = this . getEnabledMenuItems ( )
@@ -296,5 +338,33 @@ export default {
296338 items . forEach ( ( item , index ) => {
297339 item . id = `${ dropdownId } -item-${ index } `
298340 } )
341+ } ,
342+
343+ positionMenu ( ) {
344+ if ( ! this . refs . menuWrapper ) return
345+
346+ const placement = this . refs . menuWrapper . getAttribute ( 'data-placement' ) || 'bottom-start'
347+ const shouldFlip = this . refs . menuWrapper . getAttribute ( 'data-flip' ) !== 'false'
348+ const offsetValue = this . refs . menuWrapper . getAttribute ( 'data-offset' )
349+
350+ const middleware = [ ]
351+ if ( offsetValue && ! isNaN ( parseInt ( offsetValue ) ) ) {
352+ middleware . push ( offset ( parseInt ( offsetValue ) ) )
353+ }
354+ if ( shouldFlip ) {
355+ middleware . push ( flip ( ) )
356+ }
357+
358+ computePosition ( this . refs . referenceElement , this . refs . menuWrapper , {
359+ placement : placement ,
360+ middleware : middleware
361+ } ) . then ( ( { x, y} ) => {
362+ Object . assign ( this . refs . menuWrapper . style , {
363+ top : `${ y } px` ,
364+ left : `${ x } px`
365+ } )
366+ } ) . catch ( error => {
367+ console . error ( '[Prima Dropdown] Failed to position menu:' , error )
368+ } )
299369 }
300370}
0 commit comments