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.{Presence, Registry, Tab} alias PhoenixKit.Utils.Routes alias PhoenixKitWeb.Components.Dashboard.TabItem # 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 """ 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 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) 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) ~H""" """ 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"""
<%!-- Group Header (if labeled) --%> <%= if @group[:label] do %>
<%= if @group[:icon] do %> <.icon name={@group[:icon]} class="w-3.5 h-3.5" /> <% end %> {@group[:label]} <%= if @group[:collapsible] do %> <.icon name={if @collapsed, do: "hero-chevron-right-mini", else: "hero-chevron-down-mini"} class="w-4 h-4" /> <% end %>
<% end %> <%!-- Group Tabs --%>
<%= for tab <- filter_top_level(@tabs) do %> <.tab_with_subtabs tab={tab} all_tabs={@tabs} viewer_counts={@viewer_counts} locale={@locale} compact={@compact} /> <% end %>
""" 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) show_subtabs = Tab.show_subtabs?(assigns.tab, assigns.tab.active) or any_subtab_active?(subtabs) assigns = assigns |> assign(:subtabs, subtabs) |> assign(:show_subtabs, show_subtabs) |> assign(:has_subtabs, subtabs != []) ~H"""
<%!-- Parent Tab --%> <%!-- Subtabs --%> <%= if @has_subtabs and @show_subtabs do %>
<%= for subtab <- @subtabs do %> <% end %>
<% end %>
""" 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, _, _} -> %> <% {: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) |> Enum.filter(&Tab.navigable?/1) |> Enum.take(assigns.max_tabs) assigns = assign(assigns, :tabs, tabs) ~H""" """ 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 %> <% 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 def mobile_fab_menu(assigns) do tabs = Registry.get_tabs_with_active(assigns.current_path, scope: assigns.scope) |> Enum.filter(&Tab.navigable?/1) assigns = assign(assigns, :tabs, tabs) ~H"""
""" end # Helper functions defp add_active_state(tabs, current_path) do Enum.map(tabs, fn tab -> Map.put(tab, :active, Tab.matches_path?(tab, current_path)) end) end defp group_tabs(tabs) do Enum.group_by(tabs, & &1.group) end defp sorted_groups(groups, grouped_tabs) do # Get groups that have tabs group_ids_with_tabs = Map.keys(grouped_tabs) |> Enum.reject(&is_nil/1) # Filter to groups that have tabs and sort by priority groups |> Enum.filter(&(&1.id in group_ids_with_tabs)) |> Enum.sort_by(& &1.priority) end defp get_overflow_tabs(scope, shown_count) do Registry.get_tabs(scope: scope) |> Enum.filter(&Tab.navigable?/1) |> Enum.drop(shown_count) end defp build_path(path, nil), do: path defp build_path(path, locale) do Routes.path(path, locale: locale) end # Filter to only top-level tabs (no parent) defp filter_top_level(tabs) do Enum.filter(tabs, &Tab.top_level?/1) end # Get subtabs for a given parent tab ID defp get_subtabs_for(parent_id, all_tabs) do Enum.filter(all_tabs, fn tab -> tab.parent == parent_id end) |> Enum.sort_by(& &1.priority) end # Check if any subtab is currently active defp any_subtab_active?(subtabs) do Enum.any?(subtabs, & &1.active) end # 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