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)}