Skip to content

Commit 45e09d6

Browse files
authored
Merge pull request #1975 from naymspace/feature/fix-dropdown-close-on-native-picker
Fix dropdown closing on native picker interaction in Brave and Edge
2 parents fc8b949 + cb27142 commit 45e09d6

8 files changed

Lines changed: 316 additions & 7 deletions

File tree

assets/js/hooks/_dropdown.js

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* Manages dropdown open state programmatically via the `dropdown-open` class.
3+
*
4+
* daisyUI's CSS-only dropdown relies on `:focus-within` to stay open. That
5+
* mechanism breaks when the dropdown contains a native form control like a
6+
* `<select>`: in some Chromium-based browsers (notably Brave and Edge),
7+
* opening a native picker moves focus to `<body>`, so `:focus-within` flips to
8+
* false and the dropdown collapses mid-interaction.
9+
*
10+
* Two daisyUI behaviors complicate the JS toggle:
11+
*
12+
* 1. While the dropdown is open, daisyUI applies `pointer-events: none` to
13+
* the trigger. Pointer events at the trigger's position then re-target
14+
* to the dropdown root, so a listener attached to the trigger element
15+
* wouldn't fire on the close-on-second-click. Listen on the root and
16+
* treat any mousedown that's *not* inside the menu as a toggle — that
17+
* covers both the trigger (first click) and the re-targeted root
18+
* (second click).
19+
*
20+
* 2. After dismissing a native picker, Chromium sometimes fires a
21+
* synthesized "light-dismiss" click on the page underneath, without a
22+
* preceding mousedown there. The outside-click handler tracks where the
23+
* last mousedown landed and ignores outside clicks whose mousedown was
24+
* inside the dropdown.
25+
*
26+
* Document listeners only run while the dropdown is open, so pages with many
27+
* dropdowns don't pay an event-handler tax for closed ones.
28+
*/
29+
export default {
30+
mounted () {
31+
this.trigger = this.el.querySelector(`#${this.el.id}-trigger`)
32+
this.menu = this.el.querySelector(`#${this.el.id}-menu`)
33+
if (!this.trigger) return
34+
35+
this.isOpen = false
36+
this.mousedownInside = false
37+
38+
this.handleRootMousedown = this.handleRootMousedown.bind(this)
39+
this.handleTriggerKeydown = this.handleTriggerKeydown.bind(this)
40+
this.handleDocumentMousedown = this.handleDocumentMousedown.bind(this)
41+
this.handleDocumentClick = this.handleDocumentClick.bind(this)
42+
this.handleDocumentKeydown = this.handleDocumentKeydown.bind(this)
43+
44+
this.el.addEventListener('mousedown', this.handleRootMousedown)
45+
this.trigger.addEventListener('keydown', this.handleTriggerKeydown)
46+
},
47+
beforeUpdate () {
48+
// Remember which element inside the dropdown had focus, so we can restore
49+
// it after morphdom — LiveView's built-in focus preservation can drop focus
50+
// when the surrounding form is re-rendered, even though the input node
51+
// itself isn't replaced.
52+
this.focusedBeforeUpdate = this.el.contains(document.activeElement)
53+
? document.activeElement
54+
: null
55+
},
56+
updated () {
57+
// Restore the open state across LiveView re-renders, since morphdom strips
58+
// classes that aren't in the server-rendered HTML.
59+
this.el.classList.toggle('dropdown-open', this.isOpen)
60+
61+
if (this.focusedBeforeUpdate && !this.el.contains(document.activeElement)) {
62+
const target = this.focusedBeforeUpdate.isConnected
63+
? this.focusedBeforeUpdate
64+
: this.focusedBeforeUpdate.id && this.el.querySelector(`#${this.focusedBeforeUpdate.id}`)
65+
target?.focus()
66+
}
67+
this.focusedBeforeUpdate = null
68+
},
69+
destroyed () {
70+
this.detachDocumentListeners()
71+
this.el.removeEventListener('mousedown', this.handleRootMousedown)
72+
this.trigger?.removeEventListener('keydown', this.handleTriggerKeydown)
73+
},
74+
open () {
75+
if (this.isOpen) return
76+
this.isOpen = true
77+
this.el.classList.add('dropdown-open')
78+
this.attachDocumentListeners()
79+
},
80+
close () {
81+
if (!this.isOpen) return
82+
this.isOpen = false
83+
this.mousedownInside = false
84+
this.el.classList.remove('dropdown-open')
85+
this.detachDocumentListeners()
86+
},
87+
toggle () {
88+
if (this.isOpen) this.close()
89+
else this.open()
90+
},
91+
attachDocumentListeners () {
92+
document.addEventListener('mousedown', this.handleDocumentMousedown, true)
93+
document.addEventListener('click', this.handleDocumentClick, true)
94+
document.addEventListener('keydown', this.handleDocumentKeydown)
95+
},
96+
detachDocumentListeners () {
97+
document.removeEventListener('mousedown', this.handleDocumentMousedown, true)
98+
document.removeEventListener('click', this.handleDocumentClick, true)
99+
document.removeEventListener('keydown', this.handleDocumentKeydown)
100+
},
101+
handleRootMousedown (event) {
102+
// mousedown reached `this.el`, so it's inside the dropdown. Set the flag
103+
// up front: when this mousedown is the one that *opens* the dropdown, the
104+
// document-level listener isn't attached yet and won't catch it.
105+
this.mousedownInside = true
106+
if (this.menu?.contains(event.target)) return
107+
this.toggle()
108+
},
109+
handleTriggerKeydown (event) {
110+
// Match WAI-ARIA button semantics: Enter and Space activate the trigger.
111+
if (event.key !== 'Enter' && event.key !== ' ') return
112+
event.preventDefault()
113+
this.toggle()
114+
},
115+
handleDocumentMousedown (event) {
116+
this.mousedownInside = this.el.contains(event.target)
117+
},
118+
handleDocumentClick (event) {
119+
if (this.el.contains(event.target)) return
120+
if (this.mousedownInside) {
121+
this.mousedownInside = false
122+
return
123+
}
124+
this.close()
125+
},
126+
handleDocumentKeydown (event) {
127+
if (event.key !== 'Escape') return
128+
this.close()
129+
this.trigger.focus()
130+
}
131+
}

assets/js/hooks/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { default as BackpexCancelEntry } from './_cancel_entry'
22
export { default as BackpexDragHover } from './_drag_hover'
3+
export { default as BackpexDropdown } from './_dropdown'
34
export { default as BackpexSidebarSections } from './_sidebar_sections'
45
export { default as BackpexStickyActions } from './_sticky_actions'
56
export { default as BackpexThemeSelector } from './_theme_selector'

lib/backpex/filters/select.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ defmodule Backpex.Filters.Select do
103103
def render_form(assigns) do
104104
~H"""
105105
<select
106+
id={@form[@field].id}
106107
name={@form[@field].name}
107108
class={[
108109
"select select-sm",

lib/backpex/html/core_components.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ defmodule Backpex.HTML.CoreComponents do
6363
end)
6464

6565
~H"""
66-
<div id={@id} class={["dropdown", @class]} {@rest}>
66+
<div id={@id} class={["dropdown", @class]} phx-hook="BackpexDropdown" {@rest}>
6767
<div
6868
id={"#{@id}-trigger"}
6969
role="button"

priv/static/js/backpex.cjs.js

Lines changed: 88 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

priv/static/js/backpex.cjs.js.map

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

priv/static/js/backpex.esm.js

Lines changed: 88 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

priv/static/js/backpex.esm.js.map

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)