Skip to content

Commit 3eaaa41

Browse files
committed
click-away portal handling (#4154)
* click-away portal handling Closes #4141. * fix and test
1 parent 1613bff commit 3eaaa41

4 files changed

Lines changed: 57 additions & 3 deletions

File tree

assets/js/phoenix_live_view/live_socket.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
PHX_RELOAD_STATUS,
3030
PHX_RUNTIME_HOOK,
3131
PHX_DROP_TARGET_ACTIVE_CLASS,
32+
PHX_TELEPORTED_SRC,
3233
} from "./constants";
3334

3435
import {
@@ -872,16 +873,25 @@ export default class LiveSocket {
872873

873874
dispatchClickAway(e, clickStartedAt) {
874875
const phxClickAway = this.binding("click-away");
876+
const portal = clickStartedAt.closest(`[${PHX_TELEPORTED_SRC}]`);
877+
const portalStartedAt =
878+
portal && DOM.byId(portal.getAttribute(PHX_TELEPORTED_SRC));
875879
DOM.all(document, `[${phxClickAway}]`, (el) => {
880+
let startedAt = clickStartedAt;
881+
if (portal && !portal.contains(el)) {
882+
// If we have a portal and the click-away element is not inside it,
883+
// then treat the portal source as the starting point instead.
884+
startedAt = portalStartedAt;
885+
}
876886
if (
877887
!(
878-
el.isSameNode(clickStartedAt) ||
879-
el.contains(clickStartedAt) ||
888+
el.isSameNode(startedAt) ||
889+
el.contains(startedAt) ||
880890
// When clicking a link with custom method,
881891
// phoenix_html triggers a click on a submit button
882892
// of a hidden form appended to the body. For such cases
883893
// where the clicked target is hidden, we skip click-away.
884-
!JS.isVisible(clickStartedAt)
894+
!JS.isVisible(startedAt)
885895
)
886896
) {
887897
this.withinOwners(el, (view) => {

assets/test/view_test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1479,6 +1479,7 @@ describe("View Hooks", function () {
14791479
},
14801480
);
14811481
const customEl = document.createElement("custom-el");
1482+
customEl.id = "foo";
14821483
el.appendChild(customEl);
14831484
simulateJoinedView(el, liveSocket);
14841485
});

test/e2e/support/portal.ex

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,21 @@ defmodule Phoenix.LiveViewTest.E2E.PortalLive do
213213
</div>
214214
</.portal>
215215
</div>
216+
217+
<.modal id="non-teleported-modal">
218+
This is a non-teleported modal. Open the menu and click an item. The modal must not close.
219+
<.button phx-click={JS.show(to: "#teleported-menu-content")}>Open menu</.button>
220+
<.portal id="teleported-menu" target="body">
221+
<div
222+
id="teleported-menu-content"
223+
class="hidden z-[100] fixed top-0 left-0 border border-red-500 p-4 bg-white"
224+
>
225+
<.button phx-click={JS.hide(to: "#teleported-menu-content")}>Close menu</.button>
226+
</div>
227+
</.portal>
228+
</.modal>
229+
230+
<.button phx-click={show_modal("non-teleported-modal")}>Open non-teleported modal</.button>
216231
"""
217232
end
218233

test/e2e/tests/portal.spec.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,31 @@ test("nested portals cleanup and re-render correctly", async ({ page }) => {
189189
await expect(page.locator("#outer-portal")).toHaveCount(1);
190190
await expect(page.locator("#inner-portal")).toHaveCount(1);
191191
});
192+
193+
test("click-away is portal aware", async ({ page }) => {
194+
await page.goto("/portal?tick=false");
195+
await syncLV(page);
196+
197+
await page.getByRole("button", { name: "Open non-teleported modal" }).click();
198+
await expect(page.locator("#non-teleported-modal-content")).toBeVisible();
199+
await page.getByRole("button", { name: "Open menu" }).click();
200+
await expect(page.locator("#teleported-menu-content")).toBeVisible();
201+
await page.getByRole("button", { name: "Close menu" }).click();
202+
await expect(page.locator("#teleported-menu-content")).toBeHidden();
203+
204+
// Modal must still be visible, despite click away
205+
await expect(page.locator("#non-teleported-modal-content")).toBeVisible();
206+
// trigger click-away
207+
await page
208+
.locator("#non-teleported-modal .fixed[role='dialog']")
209+
.click({ position: { x: 0, y: 0 } });
210+
await expect(page.locator("#non-teleported-modal-content")).toBeHidden();
211+
212+
// Test that click-away also works properly for teleported modals
213+
await page.getByRole("button", { name: "Open modal" }).click();
214+
await expect(page.locator("#my-modal-content")).toBeVisible();
215+
await page
216+
.locator("#my-modal .fixed[role='dialog']")
217+
.click({ position: { x: 0, y: 0 } });
218+
await expect(page.locator("#my-modal-content")).toBeHidden();
219+
});

0 commit comments

Comments
 (0)