Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions assets/js/hooks/dropdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
]
Expand All @@ -76,6 +75,7 @@ export default {

cleanup() {
this.cleanupAutoUpdate()
this.cleanupClickOutsideHandler()

if (this.listeners) {
this.listeners.forEach(([element, event, handler]) => {
Expand All @@ -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),
Expand Down Expand Up @@ -210,10 +227,6 @@ export default {
this.setFocus(matchingItems[nextIndex])
},

handleClose() {
this.hideMenu()
},

handleToggle() {
this.toggleMenu()
},
Expand Down Expand Up @@ -295,19 +308,20 @@ 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
// briefly appearing at wrong position before jumping to correct position.
this.refs.menuWrapper.style.display = 'block'
this.positionMenu()
liveSocket.execJS(this.refs.menu, this.refs.menu.getAttribute('js-show'))
this.setupClickOutsideHandler()
}
},

Expand All @@ -324,6 +338,8 @@ export default {
if (items.length > 0) {
this.setFocus(items[0])
}

this.setupClickOutsideHandler()
},

showMenuAndFocusLast() {
Expand All @@ -339,6 +355,8 @@ export default {
if (items.length > 0) {
this.setFocus(items[items.length - 1])
}

this.setupClickOutsideHandler()
},

setupAriaRelationships(button, menu) {
Expand Down
26 changes: 21 additions & 5 deletions assets/js/hooks/listbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
]
Expand All @@ -93,6 +92,7 @@ export default {

cleanup() {
this.cleanupAutoUpdate()
this.cleanupClickOutsideHandler()

if (this.listeners) {
this.listeners.forEach(([element, event, handler]) => {
Expand All @@ -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),
Expand Down Expand Up @@ -231,10 +248,6 @@ export default {
this.setFocus(matchingOptions[nextIndex])
},

handleClose() {
this.hideListbox()
},

handleToggle() {
this.toggleListbox()
},
Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions demo/lib/demo_web/live/fixtures_live.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,11 @@
<div :if={@live_action == :listbox_form}>
<.listbox_form_fixture {assigns} />
</div>

<div :if={@live_action == :listbox_with_transition}>
<.listbox_with_transition_fixture />
</div>

<div :if={@live_action == :dropdown_with_transition}>
<.dropdown_with_transition_fixture />
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<div>
<.dropdown id="dropdown-transition">
<.dropdown_trigger id="dropdown-transition-trigger">
Open Dropdown
</.dropdown_trigger>
<.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>
<.dropdown_item id="dropdown-transition-item-1">
Banana
</.dropdown_item>
<.dropdown_item id="dropdown-transition-item-2">
Cherry
</.dropdown_item>
</.dropdown_menu>
</.dropdown>
<div id="outside-area" style="height: 50px; width: 100%;">
<!-- Invisible area for click-outside testing -->
</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<div>
<.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_value>
</.listbox_trigger>

<.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>
<.listbox_option id="listbox-transition-option-banana" value="banana" display="Banana">
Banana
</.listbox_option>
<.listbox_option id="listbox-transition-option-cherry" value="cherry" display="Cherry">
Cherry
</.listbox_option>
</.listbox_options>
</.listbox>
<div id="outside-area" style="height: 50px; width: 100%;">
<!-- Invisible area for click-outside testing -->
</div>
</div>
2 changes: 2 additions & 0 deletions demo/lib/demo_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
35 changes: 35 additions & 0 deletions demo/test/wallaby/demo_web/dropdown_transition_test.exs
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions demo/test/wallaby/demo_web/listbox_transition_test.exs
Original file line number Diff line number Diff line change
@@ -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
1 change: 0 additions & 1 deletion lib/prima/dropdown.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down
1 change: 0 additions & 1 deletion lib/prima/listbox.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down
Loading