Skip to content

Commit 489b014

Browse files
authored
Merge pull request #5 from plausible/dropdown-floating
Use floating-ui for dropdowns
2 parents 341a5d8 + a71ceae commit 489b014

File tree

12 files changed

+461
-341
lines changed

12 files changed

+461
-341
lines changed

assets/js/hooks/combobox.js

Lines changed: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const KEYS = {
1212
const SELECTORS = {
1313
SEARCH_INPUT: 'input[data-prima-ref=search_input]',
1414
SUBMIT_CONTAINER: '[data-prima-ref=submit_container]',
15+
OPTIONS_WRAPPER: '[data-prima-ref="options-wrapper"]',
1516
OPTIONS: '[data-prima-ref="options"]',
1617
OPTION: '[role=option]',
1718
CREATE_OPTION: '[data-prima-ref=create-option]',
@@ -56,14 +57,15 @@ export default {
5657
this.refs = {
5758
searchInput: this.el.querySelector(SELECTORS.SEARCH_INPUT),
5859
submitContainer: this.el.querySelector(SELECTORS.SUBMIT_CONTAINER),
60+
optionsWrapper: this.el.querySelector(SELECTORS.OPTIONS_WRAPPER),
5961
optionsContainer: this.el.querySelector(SELECTORS.OPTIONS),
6062
selectionsContainer: this.el.querySelector(SELECTORS.SELECTIONS)
6163
}
6264

6365
this.refs.createOption = this.refs.optionsContainer?.querySelector(SELECTORS.CREATE_OPTION)
6466
this.refs.selectionTemplate = this.refs.selectionsContainer?.querySelector(SELECTORS.SELECTION_TEMPLATE)
6567

66-
const referenceSelector = this.refs.optionsContainer?.getAttribute('data-reference')
68+
const referenceSelector = this.refs.optionsWrapper?.getAttribute('data-reference')
6769
this.refs.referenceElement = referenceSelector ? document.querySelector(referenceSelector) : this.refs.searchInput
6870

6971
this.mode = this.getMode()
@@ -83,7 +85,9 @@ export default {
8385
if (this.refs.optionsContainer) {
8486
this.listeners.push(
8587
[this.refs.optionsContainer, 'click', this.handleClick.bind(this)],
86-
[this.refs.optionsContainer, 'mouseover', this.handleHover.bind(this)]
88+
[this.refs.optionsContainer, 'mouseover', this.handleHover.bind(this)],
89+
[this.refs.optionsContainer, 'phx:show-start', this.handleShowStart.bind(this)],
90+
[this.refs.optionsContainer, 'phx:hide-end', this.handleHideEnd.bind(this)]
8791
)
8892
}
8993

@@ -389,8 +393,7 @@ export default {
389393

390394
handleAsyncMode() {
391395
if (this.refs.searchInput.value.length > 0) {
392-
this.liveSocket.execJS(this.refs.optionsContainer, this.refs.optionsContainer.getAttribute('js-show'));
393-
this.refs.searchInput.setAttribute('aria-expanded', 'true')
396+
this.showOptions()
394397
}
395398
this.focusedOptionBeforeUpdate = this.getCurrentFocusedOption()?.dataset.value
396399
},
@@ -439,12 +442,12 @@ export default {
439442
option.setAttribute('data-hidden', 'true')
440443
},
441444

442-
async positionOptions() {
443-
if (!this.refs.optionsContainer) return
445+
positionOptions() {
446+
if (!this.refs.optionsWrapper) return
444447

445-
const placement = this.refs.optionsContainer.getAttribute('data-placement') || 'bottom-start'
446-
const shouldFlip = this.refs.optionsContainer.getAttribute('data-flip') !== 'false'
447-
const offsetValue = this.refs.optionsContainer.getAttribute('data-offset')
448+
const placement = this.refs.optionsWrapper.getAttribute('data-placement') || 'bottom-start'
449+
const shouldFlip = this.refs.optionsWrapper.getAttribute('data-flip') !== 'false'
450+
const offsetValue = this.refs.optionsWrapper.getAttribute('data-offset')
448451

449452
const middleware = []
450453
if (offsetValue && !isNaN(parseInt(offsetValue))) {
@@ -454,20 +457,17 @@ export default {
454457
middleware.push(flip())
455458
}
456459

457-
try {
458-
const {x, y} = await computePosition(this.refs.referenceElement, this.refs.optionsContainer, {
459-
placement: placement,
460-
middleware: middleware
461-
})
462-
463-
Object.assign(this.refs.optionsContainer.style, {
464-
position: 'absolute',
460+
computePosition(this.refs.referenceElement, this.refs.optionsWrapper, {
461+
placement: placement,
462+
middleware: middleware
463+
}).then(({x, y}) => {
464+
Object.assign(this.refs.optionsWrapper.style, {
465465
top: `${y}px`,
466466
left: `${x}px`
467467
})
468-
} catch (error) {
468+
}).catch(error => {
469469
console.error('[Prima Combobox] Failed to position options:', error)
470-
}
470+
})
471471
},
472472

473473
cleanupAutoUpdate() {
@@ -478,21 +478,29 @@ export default {
478478
},
479479

480480
showOptions() {
481+
// Wrapper pattern: Show wrapper first (display:block) so Floating UI can measure it,
482+
// then position it, then trigger inner options transition. This prevents the options from
483+
// briefly appearing at wrong position before jumping to correct position.
484+
this.refs.optionsWrapper.style.display = 'block'
485+
this.positionOptions()
481486
this.liveSocket.execJS(this.refs.optionsContainer, this.refs.optionsContainer.getAttribute('js-show'));
482487

483-
this.refs.searchInput.setAttribute('aria-expanded', 'true')
484-
485488
this.focusFirstOption()
489+
this.setupClickOutsideHandler()
490+
},
486491

487-
requestAnimationFrame(() => {
488-
this.positionOptions()
489-
})
492+
handleShowStart() {
493+
this.refs.searchInput.setAttribute('aria-expanded', 'true')
490494

491-
this.autoUpdateCleanup = autoUpdate(this.refs.referenceElement, this.refs.optionsContainer, () => {
495+
// Setup autoUpdate to reposition on scroll/resize
496+
this.autoUpdateCleanup = autoUpdate(this.refs.referenceElement, this.refs.optionsWrapper, () => {
492497
this.positionOptions()
493498
})
499+
},
494500

495-
this.setupClickOutsideHandler()
501+
handleHideEnd() {
502+
this.refs.optionsWrapper.style.display = 'none'
503+
this.cleanupAutoUpdate()
496504
},
497505

498506
hideOptions() {
@@ -501,14 +509,13 @@ export default {
501509
this.liveSocket.execJS(this.refs.optionsContainer, this.refs.optionsContainer.getAttribute('js-hide'));
502510
this.refs.searchInput.setAttribute('aria-expanded', 'false')
503511
this.refs.searchInput.removeAttribute('aria-activedescendant')
504-
this.cleanupAutoUpdate()
505512

506513
this.refs.optionsContainer.addEventListener('phx:hide-end', () => {
507514
const regularOptions = this.getRegularOptions()
508515
for (const option of regularOptions) {
509516
this.showOption(option)
510517
}
511-
})
518+
}, { once: true })
512519
},
513520

514521
setupClickOutsideHandler() {

assets/js/hooks/dropdown.js

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { computePosition, flip, offset, autoUpdate } from '@floating-ui/dom';
2+
13
const KEYS = {
24
ARROW_UP: 'ArrowUp',
35
ARROW_DOWN: 'ArrowDown',
@@ -12,6 +14,7 @@ const KEYS = {
1214

1315
const 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
}

lib/prima/combobox.ex

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -381,24 +381,35 @@ defmodule Prima.Combobox do
381381
</.combobox_options>
382382
383383
"""
384+
385+
# Two-div structure separates positioning from transitions:
386+
# - Outer wrapper: Handles Floating UI positioning (must be display:block for measurements)
387+
# - Inner options: Handles CSS transitions (starts hidden, transitions in after positioning)
388+
# This prevents visual "jumping" where options briefly appear at wrong position before
389+
# repositioning. Floating UI cannot measure display:none elements.
384390
def combobox_options(assigns) do
385391
~H"""
386392
<div
387-
id={@id}
388-
role="listbox"
389-
class={@class}
390-
style="display: none;"
391-
js-show={JS.show(transition: @transition_enter)}
392-
js-hide={JS.hide(transition: @transition_leave)}
393-
phx-click-away={JS.dispatch("prima:combobox:reset")}
394-
data-prima-ref="options"
393+
style="position: absolute; top: 0; left: 0;"
394+
data-prima-ref="options-wrapper"
395395
data-reference={@reference}
396396
data-placement={@placement}
397397
data-flip={@flip}
398398
data-offset={@offset}
399-
{@rest}
400399
>
401-
{render_slot(@inner_block)}
400+
<div
401+
id={@id}
402+
role="listbox"
403+
class={@class}
404+
style="display: none;"
405+
js-show={JS.show(transition: @transition_enter)}
406+
js-hide={JS.hide(transition: @transition_leave)}
407+
phx-click-away={JS.dispatch("prima:combobox:reset")}
408+
data-prima-ref="options"
409+
{@rest}
410+
>
411+
{render_slot(@inner_block)}
412+
</div>
402413
</div>
403414
"""
404415
end

0 commit comments

Comments
 (0)