defmodule Corex.Menu do @moduledoc ~S''' Phoenix implementation of [Zag.js Menu](https://zagjs.com/components/react/menu). ## Examples ### List You must use `Corex.Tree.Item` struct for items. The value for each item is optional, useful for controlled mode and API to identify the item. You can specify disabled for each item and nested children. ```heex <.menu class="menu" items={[ %Corex.Tree.Item{ id: "edit", label: "Edit" }, %Corex.Tree.Item{ id: "duplicate", label: "Duplicate" }, %Corex.Tree.Item{ id: "delete", label: "Delete" } ]} > <:trigger>Actions <:indicator> <.icon name="hero-chevron-down" /> ``` ### Nested Menu Use `children` in `Corex.Tree.Item` to create nested menus. ```heex <.menu class="menu" items={[ %Corex.Tree.Item{ id: "new-tab", label: "New tab" }, %Corex.Tree.Item{ id: "share", label: "Share", children: [ %Corex.Tree.Item{ id: "messages", label: "Messages" }, %Corex.Tree.Item{ id: "airdrop", label: "Airdrop" }, %Corex.Tree.Item{ id: "whatsapp", label: "WhatsApp" } ] }, %Corex.Tree.Item{ id: "print", label: "Print..." } ]} > <:trigger>Click me ``` ### Nested Menu with Custom Indicator Use the `:nested_indicator` slot to customize the indicator shown on items with nested menus (defaults to arrow right →). ```heex <.menu class="menu" items={[ %Corex.Tree.Item{ id: "share", label: "Share", children: [ %Corex.Tree.Item{id: "messages", label: "Messages"} ] } ]} > <:trigger>Click me <:nested_indicator> <.icon name="hero-arrow-right" /> ``` ### Grouped Items Use `group` in `Corex.Tree.Item` to group related items. The group value is used as the section label (same as select). ```heex <.menu class="menu" items={[ %Corex.Tree.Item{ id: "edit", label: "Edit", group: "Actions" }, %Corex.Tree.Item{ id: "duplicate", label: "Duplicate", group: "Actions" }, %Corex.Tree.Item{ id: "account-1", label: "Account 1", group: "Accounts" }, %Corex.Tree.Item{ id: "account-2", label: "Account 2", group: "Accounts" } ]} > <:trigger>Actions <:indicator> <.icon name="hero-chevron-down" /> ``` ### Controlled Render a menu controlled by the server. You must use the `on_select` event to handle selection on the server. ```elixir defmodule MyAppWeb.MenuLive do use MyAppWeb, :live_view def mount(_params, _session, socket) do {:ok, assign(socket, :open, false)} end def handle_event("on_select", %{"value" => value}, socket) do IO.inspect("Selected: #{value}") {:noreply, socket} end def handle_event("on_open_change", %{"open" => open}, socket) do {:noreply, assign(socket, :open, open)} end def render(assigns) do ~H""" <.menu id="my-menu" controlled open={@open} on_select="on_select" on_open_change="on_open_change" class="menu" items={[ %Corex.Tree.Item{id: "edit", label: "Edit"}, %Corex.Tree.Item{id: "duplicate", label: "Duplicate"}, %Corex.Tree.Item{id: "delete", label: "Delete"} ]} > <:trigger>Actions <:indicator> <.icon name="hero-chevron-down" /> """ end end ``` ## API Control In order to use the API, you must use an id on the component ***Client-side*** ```heex ``` ***Server-side*** ```elixir def handle_event("open_menu", _, socket) do {:noreply, Corex.Menu.set_open(socket, "my-menu", true)} end ``` ## Styling Use data attributes to target elements: ```css [data-scope="menu"][data-part="root"] {} [data-scope="menu"][data-part="trigger"] {} [data-scope="menu"][data-part="positioner"] {} [data-scope="menu"][data-part="content"] {} [data-scope="menu"][data-part="item"] {} [data-scope="menu"][data-part="separator"] {} [data-scope="menu"][data-part="item-group"] {} [data-scope="menu"][data-part="item-group-label"] {} ``` If you wish to use the default Corex styling, you can use the class `menu` on the component. This requires to install `Mix.Tasks.Corex.Design` first and import the component css file. ```css @import "../corex/main.css"; @import "../corex/tokens/themes/neo/light.css"; @import "../corex/components/menu.css"; ``` You can then use modifiers ```heex <.menu class="menu menu--accent menu--lg"> ``` Learn more about modifiers and [Corex Design](https://corex-ui.com/components/menu#modifiers) ''' @doc type: :component use Phoenix.Component alias Corex.Menu.Anatomy.{Props, Root, Trigger, Item, Group} alias Corex.Menu.Connect @doc """ Renders a menu component. You can use either: - The `:items` attribute for programmatic item generation from a list of `%Corex.Tree.Item{}` structs - Manual item definition with full control using slots When using `:items`, each item MUST be a `%Corex.Tree.Item{}` struct with: - `:id` (required) - unique identifier for the item - `:label` (required) - label text for the item - `:children` (optional) - list of nested `%Corex.Tree.Item{}` structs for nested menus - `:disabled` (optional, default: false) - whether the item is disabled - `:group` (optional) - group identifier for grouping items - `:meta` (optional) - map containing additional metadata """ attr(:id, :string, required: false, doc: "The id of the menu, useful for API to identify the menu" ) attr(:items, :list, default: nil, doc: "The items of the menu, must be a list of %Corex.Tree.Item{} structs" ) attr(:open, :boolean, default: false, doc: "The initial open state or the controlled open state of the menu" ) attr(:controlled, :boolean, default: false, doc: "Whether the menu is controlled. Only in LiveView, the on_select and on_open_change events are required" ) attr(:close_on_select, :boolean, default: true, doc: "Whether to close the menu when an item is selected" ) attr(:loop_focus, :boolean, default: false, doc: "Whether to loop focus when navigating with keyboard" ) attr(:typeahead, :boolean, default: true, doc: "Whether to enable typeahead navigation" ) attr(:composite, :boolean, default: false, doc: "Whether the menu is composed with other composite widgets" ) attr(:disabled, :boolean, default: false, doc: "Whether the menu trigger is disabled" ) attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"], doc: "The direction of the menu. When nil, derived from document (html lang + config :rtl_locales)" ) attr(:aria_label, :string, default: nil, doc: "The aria-label for the menu" ) attr(:on_select, :string, default: nil, doc: "The server event name when an item is selected" ) attr(:on_select_client, :string, default: nil, doc: "The client event name when an item is selected" ) attr(:redirect, :boolean, default: false, doc: "When true, selecting an item redirects to the item's id (e.g. path). When not connected to LiveView the hook sets window.location; when connected use on_select and redirect(socket, to: value) in your handler. Per item: set redirect: false on a Tree.Item to disable redirect for that item; set new_tab: true to open that item's URL in a new tab." ) attr(:on_open_change, :string, default: nil, doc: "The server event name when the menu opens or closes" ) attr(:on_open_change_client, :string, default: nil, doc: "The client event name when the menu opens or closes" ) attr(:rest, :global) slot(:trigger, required: true, doc: "The trigger button content") slot(:indicator, required: false, doc: "Optional indicator content (e.g., icon or arrow)") slot(:nested_indicator, required: false, doc: "Optional indicator content for nested menu triggers (defaults to arrow right)" ) slot(:item, required: false, doc: "Optional. Custom content for each menu item. Use :let={item} to receive the item (Corex.Tree.Item)." ) def menu(assigns) do assigns = assigns |> assign_new(:id, fn -> "#{System.unique_integer([:positive])}" end) |> validate_items() |> assign_menu_entries() ~H"""
""" end attr(:menu_id, :string, required: true) attr(:item, Corex.Tree.Item, required: true) attr(:dir, :string, required: true) attr(:nested_indicator, :list, required: true) attr(:item_slot, :list, required: true) defp menu_nested_items(assigns) do base_id = String.replace_prefix(assigns.menu_id, "menu:", "") nested_id = "#{base_id}:#{assigns.item.id}" assigns = assign(assigns, :nested_id, nested_id) ~H"""