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

Summary

Functions