LiveInteractionContracts. Components. Popover
(live_interaction_contracts v1.0.0)
Copy Markdown
View Source
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
popoverattribute is a server-rendered constant (a client-added one would be stripped by reconciliation) - content is server-rendered HEEx (the slot)
toggleis relayed only as lossy observation (opt-in viaon_open_change)aria-expandedis client-managed and protected withJS.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.
Summary
Functions
Attributes
id(:string) (required) - stable id; all wiring is derived from it.mode(:string) - "auto" = native light-dismiss; "manual" = explicit dismiss only. Defaults to"auto". Must be one of"auto", or"manual".role(:string) - listbox | menu | dialog | ... Defaults to"dialog".on_open_change(:string) - optional LiveView event relayed on native toggle (lossy observation only). Defaults tonil.placement(:string) - 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 customstyleon the trigger or content element.Defaults to
nil. Must be one ofnil,"bottom", or"top".Global attributes are accepted. extra attrs on the popover content element.
Slots
trigger(required)content(required)