Packages

phoenix_kit

1.7.151
1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.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.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web components dashboard sidebar.ex
Raw

lib/phoenix_kit_web/components/dashboard/sidebar.ex

defmodule PhoenixKitWeb.Components.Dashboard.Sidebar do
@moduledoc """
Sidebar component for the user dashboard.
Renders the complete dashboard navigation with:
- Grouped tabs with headers
- Active state highlighting
- Badge indicators
- Presence counts
- Attention animations
- Mobile bottom navigation
- Collapsible groups
- Context selector (when `position: :sidebar` is configured)
## Usage
<.dashboard_sidebar
current_path={@url_path}
scope={@phoenix_kit_current_scope}
locale={@current_locale}
/>
## Live Updates
The sidebar automatically updates when tabs change if you subscribe to updates:
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(PhoenixKit.PubSub, PhoenixKit.Dashboard.pubsub_topic())
end
{:ok, socket}
end
def handle_info({:tab_updated, _tab}, socket) do
{:noreply, assign(socket, :tabs, PhoenixKit.Dashboard.get_tabs())}
end
"""
use Phoenix.Component
alias PhoenixKit.Dashboard.{Group, Presence, Registry, Tab}
alias PhoenixKit.Utils.Routes
alias PhoenixKitWeb.Components.Dashboard.TabItem
import PhoenixKit.Dashboard.TabHelpers
# Use the icon component from Core.Icon to avoid circular dependencies
import PhoenixKitWeb.Components.Core.Icon, only: [icon: 1]
@doc """
Renders the complete dashboard sidebar with all tabs.
## Attributes
- `current_path` - The current URL path for active state detection
- `scope` - The current authentication scope for visibility filtering
- `locale` - The current locale for path generation
- `tabs` - Optional pre-loaded tabs (defaults to loading from registry)
- `viewer_counts` - Optional map of tab_id => viewer_count
- `collapsed_groups` - Set of collapsed group IDs
- `show_presence` - Show presence indicators (default: true)
- `compact` - Render in compact mode (default: false)
- `class` - Additional CSS classes
- `show_context_selector` - Show context selector at top of sidebar (default: false)
- `dashboard_contexts` - List of available contexts
- `current_context` - Currently selected context
- `context_selector_config` - ContextSelector config struct
## Multi-Selector Attributes (optional)
- `context_selector_configs` - List of all ContextSelector configs
- `dashboard_contexts_map` - Map of key => list of contexts
- `current_contexts_map` - Map of key => current context
- `show_context_selectors_map` - Map of key => boolean
"""
attr :current_path, :string, default: "/dashboard"
attr :scope, :any, default: nil
attr :locale, :string, default: nil
attr :tabs, :list, default: nil
attr :viewer_counts, :map, default: %{}
attr :collapsed_groups, :any, default: MapSet.new()
attr :show_presence, :boolean, default: true
attr :compact, :boolean, default: false
attr :class, :string, default: ""
attr :show_context_selector, :boolean, default: false
attr :dashboard_contexts, :list, default: []
attr :current_context, :any, default: nil
attr :context_selector_config, :any, default: nil
# Multi-selector attributes
attr :context_selector_configs, :list, default: []
attr :dashboard_contexts_map, :map, default: %{}
attr :current_contexts_map, :map, default: %{}
attr :show_context_selectors_map, :map, default: %{}
def dashboard_sidebar(assigns) do
# Load tabs if not provided
tabs =
case assigns.tabs do
nil ->
Registry.get_tabs_with_active(assigns.current_path, scope: assigns.scope, level: :user)
tabs ->
add_active_state(tabs, assigns.current_path)
end
# Group tabs
grouped_tabs = group_tabs(tabs)
groups = Registry.get_groups()
# Get viewer counts if not provided and presence is enabled
viewer_counts =
if assigns.show_presence and map_size(assigns.viewer_counts) == 0 do
Presence.get_all_tab_counts()
else
assigns.viewer_counts
end
assigns =
assigns
|> assign(:tabs, tabs)
|> assign(:grouped_tabs, grouped_tabs)
|> assign(:groups, groups)
|> assign(:viewer_counts, viewer_counts)
# Check if multi-selector mode
has_multi_selector = assigns.context_selector_configs != []
assigns = assign(assigns, :has_multi_selector, has_multi_selector)
~H"""
<nav class={["space-y-1", @class]} role="navigation" aria-label="Dashboard navigation">
<%!-- Context Selector(s) at top (sub_position: :start) --%>
<%= if @has_multi_selector do %>
<%!-- Multi-selector mode --%>
<PhoenixKitWeb.Components.Dashboard.MultiContextSelector.multi_context_selector_sidebar
position={:sidebar}
sub_position={:start}
configs={@context_selector_configs}
contexts_map={@dashboard_contexts_map}
current_map={@current_contexts_map}
show_map={@show_context_selectors_map}
/>
<% else %>
<%!-- Legacy single selector --%>
<%= if show_context_selector_at?(@show_context_selector, @context_selector_config, :start) do %>
<PhoenixKitWeb.Components.Dashboard.ContextSelector.sidebar_context_selector
contexts={@dashboard_contexts}
current={@current_context}
config={@context_selector_config}
/>
<% end %>
<% end %>
<%= for group <- sorted_groups(@groups, @grouped_tabs) do %>
<.tab_group
group={group}
tabs={Map.get(@grouped_tabs, group.id, [])}
viewer_counts={@viewer_counts}
locale={@locale}
collapsed={MapSet.member?(@collapsed_groups, group.id)}
compact={@compact}
/>
<% end %>
<%!-- Render ungrouped tabs with possible context selector by priority --%>
<.tabs_with_context_selector
tabs={filter_top_level(Map.get(@grouped_tabs, nil, []))}
all_tabs={Map.get(@grouped_tabs, nil, [])}
viewer_counts={@viewer_counts}
locale={@locale}
compact={@compact}
show_context_selector={
show_context_selector_with_priority?(@show_context_selector, @context_selector_config)
}
dashboard_contexts={@dashboard_contexts}
current_context={@current_context}
context_selector_config={@context_selector_config}
/>
<%!-- Note: Bottom context selector (sub_position: :end) is rendered by the layout, not here --%>
</nav>
"""
end
@doc """
Renders a group of tabs with optional header.
"""
attr :group, :map, required: true
attr :tabs, :list, required: true
attr :viewer_counts, :map, default: %{}
attr :locale, :string, default: nil
attr :collapsed, :boolean, default: false
attr :compact, :boolean, default: false
def tab_group(assigns) do
~H"""
<div
class="space-y-1"
data-group-id={@group.id}
data-collapsed={@collapsed}
>
<%!-- Group Header (if labeled) --%>
<%= if Group.localized_label(@group) do %>
<div
class={[
"px-3 py-2 text-xs font-semibold text-base-content/50 uppercase tracking-wider",
@group.collapsible &&
"cursor-pointer hover:text-base-content/70 flex items-center justify-between"
]}
phx-click={@group.collapsible && "toggle_dashboard_group"}
phx-value-group={@group.id}
>
<span class="flex items-center gap-2">
<%= if @group.icon do %>
<.icon name={@group.icon} class="w-3.5 h-3.5" />
<% end %>
{Group.localized_label(@group)}
</span>
<%= if @group.collapsible do %>
<.icon
name={if @collapsed, do: "hero-chevron-right-mini", else: "hero-chevron-down-mini"}
class="w-4 h-4"
/>
<% end %>
</div>
<% end %>
<%!-- Group Tabs --%>
<div class={[@collapsed && "hidden"]}>
<%= for tab <- filter_top_level(@tabs) do %>
<.tab_with_subtabs
tab={tab}
all_tabs={@tabs}
viewer_counts={@viewer_counts}
locale={@locale}
compact={@compact}
/>
<% end %>
</div>
</div>
"""
end
@doc """
Renders a tab along with its subtabs (if any).
Subtabs are shown based on the parent tab's `subtab_display` setting:
- `:when_active` - Subtabs only shown when parent is active
- `:always` - Subtabs always visible
"""
attr :tab, :any, required: true
attr :all_tabs, :list, required: true
attr :viewer_counts, :map, default: %{}
attr :locale, :string, default: nil
attr :compact, :boolean, default: false
def tab_with_subtabs(assigns) do
subtabs = get_subtabs_for(assigns.tab.id, assigns.all_tabs)
subtab_active = any_subtab_active?(subtabs)
show_subtabs =
Tab.show_subtabs?(assigns.tab, assigns.tab.active) or subtab_active
# If redirect_to_first_subtab is true, modify the parent tab's path
display_tab = maybe_redirect_to_first_subtab(assigns.tab, subtabs)
# Determine if parent should be highlighted
# If highlight_with_subtabs is false (default) and a subtab is active, don't highlight parent
highlight_with_subtabs = Map.get(assigns.tab, :highlight_with_subtabs, false)
parent_active =
if subtab_active and not highlight_with_subtabs do
false
else
assigns.tab.active
end
assigns =
assigns
|> assign(:subtabs, subtabs)
|> assign(:show_subtabs, show_subtabs)
|> assign(:has_subtabs, subtabs != [])
|> assign(:display_tab, display_tab)
|> assign(:parent_active, parent_active)
~H"""
<div class="tab-with-subtabs" data-tab-id={@tab.id} data-has-subtabs={@has_subtabs}>
<%!-- Parent Tab --%>
<TabItem.tab_item
tab={@display_tab}
active={@parent_active}
viewer_count={Map.get(@viewer_counts, @tab.id, 0)}
locale={@locale}
compact={@compact}
/>
<%!-- Subtabs --%>
<%= if @has_subtabs and @show_subtabs do %>
<div class="subtabs pl-2 border-l-2 border-base-300 ml-4 mt-1 space-y-0.5">
<%= for subtab <- @subtabs do %>
<TabItem.tab_item
tab={subtab}
active={subtab.active}
viewer_count={Map.get(@viewer_counts, subtab.id, 0)}
locale={@locale}
compact={@compact}
parent_tab={@tab}
/>
<% end %>
</div>
<% end %>
</div>
"""
end
@doc """
Renders tabs with a context selector inserted at the appropriate priority position.
"""
attr :tabs, :list, required: true
attr :all_tabs, :list, required: true
attr :viewer_counts, :map, default: %{}
attr :locale, :string, default: nil
attr :compact, :boolean, default: false
attr :show_context_selector, :boolean, default: false
attr :dashboard_contexts, :list, default: []
attr :current_context, :any, default: nil
attr :context_selector_config, :any, default: nil
def tabs_with_context_selector(assigns) do
context_priority = get_context_selector_priority(assigns.context_selector_config)
# Create list of items with their priorities, including context selector if needed
items =
assigns.tabs
|> Enum.map(fn tab -> {:tab, tab, tab.priority} end)
|> maybe_add_context_selector(assigns.show_context_selector, context_priority)
|> Enum.sort_by(fn {_type, _item, priority} -> priority end)
assigns = assign(assigns, :items, items)
~H"""
<%= for item <- @items do %>
<%= case item do %>
<% {:context_selector, _, _} -> %>
<PhoenixKitWeb.Components.Dashboard.ContextSelector.sidebar_context_selector
contexts={@dashboard_contexts}
current={@current_context}
config={@context_selector_config}
/>
<% {:tab, tab, _} -> %>
<.tab_with_subtabs
tab={tab}
all_tabs={@all_tabs}
viewer_counts={@viewer_counts}
locale={@locale}
compact={@compact}
/>
<% end %>
<% end %>
"""
end
defp maybe_add_context_selector(items, false, _priority), do: items
defp maybe_add_context_selector(items, true, nil), do: items
defp maybe_add_context_selector(items, true, priority) do
[{:context_selector, nil, priority} | items]
end
@doc """
Renders a mobile-friendly bottom navigation bar.
## Attributes
- `current_path` - The current URL path for active state detection
- `scope` - The current authentication scope
- `locale` - The current locale
- `max_tabs` - Maximum tabs to show (default: 5)
- `class` - Additional CSS classes
"""
attr :current_path, :string, default: "/dashboard"
attr :scope, :any, default: nil
attr :locale, :string, default: nil
attr :max_tabs, :integer, default: 5
attr :class, :string, default: ""
def mobile_navigation(assigns) do
tabs =
Registry.get_tabs_with_active(assigns.current_path, scope: assigns.scope, level: :user)
|> Enum.filter(&Tab.navigable?/1)
|> Enum.take(assigns.max_tabs)
assigns = assign(assigns, :tabs, tabs)
~H"""
<nav
class={[
"fixed bottom-0 left-0 right-0 bg-base-100 border-t border-base-300 z-50 lg:hidden",
@class
]}
role="navigation"
aria-label="Mobile navigation"
>
<div class="flex items-center justify-around">
<%= for tab <- @tabs do %>
<TabItem.mobile_tab_item
tab={tab}
active={tab.active}
locale={@locale}
/>
<% end %>
<.more_menu tabs={get_overflow_tabs(@scope, @max_tabs)} locale={@locale} />
</div>
</nav>
"""
end
@doc """
Renders a "more" dropdown menu for overflow tabs on mobile.
"""
attr :tabs, :list, required: true
attr :locale, :string, default: nil
def more_menu(assigns) do
~H"""
<%= if length(@tabs) > 0 do %>
<div class="dropdown dropdown-top dropdown-end">
<label
tabindex="0"
class="flex flex-col items-center justify-center py-2 px-3 cursor-pointer text-base-content/60 hover:text-base-content"
>
<.icon name="hero-ellipsis-horizontal" class="w-6 h-6" />
<span class="text-xs mt-1">More</span>
</label>
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-52 mb-2">
<%= for tab <- @tabs do %>
<li>
<.link navigate={build_path(tab.path, @locale)} class="flex items-center gap-2">
<%= if tab.icon do %>
<.icon name={tab.icon} class="w-4 h-4" />
<% end %>
<span>{Tab.localized_label(tab)}</span>
<%= if tab.badge do %>
<PhoenixKitWeb.Components.Dashboard.Badge.dashboard_badge
badge={tab.badge}
class="badge-xs"
/>
<% end %>
</.link>
</li>
<% end %>
</ul>
</div>
<% end %>
"""
end
@doc """
Renders a floating action button for mobile that opens a tab menu.
Includes context selector at the top if configured and user has multiple contexts.
"""
attr :current_path, :string, default: "/dashboard"
attr :scope, :any, default: nil
attr :locale, :string, default: nil
attr :class, :string, default: ""
attr :show_context_selector, :boolean, default: false
attr :dashboard_contexts, :list, default: []
attr :current_context, :any, default: nil
attr :context_selector_config, :any, default: nil
# Multi-selector attributes
attr :context_selector_configs, :list, default: []
attr :dashboard_contexts_map, :map, default: %{}
attr :current_contexts_map, :map, default: %{}
attr :show_context_selectors_map, :map, default: %{}
def mobile_fab_menu(assigns) do
all_tabs =
Registry.get_tabs_with_active(assigns.current_path, scope: assigns.scope, level: :user)
|> Enum.filter(&Tab.navigable?/1)
# Get only top-level tabs for rendering (subtabs handled separately)
top_level_tabs = filter_top_level(all_tabs)
# Check if multi-selector mode
has_multi_selector = assigns.context_selector_configs != []
assigns =
assigns
|> assign(:all_tabs, all_tabs)
|> assign(:top_level_tabs, top_level_tabs)
|> assign(:has_multi_selector, has_multi_selector)
~H"""
<div class={["fixed bottom-4 right-4 z-50 lg:hidden", @class]}>
<div class="dropdown dropdown-top dropdown-end">
<label tabindex="0" class="btn btn-primary btn-circle shadow-lg">
<.icon name="hero-bars-3" class="w-5 h-5" />
</label>
<div
tabindex="0"
class="dropdown-content shadow bg-base-100 rounded-box w-56 mb-2 border border-base-300 max-h-96 overflow-y-auto"
>
<%!-- Mobile Context Selector(s) --%>
<%= if @has_multi_selector do %>
<PhoenixKitWeb.Components.Dashboard.MultiContextSelector.multi_context_selector_mobile
configs={@context_selector_configs}
contexts_map={@dashboard_contexts_map}
current_map={@current_contexts_map}
show_map={@show_context_selectors_map}
/>
<% else %>
<%= if @show_context_selector and @context_selector_config && @context_selector_config.enabled do %>
<PhoenixKitWeb.Components.Dashboard.ContextSelector.mobile_context_selector
contexts={@dashboard_contexts}
current={@current_context}
config={@context_selector_config}
/>
<% end %>
<% end %>
<%!-- Navigation Tabs with Subtab Support --%>
<ul class="menu p-2">
<%= for tab <- @top_level_tabs do %>
<.mobile_tab_with_subtabs
tab={tab}
all_tabs={@all_tabs}
locale={@locale}
/>
<% end %>
</ul>
</div>
</div>
</div>
"""
end
# Renders a mobile tab with its subtabs (if any).
# Handles subtab visibility, redirect_to_first_subtab, and highlight_with_subtabs.
attr :tab, :any, required: true
attr :all_tabs, :list, required: true
attr :locale, :string, default: nil
defp mobile_tab_with_subtabs(assigns) do
subtabs = get_subtabs_for(assigns.tab.id, assigns.all_tabs)
subtab_active = any_subtab_active?(subtabs)
show_subtabs =
Tab.show_subtabs?(assigns.tab, assigns.tab.active) or subtab_active
# Apply redirect_to_first_subtab logic
display_tab = maybe_redirect_to_first_subtab(assigns.tab, subtabs)
# Apply highlight_with_subtabs logic
highlight_with_subtabs = Map.get(assigns.tab, :highlight_with_subtabs, false)
parent_active =
if subtab_active and not highlight_with_subtabs do
false
else
assigns.tab.active
end
assigns =
assigns
|> assign(:subtabs, subtabs)
|> assign(:show_subtabs, show_subtabs)
|> assign(:has_subtabs, subtabs != [])
|> assign(:display_tab, display_tab)
|> assign(:parent_active, parent_active)
~H"""
<%!-- Parent Tab --%>
<li>
<.link
navigate={build_path(@display_tab.path, @locale)}
class={[
"flex items-center gap-3",
@parent_active && "bg-primary text-primary-content"
]}
>
<%= if @tab.icon do %>
<.icon name={@tab.icon} class="w-4 h-4" />
<% end %>
<span>{Tab.localized_label(@tab)}</span>
<%= if @tab.badge do %>
<PhoenixKitWeb.Components.Dashboard.Badge.dashboard_badge
badge={@tab.badge}
class="ml-auto badge-xs"
/>
<% end %>
</.link>
</li>
<%!-- Subtabs (indented, smaller) --%>
<%= if @has_subtabs and @show_subtabs do %>
<%= for subtab <- @subtabs do %>
<li>
<.link
navigate={build_path(subtab.path, @locale)}
class={[
"flex items-center gap-3 pl-6 text-sm",
subtab.active && "bg-primary/80 text-primary-content"
]}
>
<%= if subtab.icon do %>
<.icon name={subtab.icon} class="w-3 h-3" />
<% end %>
<span>{Tab.localized_label(subtab)}</span>
<%= if subtab.badge do %>
<PhoenixKitWeb.Components.Dashboard.Badge.dashboard_badge
badge={subtab.badge}
class="ml-auto badge-xs"
/>
<% end %>
</.link>
</li>
<% end %>
<% end %>
"""
end
# Helper functions
defp get_overflow_tabs(scope, shown_count) do
Registry.get_tabs(scope: scope, level: :user)
|> Enum.filter(&Tab.navigable?/1)
|> Enum.drop(shown_count)
end
# Always apply URL prefix via Routes.path
# When locale is nil, use :none to skip locale prefix but still apply URL prefix
defp build_path(path, nil) do
if String.starts_with?(path, "/admin") do
Routes.path(path)
else
Routes.path(path, locale: :none)
end
end
defp build_path(path, locale) do
if String.starts_with?(path, "/admin") do
Routes.admin_path(path, locale)
else
Routes.path(path, locale: locale)
end
end
# Check if any subtab is currently active
defp any_subtab_active?(subtabs) do
Enum.any?(subtabs, & &1.active)
end
# If redirect_to_first_subtab is enabled, replace the tab's path with the first subtab's path
defp maybe_redirect_to_first_subtab(%{redirect_to_first_subtab: true} = tab, [first_subtab | _]) do
%{tab | path: first_subtab.path}
end
defp maybe_redirect_to_first_subtab(tab, _subtabs), do: tab
# Check if context selector should show at a specific position
defp show_context_selector_at?(false, _config, _position), do: false
defp show_context_selector_at?(_show, nil, _position), do: false
defp show_context_selector_at?(_show, %{enabled: false}, _position), do: false
defp show_context_selector_at?(true, %{position: :sidebar, sub_position: :start}, :start),
do: true
defp show_context_selector_at?(_, _, _), do: false
# Check if context selector should show with priority (among tabs)
defp show_context_selector_with_priority?(false, _config), do: false
defp show_context_selector_with_priority?(_show, nil), do: false
defp show_context_selector_with_priority?(_show, %{enabled: false}), do: false
defp show_context_selector_with_priority?(true, %{
position: :sidebar,
sub_position: {:priority, _}
}),
do: true
defp show_context_selector_with_priority?(_, _), do: false
# Get the priority value for the context selector
defp get_context_selector_priority(%{sub_position: {:priority, n}}), do: n
defp get_context_selector_priority(_), do: nil
end