Current section
Files
Jump to
Current section
Files
live_interaction_contracts
CONTRACT_TOOLTIP.md
CONTRACT_TOOLTIP.md
# CONTRACT_TOOLTIP (v1, frozen)
Tooltip is `popover` (open/close, browser-owned) with a **hover/focus trigger**
instead of a click trigger. It is simpler than menu: no cursor, no
`aria-activedescendant`, no item navigation — and, unlike popover and menu, no
client-managed ARIA reflection at all.
## Classification: green
Tooltip inherits popover's green classification for open/close (see
REFERENCE_POPOVER_CONTRACT.md): open state lives in the top layer via the
native `popover` attribute, never in a server-reconciled attribute or server
assign. All eight conformance points below passed on every engine, across
both runs.
**Why it is a cleaner green than popover/menu, not just an equally clean
one:** popover's own contract document notes that even a green machine's
component still touches the amber `ignore_attributes` technique, because
`aria-expanded` is a client-managed attribute reflecting browser state onto
the trigger. Menu carries three such reflected attributes
(`aria-expanded`, `aria-activedescendant`, `data-highlighted`). Tooltip has
**none** — there is no expanded state to reflect (tooltips are not
disclosure widgets) and no cursor. `aria-describedby` is a plain
server-rendered constant that never changes, so the component needs zero
`phx-mounted={JS.ignore_attributes([...])}` calls. It is the first
component in this library where the whole contract is discharged by
Delegation alone, with no ARIA-reflection escape hatch at all.
## Responsibilities
* **Open/closed state** — browser-owned, identical mechanism to popover:
the top layer via the native `popover` attribute. The server never
renders or drives it.
* **Structural attributes** — `popover` and `role="tooltip"` are
server-rendered constants in the component template, per KERNEL rule 1.2.
* **The trigger reference** — `aria-describedby={tooltip_id}` on the
trigger is a server-rendered constant, always pointing at the caller's
single explicit `id`. It is not client-managed and needs no
`ignore_attributes` protection, unlike popover's `aria-expanded` or
menu's `aria-activedescendant`/`data-highlighted`.
* **The hover/focus trigger** — there is no native declarative hover
trigger (unlike `popovertarget`, which is click-only), so a minimal hook
(`LicTooltip`) opens the tooltip on `mouseenter`/`focus` and closes it on
`mouseleave`/`blur`, by calling `showPopover()`/`hidePopover()`. The hook
is the user's proxy for a gesture the platform has no declarative trigger
for — it only *calls* the state transition; the open **state** itself
still lives in the top layer, never in a client attribute or server
assign.
* **Content** — server-rendered HEEx (the `:content` slot); the tooltip is
a shell, not an island.
* **No cursor, no selection, no `aria-expanded`.** Tooltips have no
expanded state and no navigable children — the anatomy is deliberately
minimal.
## Conformance points (8), verified on chromium / firefox / webkit, 2 runs each
(Point 8, added with the `placement` attr: zero-JS anchored positioning — glued on
open, geometry byte-stable across a patch, follows a patch-moved anchor, flips at
the viewport edge, tracks scroll.)
1. **Hover opens** — `mouseenter` on the trigger calls `showPopover()`;
`:popover-open` becomes true and `aria-describedby` points at the
tooltip id. Passed, all engines.
2. **Mouseleave closes** — moving the pointer off the trigger calls
`hidePopover()`; `:popover-open` becomes false. Passed, all engines.
3. **Focus opens** — focusing the trigger opens the tooltip, verified
against `document.activeElement` (not a client flag). Passed, all
engines.
4. **Blur closes** — blurring the trigger closes the tooltip. Passed, all
engines.
5. **Stays open across a content patch, and the content actually
updates** — a patch that changes the `:content` slot's rendered text
leaves `:popover-open` true, the new text is observed in the DOM, and
the tooltip node's identity marker survives. Passed, all engines.
6. **Stays open across an attribute patch and a sibling patch** — a patch
that changes an attribute on the tooltip element itself, and a
separate patch to a fully unrelated sibling, both leave `:popover-open`
true. Passed, all engines.
7. **`aria-describedby` survives a trigger re-render, and node identity is
preserved throughout** — a patch that changes the trigger's own
displayed label leaves `aria-describedby` unchanged (it was never
client-managed, so there is nothing to strip), the tooltip stays open,
and the tooltip element's JS expando marker (`__mark`, set before any
patch in the run) is still present, proving the same DOM node
persisted. Passed, all engines.
## Honest caveats
* **A patch that moves a hovered trigger closes the tooltip on Chromium only.**
When a server patch shifts the trigger out from under a stationary pointer,
Chromium fires `mouseleave` (Firefox/WebKit do not fire boundary events on
layout-only movement), so the hook closes the tooltip there. For a tooltip this
is arguably the *correct* UX (the user is no longer hovering the thing being
explained), but it is an engine difference consumers should know: hover-opened
tooltips do not survive anchor-moving patches on Chromium; focus-opened ones do
on all engines. Found by the positioning conformance test.
* **Playwright hover requires an actual pointer move.** After a full page
navigation, the OS-level simulated cursor can already be sitting at the
same viewport coordinates as the freshly re-rendered trigger (identical
layout across reloads), and at least one engine then does not fire a new
`mouseenter` because the pointer "didn't move" relative to its last
recorded position. This is not a defect in the component or hook — it is
a test-harness gotcha specific to driving *hover* (none of the other
reference components in this library are hover-triggered, so it had not
come up before). The driver works around it by moving the mouse to a
neutral point before every `hover()` call. Anyone re-testing a
hover-triggered machine with Playwright needs this same workaround; it
is worth adding to BUILDING_COMPONENTS.md's gotcha catalogue for the
next hover-driven component.
* **No hover-intent delay / no touch fallback.** The reference hook opens
and closes immediately on `mouseenter`/`mouseleave` with no debounce,
and has no tap-to-show behavior for touch input (tooltips are
conventionally hover/focus-only; touch accessibility for tooltips is a
known, separately-scoped UX problem, not attempted here).
* **`showPopover()`/`hidePopover()` are guarded against
`InvalidStateError`.** Calling `showPopover()` on an already-open
popover (e.g. `mouseenter` firing while focus already opened it) throws
natively; the hook checks `:popover-open` before calling either method.
This is defensive plumbing, not a conformance point in its own right.
* **Single trigger/content pair only.** No nested tooltips, no delayed
"tooltip of a tooltip". Anchored `placement` inherits the frozen constraints in
REFERENCE_POPOVER_CONTRACT.md §Positioning; additional placement modes need their
own conformance rows.
* **`mode="manual"` by default.** The component defaults to `popover="manual"`
rather than `"auto"` so the hook is the sole owner of open/close;
`"auto"` remains available for callers who want native light-dismiss/
auto-close-siblings behavior layered on top, but that combination is not
separately conformance-tested here.
## Relationship to the kernel
Tooltip is the second reuse of the popover anatomy (`one function
component + named slots + a single explicit id, derive all wiring`) and
the first case where "derive all wiring" produces **zero** client-managed
attributes — a useful data point that the anatomy's amber
`ignore_attributes` touch (noted in both REFERENCE_POPOVER_CONTRACT.md and
CONTRACT_MENU.md) is a property of *what a given machine reflects*, not an
unavoidable tax on every green machine built on this anatomy.
*Evidence: `priv/tooltip-reference/tooltip-reference-results.json`
(version-specific engine numbers live there, not in this file, per
BUILDING_COMPONENTS.md §5).*