defmodule PhoenixKitWeb.Components.Dashboard.TabItem do @moduledoc """ Tab item component for dashboard navigation. Renders individual tabs with support for: - Icons and labels - Badges and indicators - Active state highlighting - Attention animations - External links - Tooltips - Presence indicators """ use Phoenix.Component require Logger alias PhoenixKit.Dashboard.Tab alias PhoenixKit.Utils.Routes alias PhoenixKitWeb.Components.Dashboard.Badge, as: BadgeComponent # Use the icon component from Core.Icon to avoid circular dependencies import PhoenixKitWeb.Components.Core.Icon, only: [icon: 1] # Valid Tailwind padding-left classes that are typically included in builds @valid_tailwind_indents ~w( pl-0 pl-0.5 pl-1 pl-1.5 pl-2 pl-2.5 pl-3 pl-3.5 pl-4 pl-5 pl-6 pl-7 pl-8 pl-9 pl-10 pl-11 pl-12 pl-14 pl-16 pl-20 pl-24 pl-28 pl-32 pl-36 pl-40 pl-44 pl-48 pl-52 pl-56 pl-60 pl-64 pl-72 pl-80 pl-96 pl-px ) @doc """ Renders a dashboard tab item. ## Attributes - `tab` - The Tab struct - `active` - Whether this tab is currently active - `viewer_count` - Number of users viewing this tab (optional) - `locale` - Current locale for path generation - `compact` - Render in compact mode (icon only) - `class` - Additional CSS classes ## Examples <.tab_item tab={@tab} active={@tab.active} /> <.tab_item tab={@tab} active={true} viewer_count={3} /> """ attr :tab, :any, required: true attr :active, :boolean, default: false attr :viewer_count, :integer, default: 0 attr :locale, :string, default: nil attr :compact, :boolean, default: false attr :class, :string, default: "" attr :parent_tab, :any, default: nil def tab_item(assigns) do cond do Tab.divider?(assigns.tab) -> render_divider(assigns) Tab.group_header?(assigns.tab) -> render_group_header(assigns) true -> render_tab(assigns) end end defp render_tab(assigns) do path = build_path(assigns.tab.path, assigns.locale) is_subtab = Tab.subtab?(assigns.tab) # For subtabs, get style from parent_tab if provided, otherwise fall back to global defaults subtab_style = get_subtab_style(assigns.tab, assigns.parent_tab) # Get both class and optional inline style for subtab indent {tab_class, tab_style} = tab_classes(assigns.active, assigns.tab.attention, is_subtab, subtab_style, assigns.class) assigns = assigns |> assign(:path, path) |> assign(:is_subtab, is_subtab) |> assign(:subtab_style, subtab_style) |> assign(:tab_class, tab_class) |> assign(:tab_style, tab_style) ~H""" <%= if @tab.external do %> <.tab_content tab={@tab} active={@active} viewer_count={@viewer_count} compact={@compact} is_subtab={@is_subtab} subtab_style={@subtab_style} /> <%= if @tab.external do %> <.icon name="hero-arrow-top-right-on-square" class="w-3 h-3 ml-auto opacity-50" /> <% end %> <% else %> <.link navigate={@path} class={@tab_class} style={@tab_style} title={@tab.tooltip} data-tab-id={@tab.id} data-parent-id={@tab.parent} > <.tab_content tab={@tab} active={@active} viewer_count={@viewer_count} compact={@compact} is_subtab={@is_subtab} subtab_style={@subtab_style} /> <% end %> """ end defp render_divider(assigns) do ~H""" <%= if @tab.label do %>
{@tab.label}
<% else %>
<% end %> """ end defp render_group_header(assigns) do collapsible = assigns.tab.metadata[:collapsible] || false collapsed = assigns.tab.metadata[:collapsed] || false assigns = assigns |> assign(:collapsible, collapsible) |> assign(:collapsed, collapsed) ~H"""
<%= if @tab.icon do %> <.icon name={@tab.icon} class="w-3.5 h-3.5" /> <% end %> {@tab.label} <%= if @collapsible do %> <.icon name={if @collapsed, do: "hero-chevron-right", else: "hero-chevron-down"} class="w-3.5 h-3.5" /> <% end %>
""" end @doc """ Renders the inner content of a tab (icon, label, badge, presence). """ attr :tab, :any, required: true attr :active, :boolean, default: false attr :viewer_count, :integer, default: 0 attr :compact, :boolean, default: false attr :is_subtab, :boolean, default: false attr :subtab_style, :map, default: %{} def tab_content(assigns) do # Check if tab has wrap_label attribute or fall back to global setting wrap_label = Map.get(assigns.tab, :wrap_label) || PhoenixKit.Settings.get_setting_cached("shop_category_name_display", "truncate") == "wrap" assigns = assign(assigns, :wrap_label, wrap_label) ~H"""
<%= if icon_image_url = Map.get(@tab.metadata || %{}, :icon_image_url) do %> <% else %> <%= if @tab.icon do %> <.icon name={@tab.icon} class={icon_classes(@active, @tab.attention, @is_subtab, @subtab_style)} /> <% end %> <% end %> <%= unless @compact do %> {@tab.label} <% end %>
<%= if @tab.badge do %> <% end %> <%= if @viewer_count > 0 do %> <% end %>
""" end @doc """ Renders a mobile-friendly tab item for bottom navigation. """ attr :tab, :any, required: true attr :active, :boolean, default: false attr :locale, :string, default: nil attr :class, :string, default: "" def mobile_tab_item(assigns) do # Skip dividers and headers for mobile if Tab.navigable?(assigns.tab) do path = build_path(assigns.tab.path, assigns.locale) assigns = assign(assigns, :path, path) ~H""" <.link navigate={@path} class={mobile_tab_classes(@active, @tab.attention, @class)} data-tab-id={@tab.id} >
<%= if @tab.icon do %> <.icon name={@tab.icon} class={mobile_icon_classes(@active)} /> <% end %> <%= if @tab.badge && PhoenixKit.Dashboard.Badge.visible?(@tab.badge) do %> <% end %>
{@tab.label} """ else ~H"" end end # Helper functions # 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) when is_binary(path) do # Check if path already contains a locale prefix to avoid double locale if path_has_locale_prefix?(path) do Routes.path(path, locale: :none) else if admin_path?(path) do Routes.admin_path(path, locale) else Routes.path(path, locale: locale) end end end defp admin_path?(path), do: String.starts_with?(path, "/admin") @doc """ Checks if a path already contains a locale prefix (e.g., /uk/, /en/, /zh-Hans/). Returns true if the path starts with a locale pattern. """ def path_has_locale_prefix?(path) when is_binary(path) do # Matches: /uk/, /en/, /zh-Hans/, /pt-BR/ etc. String.match?(path, ~r/^\/[a-z]{2}(-[A-Z][a-z]{2,3})?\//u) end def path_has_locale_prefix?(_), do: false # Returns {class_string, inline_style} tuple # Supports both Tailwind classes and inline CSS values for subtab indent defp tab_classes(active, attention, is_subtab, subtab_style, extra_class) do base = "flex items-center py-2 text-sm font-medium rounded-lg transition-all duration-200" # Subtabs use configurable indent (default in :dashboard_subtab_style config) # Now supports inline CSS values (px, rem, em, %) in addition to Tailwind classes {padding_class, inline_style} = if is_subtab do case resolve_indent(subtab_style[:indent]) do {:class, class} -> {"#{class} pr-3", nil} {:style, style} -> {"pr-3", style} end else {"px-3", nil} end active_class = if active do if is_subtab do "bg-primary/80 text-primary-content" else "bg-primary text-primary-content" end else if is_subtab do "text-base-content/70 hover:bg-base-200 hover:text-base-content" else "text-base-content hover:bg-base-200" end end attention_class = attention_animation_class(attention) # Add subtab animation class if applicable animation_class = if is_subtab do subtab_animation_class(subtab_style[:animation]) else nil end class_string = [base, padding_class, active_class, attention_class, animation_class, extra_class] |> Enum.reject(&is_nil/1) |> Enum.join(" ") {class_string, inline_style} end # Resolves indent value to either a Tailwind class or inline style # Supports: Tailwind classes ("pl-4"), CSS values ("1.5rem", "24px"), integers (pixels), floats (rem) # Default indent is configured in :dashboard_subtab_style in config.ex defp resolve_indent(nil), do: {:class, "pl-3"} defp resolve_indent(value) when is_integer(value) do {:style, "padding-left: #{value}px"} end defp resolve_indent(value) when is_float(value) do {:style, "padding-left: #{value}rem"} end defp resolve_indent(value) when is_binary(value) do # Check if it contains CSS units (px, rem, em, %, vh, vw, ch, ex, etc.) if Regex.match?(~r/^[\d.]+\s*(px|rem|em|%|vh|vw|ch|ex|vmin|vmax)$/i, value) do {:style, "padding-left: #{value}"} else # Treat as Tailwind class unless value in @valid_tailwind_indents do Logger.warning( "[PhoenixKit] Subtab indent '#{value}' may not be a valid Tailwind class. " <> "Consider using inline CSS values (e.g., '1.5rem', '24px', or integer 24) " <> "to avoid Tailwind purging issues." ) end {:class, value} end end defp resolve_indent(_value), do: {:class, "pl-3"} defp icon_classes(_active, attention, is_subtab, subtab_style) do # Subtabs use configurable icon size, defaults to "w-4 h-4" base = if is_subtab do icon_size = subtab_style[:icon_size] || "w-4 h-4" "#{icon_size} shrink-0" else "w-5 h-5 shrink-0" end attention_class = case attention do :glow -> "drop-shadow-[0_0_8px_currentColor]" _ -> nil end [base, attention_class] |> Enum.reject(&is_nil/1) |> Enum.join(" ") end # Gets subtab style configuration with cascade: subtab -> parent -> global defaults # Priority: subtab's own style > parent tab's style > global config defp get_subtab_style(tab, parent_tab) do global_style = PhoenixKit.Config.get(:dashboard_subtab_style, []) %{ # Defaults come from :dashboard_subtab_style in config.ex indent: get_style_value(:subtab_indent, tab, parent_tab, global_style, :indent), icon_size: get_style_value(:subtab_icon_size, tab, parent_tab, global_style, :icon_size), text_size: get_style_value(:subtab_text_size, tab, parent_tab, global_style, :text_size), animation: get_style_value(:subtab_animation, tab, parent_tab, global_style, :animation) } end # Fallback chain: tab -> parent -> global config (defaults in config.ex) defp get_style_value(tab_key, tab, parent_tab, global_style, global_key) do Map.get(tab, tab_key) || (parent_tab && Map.get(parent_tab, tab_key)) || Keyword.get(global_style, global_key) end defp subtab_animation_class(nil), do: nil defp subtab_animation_class(:none), do: nil defp subtab_animation_class(:slide), do: "animate-slide-in-left" defp subtab_animation_class(:fade), do: "animate-fade-in" defp subtab_animation_class(:collapse), do: "animate-collapse-open" defp subtab_animation_class(_), do: nil defp mobile_tab_classes(active, attention, extra_class) do base = "flex flex-col items-center justify-center py-2 px-3 min-w-[4rem] transition-all" active_class = if active do "text-primary" else "text-base-content/60 hover:text-base-content" end attention_class = attention_animation_class(attention) [base, active_class, attention_class, extra_class] |> Enum.reject(&is_nil/1) |> Enum.join(" ") end defp mobile_icon_classes(active) do base = "w-6 h-6" active_class = if active, do: "text-primary", else: "" "#{base} #{active_class}" end defp attention_animation_class(nil), do: nil defp attention_animation_class(:pulse), do: "animate-pulse" defp attention_animation_class(:bounce), do: "animate-bounce" defp attention_animation_class(:shake), do: "animate-shake" defp attention_animation_class(:glow), do: "animate-glow" defp attention_animation_class(_), do: nil end