Packages
petal_components
4.6.2
4.6.2
4.6.1
4.6.0
4.5.0
4.4.0
4.3.0
4.2.1
4.2.0
4.1.2
4.1.1
4.1.0
4.0.12
4.0.11
4.0.10
4.0.6
4.0.5
4.0.4
4.0.3
4.0.1
3.2.2
3.2.1
3.2.0
3.1.0
3.0.2
3.0.1
3.0.0
2.9.3
2.9.2
retired
2.9.1
retired
2.9.0
retired
2.8.4
2.8.3
2.8.2
2.8.1
2.8.0
2.7.4
2.7.3
2.7.2
2.7.1
2.7.0
2.6.1
2.6.0
2.5.2
2.5.1
2.5.0
2.4.3
2.4.2
2.4.1
2.4.0
2.3.0
2.2.1
2.2.0
2.1.2
2.1.1
2.1.0
2.0.6
2.0.5
2.0.4
2.0.3
2.0.2
2.0.1
2.0.0
1.9.3
1.9.2
1.9.1
1.9.0
1.8.0
1.7.1
1.7.0
1.6.2
1.6.1
1.6.0
1.5.5
1.5.4
1.5.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.0
1.2.14
1.2.13
1.2.12
1.2.11
1.2.10
1.2.9
1.2.8
1.2.7
1.2.6
1.2.5
1.2.4
1.2.3
1.2.2
1.2.1
1.2.0
1.1.6
1.1.5
1.1.4
1.1.3
1.1.2
1.1.1
1.1.0
1.0.8
1.0.7
1.0.6
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
0.19.10
0.19.9
0.19.8
0.19.7
0.19.6
0.19.5
0.19.4
0.19.3
0.19.2
0.19.1
0.19.0
0.18.5
0.18.4
0.18.3
0.18.2
0.18.1
0.18.0
0.17.7
0.17.6
0.17.5
0.17.4
0.17.3
0.17.2
0.17.1
0.17.0
0.16.0
0.15.0
0.14.1
0.14.0
0.13.7
0.13.6
0.13.5
0.13.4
0.13.3
0.13.2
0.13.1
0.13.0
0.12.0
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.8
0.10.7
0.10.6
0.10.5
0.10.4
0.10.3
0.10.2
0.10.1
0.10.0
0.9.3
0.9.2
0.9.1
0.9.0
0.8.0
0.7.0
0.6.1
0.6.0
0.5.1
0.5.0
0.4.0
0.3.2
0.3.1
0.3.0
0.2.2
0.2.1
0.2.0
0.1.0
Shadcn-style Phoenix LiveView components that AI assistants can actually use. Pair with the MCP server so AI coding tools can inspect the real component API.
Current section
Files
Jump to
Current section
Files
lib/petal_components/menu.ex
defmodule PetalComponents.Menu do
use Phoenix.Component
import PetalComponents.Link
import PetalComponents.Icon
import PetalComponents.Helpers, only: [compose_js: 2]
alias Phoenix.LiveView.JS
@doc """
## Menu items structure
Menu items (main_menu_items + user_menu_items) should have this structure:
[
%{
name: :sign_in,
label: "Sign in",
path: "/sign-in,
icon: "hero-key",
}
]
### Name
The name is used to identify the menu item. It is used to highlight the current menu item.
<.sidebar_layout current_page={:sign_in} ...>
### Label
This is the text that will be displayed in the menu.
### Path
This is the path that the user will be taken to when they click the menu item.
The default link type is a live_redirect. This will work for non-live view links too.
#### Live patching
Let's say you have three menu items that point to the same live view. In this case we can utilize a live_patch link. To do this, you add the `patch_group` key to the menu item.
[
%{name: :one, label: "One", path: "/one, icon: "hero-key", patch_group: :my_unique_group},
%{name: :two, label: "Two", path: "/two, icon: "hero-key", patch_group: :my_unique_group},
%{name: :three, label: "Three", path: "/three, icon: "hero-key", patch_group: :my_unique_group},
%{name: :another_link, label: "Other", path: "/other, icon: "hero-key"},
]
Now, if you're on page `:one`, and click a link in the menu to either `:two`, or `:three`, the live view will be patched because they are in the same `patch_group`. If you click `:another_link`, the live view will be redirected.
### Icons
The icon should match to a Heroicon (Petal Components must be installed).
If you have your own icon, you can pass a function to the icon attribute instead of an atom:
[
%{
name: :sign_in,
label: "Sign in",
path: "/sign-in,
icon: &my_cool_icon/1,
}
]
Or just pass a string of HTML:
[
%{
name: :sign_in,
label: "Sign in",
path: "/sign-in,
icon: "<svg>...</svg>",
}
]
## Nested menu items
You can have nested menu items that will be displayed in a dropdown menu. To do this, you add a `menu_items` key to the menu item. eg:
[
%{
name: :auth,
label: "Auth",
icon: "hero-key",
menu_items: [
%{
name: :sign_in,
label: "Sign in",
path: "/sign-in,
icon: "hero-key",
},
%{
name: :sign_up,
label: "Sign up",
path: "/sign-up,
icon: "hero-key",
},
]
}
]
## Menu groups
Sidebar supports multi menu groups for the side menu. eg:
User
- Profile
- Settings
Company
- Dashboard
- Company Settings
To enable this, change the structure of main_menu_items to this:
main_menu_items = [
%{
title: "Menu group 1",
menu_items: [ ... menu items ... ]
},
%{
title: "Menu group 2",
menu_items: [ ... menu items ... ]
},
]
"""
attr :menu_items, :list, required: true
attr :current_page, :atom, required: true
attr :title, :string, default: nil
attr :on_toggle, JS,
default: %JS{},
doc: "additional JS commands to run when a submenu is toggled (LiveView.JS only)"
def vertical_menu(%{menu_items: []} = assigns) do
~H"""
"""
end
def vertical_menu(assigns) do
~H"""
<%= if menu_items_grouped?(@menu_items) do %>
<div class="pc-vertical-menu">
<.menu_group
:for={menu_group <- @menu_items}
title={menu_group[:title]}
menu_items={menu_group.menu_items}
current_page={@current_page}
on_toggle={@on_toggle}
/>
</div>
<% else %>
<.menu_group
title={@title}
menu_items={@menu_items}
current_page={@current_page}
on_toggle={@on_toggle}
/>
<% end %>
"""
end
attr :current_page, :atom
attr :menu_items, :list
attr :title, :string
attr :on_toggle, JS, default: %JS{}
def menu_group(assigns) do
~H"""
<nav :if={@menu_items != []}>
<h3 :if={@title} class="pc-vertical-menu__menu-group__title">
{@title}
</h3>
<div class="pc-vertical-menu__menu-group__wrapper">
<div class="pc-vertical-menu__menu-group">
<.vertical_menu_item
:for={menu_item <- @menu_items}
all_menu_items={@menu_items}
current_page={@current_page}
on_toggle={@on_toggle}
{menu_item}
/>
</div>
</div>
</nav>
"""
end
attr :current_page, :atom
attr :path, :string, default: nil
attr :icon, :any, default: nil
attr :label, :string
attr :name, :atom, default: nil
attr :menu_items, :list, default: nil
attr :all_menu_items, :list, default: nil
attr :patch_group, :atom, default: nil
attr :link_type, :string, default: "live_redirect"
attr :on_toggle, JS, default: %JS{}
def vertical_menu_item(%{menu_items: nil} = assigns) do
current_item = find_item(assigns.name, assigns.all_menu_items)
assigns = assign(assigns, :current_item, current_item)
~H"""
<.a
to={@path}
link_type={
if @current_item[:patch_group] &&
@current_item[:patch_group] == @patch_group,
do: "live_patch",
else: "live_redirect"
}
class={menu_item_classes(@current_page, @name)}
>
<.menu_icon :if={@icon} icon={@icon} is_active={@current_page == @name} />
<div class="pc-vertical-menu-item__label">{@label}</div>
</.a>
"""
end
def vertical_menu_item(%{menu_items: _} = assigns) do
assigns =
assigns
|> assign_new(:submenu_id, fn -> "submenu_#{Ecto.UUID.generate()}" end)
|> assign_new(:icon_id, fn -> "icon_#{Ecto.UUID.generate()}" end)
~H"""
<div
phx-update="ignore"
id={"dropdown_#{@label |> String.downcase() |> String.replace(" ", "_")}"}
{js_attributes("container", %{name: @name, current_page: @current_page, menu_items: @menu_items})}
>
<button
type="button"
class={menu_item_classes(@current_page, @name)}
{js_attributes("button", %{submenu_id: @submenu_id, icon_id: @icon_id, on_toggle: @on_toggle})}
>
<.menu_icon icon={@icon} />
<div class="pc-vertical-menu-item__toggle-label">
{@label}
</div>
<div class="pc-vertical-menu-item__toggle-chevron__wrapper">
<.icon
name="hero-chevron-right"
id={@icon_id}
{js_attributes("icon", %{class: "pc-vertical-menu-item__toggle-chevron__icon", name: @name, current_page: @current_page, menu_items: @menu_items})}
/>
</div>
</button>
<div
id={@submenu_id}
class="pc-vertical-menu-item__submenu-wrapper"
{js_attributes("submenu", %{name: @name, current_page: @current_page, menu_items: @menu_items})}
>
<.vertical_menu_item :for={menu_item <- @menu_items} current_page={@current_page} {menu_item} />
</div>
</div>
"""
end
attr :icon, :any, default: nil
attr :is_active, :boolean, default: false
defp menu_icon(assigns) do
~H"""
<%= cond do %>
<% is_function(@icon) -> %>
{Phoenix.LiveView.TagEngine.component(
@icon,
[class: menu_icon_classes(@is_active)],
{__ENV__.module, __ENV__.function, __ENV__.file, __ENV__.line}
)}
<% is_binary(@icon) && String.match?(@icon, ~r/svg|img/) -> %>
{Phoenix.HTML.raw(@icon)}
<% true -> %>
<.icon name={@icon} class={menu_icon_classes(@is_active)} />
<% end %>
"""
end
defp menu_items_grouped?(menu_items) do
Enum.all?(menu_items, fn menu_item ->
Map.has_key?(menu_item, :title)
end)
end
# Check whether the current namge equals the current page or whether any of the menu items have the current page as their name. A menu_item may have sub-items, so we need to check recursively.
defp menu_item_active?(name, current_page, menu_items) do
name == current_page ||
Enum.any?(menu_items, fn menu_item ->
menu_item_active?(menu_item[:name], current_page, menu_item[:menu_items] || [])
end)
end
defp menu_icon_classes(is_active),
do:
"pc-vertical-menu-item__icon pc-vertical-menu-item__icon--#{if is_active, do: "active", else: "inactive"}"
defp menu_item_base_classes(),
do: "pc-vertical-menu-item"
# Active state
defp menu_item_classes(page, page),
do: "#{menu_item_base_classes()} group pc-vertical-menu-item--active"
# Inactive state
defp menu_item_classes(_current_page, _link_page),
do: "#{menu_item_base_classes()} group pc-vertical-menu-item--inactive"
defp find_item(name, menu_items) when is_list(menu_items) do
Enum.find(menu_items, fn menu_item ->
if menu_item[:name] == name do
true
else
find_item(name, menu_item[:menu_items] || [])
end
end)
end
defp find_item(_, _), do: nil
defp js_attributes("container", _args) do
%{}
end
defp js_attributes("button", %{
submenu_id: submenu_id,
icon_id: icon_id,
on_toggle: on_toggle
}) do
click =
compose_js(
on_toggle,
JS.toggle(
to: "##{submenu_id}",
display: "block"
)
|> JS.remove_class(
"rotate-90",
to: "##{icon_id}.rotate-90"
)
|> JS.add_class(
"rotate-90",
to: "##{icon_id}:not(.rotate-90)"
)
|> JS.focus_first(to: "##{submenu_id}")
)
%{
"phx-click": click
}
end
defp js_attributes("icon", %{
class: class,
name: name,
current_page: current_page,
menu_items: menu_items
}) do
rotate = if menu_item_active?(name, current_page, menu_items), do: "rotate-90"
%{
class: [class, rotate]
}
end
defp js_attributes("submenu", %{
name: name,
current_page: current_page,
menu_items: menu_items
}) do
display = if menu_item_active?(name, current_page, menu_items), do: "block", else: "none"
%{
style: "display: #{display};"
}
end
end