Context menu

v0.0.10

v0.0.10StableMobStatelessApache-2.0
Installation mix mishka.ui.gen.mob context_menu
Source View Source

The Mob Context Menu in Mishka Chelekom opens the actions for one row or object on iOS and Android screens built with Mob. A user holds the object to open it, which is the touch version of a right-click. It is the native port of the headless Context Menu.

Overview of the Mob Context Menu component for iOS and Android

A context menu and a dropdown menu are the same list of actions. Only the gesture that opens them differs, so the Context Menu renders through the Menu. It accepts every Menu prop and row tag.

The web component opens on contextmenu, which is a right-click or a long press on touch. On a phone you wrap the object in trigger/3 and hold it. A plain tap passes straight through, so the row keeps whatever it did before. A long press works on both iOS and Android.

A long press is invisible unless something points to it, so add a visible control such as a ⋯ button that opens the same menu.

The screen owns open. Open one menu at a time with a test such as open={@open_for == id}. When you set for_label, the menu shows it as a heading above the actions.

The menu opens where you place it: in the layout under the row, or inside a bottom Drawer. It does not open at the pointer.

Generate the context menu component

Run this from your Mob app's root to add the component to lib/<app>/components/:

bash
mix mishka.ui.gen.mob context_menu

The generator registers the tag, so you can use it in ~MOB markup. It also generates the Menu that the Context Menu renders through, and the Menu brings its own Popover. The examples on this page use the mishka_ prefix of the showcase app (--module-prefix mishka_), so the tag is <MishkaContextMenu>. Without a prefix, the tag is <ContextMenu>.

Hold a row to open its context menu

Press and hold a row to open its actions, the way the web opens them on right-click. trigger/3 wraps the row. A long press sends {:tap, {tag, id}}, so one clause serves a whole list of rows.

The menu gets for_label so it names the file the actions apply to. Each row also has a ⋯ button that opens the same menu.

Hold a row to open its context menu

Context menu example card titled Hold a row, the touch right-click, with report.pdf and notes.md rows, each with a three dot button, and the text No action yet
A file row report.pdf with an action menu underneath listing Rename, Duplicate and a red Delete.

Left: the example as it loads. Right: after a long press. Hold a row to open its actions.

elixir
# trigger/3 wraps the object; holding it fires on_long_press. A plain
# tap passes straight through, so the row keeps whatever it already did.
{MishkaContextMenu.trigger(id, [row_content()], on_hold: :open_for)}

<MishkaContextMenu
  open={@open_for == id}
  for_label={name}
  on_select={:act}
>
  <MishkaMenuItem id={:rename} label="Rename" icon="✎" />
  <MishkaMenuItem id={:delete} label="Delete" icon="🗑" danger={true} />
</MishkaContextMenu>

def handle_info({:tap, {:open_for, id}}, socket) do
  {:noreply, assign(socket, :open_for, id)}
end

Context menu without a subject

Leave out for_label and the Context Menu is exactly a Menu: the actions without a heading. The rows come in as children, either as tags or as a list of nodes like {items} below.

Context menu without a subject

Context menu example card titled Without a subject, with an open menu listing Open, Rename and a red Delete below a divider, each row with a small icon
heex
<MishkaContextMenu open={true} on_select={:act}>{items}</MishkaContextMenu>

Props

PropTypeDefaultDescription
for_labelstringnilThe object these actions apply to, shown as a heading above the actions.
openbooleanfalseWhether the menu is shown.
on_selectevent tagnoneSent as {:tap, {tag, item_id}}.
everything elsesee MenunoneThe Context Menu renders through the Menu, so it accepts width, danger_color, the panel props and the row tags.

trigger/3

trigger(id, children, opts) wraps the object the actions belong to. The options are :on_hold, an event tag, and :test_id, a string that becomes the wrapper's native test tag. Holding the wrapper sends {:tap, {tag, id}}.

Events

Two messages reach your screen in handle_info/2. A long press on the trigger sends {:tap, {tag, id}} with the tag you set in on_hold and the id you gave trigger/3. A tap on an enabled row sends {:tap, {tag, item_id}} with the tag you set in on_select.

elixir
def handle_info({:tap, {:open_for, id}}, socket) do
  {:noreply, Mob.Socket.assign(socket, :open_for, id)}
end

def handle_info({:tap, {:act, action}}, socket) do
  socket =
    socket
    |> Mob.Socket.assign(:last, action)
    |> Mob.Socket.assign(:open_for, nil)

  {:noreply, socket}
end

Differences from the web component

The Mob Context Menu does not port these parts of the headless Context Menu:

  • Placement at the pointer. A touch has no cursor to open at, and a phone has no room for a menu pinned to a coordinate.
  • The web component's id and *_class attributes.