LiveInteractionContracts. Components. Menu
(live_interaction_contracts v1.0.0)
Copy Markdown
View Source
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", androle="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-activedescendantlives on the trigger (focus never leaves it),data-highlightedlives on the active item, both moved by ArrowDown/ArrowUp — this is deliberately not roving tabindex aria-expanded,aria-activedescendant, anddata-highlightedare client-managed reflected attributes; each is protected withJS.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_actionwith%{"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.