Current section

Files

Jump to
petal_components lib petal_components menu.ex
Raw

lib/petal_components/menu.ex

defmodule PetalComponents.Menu do
use Phoenix.Component, global_prefixes: ~w(x-)
import PetalComponents.Link
import PetalComponents.Icon
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(:js_lib, :string,
default: PetalComponents.default_js_lib(),
values: ["alpine_js", "live_view_js"],
doc: "javascript library used for toggling"
)
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}
js_lib={@js_lib}
title={menu_group[:title]}
menu_items={menu_group.menu_items}
current_page={@current_page}
/>
</div>
<% else %>
<.menu_group
js_lib={@js_lib}
title={@title}
menu_items={@menu_items}
current_page={@current_page}
/>
<% end %>
"""
end
attr :current_page, :atom
attr :menu_items, :list
attr :title, :string
attr(:js_lib, :string,
default: PetalComponents.default_js_lib(),
values: ["alpine_js", "live_view_js"],
doc: "javascript library used for toggling"
)
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}
js_lib={@js_lib}
all_menu_items={@menu_items}
current_page={@current_page}
{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(:js_lib, :string,
default: PetalComponents.default_js_lib(),
values: ["alpine_js", "live_view_js"],
doc: "javascript library used for toggling"
)
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", @js_lib, %{name: @name, current_page: @current_page, menu_items: @menu_items})}
>
<button
type="button"
class={menu_item_classes(@current_page, @name)}
{js_attributes("button", @js_lib, %{submenu_id: @submenu_id, icon_id: @icon_id})}
>
<.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", @js_lib, %{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", @js_lib, %{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", "alpine_js", %{
name: name,
current_page: current_page,
menu_items: menu_items
}) do
%{
"x-data": "{ open: #{menu_item_active?(name, current_page, menu_items)}}"
}
end
defp js_attributes("button", "alpine_js", _args) do
%{
"@click.prevent": "open = !open"
}
end
defp js_attributes("icon", "alpine_js", %{class: class}) do
%{
class: class,
"x-bind:class": "{ 'rotate-90': open }"
}
end
defp js_attributes("submenu", "alpine_js", %{
name: name,
current_page: current_page,
menu_items: menu_items
}) do
%{
"x-show": "open",
"x-cloak": "#{!menu_item_active?(name, current_page, menu_items)}"
}
end
defp js_attributes("container", "live_view_js", _args) do
%{}
end
defp js_attributes("button", "live_view_js", %{submenu_id: submenu_id, icon_id: icon_id}) do
click =
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)"
)
%{
"phx-click": click
}
end
defp js_attributes("icon", "live_view_js", %{
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", "live_view_js", %{
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