Opinionated wrapper LV that pairs the <.popup_host> function
component with the emit-mode PubSub contract.
A host app mounts this LV (typically via live_render) and gets
popup-driven UX for free — no host-side handle_info subscription,
no modal-stack state management, no frame-ref bookkeeping.
What it does
- Subscribes to a host-supplied PubSub topic on connect.
- Optionally renders a "root view" inline (the always-visible LV;
OverviewLiveis the typical pick) by passingsession["root_view"]. - On
{:projects, :opened, ...}— pushes a frame onto the modal stack and renders the target LV inside a<dialog>overlay. On
{:projects, :closed | :saved | :deleted, %{frame_ref: ref}}— pops the top frame iffrefmatches (race-safe against stale events). On{:projects, :dirty, %{frame_ref: ref, dirty: bool}}— marks that frame non-closeable (Esc / backdrop confirm first) while the form holds unsaved input.- Generates a unique
frame_refper push and stamps it into the child LV's session along withmode: "emit"and the host topic, so the child's own emits flow back through this LV. - Caps stack depth at
session["max_stack_depth"](defaults to 5; accepts an integer in1..20, anything outside that band resets to the default) to prevent runaway recursion if a misbehaved LV emits:openedon every mount.
Session contract
"pubsub_topic"(required) — PubSub topic string. The host owns the topic name; this LV does not invent it (so two embeds on the same page can use different topics if needed)."root_view"(optional) —%{"lv" => "Module.Name", "session" => %{...}}. The always-visible LV.:lvis whitelist-validated."wrapper_class"(optional) — outer div class. Defaults to"flex flex-col w-full"."max_stack_depth"(optional) — positive integer in1..20overriding the default 5-frame cap. Values outside that band are clamped to the default with a logged warning."placement"(optional) —"center"(default) or"end": the drawer — every frame is a full-height sheet sliding in from the right edge (core<.modal placement={:end}>), the shape for a create/edit form opened over the page it belongs to. The admin's project page uses it for its task forms."max_width"(optional) — core<.modal>max_widthfor every frame:"6xl"by default for the centered box,"2xl"by default for the drawer."modal_box_class"(optional) — extra classes on every frame'smodal-box(a host that wants a specific width or padding).
Example: dashboard root view (OverviewLive)
For a dedicated admin page that lists projects/tasks/templates with modal-stacked detail views:
live "/orders/:id/projects", MyApp.OrderProjectsLive
# ... and in MyApp.OrderProjectsLive's render:
{Phoenix.Component.live_render(@socket, PhoenixKitProjects.Web.PopupHostLive,
id: "projects-popup-host",
session: %{
"pubsub_topic" => "host:orders:" <> @order_id,
"root_view" => %{
"lv" => "PhoenixKitProjects.Web.OverviewLive",
"session" => %{
"wrapper_class" => "flex flex-col w-full px-4 py-6 gap-6"
}
}
})}Example: single project show inside a host record's edit page
The common host-app shape — a host record (order, ticket, etc.) has one linked project and the edit page embeds its detail view:
{Phoenix.Component.live_render(@socket, PhoenixKitProjects.Web.PopupHostLive,
id: "embedded-project-host-#{@host_record.uuid}",
session: %{
"pubsub_topic" => "host:foo:" <> @host_record.uuid,
"root_view" => %{
"lv" => "PhoenixKitProjects.Web.ProjectShowLive",
"session" => %{"id" => @host_record.project_uuid}
},
"locale" => @locale
})}Whenever the embedded LV (or anything it transitively opens) emits
:opened, this LV renders the target inside a modal on the host's
existing page. No URL change. No DOM replacement.