Corex.Menu (Corex v0.2.0)

View Source

Menu for Phoenix LiveView. Behavior follows Zag.js Menu.

Anatomy

List

You must use Corex.Tree.Item struct for items.

The value for each item is optional in maps passed to Corex.Tree.new/1 (auto-generated when omitted).

You can specify disabled for each item and nested children.

<.menu
  class="menu"
  items={[
    %Corex.Tree.Item{
      value: "edit",
      label: "Edit"
    },
    %Corex.Tree.Item{
      value: "duplicate",
      label: "Duplicate"
    },
    %Corex.Tree.Item{
      value: "delete",
      label: "Delete"
    }
  ]}
>
  <:trigger>Actions</:trigger>
  <:indicator>
    <.heroicon name="hero-chevron-down" />
  </:indicator>
</.menu>

Nested Menu

Use children in Corex.Tree.Item to create nested menus.

<.menu
  class="menu"
  items={[
    %Corex.Tree.Item{
      value: "new-tab",
      label: "New tab"
    },
    %Corex.Tree.Item{
      value: "share",
      label: "Share",
      children: [
        %Corex.Tree.Item{
          value: "messages",
          label: "Messages"
        },
        %Corex.Tree.Item{
          value: "airdrop",
          label: "Airdrop"
        },
        %Corex.Tree.Item{
          value: "whatsapp",
          label: "WhatsApp"
        }
      ]
    },
    %Corex.Tree.Item{
      value: "print",
      label: "Print"
    }
  ]}
>
  <:trigger>Click me</:trigger>
</.menu>

Nested Menu with Custom Indicator

Use the :nested_indicator slot to customize the indicator shown on items with nested menus (defaults to arrow right →).

<.menu
  class="menu"
  items={[
    %Corex.Tree.Item{
      value: "share",
      label: "Share",
      children: [
        %Corex.Tree.Item{value: "messages", label: "Messages"}
      ]
    }
  ]}
>
  <:trigger>Click me</:trigger>
  <:nested_indicator>
    <.heroicon name="hero-arrow-right" />
  </:nested_indicator>
</.menu>

Custom item

Use the :item slot to customize each menu row (for example icons beside labels).

<.menu
  class="menu"
  items={[
    %Corex.Tree.Item{value: "fra", label: "France"},
    %Corex.Tree.Item{value: "bel", label: "Belgium"},
    %Corex.Tree.Item{value: "deu", label: "Germany"}
  ]}
>
  <:item :let={item}>
    <Flagpack.flag name={String.to_existing_atom(to_string(item.value))} />
    {item.label}
  </:item>
  <:trigger>Actions</:trigger>
  <:indicator>
    <.heroicon name="hero-chevron-down" />
  </:indicator>
</.menu>

Grouped Items

Use group in Corex.Tree.Item to group related items. The group value is used as the section label (same as select).

<.menu
  class="menu"
  items={[
    %Corex.Tree.Item{
      value: "edit",
      label: "Edit",
      group: "Actions"
    },
    %Corex.Tree.Item{
      value: "duplicate",
      label: "Duplicate",
      group: "Actions"
    },
    %Corex.Tree.Item{
      value: "account-1",
      label: "Account 1",
      group: "Accounts"
    },
    %Corex.Tree.Item{
      value: "account-2",
      label: "Account 2",
      group: "Accounts"
    }
  ]}
>
  <:trigger>Actions</:trigger>
  <:indicator>
    <.heroicon name="hero-chevron-down" />
  </:indicator>
</.menu>

Patterns

Navigation

Set redirect on the component so selecting an item navigates to the item's value (e.g. path). Per item, choose the navigation kind explicitly via the item's :redirect field:

  • :href (default) - full page redirect via window.location (safe everywhere)
  • :patch - LiveView js().patch(url) (caller asserts: same LV mount + matching live route)
  • :navigate - LiveView js().navigate(url) (caller asserts: another LV in the same live_session)
  • false - disable redirect for this item (e.g. let your on_select server handler decide)

Set new_tab: true on an item to open its destination in a new tab via window.open.

Breaking change

Earlier versions were no-ops when LiveView was connected (the server handler was expected to call redirect/2). The hook now performs a hard :href redirect by default. Opt back into the old behavior by setting the per-item redirect: false, or opt into LV-aware navigation with redirect: :patch / redirect: :navigate.

Controller

When not connected to LiveView, the hook always performs a full page redirect via window.location.

<.menu
  class="menu"
  redirect
  items={[
    %Corex.Tree.Item{value: "/", label: "Home"},
    %Corex.Tree.Item{value: "/docs", label: "Docs"},
    %Corex.Tree.Item{value: "https://example.com", label: "External", new_tab: true}
  ]}
>
  <:trigger>Navigate</:trigger>
  <:indicator>
    <.heroicon name="hero-chevron-down" />
  </:indicator>
</.menu>

LiveView

When connected to LiveView, use on_select and redirect in the callback. The payload includes value (the item value).

defmodule MyAppWeb.NavMenuLive do
  use MyAppWeb, :live_view

  def handle_event("handle_select", %{"value" => value}, socket) do
    {:noreply, push_navigate(socket, to: value)}
  end

  def render(assigns) do
    ~H"""
    <.menu
      class="menu"
      redirect
      on_select="handle_select"
      items={[
        %Corex.Tree.Item{value: "/", label: "Home"},
        %Corex.Tree.Item{value: "/docs", label: "Docs"}
      ]}
    >
      <:trigger>Navigate</:trigger>
      <:indicator>
        <.heroicon name="hero-chevron-down" />
      </:indicator>
    </.menu>
    """
  end
end

API

Requires a stable id on <.menu>.

FunctionActionReturns
set_open/2Set open state (client)%Phoenix.LiveView.JS{}
set_open/3Set open state (server)socket

set_open

<.action phx-click={Corex.Menu.set_open("menu-api", true)} class="button ui-size-sm">
  Open Menu
</.action>
def handle_event("open_menu", _, socket) do
  {:noreply, Corex.Menu.set_open(socket, "menu-api", true)}
end

Events

Server events

EventWhenPayload
on_select="menu_selected"Item selected%{"id" => id, "value" => value}
on_open_change="menu_open_changed"Open state changes%{"id" => id, "open" => open}

on_select

<.menu
  class="menu"
  on_select="menu_selected"
  items={[
    %Corex.Tree.Item{value: "menu", label: "Menu"},
    %Corex.Tree.Item{value: "combobox", label: "Combobox"},
    %Corex.Tree.Item{value: "select", label: "Select"}
  ]}
>
  <:trigger>Actions</:trigger>
  <:indicator><.heroicon name="hero-chevron-down" /></:indicator>
</.menu>
def handle_event("menu_selected", %{"value" => value}, socket) do
  {:noreply, socket}
end

on_open_change

<.menu
  class="menu"
  on_open_change="menu_open_changed"
  items={[
    %Corex.Tree.Item{value: "menu", label: "Menu"},
    %Corex.Tree.Item{value: "combobox", label: "Combobox"},
    %Corex.Tree.Item{value: "select", label: "Select"}
  ]}
>
  <:trigger>Actions</:trigger>
  <:indicator><.heroicon name="hero-chevron-down" /></:indicator>
</.menu>

Client events

EventWhenevent.detail
on_select_client="menu-selected"Item selectedid, value
on_open_change_client="menu-open-changed"Open state changesid, open

Style

Import menu.css and stack modifiers on the host (class on <.menu>).

@import "../corex/corex.css";

Axes: Semantic (ui-accent, ui-brand, ui-alert, ui-info, ui-success), Variant (ui-solid), Size (ui-size-smui-size-xl), Radius (ui-rounded-*), Max height (ui-max-height-* on the host; clamps content). See the modifier guide.

Semantic modifiers set palette variables on triggers and selected items. Variant modifiers control trigger and content panel surface treatment. Default is subtle; add menu ui-solid for a filled trigger or panel.

Semantic

ModifierClasses
Defaultmenu
Accentmenu ui-accent
Brandmenu ui-brand
Alertmenu ui-alert
Infomenu ui-info
Successmenu ui-success

Variant

Visual treatment of the trigger and content panel. Combine with a semantic modifier for palette-driven ink and fill.

ModifierClasses
Subtle (default)menu or menu ui-accent
Solidmenu ui-accent ui-solid

Size

ModifierClasses
SMmenu ui-size-sm
MDmenu ui-size-md
LGmenu ui-size-lg
XLmenu ui-size-xl

Max height

Opt-in clamp on the menu content. Example: menu ui-max-height-xs.

Summary

Components

Renders a menu component.

API

Set menu open state from a control (phx-click). Targets the root with id menu:<id>.

Set open state from handle_event. Pushes menu_set_open.

Components

API

set_open(menu_id, open)

Set menu open state from a control (phx-click). Targets the root with id menu:<id>.

<.action phx-click={Corex.Menu.set_open("my-menu", true)}>Open</.action>
<.menu id="my-menu" class="menu" items={[%Corex.Tree.Item{label: "Edit", value: "edit"}]}>
  <:trigger>Actions</:trigger>
</.menu>
document.querySelector('[id="menu:my-menu"]')?.dispatchEvent(
  new CustomEvent("corex:menu:set-open", {
    bubbles: false,
    detail: { open: true },
  })
);

set_open(socket, menu_id, open)

Set open state from handle_event. Pushes menu_set_open.

<.action phx-click="open_menu">Open</.action>
<.menu id="my-menu" class="menu" items={[%Corex.Tree.Item{label: "Edit", value: "edit"}]}>
  <:trigger>Actions</:trigger>
</.menu>
def handle_event("open_menu", _, socket) do
  {:noreply, Corex.Menu.set_open(socket, "my-menu", true)}
end