Skip to content

Instantly share code, notes, and snippets.

@SteffenDE
Last active June 20, 2025 17:05
Show Gist options
  • Select an option

  • Save SteffenDE/f599405c7c2eddbb14723ed4f3b7213f to your computer and use it in GitHub Desktop.

Select an option

Save SteffenDE/f599405c7c2eddbb14723ed4f3b7213f to your computer and use it in GitHub Desktop.
Application.put_env(:sample, Example.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 5001],
server: true,
live_view: [signing_salt: "aaaaaaaa"],
secret_key_base: String.duplicate("a", 64)
)
Mix.install(
[
{:plug_cowboy, "~> 2.5"},
{:jason, "~> 1.0"},
{:phoenix, "~> 1.7"},
{:phoenix_live_view,
github: "phoenixframework/phoenix_live_view", branch: "main", override: true}
]
)
defmodule Example.ErrorView do
def render(template, _), do: Phoenix.Controller.status_message_from_template(template)
end
defmodule Example.PortalLive do
use Phoenix.LiveView
alias Phoenix.LiveView.JS
def render("root.html", assigns) do
~H"""
<!DOCTYPE html>
<head>
<meta name="csrf-token" content={Plug.CSRFProtection.get_csrf_token()} />
<script src="https://cdn.tailwindcss.com/3.4.3">
</script>
<script src="/assets/phoenix/phoenix.min.js">
</script>
<script type="module">
import { LiveSocket } from "/assets/phoenix_live_view/phoenix_live_view.esm.js";
import { computePosition, autoUpdate, offset } from "https://cdn.jsdelivr.net/npm/@floating-ui/dom@1.7.0/+esm";
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
let liveSocket = new LiveSocket("/live", window.Phoenix.Socket, {
params: {_csrf_token: csrfToken},
hooks: {
PortalTooltip: {
mounted() {
this.tooltipEl = document.getElementById(this.el.dataset.id);
this.activatorEl = this.el.querySelector(`#${this.el.dataset.id}-activator`);
this.activatorEl.addEventListener("focusin", () => this.queueShow());
this.activatorEl.addEventListener("mouseover", () => this.queueShow());
this.activatorEl.addEventListener("focusout", () => this.queueHide());
this.activatorEl.addEventListener("mouseout", () => this.queueHide());
this.el.addEventListener("phx:hide-tooltip", () => this.hide());
},
destroyed() {
this.cleanup && this.cleanup();
},
queueShow() {
clearTimeout(this.hideTimeout);
this.showTimeout = setTimeout(() => this.show(), 200);
},
queueHide() {
clearTimeout(this.showTimeout);
this.hideTimeout = setTimeout(() => this.hide(), 50);
},
show() {
this.cleanup && this.cleanup();
this.cleanup = autoUpdate(this.activatorEl, this.tooltipEl, () => {
computePosition(this.activatorEl, this.tooltipEl, {
placement: this.el.dataset.position,
middleware: [offset(10)]
}).then(({ x, y }) => {
this.tooltipEl.style.left = `${x}px`;
this.tooltipEl.style.top = `${y}px`;
});
});
this.liveSocket.execJS(this.el, this.el.dataset.show);
},
hide() {
this.liveSocket.execJS(this.el, this.el.dataset.hide);
this.cleanup && this.cleanup();
},
}
}
})
liveSocket.connect()
window.liveSocket = liveSocket
</script>
</head>
<body>
<main style="flex: 1; padding: 2rem;">
{@inner_content}
</main>
<div id="root-portal"></div>
</body>
"""
end
def render("live.html", assigns) do
~H"""
{@inner_content}
<div id="portal-target"></div>
"""
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(:count, 0)
|> assign(:render_modal, true)
|> then(&{:ok, &1, layout: {__MODULE__, :live}})
end
@impl Phoenix.LiveView
def handle_info(:tick, socket) do
{:noreply, assign(socket, :count, socket.assigns.count + 1)}
end
@impl Phoenix.LiveView
def render(assigns) do
~H"""
<div class="border border-sky-600 overflow-hidden mt-8 p-4 flex gap-4">
<.tooltip id="tooltip-example-portal">
<:activator>
<.button>Hover me</.button>
</:activator>
Hey there! {@count}
</.tooltip>
<.tooltip id="tooltip-example-no-portal" portal={false}>
<:activator>
<.button>Hover me (no portal)</.button>
</:activator>
Hey there! {@count}
</.tooltip>
</div>
"""
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"""
<button
type={@type}
class={[
"phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3",
"text-sm font-semibold leading-6 text-white active:text-white/80",
@class
]}
{@rest}
>
{render_slot(@inner_block)}
</button>
"""
end
attr :id, :string, required: true
attr :portal, :boolean, default: true
slot :activator, required: true
slot :inner_block, required: true
def tooltip(assigns) do
~H"""
<div
id={"#{@id}-wrapper"}
class="relative inline-block w-fit"
phx-hook="PortalTooltip"
data-id={@id}
data-show={show_tooltip(@id)}
data-hide={hide_tooltip(@id)}
data-position="top"
phx-window-keydown={JS.dispatch("phx:hide-tooltip")}
phx-key="escape"
>
<div id={"#{@id}-activator"} aria-describedby={@id} data-activator>
{render_slot(@activator)}
</div>
<.portal :if={@portal} id={"#{@id}-portal"} target="body">
<div
id={@id}
phx-mounted={JS.ignore_attributes(["style"])}
role="tooltip"
class="hidden absolute top-0 left-0 z-50 bg-sky-800 text-white text-xs p-1"
>
{render_slot(@inner_block)}
</div>
</.portal>
<div
:if={!@portal}
id={@id}
phx-mounted={JS.ignore_attributes(["style"])}
role="tooltip"
class="hidden absolute top-0 left-0 z-50 bg-sky-800 text-white text-xs p-1"
>
{render_slot(@inner_block)}
</div>
</div>
"""
end
defp show_tooltip(id) do
JS.show(
to: "##{id}",
transition:
{"transform ease-out duration-200 transition origin-bottom",
"scale-95 translate-y-0.5 opacity-0", "scale-100 translate-y-0 opacity-100"},
display: "block",
time: 200,
blocking: false
)
end
def hide_tooltip(id) do
JS.hide(
to: "##{id}",
transition: {"transition ease-in duration-100", "opacity-100", "opacity-0"},
time: 100,
blocking: false
)
end
end
defmodule Example.Router do
use Phoenix.Router
import Phoenix.LiveView.Router
pipeline :browser do
plug(:accepts, ["html"])
plug :put_root_layout, {Example.PortalLive, :root}
end
scope "/", Example do
pipe_through(:browser)
live("/", PortalLive, :index)
end
end
defmodule Example.Endpoint do
use Phoenix.Endpoint, otp_app: :sample
socket("/live", Phoenix.LiveView.Socket)
plug Plug.Static, from: {:phoenix, "priv/static"}, at: "/assets/phoenix"
plug Plug.Static, from: {:phoenix_live_view, "priv/static"}, at: "/assets/phoenix_live_view"
plug(Example.Router)
end
{:ok, _} = Supervisor.start_link([Example.Endpoint], strategy: :one_for_one)
Process.sleep(:infinity)
@absowoot
Copy link
Copy Markdown

absowoot commented Jun 20, 2025

Quick note -- line 189 should be:

<.portal :if={@portal} id={"#{@id}-portal"} target="#tooltips">

@SteffenDE
Copy link
Copy Markdown
Author

@absowoot thank you, we changed the target to allow a selector instead of an id very shortly before the release. I adjusted the target to "body" instead, since the separate container is also not necessary any more :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment