Packages

A real-browser external-app verifier for patch-safe Phoenix LiveView interactions, with secondary unstyled reference components.

Current section

Files

Jump to
live_interaction_contracts lib live_interaction_contracts components menu.ex
Raw

lib/live_interaction_contracts/components/menu.ex

defmodule LiveInteractionContracts.Components.Menu do
@moduledoc """
Headless, unstyled, **patch-safe** dropdown menu — `popover` (open/close,
browser-owned) composed with a **Cursor** (`aria-activedescendant`) over
server-rendered, identity-bearing menu items.
## HEEx-native anatomy (same idiom as `<.popover>`)
> one function component + named slots + a single explicit `id`; the
> component derives all wiring — trigger id, menu id, item DOM ids — from
> that id.
<.menu id="user-menu" on_action="menu_select">
<:trigger>Open menu</:trigger>
<:item :for={i <- @items} id={i.id}>{i.label}</:item>
</.menu>
## Contract compliance (baked in — see CONTRACT_MENU.md)
* open state is **browser-owned**: native `popovertarget` + top layer,
identical to `<.popover>`; the server never renders or owns it
* `popover`, `role="menu"`, and `role="menuitem"` are **server-rendered
constants** (structural attributes a client-added one would be stripped
by reconciliation, per KERNEL rule 1.2)
* menu items are **server-rendered**, identity-bearing children — each
carries a caller-supplied logical id (`data-item`) that survives patches
* the active item is a **Cursor** (see CURSOR_CONTRACT.md):
`aria-activedescendant` lives on the trigger (focus never leaves it),
`data-highlighted` lives on the active item, both moved by ArrowDown/ArrowUp
— this is deliberately **not** roving tabindex
* `aria-expanded`, `aria-activedescendant`, and `data-highlighted` are
client-managed reflected attributes; each is protected with
`JS.ignore_attributes([...])` so a patch that re-renders the trigger or
an item cannot strip it (KERNEL rule 1.3)
* item activation (Enter on the active item, or a real click on an item)
is relayed to the server via `on_action` with `%{"id" => item_id}` —
selection is an **event**, never client-held state
* when the active item is removed by a patch, the cursor **collapses to
clear** (deterministic; never left dangling on a stale node)
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 :on_action, :string,
required: true,
doc: "LiveView event pushed with %{\"id\" => item_id} on item activation (Enter or click)"
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 menu element.
"""
attr :rest, :global, doc: "extra attrs on the menu content element"
slot :trigger, required: true
slot :item, required: true do
attr :id, :string, required: true, doc: "stable logical id for this item"
end
def menu(assigns) do
~H"""
<button
id={@id <> "-trigger"}
type="button"
popovertarget={@id}
aria-controls={@id}
aria-expanded="false"
aria-activedescendant=""
data-lic-menu={@id}
data-lic-on-action={@on_action}
style={@placement && anchor_name_style(@id)}
phx-hook=".LicMenu"
phx-mounted={JS.ignore_attributes(["aria-expanded", "aria-activedescendant"])}
>{render_slot(@trigger)}</button>
<div id={@id} popover={@mode} role="menu" style={@placement && anchored_style(@id, @placement)} {@rest}>
<div
:for={it <- @item}
id={@id <> "-item-" <> it.id}
role="menuitem"
data-item={it.id}
data-highlighted="false"
phx-click={@on_action}
phx-value-id={it.id}
phx-mounted={JS.ignore_attributes(["data-highlighted"])}
>{render_slot(it)}</div>
</div>
<script :type={Phoenix.LiveView.ColocatedHook} name=".LicMenu">
// Menu: popover (open/close, native) + Cursor (aria-activedescendant) over
// server-rendered <div role="menuitem"> children. Focus stays on the trigger
// (never on an item — that is what makes aria-activedescendant, not roving
// tabindex, the correct strategy per CURSOR_CONTRACT.md). This hook:
// - mirrors native toggle into aria-expanded (same as LicPopoverTrigger)
// - moves aria-activedescendant / data-highlighted on ArrowDown / ArrowUp
// - relays Enter-on-active-item as a lossless *event* (on_action) — the
// server, not the client, owns the selection outcome
// - collapses the cursor to "clear" if the active item is ever removed by
// a server patch (deterministic; never left pointing at a dead node)
// Item lookups are always live DOM queries (never cached across ticks), so a
// server patch that adds/removes/reorders items is picked up for free.
export default {
mounted() {
this.pop = document.getElementById(this.el.getAttribute("data-lic-menu"));
this.onAction = this.el.getAttribute("data-lic-on-action") || null;
this.activeId = null;
this.el.setAttribute("aria-expanded", "false");
this.el.setAttribute("aria-activedescendant", "");
if (!this.pop) return;
const items = () => Array.from(this.pop.querySelectorAll('[role="menuitem"]'));
const setActive = (id) => {
this.activeId = id;
for (const it of items()) {
it.setAttribute("data-highlighted", it.getAttribute("data-item") === id ? "true" : "false");
}
if (id == null) {
this.el.setAttribute("aria-activedescendant", "");
} else {
const node = items().find((it) => it.getAttribute("data-item") === id);
this.el.setAttribute("aria-activedescendant", node ? node.id : "");
}
};
this._setActive = setActive;
this._onToggle = (e) => {
const open = e.newState === "open";
this.el.setAttribute("aria-expanded", open ? "true" : "false");
if (open) {
// Some engines (WebKit) do not focus a <button> on click, so the
// trigger would never receive the ArrowUp/ArrowDown/Enter keydowns
// the Cursor depends on. Assert focus once, on open, to make "focus
// stays on the trigger" true on every enginethis is a one-time
// open-time assignment, not the per-patch re-focus CURSOR_CONTRACT
// forbids.
this.el.focus();
} else {
setActive(null); // closing always clears the cursor
}
};
this.pop.addEventListener("toggle", this._onToggle);
this._onKeydown = (e) => {
if (this.el.getAttribute("aria-expanded") !== "true") return;
const list = items();
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
if (list.length === 0) return;
let idx = list.findIndex((it) => it.getAttribute("data-item") === this.activeId);
if (e.key === "ArrowDown") idx = idx < 0 ? 0 : Math.min(idx + 1, list.length - 1);
else idx = idx < 0 ? list.length - 1 : Math.max(idx - 1, 0);
setActive(list[idx].getAttribute("data-item"));
} else if (e.key === "Enter") {
if (this.activeId != null && this.onAction) {
e.preventDefault();
this.pushEvent(this.onAction, { id: this.activeId });
}
} else if (e.key === "Escape") {
if (typeof this.pop.hidePopover === "function") this.pop.hidePopover();
}
};
this.el.addEventListener("keydown", this._onKeydown);
// Collapse policy (CURSOR_CONTRACT.md): if a patch removes the active
// item, clear rather than leave aria-activedescendant dangling.
this._mo = new MutationObserver(() => {
if (this.activeId == null) return;
const stillLive = items().some((it) => it.getAttribute("data-item") === this.activeId);
if (!stillLive) setActive(null);
});
this._mo.observe(this.pop, { childList: true, subtree: true });
},
destroyed() {
if (this.pop && this._onToggle) this.pop.removeEventListener("toggle", this._onToggle);
if (this._onKeydown) this.el.removeEventListener("keydown", this._onKeydown);
if (this._mo) this._mo.disconnect();
}
}
</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