A desktop-style context menu: right-click (or touch-and-hold) any matching element to open an action menu at the pointer.
One menu element serves every row it matches. A tree of five hundred nodes
renders one hidden <ul>, not five hundred — the ContextMenu JS hook finds
the row the pointer landed on, copies its identifier onto each item's
phx-value-*, and opens the menu there. Opening costs no server round-trip,
and the event that fires afterwards still carries the right target.
Usage
Mark the rows, then declare one menu that selects them:
<li data-context-value={folder.uuid} data-context-label={folder.name}>
…
</li>
<.context_menu id="folder-menu" selector="[data-context-value]" value_name="folder-uuid">
<.context_menu_button phx-click="start_rename_folder" icon="hero-pencil" label="Rename" />
<.context_menu_divider />
<.context_menu_button phx-click="delete_folder" icon="hero-trash" label="Delete" variant="error" />
</.context_menu>A right-click on that row opens the menu and the "Delete" item fires
delete_folder with %{"folder-uuid" => folder.uuid} — the same param shape
the row's own phx-click already uses, so handlers are shared rather than
duplicated.
Two menus on one page
Declare one per row kind, each with its own selector:
<.context_menu id="folder-menu" selector="[data-context-kind=folder]" …>
<.context_menu id="note-menu" selector="[data-context-kind=note]" …>When a click matches more than one (a note row nested inside a folder row),
the deepest match wins — DOM order does not decide it. Use within to
confine a menu to one region of the page when the same selector appears in
several.
Touch
A press held for long_press_ms (default 450ms, cancelled by a 10px move)
opens the menu at the touch point and vibrates briefly where supported. The
click that follows the release is swallowed, so holding a row does not also
activate it. Pass long_press={false} for a page whose touch gesture is
already taken.
Long-press collides with MediaDragDrop
MediaDragDrop binds its own 450ms long press to
[data-draggable-file] / [data-draggable-folder] and pushes
long_press_select. On a page running both, one hold fires both gestures.
A disjoint selector is not enough: rows are matched with
Element.closest/1, so the two collide whenever one element merely
contains the other — and in FolderExplorer they do, both ways
(data-draggable-folder sits on a button inside the
data-context-kind="folder" row; a leaf <li> carries
data-draggable-file and data-context-kind="item" together). The
MediaBrowser sidebar is exactly this shape.
There is a second effect worth knowing: this hook swallows the
post-long-press click at document capture, which is upstream of the
per-element listener MediaDragDrop uses to clear its own _lpFired
flag — so that flag stays set and eats one later tap on the same card.
On a page running both, pass long_press={false} (right-click still
works) or don't wire MediaDragDrop.
What items can do
Items are buttons and links whose markup is TableRowMenu's, so a context
menu and a ⋮ row menu look identical. Only phx-value-* is stamped per
row: an item's navigate/href is fixed at render time and cannot vary by
target. For "open this one", use a button and push_navigate/2 from the
handler.
Where the events land
The menu is portaled to <body> while open, so position: fixed escapes any
<dialog> or transformed ancestor. LiveView routes clicks from an element
outside every view root to the main LiveView, which is what a plain
LiveView or a phx-target-carrying item wants. Inside a nested LiveView
(a sticky one, say), pass an explicit phx-target on each item — the fallback
would otherwise deliver to the main view.
Summary
Functions
Renders one context menu for every element matching selector.
An action item. Takes phx-click and friends through rest; the hook adds
phx-value-* for the right-clicked row.
A separator between item groups.
A navigation item with a fixed destination.