Packages

phoenix_kit

2.37.2
2.60.3 2.60.2 2.60.1 2.60.0 2.59.0 2.58.0 2.57.1 2.57.0 2.56.1 2.56.0 2.55.1 2.55.0 2.54.2 2.54.1 2.54.0 2.53.0 2.52.2 2.52.1 2.52.0 2.51.0 2.50.0 2.49.1 2.49.0 2.48.0 2.47.0 2.46.0 2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 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 language_switcher.ex
Raw

lib/phoenix_kit_web/components/language_switcher.ex

defmodule PhoenixKitWeb.Components.LanguageSwitcher do
  @moduledoc """
  Shared language switcher component for PhoenixKit.

  A configurable row of language options that works across all contexts:
  admin form tabs, publishing editors, public page navigation, etc.

  ## Display modes

  - `:auto` (default) — shows full names when ≤ `auto_threshold` languages,
    short codes when more
  - `:full` — always show full names (e.g., "English", "French")
  - `:compact` — always show short codes (e.g., "EN", "FR")

  ## Visual variants

  - `:inline` (default) — pipe-separated items: `EN | FR | ES`
  - `:tabs` — pill-shaped buttons on a rounded background bar
  - `:pills` — individual pill-shaped chips, each with its own background

  ## Interaction modes

  - **Button** — set `on_click` (event name) or `on_click_js` (fn returning `%JS{}`)
  - **Link** — each language map has a `:url` key, renders `<.link navigate={url}>`
  - **Display-only** — no click handler and no URL, renders a `<span>`

  ## Status dots

  When `show_status` is true, a colored dot appears before each language code.
  The dot color is resolved in priority order:

  1. `dot_color` — explicit daisyUI color class (e.g., `"success"`, `"warning"`)
  2. `status` — mapped automatically: `"published"` → green, `"draft"` → yellow,
     `"archived"` → gray
  3. `exists` — `true` → green dot, `false` → dim dot

  This makes the dots work for any use case: publishing post status, form content
  indicators ("has translations"), or custom per-module states.

  ## Examples

      <%!-- Admin form: tabs with primary star, skeleton switching --%>
      <.language_switcher
        languages={@language_tabs}
        current_language={@current_lang}
        on_click_js={&switch_lang_js(&1, @current_lang)}
        show_primary={true}
        primary_divider={true}
        variant={:tabs}
      />

      <%!-- Publishing editor: compact codes with status dots --%>
      <.language_switcher
        languages={@editor_languages}
        current_language={@current_language}
        on_click_js={&switch_lang_js(&1, @current_language)}
        show_status={true}
        show_add={true}
        primary_divider={true}
      />

      <%!-- Public page: navigation links, no dots --%>
      <.language_switcher
        languages={@translations}
        current_language={@current_language}
      />

      <%!-- Post overview: pills with status dots and "Primary" label --%>
      <.language_switcher
        languages={@post_languages}
        current_language={@current_language}
        show_status={true}
        show_primary_label={true}
        variant={:pills}
        prefix_urls={true}
      />

      <%!-- Form with content indicators --%>
      <.language_switcher
        languages={Enum.map(@language_tabs, &Map.put(&1, :exists, has_content?(&1.code)))}
        current_language={@current_lang}
        on_click_js={&switch_lang_js(&1, @current_lang)}
        show_status={true}
        variant={:tabs}
      />
  """

  use Phoenix.Component
  use Gettext, backend: PhoenixKitWeb.Gettext

  alias PhoenixKit.Utils.Routes

  @doc """
  Renders a language switcher.

  ## Language map structure

  Each item in `:languages` should be a map with:

  - `code` (required) — language code (e.g., "en-US", "fr")
  - `name` — full display name (e.g., "English"). Falls back to uppercased code.
  - `short_code` — short display code (e.g., "EN"). Auto-derived from code if absent.
  - `flag` — flag emoji (e.g., "🇺🇸")
  - `url` — navigation URL (enables link mode)
  - `is_primary` — boolean, marks the primary language
  - `status` — "published", "draft", "archived", or nil (for dot color)
  - `exists` — boolean, whether content exists (inferred from status if absent)
  - `dot_color` — explicit daisyUI color class override. Valid values:
    `"success"`, `"warning"`, `"error"`, `"info"`, `"primary"`, `"secondary"`,
    `"accent"`, `"neutral"`, `"base-content/20"`, `"base-content/40"`.
    Invalid values are silently ignored (falls back to status/exists color).
  - `enabled` — boolean, whether this language is enabled in the system (default: true)
  - `known` — boolean, whether this language code is recognized (default: true)
  - `uuid` — optional ID, forwarded as `phx-value-uuid` in button mode

  ## Attributes

  - `languages` — list of language maps (required)
  - `current_language` — currently active language code
  - `display` — `:auto`, `:full`, or `:compact`. Default: `:auto`
  - `auto_threshold` — show full names when language count ≤ this. Default: 3
  - `show_status` — show status indicator dots. Default: false
  - `show_flags` — show flag emojis. Default: false
  - `show_primary` — show star icon on primary language. Default: false
  - `show_primary_label` — show "Primary" text label on primary language. Default: false
  - `show_add` — style missing languages as addable (green). Default: false
  - `exclude_primary` — exclude the primary language from the list. Default: false
  - `primary_divider` — show a vertical divider after the primary language. Default: false
  - `on_click` — event name for button click. Default: nil
  - `on_click_js` — `fn(lang_code) -> %JS{}` for custom click. Default: nil
  - `phx_target` — target for phx-click. Default: nil
  - `variant` — `:inline`, `:tabs`, or `:pills`. Default: `:inline`
  - `size` — `:xs`, `:sm`, or `:md`. Default: `:sm`
  - `prefix_urls` — when true, URLs in language maps are passed through
    `PhoenixKit.Utils.Routes.path/1` for prefix-aware routing. Default: false
  - `id` — optional HTML id for the container element. Default: nil
  - `class` — additional CSS classes. Default: ""

  ## Notes

  - `auto_threshold` is evaluated against the *displayed* language count
    (after filtering and `exclude_primary`), so excluding the primary
    language may change the display mode.
  - `on_click_js` must be a pure function returning `%Phoenix.LiveView.JS{}`
    — it is called during render.
  """
  attr :languages, :list, required: true
  attr :current_language, :string, default: nil
  attr :display, :atom, default: :auto, values: [:auto, :full, :compact]
  attr :auto_threshold, :integer, default: 3
  attr :show_status, :boolean, default: false
  attr :show_flags, :boolean, default: false
  attr :show_primary, :boolean, default: false
  attr :show_primary_label, :boolean, default: false
  attr :show_add, :boolean, default: false
  attr :exclude_primary, :boolean, default: false
  attr :primary_divider, :boolean, default: false
  attr :on_click, :string, default: nil
  attr :on_click_js, :any, default: nil
  attr :phx_target, :any, default: nil
  attr :variant, :atom, default: :inline, values: [:inline, :tabs, :pills]
  attr :size, :atom, default: :sm, values: [:xs, :sm, :md]
  attr :prefix_urls, :boolean, default: false
  attr :id, :string, default: nil
  attr :class, :string, default: ""

  def language_switcher(assigns) do
    assigns = coerce_attrs(assigns)
    validate_config(assigns)
    languages = filter_languages(assigns.languages, assigns.show_status)

    languages =
      if assigns.exclude_primary,
        do: Enum.reject(languages, &lang_primary?/1),
        else: languages

    languages =
      if assigns.prefix_urls,
        do: prefix_language_urls(languages),
        else: languages

    use_full_names =
      case assigns.display do
        :full -> true
        :compact -> false
        :auto -> length(languages) <= assigns.auto_threshold
      end

    last_idx = max(length(languages) - 1, 0)

    # Precompute which indices get a divider after them, so we don't need
    # Enum.at/2 lookups during rendering.
    divider_indices =
      if assigns.primary_divider do
        languages
        |> Enum.with_index()
        |> Enum.filter(fn {lang, idx} -> show_divider?(true, lang, idx, last_idx) end)
        |> Enum.map(fn {_lang, idx} -> idx end)
        |> MapSet.new()
      else
        MapSet.new()
      end

    assigns =
      assigns
      |> assign(:filtered_languages, languages)
      |> assign(:use_full_names, use_full_names)
      |> assign(:last_idx, last_idx)
      |> assign(:divider_indices, divider_indices)

    ~H"""
    <div
      :if={show_switcher?(@filtered_languages, @variant)}
      id={@id}
      role={if @variant == :tabs, do: "tablist"}
      class={switcher_container_class(@variant, @size, @class)}
    >
      <%= for {lang, idx} <- Enum.with_index(@filtered_languages) do %>
        <span
          :if={
            idx > 0 && (idx - 1) not in @divider_indices && show_separator?(@variant, @use_full_names)
          }
          class={["text-base-content/30", size_text_class(@size)]}
        >
          |
        </span>
        <.switcher_item
          lang={lang}
          current_language={@current_language}
          use_full_names={@use_full_names}
          show_status={@show_status}
          show_flags={@show_flags}
          show_primary={@show_primary}
          show_primary_label={@show_primary_label}
          show_add={@show_add}
          on_click={@on_click}
          on_click_js={@on_click_js}
          phx_target={@phx_target}
          variant={@variant}
          size={@size}
        />
        <span
          :if={idx in @divider_indices}
          class="w-px h-4 bg-base-content/20 self-center"
        />
      <% end %>
    </div>
    """
  end

  # ── Item rendering ─────────────────────────────────────────────

  attr :lang, :map, required: true
  attr :current_language, :string, default: nil
  attr :use_full_names, :boolean, required: true
  attr :show_status, :boolean, default: false
  attr :show_flags, :boolean, default: false
  attr :show_primary, :boolean, default: false
  attr :show_primary_label, :boolean, default: false
  attr :show_add, :boolean, default: false
  attr :on_click, :string, default: nil
  attr :on_click_js, :any, default: nil
  attr :phx_target, :any, default: nil
  attr :variant, :atom, default: :inline
  attr :size, :atom, default: :sm

  defp switcher_item(assigns) do
    lang = assigns.lang
    is_current = assigns.current_language == lang_code(lang)
    exists = lang_exists?(lang)
    is_primary = lang_primary?(lang)
    enabled = lang_enabled?(lang)
    known = lang_known?(lang)
    status = lang_status(lang)

    assigns =
      assigns
      |> assign(:is_current, is_current)
      |> assign(:exists, exists)
      |> assign(:is_primary, is_primary)
      |> assign(:enabled, enabled)
      |> assign(:known, known)
      |> assign(:status, status)
      |> assign(:code, lang_code(lang))
      |> assign(:uuid, lang_field(lang, :uuid))
      |> assign(:url, lang_field(lang, :url))
      |> assign(:flag, lang_field(lang, :flag))
      |> assign(:item_title, build_title(lang, exists, enabled, known, assigns.show_status))
      |> assign(:resolved_click_js, resolve_click_js(assigns.on_click_js, lang_code(lang)))

    ~H"""
    <%= if @on_click_js || @on_click do %>
      <button
        type="button"
        role={if @variant == :tabs, do: "tab"}
        phx-click={@resolved_click_js || @on_click}
        phx-value-language={unless @on_click_js, do: @code}
        phx-value-uuid={unless @on_click_js, do: @uuid}
        phx-value-status={unless @on_click_js, do: @status}
        phx-target={@phx_target}
        class={[
          "cursor-pointer"
          | item_classes(
              @is_current,
              @exists,
              @is_primary,
              @show_add,
              @variant,
              @size,
              @enabled,
              @known
            )
        ]}
        title={@item_title}
        aria-pressed={to_string(@is_current)}
      >
        <.item_content {item_content_assigns(assigns)} />
      </button>
    <% else %>
      <%= if @url do %>
        <.link
          navigate={@url}
          class={[
            "cursor-pointer"
            | item_classes(
                @is_current,
                @exists,
                @is_primary,
                @show_add,
                @variant,
                @size,
                @enabled,
                @known
              )
          ]}
          title={@item_title}
          aria-current={@is_current && "true"}
        >
          <.item_content {item_content_assigns(assigns)} />
        </.link>
      <% else %>
        <span
          class={
            display_only_classes(
              item_classes(
                @is_current,
                @exists,
                @is_primary,
                @show_add,
                @variant,
                @size,
                @enabled,
                @known
              )
            )
          }
          title={@item_title}
          aria-current={@is_current && "true"}
        >
          <.item_content {item_content_assigns(assigns)} />
        </span>
      <% end %>
    <% end %>
    """
  end

  # Extracts the assigns needed by item_content to avoid repeating them 3 times.
  defp item_content_assigns(assigns) do
    Map.take(assigns, [
      :lang,
      :is_current,
      :exists,
      :is_primary,
      :enabled,
      :known,
      :status,
      :flag,
      :use_full_names,
      :show_status,
      :show_flags,
      :show_primary,
      :show_primary_label,
      :variant,
      :size
    ])
  end

  # ── Item content (dot + flag + label + star/label) ─────────────

  attr :lang, :map, required: true
  attr :is_current, :boolean, required: true
  attr :exists, :boolean, required: true
  attr :is_primary, :boolean, required: true
  attr :enabled, :boolean, required: true
  attr :known, :boolean, required: true
  attr :status, :string, default: nil
  attr :flag, :string, default: nil
  attr :use_full_names, :boolean, required: true
  attr :show_status, :boolean, required: true
  attr :show_flags, :boolean, required: true
  attr :show_primary, :boolean, required: true
  attr :show_primary_label, :boolean, required: true
  attr :variant, :atom, required: true
  attr :size, :atom, required: true

  defp item_content(assigns) do
    ~H"""
    <span class="inline-flex items-center gap-1">
      <span :if={@show_status} class={dot_classes(@lang, @exists, @size)} />
      <span :if={@show_flags && @flag} class={flag_size_class(@size)}>
        {@flag}
      </span>
      <span class={
        label_classes(
          @exists,
          @status,
          @is_current,
          @is_primary,
          @enabled,
          @known,
          @variant,
          @size
        )
      }>
        {display_label(@lang, @use_full_names)}
      </span>
      <.primary_star :if={@show_primary && @is_primary} size={@size} />
      <span
        :if={@show_primary_label && @is_primary}
        class="text-xs text-primary/70"
      >
        {gettext("Primary")}
      </span>
    </span>
    """
  end

  attr :size, :atom, required: true

  defp primary_star(assigns) do
    ~H"""
    <svg
      xmlns="http://www.w3.org/2000/svg"
      viewBox="0 0 24 24"
      fill="currentColor"
      class={["text-primary", star_size_class(@size)]}
    >
      <path
        fill-rule="evenodd"
        d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z"
        clip-rule="evenodd"
      />
    </svg>
    """
  end

  # ── Language map accessors ─────────────────────────────────────

  @doc false
  def lang_code(%{code: code}), do: code
  def lang_code(%{"code" => code}), do: code
  def lang_code(_), do: nil

  defp lang_exists?(%{exists: exists}) when is_boolean(exists), do: exists
  defp lang_exists?(%{"exists" => exists}) when is_boolean(exists), do: exists
  defp lang_exists?(%{status: status}) when is_binary(status), do: true
  defp lang_exists?(%{"status" => status}) when is_binary(status), do: true
  defp lang_exists?(_), do: false

  defp lang_primary?(%{is_primary: val}) when is_boolean(val), do: val
  defp lang_primary?(%{"is_primary" => val}) when is_boolean(val), do: val
  defp lang_primary?(_), do: false

  defp lang_enabled?(%{enabled: val}) when is_boolean(val), do: val
  defp lang_enabled?(%{"enabled" => val}) when is_boolean(val), do: val
  defp lang_enabled?(_), do: true

  defp lang_known?(%{known: val}) when is_boolean(val), do: val
  defp lang_known?(%{"known" => val}) when is_boolean(val), do: val
  defp lang_known?(_), do: true

  # Prepends cursor-default and strips hover: classes for display-only spans.
  # Preserves title tooltip (unlike pointer-events-none) while removing
  # visual interactivity cues.
  defp display_only_classes(classes) when is_list(classes) do
    ["cursor-default" | Enum.map(classes, &strip_hover/1)]
  end

  defp strip_hover(class) when is_binary(class) do
    class
    |> String.split(" ")
    |> Enum.reject(&String.starts_with?(&1, "hover:"))
    |> Enum.join(" ")
  end

  defp strip_hover(other), do: other

  # Generic field accessor for optional fields (uuid, url, flag, etc.)
  defp lang_field(lang, key) when is_map(lang) and is_atom(key) do
    lang[key] || lang[Atom.to_string(key)]
  end

  defp lang_field(_lang, _key), do: nil

  # Safely invoke on_click_js function, returning nil on error.
  defp resolve_click_js(nil, _code), do: nil

  defp resolve_click_js(fun, code) when is_function(fun, 1) do
    fun.(code)
  rescue
    _ -> nil
  end

  defp resolve_click_js(_invalid, _code), do: nil

  # ── Display helpers ────────────────────────────────────────────

  defp display_label(lang, true = _full_names) when is_map(lang) do
    lang[:name] || lang["name"] || format_short_code(lang)
  end

  defp display_label(lang, false = _compact) when is_map(lang) do
    format_short_code(lang)
  end

  defp format_short_code(lang) when is_map(lang) do
    code =
      lang[:short_code] || lang["short_code"] ||
        lang[:display_code] || lang["display_code"] ||
        derive_short_code(lang)

    if is_binary(code), do: String.upcase(code), else: code
  end

  defp derive_short_code(lang) do
    case lang[:code] || lang["code"] do
      code when is_binary(code) -> code |> String.split("-") |> List.first() |> String.upcase()
      _ -> "?"
    end
  end

  defp prefix_language_urls(languages) do
    Enum.map(languages, fn lang ->
      case lang_field(lang, :url) do
        nil ->
          lang

        url when is_binary(url) ->
          try do
            Map.put(lang, :url, Routes.path(url))
          rescue
            _ -> lang
          end

        _ ->
          lang
      end
    end)
  end

  defp filter_languages(languages, true = _show_status), do: languages

  # When show_status is false (public mode), only show languages with published content.
  # Languages without exists/status fields pass through — they're UI tabs, not content entries.
  defp filter_languages(languages, false = _public) do
    Enum.filter(languages, fn lang ->
      has_content_fields =
        Map.has_key?(lang, :exists) || Map.has_key?(lang, "exists") ||
          Map.has_key?(lang, :status) || Map.has_key?(lang, "status")

      if has_content_fields do
        lang_exists?(lang) && lang_status(lang) in [nil, "published"]
      else
        true
      end
    end)
  end

  defp lang_status(%{status: status}), do: status
  defp lang_status(%{"status" => status}), do: status
  defp lang_status(_), do: nil

  # Pills can render a single language (useful for status display).
  # Inline and tabs need at least 2 to be meaningful.
  defp show_switcher?([], _variant), do: false
  defp show_switcher?(_languages, :pills), do: true
  defp show_switcher?([_single], _variant), do: false
  defp show_switcher?(_languages, _variant), do: true

  # Pipe separators: always for inline, only in compact mode for tabs, never for pills.
  defp show_separator?(:inline, _use_full_names), do: true
  defp show_separator?(:tabs, use_full_names), do: !use_full_names
  defp show_separator?(:pills, _use_full_names), do: false

  defp show_divider?(true, lang, idx, last_idx) do
    lang_primary?(lang) && idx < last_idx
  end

  # ── Styling ────────────────────────────────────────────────────

  defp switcher_container_class(:inline, size, extra) do
    ["inline-flex items-center flex-wrap", size_gap_class(size), extra]
  end

  defp switcher_container_class(:tabs, _size, extra) do
    [
      "inline-flex flex-wrap items-center gap-1 p-1 bg-base-200 rounded-box",
      extra
    ]
  end

  defp switcher_container_class(:pills, _size, extra) do
    ["flex flex-wrap gap-2", extra]
  end

  defp item_classes(is_current, exists, is_primary, show_add, variant, size, enabled, known) do
    case variant do
      :pills ->
        pill_classes(is_current, is_primary, exists, show_add, size, enabled, known)

      other ->
        base = [
          "inline-flex items-center rounded transition-all",
          size_padding_class(size)
        ]

        state =
          case other do
            :tabs -> tab_state_class(is_current)
            :inline -> inline_state_class(is_current, exists, show_add, enabled, known)
          end

        base ++ [state]
    end
  end

  defp pill_classes(is_current, is_primary, exists, show_add, size, enabled, known) do
    degraded = !enabled or !known

    base = [
      "inline-flex items-center gap-1.5 rounded-lg transition-all",
      pill_padding_class(size)
    ]

    state = pill_state(is_current, is_primary, exists, show_add, degraded)
    base ++ [state]
  end

  defp pill_state(_current, true = _primary, _exists, _add, true = _degraded),
    do: "bg-primary/10 border border-primary/20 opacity-60"

  defp pill_state(_current, true = _primary, _exists, _add, false),
    do: "bg-primary/10 border border-primary/20 hover:bg-primary/20"

  defp pill_state(true = _current, _primary, _exists, _add, true = _degraded),
    do: "bg-base-300 border border-base-content/20 opacity-60"

  defp pill_state(_current, _primary, _exists, _add, true = _degraded),
    do: "bg-base-200/50 border border-base-content/10 opacity-60"

  defp pill_state(true = _current, _primary, _exists, _add, _degraded),
    do: "bg-base-300 border border-base-content/20"

  defp pill_state(_current, _primary, false = _exists, true = _add, _degraded),
    do: "bg-success/5 border border-dashed border-success/20 hover:bg-success/10"

  defp pill_state(_current, _primary, false = _exists, _add, _degraded),
    do: "bg-base-200/50 border border-dashed border-base-content/10 hover:bg-base-200"

  defp pill_state(_current, _primary, _exists, _add, _degraded),
    do: "bg-base-200 hover:bg-base-300"

  defp pill_padding_class(:xs), do: "px-2 py-1"
  defp pill_padding_class(:sm), do: "px-3 py-1.5"
  defp pill_padding_class(:md), do: "px-4 py-2"

  defp tab_state_class(true = _current), do: "bg-primary/20 text-primary font-semibold shadow-sm"
  defp tab_state_class(false), do: "hover:bg-base-100/50"

  defp inline_state_class(is_current, exists, show_add, enabled, known) do
    cond do
      is_current and (!enabled or !known) ->
        "bg-base-content/30 text-base-content/50 font-semibold"

      !enabled or !known ->
        "text-base-content/40 hover:bg-base-200/50"

      is_current ->
        "bg-primary/30 text-primary font-semibold"

      !exists && show_add ->
        "text-success hover:bg-success/10"

      exists ->
        "hover:bg-base-200"

      true ->
        "text-base-content/40"
    end
  end

  defp label_classes(exists, status, is_current, is_primary, enabled, known, variant, size) do
    base = [size_text_class(size)]

    weight =
      cond do
        is_primary -> "font-bold"
        is_current -> "font-semibold"
        true -> "font-medium"
      end

    decoration = if !enabled or !known, do: "line-through", else: nil
    color = label_color(exists, status, is_current, variant)

    Enum.filter(base ++ [weight, decoration, color], & &1)
  end

  # In :tabs and :pills variants, text color is neutral (controlled by container/pill bg).
  # In :inline variant, text color reflects content status.
  defp label_color(_exists, _status, true = _current, _variant), do: nil
  defp label_color(_exists, _status, _current, :tabs), do: nil
  defp label_color(_exists, _status, _current, :pills), do: nil

  defp label_color(exists, status, false, :inline) do
    cond do
      !exists -> "text-base-content/40"
      status == "published" -> "text-success"
      status == "draft" -> "text-warning"
      status == "archived" -> "text-base-content/40"
      true -> nil
    end
  end

  # Dot color resolution: dot_color > status > exists
  defp dot_classes(lang, exists, size) do
    base = ["rounded-full inline-block", dot_size_class(size)]
    color = resolve_dot_color(lang, exists)
    base ++ [color]
  end

  @valid_dot_colors ~w(success warning error info primary secondary accent neutral base-content/20 base-content/40)

  defp resolve_dot_color(lang, exists) when is_map(lang) do
    explicit = lang[:dot_color] || lang["dot_color"]

    if explicit && explicit in @valid_dot_colors do
      "bg-#{explicit}"
    else
      status_dot_color(lang_status(lang), exists)
    end
  end

  defp status_dot_color("published", _exists), do: "bg-success"
  defp status_dot_color("draft", _exists), do: "bg-warning"
  defp status_dot_color("archived", _exists), do: "bg-base-content/40"
  defp status_dot_color(_status, true), do: "bg-success"
  defp status_dot_color(_status, false), do: "bg-base-content/20"
  defp status_dot_color(_status, _exists), do: "bg-base-content/20"

  # ── Size classes ───────────────────────────────────────────────

  defp dot_size_class(:xs), do: "w-1.5 h-1.5"
  defp dot_size_class(:sm), do: "w-2 h-2"
  defp dot_size_class(:md), do: "w-2.5 h-2.5"

  defp size_text_class(:xs), do: "text-xs"
  defp size_text_class(:sm), do: "text-sm"
  defp size_text_class(:md), do: "text-base"

  defp size_padding_class(:xs), do: "px-1 py-0.5"
  defp size_padding_class(:sm), do: "px-1.5 py-0.5"
  defp size_padding_class(:md), do: "px-2 py-1"

  defp size_gap_class(:xs), do: "gap-0.5"
  defp size_gap_class(:sm), do: "gap-1"
  defp size_gap_class(:md), do: "gap-1.5"

  defp star_size_class(:xs), do: "w-2.5 h-2.5"
  defp star_size_class(:sm), do: "w-3 h-3"
  defp star_size_class(:md), do: "w-3.5 h-3.5"

  defp flag_size_class(:xs), do: "text-sm"
  defp flag_size_class(:sm), do: "text-base"
  defp flag_size_class(:md), do: "text-lg"

  # ── Titles / tooltips ──────────────────────────────────────────

  defp build_title(lang, exists, enabled, known, show_status) when is_map(lang) do
    name = lang[:name] || lang["name"] || derive_short_code(lang)

    if show_status do
      build_status_title(name, exists, lang_status(lang), enabled, known)
    else
      name
    end
  end

  defp build_status_title(name, _exists, _status, _enabled, false = _known) do
    gettext("%{language} (Unknown language)", language: name)
  end

  defp build_status_title(name, exists, status, false = _enabled, _known) do
    if exists do
      status_text = status_label(status)
      gettext("%{language} (Disabled — %{status})", language: name, status: status_text)
    else
      gettext("Add %{language} translation", language: name)
    end
  end

  defp build_status_title(name, false = _exists, _status, _enabled, _known) do
    gettext("Add %{language} translation", language: name)
  end

  defp build_status_title(name, true, "published", _enabled, _known) do
    gettext("%{language} (Published)", language: name)
  end

  defp build_status_title(name, true, "draft", _enabled, _known) do
    gettext("%{language} (Draft)", language: name)
  end

  defp build_status_title(name, true, "archived", _enabled, _known) do
    gettext("%{language} (Archived)", language: name)
  end

  defp build_status_title(name, _, _, _, _), do: name

  defp status_label("published"), do: gettext("Published")
  defp status_label("draft"), do: gettext("Draft")
  defp status_label("archived"), do: gettext("Archived")
  defp status_label(_), do: gettext("Unknown")

  # ── Safe coercion ───────────────────────────────────────────────
  # Ensures invalid attr values don't crash the render. Replaces bad values
  # with safe defaults so the component always produces valid HTML.

  defp coerce_attrs(assigns) do
    assigns
    |> coerce_attr(:languages, &is_list/1, [])
    |> coerce_languages()
    |> coerce_attr(:variant, &(&1 in [:inline, :tabs, :pills]), :inline)
    |> coerce_attr(:size, &(&1 in [:xs, :sm, :md]), :sm)
    |> coerce_attr(:display, &(&1 in [:auto, :full, :compact]), :auto)
    |> coerce_attr(:auto_threshold, &(is_integer(&1) and &1 >= 1), 3)
    |> coerce_on_click_js()
  end

  # Filter out non-map items from the languages list so they never reach switcher_item.
  defp coerce_languages(assigns) do
    languages = assigns.languages

    if Enum.all?(languages, &is_map/1) do
      assigns
    else
      assign(assigns, :languages, Enum.filter(languages, &is_map/1))
    end
  end

  defp coerce_attr(assigns, key, valid_fn, default) do
    if valid_fn.(assigns[key]), do: assigns, else: assign(assigns, key, default)
  end

  defp coerce_on_click_js(assigns) do
    case assigns.on_click_js do
      nil -> assigns
      fun when is_function(fun, 1) -> assigns
      _ -> assign(assigns, :on_click_js, nil)
    end
  end

  # ── Configuration validation ───────────────────────────────────
  # Logs warnings for conflicting or nonsensical attribute combinations.
  # Never crashes — the component renders with sensible precedence regardless.

  require Logger

  defp validate_config(assigns) do
    if Application.get_env(:phoenix_kit, :env) != :prod do
      do_validate_config(assigns)
    end
  end

  defp do_validate_config(assigns) do
    validate_attr_types(assigns)
    validate_click_conflict(assigns)
    validate_exclude_primary_conflicts(assigns)
    validate_show_add(assigns)
    validate_pills_divider(assigns)
    validate_language_codes(assigns)
  end

  @valid_variants [:inline, :tabs, :pills]
  @valid_sizes [:xs, :sm, :md]
  @valid_displays [:auto, :full, :compact]

  defp validate_attr_types(assigns) do
    unless is_list(assigns.languages) do
      Logger.warning(
        "language_switcher: languages must be a list, got #{inspect(assigns.languages)}. " <>
          "Defaulting to empty list."
      )
    end

    unless assigns.variant in @valid_variants do
      Logger.warning(
        "language_switcher: invalid variant #{inspect(assigns.variant)}. " <>
          "Valid values: #{inspect(@valid_variants)}. Falling back to :inline."
      )
    end

    unless assigns.size in @valid_sizes do
      Logger.warning(
        "language_switcher: invalid size #{inspect(assigns.size)}. " <>
          "Valid values: #{inspect(@valid_sizes)}. Falling back to :sm."
      )
    end

    unless assigns.display in @valid_displays do
      Logger.warning(
        "language_switcher: invalid display #{inspect(assigns.display)}. " <>
          "Valid values: #{inspect(@valid_displays)}. Falling back to :auto."
      )
    end

    if assigns.on_click_js && !is_function(assigns.on_click_js, 1) do
      Logger.warning(
        "language_switcher: on_click_js must be a 1-arity function, " <>
          "got #{inspect(assigns.on_click_js)}. It will be ignored."
      )
    end

    if assigns.on_click && !is_binary(assigns.on_click) do
      Logger.warning(
        "language_switcher: on_click must be a string event name, " <>
          "got #{inspect(assigns.on_click)}. It may not work as expected."
      )
    end

    if assigns.auto_threshold &&
         (!is_integer(assigns.auto_threshold) || assigns.auto_threshold < 1) do
      Logger.warning(
        "language_switcher: auto_threshold must be a positive integer, " <>
          "got #{inspect(assigns.auto_threshold)}. Falling back to 3."
      )
    end

    validate_language_maps(assigns.languages)
  end

  defp validate_language_maps(languages) when is_list(languages) do
    non_maps = Enum.count(languages, fn lang -> !is_map(lang) end)

    if non_maps > 0 do
      Logger.warning(
        "language_switcher: #{non_maps} item(s) in languages list are not maps. " <>
          "Each language must be a map with at least a :code key."
      )
    end
  end

  defp validate_language_maps(_), do: :ok

  defp validate_click_conflict(%{on_click: on_click, on_click_js: on_click_js})
       when not is_nil(on_click) and not is_nil(on_click_js) do
    Logger.warning(
      "language_switcher: both on_click and on_click_js are set. " <>
        "on_click_js takes precedence; on_click is used as fallback only if on_click_js returns nil."
    )
  end

  defp validate_click_conflict(_assigns), do: :ok

  defp validate_exclude_primary_conflicts(%{exclude_primary: true} = assigns) do
    if assigns.primary_divider do
      Logger.warning(
        "language_switcher: exclude_primary and primary_divider are both set. " <>
          "The divider has no effect since the primary language is excluded."
      )
    end

    if assigns.show_primary || assigns.show_primary_label do
      Logger.warning(
        "language_switcher: exclude_primary is set with show_primary or show_primary_label. " <>
          "Primary indicators have no effect since the primary language is excluded."
      )
    end
  end

  defp validate_exclude_primary_conflicts(_assigns), do: :ok

  defp validate_show_add(%{show_add: true} = assigns) do
    if !assigns.show_status do
      Logger.warning(
        "language_switcher: show_add is set but show_status is false. " <>
          "Non-existing languages are filtered out when show_status is false, " <>
          "so show_add styling will never be visible. Set show_status={true} to show addable languages."
      )
    end

    if !assigns.on_click && !assigns.on_click_js do
      has_urls = Enum.any?(assigns.languages, fn lang -> lang_field(lang, :url) != nil end)

      unless has_urls do
        Logger.warning(
          "language_switcher: show_add is set but no interaction mode is configured " <>
            "(no on_click, on_click_js, or url keys). " <>
            "Addable languages will be styled but not clickable."
        )
      end
    end
  end

  defp validate_show_add(_assigns), do: :ok

  defp validate_pills_divider(%{variant: :pills, primary_divider: true}) do
    Logger.warning(
      "language_switcher: primary_divider has no visual effect with variant :pills. " <>
        "Pills are separated by gap spacing, not dividers."
    )
  end

  defp validate_pills_divider(_assigns), do: :ok

  defp validate_language_codes(%{languages: languages}) do
    codeless_count =
      Enum.count(languages, fn lang -> lang_code(lang) == nil end)

    if codeless_count > 0 do
      Logger.warning(
        "language_switcher: #{codeless_count} language(s) in the list have no :code key. " <>
          "These items will render with \"?\" as the display code and nil for event values."
      )
    end
  end
end