From 244533f9c6c28f3e55a1eb5dbb576ec82e098b25 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Wed, 16 Oct 2024 13:47:29 +0200 Subject: [PATCH] proof of concept: phx-portal This is a proof of concept commit implementing a new phx binding called `phx-portal`. This binding allows to designate an element to be rendered at another location in the DOM, designated by the ID that `phx-portal` points to. This can be useful to render things like dialogs at the top layer, outside any containers that might affect their rendering (e.g. overflow: hidden). Nowadays there is also the Popover API and native `` elements, so this might not be that important any more. Because of this, I also don't feel like this is something we really want to support. This commit only shows how it could be implemented. --- assets/js/phoenix_live_view/constants.js | 2 + assets/js/phoenix_live_view/dom_patch.js | 34 +++- test/e2e/support/portal.ex | 211 +++++++++++++++++++++++ test/e2e/test_helper.exs | 1 + test/e2e/tests/portal.spec.js | 19 ++ 5 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 test/e2e/support/portal.ex create mode 100644 test/e2e/tests/portal.spec.js diff --git a/assets/js/phoenix_live_view/constants.js b/assets/js/phoenix_live_view/constants.js index e0feb4575a..e2ee897379 100644 --- a/assets/js/phoenix_live_view/constants.js +++ b/assets/js/phoenix_live_view/constants.js @@ -54,6 +54,8 @@ export const PHX_THROTTLE = "throttle" export const PHX_UPDATE = "update" export const PHX_STREAM = "stream" export const PHX_STREAM_REF = "data-phx-stream" +export const PHX_PORTAL = "portal" +export const PHX_PORTAL_REF = "data-phx-portal" export const PHX_KEY = "key" export const PHX_PRIVATE = "phxPrivate" export const PHX_AUTO_RECOVER = "auto-recover" diff --git a/assets/js/phoenix_live_view/dom_patch.js b/assets/js/phoenix_live_view/dom_patch.js index bced1943d0..bf14820fda 100644 --- a/assets/js/phoenix_live_view/dom_patch.js +++ b/assets/js/phoenix_live_view/dom_patch.js @@ -14,6 +14,8 @@ import { PHX_STREAM_REF, PHX_VIEWPORT_TOP, PHX_VIEWPORT_BOTTOM, + PHX_PORTAL, + PHX_PORTAL_REF } from "./constants" import { @@ -62,6 +64,7 @@ export default class DOMPatch { this.cidPatch = isCid(this.targetCID) this.pendingRemoves = [] this.phxRemove = this.liveSocket.binding("remove") + this.portal = this.liveSocket.binding(PHX_PORTAL) this.targetContainer = this.isCIDPatch() ? this.targetCIDContainer(html) : container this.callbacks = { beforeadded: [], beforeupdated: [], beforephxChildAdded: [], @@ -123,7 +126,18 @@ export default class DOMPatch { // tell morphdom how to add a child addChild: (parent, child) => { let {ref, streamAt} = this.getStreamInsert(child) - if(ref === undefined){ return parent.appendChild(child) } + if(ref === undefined){ + // phx-portal optimization + if(child.getAttribute && child.getAttribute(PHX_PORTAL_REF) !== null){ + const targetId = child.getAttribute(PHX_PORTAL_REF) + const portalTarget = DOM.byId(targetId) + child.removeAttribute(this.portal) + if(portalTarget.contains(child)){ return } + return portalTarget.appendChild(child) + } + // no special handling, we just append it to the parent + return parent.appendChild(child) + } this.setStreamRef(child, ref) @@ -266,6 +280,24 @@ export default class DOMPatch { // input handling DOM.copyPrivates(toEl, fromEl) + // phx-portal handling + if(fromEl.hasAttribute(this.portal) || toEl.hasAttribute(this.portal)){ + const targetId = toEl.getAttribute(this.portal) + const portalTarget = DOM.byId(targetId) + toEl.removeAttribute(this.portal) + toEl.setAttribute(PHX_PORTAL_REF, targetId) + const existing = document.getElementById(fromEl.id) + // if the child is already a descendent of the portal, + // keep it as is, to prevent unnecessary DOM operations + if(existing && portalTarget.contains(existing)){ + return existing + } else { + // appendChild will move the element to the portal + portalTarget.appendChild(fromEl) + return fromEl + } + } + // skip patching focused inputs unless focus is a select that has changed options if(isFocusedFormEl && fromEl.type !== "hidden" && !focusedSelectChanged){ this.trackBefore("updated", fromEl, toEl) diff --git a/test/e2e/support/portal.ex b/test/e2e/support/portal.ex new file mode 100644 index 0000000000..eee4b590aa --- /dev/null +++ b/test/e2e/support/portal.ex @@ -0,0 +1,211 @@ +defmodule Phoenix.LiveViewTest.E2E.PortalLive do + use Phoenix.LiveView + + alias Phoenix.LiveView.JS + + def render("live.html", assigns) do + ~H""" + + + + + +
+ +
+ <%= @inner_content %> +
+ """ + end + + @impl Phoenix.LiveView + def mount(params, _session, socket) do + case params do + %{"tick" => "false"} -> :ok + _ -> :timer.send_interval(1000, self(), :tick) + end + + socket + |> assign(:param_current, nil) + |> assign(:count, 0) + |> then(&{:ok, &1, layout: {__MODULE__, :live}}) + end + + @impl Phoenix.LiveView + def handle_params(params, _uri, socket) do + param = Map.get(params, "param") + + socket + |> assign(:param_current, param) + |> assign(:param_next, System.unique_integer()) + |> then(&{:noreply, &1}) + end + + @impl Phoenix.LiveView + def handle_info(:tick, socket) do + {:noreply, assign(socket, :count, socket.assigns.count + 1)} + end + + @impl Phoenix.LiveView + def handle_event("tick", _params, socket) do + {:noreply, assign(socket, :count, socket.assigns.count + 1)} + end + + @impl Phoenix.LiveView + def render(assigns) do + ~H""" +

Modal example

+ +

Current param: <%= @param_current %>

+ + <.button phx-click={JS.patch("/portal?param=#{@param_next}")}>Patch this LiveView + + <.button phx-click={show_modal("my-modal")}>Open modal + <.button phx-click={show_modal("my-modal-2")}>Open second modal + <.button phx-click={JS.push("tick")}>Tick + +
+ <.modal id="my-modal"> + This is a modal. +

DOM patching works as expected: <%= @count %>

+ <.button phx-click={JS.patch("/portal?param=#{@param_next}")}>Patch this LiveView + +
+ +
+ <.modal id="my-modal-2"> + This is a second modal. + +
+ """ + end + + attr :type, :string, default: nil + attr :class, :string, default: nil + attr :rest, :global, include: ~w(disabled form name value) + + slot :inner_block, required: true + + def button(assigns) do + ~H""" + + """ + end + + attr :id, :string, required: true + attr :show, :boolean, default: false + attr :on_cancel, JS, default: %JS{} + slot :inner_block, required: true + + def modal(assigns) do + ~H""" +