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 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.

Summary

Functions

popover(assigns)

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 to nil.

  • 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 custom style on the trigger or content element.

    Defaults to nil. Must be one of nil, "bottom", or "top".

  • Global attributes are accepted. extra attrs on the popover content element.

Slots

  • trigger (required)
  • content (required)