Current section
Files
Jump to
Current section
Files
lib/live_interaction_contracts/components/tooltip.ex
defmodule LiveInteractionContracts.Components.Tooltip do
@moduledoc """
Headless, unstyled, **patch-safe** tooltip — `popover` with a hover/focus
trigger instead of a click trigger. Simpler than `<.popover>`: no cursor, no
`aria-activedescendant`, no item navigation, and (unlike popover/menu) no
client-managed ARIA reflection either, since tooltips have no expanded state.
## HEEx-native anatomy (same idiom as `<.popover>` / `<.menu>`)
> one function component + named slots + a single explicit `id`; the
> component derives all wiring — trigger id, `aria-describedby` — from that
> id.
<.tooltip id="save-tip">
<:trigger>Save</:trigger>
<:content>Save this document</:content>
</.tooltip>
## Contract compliance (baked in — see CONTRACT_TOOLTIP.md)
* open state is **browser-owned**: the top layer via the native `popover`
attribute, exactly like `<.popover>`; the server never renders or owns
it, and it is never mirrored into a server-reconciled attribute
* the `popover` attribute is a **server-rendered constant** (a
client-added one would be stripped by reconciliation, KERNEL rule 1.2)
* `role="tooltip"` is a **server-rendered constant** on the content
element
* `aria-describedby` on the trigger is a **server-rendered constant** —
it always points at `@id`, the server never mutates it, so unlike
popover's `aria-expanded` or menu's `aria-activedescendant` it needs
**no** `phx-mounted={JS.ignore_attributes([...])}` protection; there is
no client-managed ARIA at all on this component
* there is **no native declarative hover trigger**, so a minimal hook
(`LicTooltip`) opens the tooltip on `mouseenter`/`focus` and closes it
on `mouseleave`/`blur` by calling `showPopover()`/`hidePopover()`; the
hook is only the trigger for the state change, the open state itself
still lives in the top layer
* content is **server-rendered** HEEx (the slot)
* no `aria-expanded` (tooltips have no expanded state), no selection, no
cursor
## Usage
<.tooltip id="save-tip">
<:trigger>Save</:trigger>
<:content>Save this document</:content>
</.tooltip>
Unstyled by design: bring your own classes via the slots and `:rest`.
"""
use Phoenix.Component
attr :id, :string, required: true, doc: "stable id; all wiring is derived from it"
attr :mode, :string,
default: "manual",
values: ~w(auto manual),
doc:
"\"manual\" (default) = only the hook opens/closes it via showPopover/hidePopover; " <>
"\"auto\" additionally participates in native light-dismiss/auto-close-siblings"
attr :placement, :string, default: nil, values: [nil, "bottom", "top"],
doc: """
optional zero-JS anchored positioning (CSS Anchor Positioning; see
reports/POSITIONING_SPIKE_REPORT.md). Server-owned static style attrs; flips at
the viewport edge; follows the anchor through patches/scroll as plain layout.
Older engines show it unanchored (documented version floor; no JS fallback).
Do not combine with a custom `style` on the trigger or tooltip element.
"""
attr :trigger_attrs, :map, default: %{},
doc: """
extra attrs spread onto the trigger <button> (class, phx-click, aria-label, …).
First real-app adoption finding (TASKS.md M3): icon-only action buttons want to
BE the trigger, so the trigger must accept the button's own attrs. The component
still owns id / aria-describedby / data-lic-tooltip / phx-hook — keys it derives
are not overridable.
"""
attr :rest, :global, doc: "extra attrs on the tooltip content element"
slot :trigger, required: true
slot :content, required: true
def tooltip(assigns) do
~H"""
<button
id={@id <> "-trigger"}
type="button"
aria-describedby={@id}
data-lic-tooltip={@id}
style={@placement && anchor_name_style(@id)}
phx-hook=".LicTooltip"
{@trigger_attrs}
>{render_slot(@trigger)}</button>
<div id={@id} popover={@mode} role="tooltip" style={@placement && anchored_style(@id, @placement)} {@rest}>{render_slot(@content)}</div>
<script :type={Phoenix.LiveView.ColocatedHook} name=".LicTooltip">
// Tooltip: popover (open/close, native top layer) with a hover/focus trigger.
// There is no native declarative hover trigger, so this hook is the user's
// proxy for that gesture — it only CALLS showPopover()/hidePopover(); the
// open STATE still lives in the top layer (browser-owned), never in a client
// attribute or server assign. No aria-expanded (tooltips have no expanded
// state), no cursor, no selection — the simplest machine in this library.
export default {
mounted() {
this.tip = document.getElementById(this.el.getAttribute("data-lic-tooltip"));
if (!this.tip) return;
const isOpen = () => {
try { return this.tip.matches(":popover-open"); } catch { return false; }
};
const open = () => {
if (typeof this.tip.showPopover !== "function") return;
if (isOpen()) return;
try { this.tip.showPopover(); } catch {}
};
const close = () => {
if (typeof this.tip.hidePopover !== "function") return;
if (!isOpen()) return;
try { this.tip.hidePopover(); } catch {}
};
this._open = open;
this._close = close;
this.el.addEventListener("mouseenter", open);
this.el.addEventListener("mouseleave", close);
this.el.addEventListener("focus", open);
this.el.addEventListener("blur", close);
},
destroyed() {
if (!this._open) return;
this.el.removeEventListener("mouseenter", this._open);
this.el.removeEventListener("mouseleave", this._close);
this.el.removeEventListener("focus", this._open);
this.el.removeEventListener("blur", this._close);
}
}
</script>
"""
end
# Zero-JS anchored positioning (reports/POSITIONING_SPIKE_REPORT.md). Duplicated
# per component so each component file stays standalone (fixtures load one 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