Current section
Files
Jump to
Current section
Files
lib/live_interaction_contracts/components/popover.ex
defmodule LiveInteractionContracts.Components.Popover do
@moduledoc """
Headless, unstyled, **patch-safe** popover — the first conformance-backed
reference component.
## HEEx-native anatomy (the design decision)
React headless libs (Base UI, Radix) compose via implicit context:
`<Popover.Root><Popover.Trigger/><Popover.Popup/></Popover.Root>`. HEEx has **no
implicit context**, so parts cannot discover each other. The Phoenix-native
answer is:
> **one function component + named slots + a single explicit `id`; the component
> derives all wiring from that id.**
From `id` alone this derives: the native `popovertarget` invoker relationship,
`aria-controls`, `aria-expanded` (hook-managed, patch-protected), the
server-rendered static `popover` attribute, and the observation hook. The caller
writes one id and two slots; the contract compliance is not their problem.
## Contract compliance (baked in — see REFERENCE_POPOVER_CONTRACT.md)
* open state is **browser-owned** — native `popovertarget` + top layer; the
server never renders or owns open state
* the `popover` attribute is a **server-rendered constant** (a client-added one
would be stripped by reconciliation)
* content is **server-rendered** HEEx (the slot)
* `toggle` is relayed only as **lossy observation** (opt-in via `on_open_change`)
* `aria-expanded` is client-managed and protected with
`JS.ignore_attributes(["aria-expanded"])` so a patch touching the trigger
cannot strip it
## Usage
<.popover id="user-menu" on_open_change="menu_toggled">
<:trigger>Open menu</:trigger>
<:content>
<.link navigate={~p"/profile"}>Profile</.link>
</:content>
</.popover>
Unstyled by design: bring your own classes via the slots and `:rest`.
"""
use Phoenix.Component
alias Phoenix.LiveView.JS
attr :id, :string, required: true, doc: "stable id; all wiring is derived from it"
attr :mode, :string, default: "auto", values: ~w(auto manual),
doc: "\"auto\" = native light-dismiss; \"manual\" = explicit dismiss only"
attr :role, :string, default: "dialog", doc: "listbox | menu | dialog | ..."
attr :on_open_change, :string, default: nil,
doc: "optional LiveView event relayed on native toggle (lossy observation only)"
attr :placement, :string, default: nil, values: [nil, "bottom", "top"],
doc: """
optional zero-JS anchored positioning (CSS Anchor Positioning; see
reports/POSITIONING_SPIKE_REPORT.md). "bottom" places the popover under the
trigger, "top" above; both flip at the viewport edge (position-try-fallbacks)
and follow the anchor through patches and scroll as plain layout. Emitted as
server-owned static style attributes, so reconciliation keeps them. Newly
available CSS — older engines show the popover unanchored (version floor is
documented; there is deliberately no JS fallback). Do not combine with a
custom `style` on the trigger or content element.
"""
attr :rest, :global, doc: "extra attrs on the popover content element"
slot :trigger, required: true
slot :content, required: true
def popover(assigns) do
~H"""
<button
id={@id <> "-trigger"}
type="button"
popovertarget={@id}
aria-controls={@id}
aria-expanded="false"
data-lic-popover={@id}
data-lic-on-open-change={@on_open_change}
style={@placement && anchor_name_style(@id)}
phx-hook=".LicPopoverTrigger"
phx-mounted={JS.ignore_attributes(["aria-expanded"])}
>{render_slot(@trigger)}</button>
<div id={@id} popover={@mode} role={@role} style={@placement && anchored_style(@id, @placement)} {@rest}>{render_slot(@content)}</div>
<script :type={Phoenix.LiveView.ColocatedHook} name=".LicPopoverTrigger">
// The ONLY client code for the popover. Open/close is native (popovertarget +
// top layer); this hook just mirrors browser-owned open state into aria-expanded
// and, if requested, relays the toggle to the server as lossy telemetry.
export default {
mounted() {
this.pop = document.getElementById(this.el.getAttribute("data-lic-popover"));
this.onToggle = this.el.getAttribute("data-lic-on-open-change") || null;
this.el.setAttribute("aria-expanded", "false");
if (!this.pop) return;
this._h = (e) => {
const open = e.newState === "open";
this.el.setAttribute("aria-expanded", open ? "true" : "false");
if (this.onToggle) this.pushEvent(this.onToggle, { open });
};
this.pop.addEventListener("toggle", this._h);
},
destroyed() {
if (this.pop && this._h) this.pop.removeEventListener("toggle", this._h);
}
}
</script>
"""
end
# Zero-JS anchored positioning (reports/POSITIONING_SPIKE_REPORT.md). Server-owned
# static style attributes — reconciliation keeps them; the browser does the rest.
# `inset: auto; margin: 0` neutralizes the UA popover centering. Kept private and
# duplicated across components so each component file stays standalone (fixtures
# load a single file).
defp anchor_name_style(id), do: "anchor-name: --lic-#{id}"
defp anchored_style(id, position) do
area =
case position do
"bottom" -> "block-end span-inline-end"
"top" -> "block-start span-inline-end"
end
"position: fixed; inset: auto; margin: 0; position-anchor: --lic-#{id}; " <>
"position-area: #{area}; position-try-fallbacks: flip-block"
end
end