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 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""" """ 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) 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"""
<%!-- 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, level: :user) |> 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 # 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"""
""" 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 --%>
  • <.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 %> {@tab.label} <%= if @tab.badge do %> <% end %>
  • <%!-- Subtabs (indented, smaller) --%> <%= if @has_subtabs and @show_subtabs do %> <%= for subtab <- @subtabs do %>
  • <.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 %> {subtab.label} <%= if subtab.badge do %> <% end %>
  • <% 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: Routes.path(path, locale: :none) 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