diff --git a/assets/js/hooks/dropdown.js b/assets/js/hooks/dropdown.js
index 10e8280..14511b1 100644
--- a/assets/js/hooks/dropdown.js
+++ b/assets/js/hooks/dropdown.js
@@ -64,7 +64,6 @@ export default {
[this.refs.menu, 'mouseover', this.handleMouseOver.bind(this)],
[this.refs.menu, 'click', this.handleMenuClick.bind(this)],
[this.el, 'keydown', this.handleKeydown.bind(this)],
- [this.el, 'prima:close', this.handleClose.bind(this)],
[this.refs.menu, 'phx:show-start', this.handleShowStart.bind(this)],
[this.refs.menu, 'phx:hide-end', this.handleHideEnd.bind(this)]
]
@@ -76,6 +75,7 @@ export default {
cleanup() {
this.cleanupAutoUpdate()
+ this.cleanupClickOutsideHandler()
if (this.listeners) {
this.listeners.forEach(([element, event, handler]) => {
@@ -92,6 +92,23 @@ export default {
}
},
+ setupClickOutsideHandler() {
+ this.cleanupClickOutsideHandler()
+ this.clickOutsideHandler = (event) => {
+ if (!this.refs.button.contains(event.target) && !this.refs.menu.contains(event.target)) {
+ this.hideMenu()
+ }
+ }
+ document.addEventListener('click', this.clickOutsideHandler)
+ },
+
+ cleanupClickOutsideHandler() {
+ if (this.clickOutsideHandler) {
+ document.removeEventListener('click', this.clickOutsideHandler)
+ this.clickOutsideHandler = null
+ }
+ },
+
handleKeydown(e) {
const keyHandlers = {
[KEYS.ARROW_UP]: () => this.navigateUp(e),
@@ -210,10 +227,6 @@ export default {
this.setFocus(matchingItems[nextIndex])
},
- handleClose() {
- this.hideMenu()
- },
-
handleToggle() {
this.toggleMenu()
},
@@ -295,12 +308,12 @@ export default {
hideMenu() {
liveSocket.execJS(this.refs.menu, this.refs.menu.getAttribute('js-hide'))
this.refs.menuWrapper.style.display = 'none'
+ this.cleanupClickOutsideHandler()
},
toggleMenu() {
if (this.isMenuVisible()) {
- liveSocket.execJS(this.refs.menu, this.refs.menu.getAttribute('js-hide'))
- this.refs.menuWrapper.style.display = 'none'
+ this.hideMenu()
} else {
// Wrapper pattern: Show wrapper first (display:block) so Floating UI can measure it,
// then position it, then trigger inner menu transition. This prevents the menu from
@@ -308,6 +321,7 @@ export default {
this.refs.menuWrapper.style.display = 'block'
this.positionMenu()
liveSocket.execJS(this.refs.menu, this.refs.menu.getAttribute('js-show'))
+ this.setupClickOutsideHandler()
}
},
@@ -324,6 +338,8 @@ export default {
if (items.length > 0) {
this.setFocus(items[0])
}
+
+ this.setupClickOutsideHandler()
},
showMenuAndFocusLast() {
@@ -339,6 +355,8 @@ export default {
if (items.length > 0) {
this.setFocus(items[items.length - 1])
}
+
+ this.setupClickOutsideHandler()
},
setupAriaRelationships(button, menu) {
diff --git a/assets/js/hooks/listbox.js b/assets/js/hooks/listbox.js
index d025416..ac3d1d4 100644
--- a/assets/js/hooks/listbox.js
+++ b/assets/js/hooks/listbox.js
@@ -81,7 +81,6 @@ export default {
[this.refs.listbox, 'mouseover', this.handleMouseOver.bind(this)],
[this.refs.listbox, 'click', this.handleListboxClick.bind(this)],
[this.el, 'keydown', this.handleKeydown.bind(this)],
- [this.el, 'prima:close', this.handleClose.bind(this)],
[this.refs.listbox, 'phx:show-start', this.handleShowStart.bind(this)],
[this.refs.listbox, 'phx:hide-end', this.handleHideEnd.bind(this)]
]
@@ -93,6 +92,7 @@ export default {
cleanup() {
this.cleanupAutoUpdate()
+ this.cleanupClickOutsideHandler()
if (this.listeners) {
this.listeners.forEach(([element, event, handler]) => {
@@ -109,6 +109,23 @@ export default {
}
},
+ setupClickOutsideHandler() {
+ this.cleanupClickOutsideHandler()
+ this.clickOutsideHandler = (event) => {
+ if (!this.refs.button.contains(event.target) && !this.refs.listbox.contains(event.target)) {
+ this.hideListbox()
+ }
+ }
+ document.addEventListener('click', this.clickOutsideHandler)
+ },
+
+ cleanupClickOutsideHandler() {
+ if (this.clickOutsideHandler) {
+ document.removeEventListener('click', this.clickOutsideHandler)
+ this.clickOutsideHandler = null
+ }
+ },
+
handleKeydown(e) {
const keyHandlers = {
[KEYS.ARROW_UP]: () => this.navigateUp(e),
@@ -231,10 +248,6 @@ export default {
this.setFocus(matchingOptions[nextIndex])
},
- handleClose() {
- this.hideListbox()
- },
-
handleToggle() {
this.toggleListbox()
},
@@ -336,6 +349,7 @@ export default {
hideListbox() {
liveSocket.execJS(this.refs.listbox, this.refs.listbox.getAttribute('js-hide'))
this.refs.optionsWrapper.style.display = 'none'
+ this.cleanupClickOutsideHandler()
},
toggleListbox() {
@@ -357,6 +371,8 @@ export default {
if (optionToFocus) {
this.setFocus(optionToFocus)
}
+
+ this.setupClickOutsideHandler()
},
// phx:show-start/phx:hide-end are dispatched asynchronously by LiveView's transition
diff --git a/demo/lib/demo_web/live/fixtures_live.html.heex b/demo/lib/demo_web/live/fixtures_live.html.heex
index b5cf48d..6be1b1d 100644
--- a/demo/lib/demo_web/live/fixtures_live.html.heex
+++ b/demo/lib/demo_web/live/fixtures_live.html.heex
@@ -101,3 +101,11 @@
<.listbox_form_fixture {assigns} />
+
+
+ <.listbox_with_transition_fixture />
+
+
+
+ <.dropdown_with_transition_fixture />
+
diff --git a/demo/lib/demo_web/live/fixtures_live/dropdown_with_transition_fixture.html.heex b/demo/lib/demo_web/live/fixtures_live/dropdown_with_transition_fixture.html.heex
new file mode 100644
index 0000000..a1b09c6
--- /dev/null
+++ b/demo/lib/demo_web/live/fixtures_live/dropdown_with_transition_fixture.html.heex
@@ -0,0 +1,24 @@
+
+ <.dropdown id="dropdown-transition">
+ <.dropdown_trigger id="dropdown-transition-trigger">
+ Open Dropdown
+
+ <.dropdown_menu
+ id="dropdown-transition-menu"
+ transition_enter={{"ease-out duration-100", "opacity-0 scale-95", "opacity-100 scale-100"}}
+ >
+ <.dropdown_item id="dropdown-transition-item-0">
+ Apple
+
+ <.dropdown_item id="dropdown-transition-item-1">
+ Banana
+
+ <.dropdown_item id="dropdown-transition-item-2">
+ Cherry
+
+
+
+
+
+
+
diff --git a/demo/lib/demo_web/live/fixtures_live/listbox_with_transition_fixture.html.heex b/demo/lib/demo_web/live/fixtures_live/listbox_with_transition_fixture.html.heex
new file mode 100644
index 0000000..dab20c3
--- /dev/null
+++ b/demo/lib/demo_web/live/fixtures_live/listbox_with_transition_fixture.html.heex
@@ -0,0 +1,29 @@
+
+ <.listbox id="listbox-transition" name="fruit_choice" value="banana">
+ <.listbox_trigger
+ id="listbox-transition-trigger"
+ class="w-64 inline-flex justify-between items-center rounded-lg bg-white border border-gray-300 px-3 py-2 text-sm text-gray-700"
+ >
+ <.listbox_value>Banana
+
+
+ <.listbox_options
+ id="listbox-transition-options"
+ class="py-1 rounded-md bg-white shadow-xs ring-1 ring-gray-300"
+ transition_enter={{"ease-out duration-100", "opacity-0 scale-95", "opacity-100 scale-100"}}
+ >
+ <.listbox_option id="listbox-transition-option-apple" value="apple" display="Apple">
+ Apple
+
+ <.listbox_option id="listbox-transition-option-banana" value="banana" display="Banana">
+ Banana
+
+ <.listbox_option id="listbox-transition-option-cherry" value="cherry" display="Cherry">
+ Cherry
+
+
+
+
+
+
+
diff --git a/demo/lib/demo_web/router.ex b/demo/lib/demo_web/router.ex
index 44ec86b..f10c803 100644
--- a/demo/lib/demo_web/router.ex
+++ b/demo/lib/demo_web/router.ex
@@ -48,6 +48,8 @@ defmodule DemoWeb.Router do
live "/fixtures/async-combobox-form-change", FixturesLive, :async_combobox_form_change
live "/fixtures/listbox", FixturesLive, :listbox
live "/fixtures/listbox-form", FixturesLive, :listbox_form
+ live "/fixtures/listbox-with-transition", FixturesLive, :listbox_with_transition
+ live "/fixtures/dropdown-with-transition", FixturesLive, :dropdown_with_transition
end
end
end
diff --git a/demo/test/wallaby/demo_web/dropdown_transition_test.exs b/demo/test/wallaby/demo_web/dropdown_transition_test.exs
new file mode 100644
index 0000000..e04e693
--- /dev/null
+++ b/demo/test/wallaby/demo_web/dropdown_transition_test.exs
@@ -0,0 +1,35 @@
+defmodule DemoWeb.DropdownTransitionTest do
+ use Prima.WallabyCase, async: true
+
+ @button Query.css("#dropdown-transition [aria-haspopup=menu]")
+ @menu Query.css("#dropdown-transition [role=menu]")
+
+ describe "with an enter transition configured" do
+ feature "stays open after the same click that opened it", %{session: session} do
+ session
+ |> visit_fixture("/fixtures/dropdown-with-transition", "#dropdown-transition")
+ |> assert_has(@menu |> Query.visible(false))
+ |> click(@button)
+ |> assert_has(@menu |> Query.visible(true))
+ |> assert_has(Query.css("#dropdown-transition [role=menuitem]") |> Query.count(3))
+ end
+
+ feature "still closes on a genuine outside click", %{session: session} do
+ session
+ |> visit_fixture("/fixtures/dropdown-with-transition", "#dropdown-transition")
+ |> click(@button)
+ |> assert_has(@menu |> Query.visible(true))
+ |> click(Query.css("#outside-area"))
+ |> assert_has(@menu |> Query.visible(false))
+ end
+
+ feature "still closes after clicking a menu item", %{session: session} do
+ session
+ |> visit_fixture("/fixtures/dropdown-with-transition", "#dropdown-transition")
+ |> click(@button)
+ |> assert_has(@menu |> Query.visible(true))
+ |> click(Query.css("#dropdown-transition-item-0"))
+ |> assert_has(@menu |> Query.visible(false))
+ end
+ end
+end
diff --git a/demo/test/wallaby/demo_web/listbox_transition_test.exs b/demo/test/wallaby/demo_web/listbox_transition_test.exs
new file mode 100644
index 0000000..a5993af
--- /dev/null
+++ b/demo/test/wallaby/demo_web/listbox_transition_test.exs
@@ -0,0 +1,38 @@
+defmodule DemoWeb.ListboxTransitionTest do
+ use Prima.WallabyCase, async: true
+
+ @button Query.css("#listbox-transition [aria-haspopup=listbox]")
+ @listbox Query.css("#listbox-transition [role=listbox]")
+
+ describe "with an enter transition configured" do
+ feature "stays open after the same click that opened it", %{session: session} do
+ session
+ |> visit_fixture("/fixtures/listbox-with-transition", "#listbox-transition")
+ |> assert_has(@listbox |> Query.visible(false))
+ |> click(@button)
+ |> assert_has(@listbox |> Query.visible(true))
+ |> assert_has(Query.css("#listbox-transition [role=option]") |> Query.count(3))
+ end
+
+ feature "still closes on a genuine outside click", %{session: session} do
+ session
+ |> visit_fixture("/fixtures/listbox-with-transition", "#listbox-transition")
+ |> click(@button)
+ |> assert_has(@listbox |> Query.visible(true))
+ |> click(Query.css("#outside-area"))
+ |> assert_has(@listbox |> Query.visible(false))
+ end
+
+ feature "still closes after selection an option", %{session: session} do
+ session
+ |> visit_fixture("/fixtures/listbox-with-transition", "#listbox-transition")
+ |> click(@button)
+ |> assert_has(@listbox |> Query.visible(true))
+ |> click(Query.css("#listbox-transition-option-cherry"))
+ |> assert_has(@listbox |> Query.visible(false))
+ |> assert_has(
+ Query.css("#listbox-transition-trigger [data-prima-ref='value']", text: "Cherry")
+ )
+ end
+ end
+end
diff --git a/lib/prima/dropdown.ex b/lib/prima/dropdown.ex
index 479a843..4b5344e 100644
--- a/lib/prima/dropdown.ex
+++ b/lib/prima/dropdown.ex
@@ -125,7 +125,6 @@ defmodule Prima.Dropdown do
js-show={JS.show(transition: @transition_enter)}
js-hide={JS.hide(transition: @transition_leave)}
role="menu"
- phx-click-away={JS.dispatch("prima:close")}
{@rest}
>
{render_slot(@inner_block)}
diff --git a/lib/prima/listbox.ex b/lib/prima/listbox.ex
index f48286b..b49d0f0 100644
--- a/lib/prima/listbox.ex
+++ b/lib/prima/listbox.ex
@@ -168,7 +168,6 @@ defmodule Prima.Listbox do
js-hide={JS.hide(transition: @transition_leave)}
role="listbox"
tabindex="-1"
- phx-click-away={JS.dispatch("prima:close")}
{@rest}
>
{render_slot(@inner_block)}