diff --git a/lib/modules/entities/web/data_form.ex b/lib/modules/entities/web/data_form.ex
index 17a5fb751..ab9323fc3 100644
--- a/lib/modules/entities/web/data_form.ex
+++ b/lib/modules/entities/web/data_form.ex
@@ -7,7 +7,8 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do
use PhoenixKitWeb, :live_view
on_mount PhoenixKit.Modules.Entities.Web.Hooks
- alias Phoenix.LiveView.JS
+ import PhoenixKitWeb.Components.MultilangForm
+
alias PhoenixKit.Modules.Entities
alias PhoenixKit.Modules.Entities.EntityData
alias PhoenixKit.Modules.Entities.Events
@@ -93,9 +94,7 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do
live_source = ensure_live_source(socket)
# Multilang state (driven by Languages module globally)
- multilang_enabled = Multilang.enabled?()
- primary_language = if multilang_enabled, do: Multilang.primary_language(), else: nil
- language_tabs = Multilang.build_language_tabs()
+ multilang_enabled = multilang_enabled?()
# Lazy re-key: if global primary changed since this record was saved,
# restructure data around the new primary language.
@@ -122,10 +121,7 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do
|> assign(:form_record_topic_key, normalize_record_key(form_record_key))
|> assign(:live_source, live_source)
|> assign(:has_unsaved_changes, false)
- |> assign(:multilang_enabled, multilang_enabled)
- |> assign(:primary_language, primary_language)
- |> assign(:current_lang, primary_language)
- |> assign(:language_tabs, language_tabs)
+ |> mount_multilang()
socket =
if connected?(socket) do
@@ -220,251 +216,42 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do
@impl true
def handle_event("switch_language", %{"lang" => lang_code}, socket) do
- enabled = Multilang.enabled_languages()
-
- if lang_code in enabled do
- {:noreply, assign(socket, :current_lang, lang_code)}
- else
- {:noreply, socket}
- end
+ {:noreply, handle_switch_language(socket, lang_code)}
end
def handle_event("validate", %{"phoenix_kit_entity_data" => data_params}, socket) do
if socket.assigns[:lock_owner?] do
- entity_uuid = socket.assigns.entity.uuid
- record_uuid = socket.assigns.data_record.uuid
- form_data = Map.get(data_params, "data", %{})
-
- data_params =
- if socket.assigns.data_record.uuid do
- data_params
- else
- data_params
- |> Map.put("created_by_uuid", socket.assigns.current_user.uuid)
- end
-
- data_params =
- if is_nil(record_uuid) do
- # Always use the primary language title for slug generation
- current_data = Ecto.Changeset.apply_changes(socket.assigns.changeset)
- previous_title = current_data.title || ""
-
- title =
- if data_params["title"] do
- data_params["title"]
- else
- # On secondary language tab, title param is absent — keep the existing title
- previous_title
- end
-
- current_slug = data_params["slug"] || ""
-
- auto_generated_slug =
- auto_generate_entity_slug(entity_uuid, record_uuid, previous_title)
-
- if current_slug == "" || current_slug == auto_generated_slug do
- Map.put(
- data_params,
- "slug",
- auto_generate_entity_slug(entity_uuid, record_uuid, title)
- )
- else
- data_params
- end
- else
- data_params
- end
-
- current_lang = socket.assigns[:current_lang]
-
- # Inject _title and _slug into form data so they flow through merge_multilang_data
- form_data =
- form_data
- |> inject_title_into_form_data(data_params, current_lang, socket.assigns)
- |> inject_slug_into_form_data(data_params, current_lang, socket.assigns)
-
- # On secondary language tabs, preserve primary-language fields that aren't in the form
- data_params = preserve_primary_fields(data_params, socket.assigns.changeset)
-
- case FormBuilder.validate_data(socket.assigns.entity, form_data, current_lang) do
- {:ok, validated_data} ->
- validated_data =
- validated_data
- |> inject_title_into_form_data(data_params, current_lang, socket.assigns)
- |> inject_slug_into_form_data(data_params, current_lang, socket.assigns)
-
- # Strip lang_title/lang_slug after injection — not schema fields
- data_params =
- data_params
- |> Map.delete("lang_title")
- |> Map.delete("lang_slug")
-
- final_data = merge_multilang_data(socket.assigns, current_lang, validated_data)
- params = Map.put(data_params, "data", final_data)
-
- changeset =
- socket.assigns.data_record
- |> EntityData.change(params)
- |> Map.put(:action, :validate)
-
- socket =
- socket
- |> assign(:changeset, changeset)
- |> broadcast_data_form_state(params)
-
- {:noreply, socket}
-
- {:error, errors} ->
- # Preserve full multilang data in both changeset and broadcast
- error_data = merge_multilang_data(socket.assigns, current_lang, form_data)
-
- data_params =
- data_params
- |> Map.delete("lang_title")
- |> Map.delete("lang_slug")
-
- error_params = Map.put(data_params, "data", error_data)
-
- changeset =
- socket.assigns.data_record
- |> EntityData.change(error_params)
- |> add_form_errors(errors)
- |> Map.put(:action, :validate)
-
- socket =
- socket
- |> assign(:changeset, changeset)
- |> broadcast_data_form_state(error_params)
-
- {:noreply, socket}
- end
+ do_validate(data_params, socket)
else
# Spectator - ignore local changes, wait for broadcasts
{:noreply, socket}
end
+ rescue
+ e ->
+ require Logger
+
+ Logger.error(
+ "Entity data validate failed: #{Exception.message(e)}\n#{Exception.format_stacktrace(__STACKTRACE__)}"
+ )
+
+ {:noreply, put_flash(socket, :error, gettext("Validation error — your data is preserved."))}
end
def handle_event("save", %{"phoenix_kit_entity_data" => data_params}, socket) do
if socket.assigns[:lock_owner?] do
- # Extract the data field from params
- form_data = Map.get(data_params, "data", %{})
-
- current_lang = socket.assigns[:current_lang]
-
- # Inject _title and _slug into form data so they flow through merge_multilang_data
- form_data =
- form_data
- |> inject_title_into_form_data(data_params, current_lang, socket.assigns)
- |> inject_slug_into_form_data(data_params, current_lang, socket.assigns)
-
- # On secondary language tabs, preserve primary-language fields that aren't in the form
- data_params = preserve_primary_fields(data_params, socket.assigns.changeset)
-
- # Validate the form data against entity field definitions
- case FormBuilder.validate_data(socket.assigns.entity, form_data, current_lang) do
- {:ok, validated_data} ->
- validated_data =
- validated_data
- |> inject_title_into_form_data(data_params, current_lang, socket.assigns)
- |> inject_slug_into_form_data(data_params, current_lang, socket.assigns)
-
- # Strip lang_title/lang_slug after injection — not schema fields
- data_params =
- data_params
- |> Map.delete("lang_title")
- |> Map.delete("lang_slug")
-
- final_data = merge_multilang_data(socket.assigns, current_lang, validated_data)
-
- # Add metadata to params
- params =
- data_params
- |> Map.put("data", final_data)
- |> maybe_add_creator_uuid(socket.assigns.current_user, socket.assigns.data_record)
-
- try do
- case save_data_record(socket, params) do
- {:ok, saved_record} ->
- if socket.assigns.data_record.uuid do
- # Update — stay on page, refresh changeset from saved record
- changeset = EntityData.change(saved_record)
-
- socket =
- socket
- |> assign(:data_record, saved_record)
- |> assign(:changeset, changeset)
- |> put_flash(:info, gettext("Data record saved successfully"))
- |> broadcast_data_form_state(params)
-
- {:noreply, socket}
- else
- # Create — navigate to the edit page for the new record
- entity_name = socket.assigns.entity.name
-
- socket =
- socket
- |> put_flash(:info, gettext("Data record created successfully"))
- |> push_navigate(
- to:
- Routes.path(
- "/admin/entities/#{entity_name}/data/#{saved_record.uuid}/edit",
- locale: socket.assigns.current_locale_base
- )
- )
-
- {:noreply, socket}
- end
-
- {:error, %Ecto.Changeset{} = changeset} ->
- socket =
- socket
- |> assign(:changeset, changeset)
- |> broadcast_data_form_state(params)
-
- {:noreply, socket}
- end
- rescue
- e ->
- require Logger
- Logger.error("Entity data save failed: #{Exception.message(e)}")
-
- {:noreply,
- put_flash(socket, :error, gettext("Something went wrong. Please try again."))}
- end
-
- {:error, errors} ->
- # Preserve full multilang data in both changeset and broadcast
- error_data = merge_multilang_data(socket.assigns, current_lang, form_data)
-
- data_params =
- data_params
- |> Map.delete("lang_title")
- |> Map.delete("lang_slug")
-
- error_params = Map.put(data_params, "data", error_data)
-
- changeset =
- socket.assigns.data_record
- |> EntityData.change(error_params)
- |> add_form_errors(errors)
-
- error_list =
- Enum.map_join(errors, "; ", fn {k, v} -> "#{k}: #{Enum.join(v, ", ")}" end)
-
- socket =
- socket
- |> assign(:changeset, changeset)
- |> put_flash(
- :error,
- gettext("Field validation errors: %{errors}", errors: error_list)
- )
- |> broadcast_data_form_state(error_params)
-
- {:noreply, socket}
- end
+ do_save(data_params, socket)
else
{:noreply, put_flash(socket, :error, gettext("Cannot save - you are spectating"))}
end
+ rescue
+ e ->
+ require Logger
+
+ Logger.error(
+ "Entity data save failed: #{Exception.message(e)}\n#{Exception.format_stacktrace(__STACKTRACE__)}"
+ )
+
+ {:noreply, put_flash(socket, :error, gettext("Something went wrong. Please try again."))}
end
def handle_event("reset", _params, socket) do
@@ -510,6 +297,245 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do
end
end
+ # ── Validate/Save helpers (below all handle_event clauses to avoid grouping warnings) ──
+
+ defp do_validate(data_params, socket) do
+ entity_uuid = socket.assigns.entity.uuid
+ record_uuid = socket.assigns.data_record.uuid
+ form_data = Map.get(data_params, "data", %{})
+
+ data_params =
+ if socket.assigns.data_record.uuid do
+ data_params
+ else
+ data_params
+ |> Map.put("created_by_uuid", socket.assigns.current_user.uuid)
+ end
+
+ data_params =
+ if is_nil(record_uuid) do
+ # Always use the primary language title for slug generation
+ current_data = Ecto.Changeset.apply_changes(socket.assigns.changeset)
+ previous_title = current_data.title || ""
+
+ title =
+ if data_params["title"] do
+ data_params["title"]
+ else
+ # On secondary language tab, title param is absent — keep the existing title
+ previous_title
+ end
+
+ current_slug = data_params["slug"] || ""
+
+ auto_generated_slug =
+ auto_generate_entity_slug(entity_uuid, record_uuid, previous_title)
+
+ if current_slug == "" || current_slug == auto_generated_slug do
+ Map.put(
+ data_params,
+ "slug",
+ auto_generate_entity_slug(entity_uuid, record_uuid, title)
+ )
+ else
+ data_params
+ end
+ else
+ data_params
+ end
+
+ current_lang = socket.assigns[:current_lang]
+
+ # Inject _title and _slug into form data so they flow through multilang merge
+ form_data =
+ form_data
+ |> inject_db_field_into_data("title", data_params, current_lang, socket.assigns)
+ |> inject_db_field_into_data("slug", data_params, current_lang, socket.assigns)
+
+ # On secondary language tabs, preserve primary-language fields that aren't in the form
+ data_params = preserve_primary_fields(data_params, socket.assigns.changeset)
+
+ case FormBuilder.validate_data(socket.assigns.entity, form_data, current_lang) do
+ {:ok, validated_data} ->
+ validated_data =
+ validated_data
+ |> inject_db_field_into_data("title", data_params, current_lang, socket.assigns)
+ |> inject_db_field_into_data("slug", data_params, current_lang, socket.assigns)
+
+ data_params = strip_lang_params(data_params)
+
+ final_data =
+ merge_multilang_data(
+ socket.assigns.changeset,
+ current_lang,
+ validated_data,
+ socket.assigns
+ )
+
+ params = Map.put(data_params, "data", final_data)
+
+ changeset =
+ socket.assigns.data_record
+ |> EntityData.change(params)
+ |> Map.put(:action, :validate)
+
+ socket =
+ socket
+ |> assign(:changeset, changeset)
+ |> broadcast_data_form_state(params)
+
+ {:noreply, socket}
+
+ {:error, errors} ->
+ # Preserve full multilang data in both changeset and broadcast
+ error_data =
+ merge_multilang_data(
+ socket.assigns.changeset,
+ current_lang,
+ form_data,
+ socket.assigns
+ )
+
+ data_params =
+ data_params
+ |> Map.delete("lang_title")
+ |> Map.delete("lang_slug")
+
+ error_params = Map.put(data_params, "data", error_data)
+
+ changeset =
+ socket.assigns.data_record
+ |> EntityData.change(error_params)
+ |> add_form_errors(errors)
+ |> Map.put(:action, :validate)
+
+ socket =
+ socket
+ |> assign(:changeset, changeset)
+ |> broadcast_data_form_state(error_params)
+
+ {:noreply, socket}
+ end
+ end
+
+ defp do_save(data_params, socket) do
+ # Extract the data field from params
+ form_data = Map.get(data_params, "data", %{})
+
+ current_lang = socket.assigns[:current_lang]
+
+ # Inject _title and _slug into form data so they flow through multilang merge
+ form_data =
+ form_data
+ |> inject_db_field_into_data("title", data_params, current_lang, socket.assigns)
+ |> inject_db_field_into_data("slug", data_params, current_lang, socket.assigns)
+
+ # On secondary language tabs, preserve primary-language fields that aren't in the form
+ data_params = preserve_primary_fields(data_params, socket.assigns.changeset)
+
+ # Validate the form data against entity field definitions
+ case FormBuilder.validate_data(socket.assigns.entity, form_data, current_lang) do
+ {:ok, validated_data} ->
+ validated_data =
+ validated_data
+ |> inject_db_field_into_data("title", data_params, current_lang, socket.assigns)
+ |> inject_db_field_into_data("slug", data_params, current_lang, socket.assigns)
+
+ data_params = strip_lang_params(data_params)
+
+ final_data =
+ merge_multilang_data(
+ socket.assigns.changeset,
+ current_lang,
+ validated_data,
+ socket.assigns
+ )
+
+ # Add metadata to params
+ params =
+ data_params
+ |> Map.put("data", final_data)
+ |> maybe_add_creator_uuid(socket.assigns.current_user, socket.assigns.data_record)
+
+ case save_data_record(socket, params) do
+ {:ok, saved_record} ->
+ if socket.assigns.data_record.uuid do
+ # Update — stay on page, refresh changeset from saved record
+ changeset = EntityData.change(saved_record)
+
+ socket =
+ socket
+ |> assign(:data_record, saved_record)
+ |> assign(:changeset, changeset)
+ |> put_flash(:info, gettext("Data record saved successfully"))
+ |> broadcast_data_form_state(params)
+
+ {:noreply, socket}
+ else
+ # Create — navigate to the edit page for the new record
+ entity_name = socket.assigns.entity.name
+
+ socket =
+ socket
+ |> put_flash(:info, gettext("Data record created successfully"))
+ |> push_navigate(
+ to:
+ Routes.path(
+ "/admin/entities/#{entity_name}/data/#{saved_record.uuid}/edit",
+ locale: socket.assigns.current_locale_base
+ )
+ )
+
+ {:noreply, socket}
+ end
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ socket =
+ socket
+ |> assign(:changeset, changeset)
+ |> broadcast_data_form_state(params)
+
+ {:noreply, socket}
+ end
+
+ {:error, errors} ->
+ # Preserve full multilang data in both changeset and broadcast
+ error_data =
+ merge_multilang_data(
+ socket.assigns.changeset,
+ current_lang,
+ form_data,
+ socket.assigns
+ )
+
+ data_params =
+ data_params
+ |> Map.delete("lang_title")
+ |> Map.delete("lang_slug")
+
+ error_params = Map.put(data_params, "data", error_data)
+
+ changeset =
+ socket.assigns.data_record
+ |> EntityData.change(error_params)
+ |> add_form_errors(errors)
+
+ error_list =
+ Enum.map_join(errors, "; ", fn {k, v} -> "#{k}: #{Enum.join(v, ", ")}" end)
+
+ socket =
+ socket
+ |> assign(:changeset, changeset)
+ |> put_flash(
+ :error,
+ gettext("Field validation errors: %{errors}", errors: error_list)
+ )
+ |> broadcast_data_form_state(error_params)
+
+ {:noreply, socket}
+ end
+ end
+
## Live updates
@impl true
@@ -664,6 +690,14 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do
end
end
+ # Strip lang_title/lang_slug from params — these are translation input names
+ # that shouldn't be passed to the changeset as DB fields.
+ defp strip_lang_params(params) do
+ params
+ |> Map.delete("lang_title")
+ |> Map.delete("lang_slug")
+ end
+
# ── Lazy re-keying helpers (primary language change) ────────
# Re-keys JSONB data in changeset if embedded primary != global primary.
@@ -793,27 +827,8 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do
# Helper Functions
- defp merge_multilang_data(assigns, lang_code, validated_data) do
- existing_data = Ecto.Changeset.get_field(assigns.changeset, :data) || %{}
-
- cond do
- # Multilang enabled — use language-aware merge
- assigns[:multilang_enabled] == true ->
- Multilang.put_language_data(existing_data, lang_code, validated_data)
-
- # Multilang disabled but data has multilang structure — preserve it
- # (prevents translation loss if Languages module is temporarily disabled)
- Multilang.multilang_data?(existing_data) ->
- Multilang.put_language_data(existing_data, lang_code, validated_data)
-
- # Flat data, no multilang — pass through as-is
- true ->
- validated_data
- end
- end
-
# When on a secondary language tab, preserve primary-language DB fields.
- # Title on secondary tab goes to JSONB _title via inject_title_into_form_data;
+ # Title on secondary tab goes to JSONB _title via inject_db_field_into_data;
# the DB title column must keep the primary language value.
@preserve_fields %{"title" => :title, "slug" => :slug, "status" => :status}
@@ -828,36 +843,6 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do
end)
end
- # Injects _title into form data map so it flows through merge_multilang_data/3.
- # On primary tab: _title comes from data_params["title"] (the DB column field).
- # On secondary tab: _title comes from data_params["lang_title"] (separate input).
- defp inject_title_into_form_data(form_data, data_params, current_lang, assigns) do
- if assigns[:multilang_enabled] == true do
- primary = assigns[:primary_language]
-
- title =
- if current_lang == primary do
- data_params["title"]
- else
- data_params["lang_title"]
- end
-
- if is_binary(title) do
- Map.put(form_data, "_title", title)
- else
- # No title submitted — preserve existing _title from JSONB data
- existing_data = Ecto.Changeset.get_field(assigns.changeset, :data) || %{}
-
- case Multilang.get_raw_language_data(existing_data, current_lang) do
- %{"_title" => existing_title} -> Map.put(form_data, "_title", existing_title)
- _ -> form_data
- end
- end
- else
- form_data
- end
- end
-
defp do_generate_slug(socket) do
changeset = socket.assigns.changeset
current_lang = socket.assigns[:current_lang]
@@ -944,36 +929,6 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do
{slug_text, data}
end
- # Injects _slug into form data map so it flows through merge_multilang_data/3.
- # On primary tab: _slug comes from data_params["slug"] (the DB column field).
- # On secondary tab: _slug comes from data_params["lang_slug"] (separate input).
- defp inject_slug_into_form_data(form_data, data_params, current_lang, assigns) do
- if assigns[:multilang_enabled] == true do
- primary = assigns[:primary_language]
-
- slug =
- if current_lang == primary do
- data_params["slug"]
- else
- data_params["lang_slug"]
- end
-
- if is_binary(slug) do
- Map.put(form_data, "_slug", slug)
- else
- # No slug submitted — preserve existing _slug from JSONB data
- existing_data = Ecto.Changeset.get_field(assigns.changeset, :data) || %{}
-
- case Multilang.get_raw_language_data(existing_data, current_lang) do
- %{"_slug" => existing_slug} -> Map.put(form_data, "_slug", existing_slug)
- _ -> form_data
- end
- end
- else
- form_data
- end
- end
-
defp broadcast_data_form_state(socket, params) when is_map(params) do
socket =
if connected?(socket) &&
@@ -1051,30 +1006,11 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do
|> EntityData.change(params)
|> Map.put(:action, :validate)
- # Refresh multilang assigns (driven by Languages module globally)
- multilang_enabled = Multilang.enabled?()
- primary_language = if multilang_enabled, do: Multilang.primary_language(), else: nil
- language_tabs = Multilang.build_language_tabs()
-
- current_lang = socket.assigns[:current_lang]
- enabled_langs = Multilang.enabled_languages()
-
- # Reset current_lang when multilang is disabled or language was removed
- current_lang =
- cond do
- not multilang_enabled -> nil
- current_lang not in enabled_langs -> primary_language
- true -> current_lang
- end
-
socket
|> assign(:entity, entity)
|> assign(:data_record, data_record)
|> assign(:changeset, changeset)
- |> assign(:multilang_enabled, multilang_enabled)
- |> assign(:primary_language, primary_language)
- |> assign(:current_lang, current_lang)
- |> assign(:language_tabs, language_tabs)
+ |> refresh_multilang()
end
defp extract_changeset_params(changeset) do
@@ -1126,17 +1062,6 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do
defp normalize_record_key(key) when is_binary(key), do: key
defp normalize_record_key(key), do: to_string(key)
- defp switch_lang_js(lang_code, current_lang) do
- if lang_code == current_lang do
- # Already on this tab — no-op to prevent skeleton ghosts
- %JS{}
- else
- JS.push("switch_language", value: %{lang: lang_code})
- |> JS.add_class("hidden", to: "[data-translatable=fields]")
- |> JS.remove_class("hidden", to: "[data-translatable=skeletons]")
- end
- end
-
defp auto_generate_entity_slug(_entity_uuid, _record_uuid, title) when title in [nil, ""],
do: ""
diff --git a/lib/modules/entities/web/data_form.html.heex b/lib/modules/entities/web/data_form.html.heex
index 1dcca9e3e..281ca816d 100644
--- a/lib/modules/entities/web/data_form.html.heex
+++ b/lib/modules/entities/web/data_form.html.heex
@@ -70,149 +70,46 @@
<%= if @multilang_enabled && length(@language_tabs) > 1 do %>
<%!-- Multilang: unified card with language tabs wrapping all content --%>
+ <% lang_data = get_lang_data(@changeset, @current_lang, @multilang_enabled) %>
- <%!-- Language tab bar --%>
-
-
-
- <.icon name="hero-language" class="w-5 h-5 text-primary" />
-
{gettext("Content Language")}
-
- <% primary_tab =
- Enum.find(@language_tabs, fn t -> t.is_primary end) %>
- <%= if primary_tab do %>
-
- <.icon name="hero-star-solid" class="w-3.5 h-3.5 text-primary" />
- {gettext("Primary: %{lang}", lang: primary_tab.name)}
-
- <% end %>
-
+ <.multilang_tabs
+ multilang_enabled={@multilang_enabled}
+ language_tabs={@language_tabs}
+ current_lang={@current_lang}
+ />
-
- <.icon name="hero-information-circle" class="w-4 h-4" />
-
- {gettext(
- "Use the language tabs below to translate this record's content. The primary language (marked with a star) is required. Other languages are optional — any empty fields will fall back to the primary language value."
- )}
-
-
-
- <% compact = length(@language_tabs) > 5 %>
-
- <%= if compact do %>
- <%= for {tab, idx} <- Enum.with_index(@language_tabs) do %>
- <%= if idx > 0 do %>
- |
- <% end %>
-
- <%= if tab.flag do %>
- {tab.flag}
- <% end %>
- {tab.short_code}
- <%= if tab.is_primary do %>
- <.icon name="hero-star-solid" class="w-3 h-3 text-primary" />
- <% end %>
-
- <% end %>
- <% else %>
- <%= for tab <- @language_tabs do %>
- <%= if tab.is_primary do %>
-
- <%= if tab.flag do %>
- {tab.flag}
- <% end %>
- {tab.name}
- <.icon name="hero-star-solid" class="w-3 h-3 text-primary" />
-
-
- <% else %>
-
- <%= if tab.flag do %>
- {tab.flag}
- <% end %>
- {tab.name}
-
- <% end %>
- <% end %>
- <% end %>
-
-
-
- <%!-- Skeleton placeholders (shown instantly on tab click).
- IMPORTANT: IDs MUST include @current_lang so morphdom treats them as new
- elements on language switch. Static IDs would preserve JS-modified classes
- across LiveView patches, causing skeletons to stick. --%>
-
-
-
-
-
+
-
<%!-- Tab content: Title & Slug (translatable) --%>
- <% lang_data =
- Multilang.get_raw_language_data(
- Ecto.Changeset.get_field(@changeset, :data),
- @current_lang
- ) %>
<.icon name="hero-information-circle" class="w-4 h-4 inline -mt-0.5" />
@@ -220,110 +117,52 @@
@@ -345,7 +184,7 @@
)}
<% end %>
-
+
<%!-- Record Settings (non-translatable, separate card) --%>
@@ -400,11 +239,6 @@
name="phoenix_kit_entity_data[entity_uuid]"
value={@entity.uuid}
/>
-
@@ -511,11 +345,6 @@
name="phoenix_kit_entity_data[entity_uuid]"
value={@entity.uuid}
/>
-
diff --git a/lib/modules/entities/web/data_view.ex b/lib/modules/entities/web/data_view.ex
index dd8c0a55c..dcf8db025 100644
--- a/lib/modules/entities/web/data_view.ex
+++ b/lib/modules/entities/web/data_view.ex
@@ -11,10 +11,11 @@ defmodule PhoenixKit.Modules.Entities.Web.DataView do
use PhoenixKitWeb, :live_view
on_mount PhoenixKit.Modules.Entities.Web.Hooks
+ import PhoenixKitWeb.Components.MultilangForm
+
alias PhoenixKit.Modules.Entities
alias PhoenixKit.Modules.Entities.EntityData
alias PhoenixKit.Modules.Entities.FormBuilder
- alias PhoenixKit.Modules.Entities.Multilang
alias PhoenixKit.Settings
@impl true
@@ -78,11 +79,6 @@ defmodule PhoenixKit.Modules.Entities.Web.DataView do
# Create a modified entity with only other fields for display
other_entity = %{entity | fields_definition: other_fields}
- # Multilang assigns (driven by Languages module globally)
- multilang_enabled = Multilang.enabled?()
- primary_language = Multilang.primary_language()
- language_tabs = Multilang.build_language_tabs()
-
socket =
socket
|> assign(:current_locale, locale)
@@ -101,10 +97,7 @@ defmodule PhoenixKit.Modules.Entities.Web.DataView do
|> assign(:public_form_description, Map.get(settings, "public_form_description", ""))
|> assign(:metadata, data_record.metadata || %{})
|> assign(:is_public_submission, public_submission?(data_record.metadata))
- |> assign(:multilang_enabled, multilang_enabled)
- |> assign(:primary_language, primary_language)
- |> assign(:current_lang, primary_language)
- |> assign(:language_tabs, language_tabs)
+ |> mount_multilang()
{:ok, socket}
end
@@ -114,13 +107,7 @@ defmodule PhoenixKit.Modules.Entities.Web.DataView do
@impl true
def handle_event("switch_language", %{"lang" => lang_code}, socket) do
- enabled = Multilang.enabled_languages()
-
- if lang_code in enabled do
- {:noreply, assign(socket, :current_lang, lang_code)}
- else
- {:noreply, socket}
- end
+ {:noreply, handle_switch_language(socket, lang_code)}
end
@impl true
@@ -202,100 +189,20 @@ defmodule PhoenixKit.Modules.Entities.Web.DataView do
<%!-- Language Selector (only when multilang enabled) --%>
<%= if @multilang_enabled && length(@language_tabs) > 1 do %>
-
-
-
- <.icon name="hero-language" class="w-5 h-5 text-primary" />
-
{gettext("Content Language")}
-
- <% primary_tab =
- Enum.find(@language_tabs, fn t -> t.is_primary end) %>
- <%= if primary_tab do %>
-
- <.icon name="hero-star-solid" class="w-3.5 h-3.5 text-primary" />
- {gettext("Primary: %{lang}", lang: primary_tab.name)}
-
- <% end %>
-
-
- <% compact = length(@language_tabs) > 5 %>
-
- <%= if compact do %>
- <%= for {tab, idx} <- Enum.with_index(@language_tabs) do %>
- <%= if idx > 0 do %>
-
|
- <% end %>
-
- <%= if tab.flag do %>
- {tab.flag}
- <% end %>
- {tab.short_code}
- <%= if tab.is_primary do %>
- <.icon name="hero-star-solid" class="w-3 h-3 text-primary" />
- <% end %>
-
- <% end %>
- <% else %>
- <%= for tab <- @language_tabs do %>
- <%= if tab.is_primary do %>
-
- <%= if tab.flag do %>
- {tab.flag}
- <% end %>
- {tab.name}
- <.icon name="hero-star-solid" class="w-3 h-3 text-primary" />
-
-
- <% else %>
-
- <%= if tab.flag do %>
- {tab.flag}
- <% end %>
- {tab.name}
-
- <% end %>
- <% end %>
- <% end %>
-
-
+ <.multilang_tabs
+ multilang_enabled={@multilang_enabled}
+ language_tabs={@language_tabs}
+ current_lang={@current_lang}
+ show_info={false}
+ />
+
<%= if @current_lang == @primary_language do %>
-
+
<.icon name="hero-information-circle" class="w-3.5 h-3.5 inline -mt-0.5" />
{gettext("This is the primary language.")}
<% else %>
-
+
<.icon name="hero-information-circle" class="w-3.5 h-3.5 inline -mt-0.5" />
{gettext("Fields without a value show the primary language value.")}
diff --git a/lib/modules/entities/web/entity_form.ex b/lib/modules/entities/web/entity_form.ex
index 7f87830c8..60b964240 100644
--- a/lib/modules/entities/web/entity_form.ex
+++ b/lib/modules/entities/web/entity_form.ex
@@ -9,13 +9,13 @@ defmodule PhoenixKit.Modules.Entities.Web.EntityForm do
require Logger
- alias Phoenix.LiveView.JS
+ import PhoenixKitWeb.Components.MultilangForm
+
alias PhoenixKit.Modules.Entities
alias PhoenixKit.Modules.Entities.Events
alias PhoenixKit.Modules.Entities.FieldTypes
alias PhoenixKit.Modules.Entities.Mirror.Exporter
alias PhoenixKit.Modules.Entities.Mirror.Storage
- alias PhoenixKit.Modules.Entities.Multilang
alias PhoenixKit.Modules.Entities.Presence
alias PhoenixKit.Modules.Entities.PresenceHelpers
alias PhoenixKit.Settings
@@ -61,11 +61,6 @@ defmodule PhoenixKit.Modules.Entities.Web.EntityForm do
live_source = ensure_live_source(socket)
- # Multilang state
- multilang_enabled = Multilang.enabled?()
- primary_language = if multilang_enabled, do: Multilang.primary_language(), else: nil
- language_tabs = Multilang.build_language_tabs()
-
socket =
socket
|> assign(:page_title, page_title)
@@ -90,11 +85,8 @@ defmodule PhoenixKit.Modules.Entities.Web.EntityForm do
|> assign(:delete_confirm_index, nil)
|> assign(:has_unsaved_changes, false)
|> assign(:mirror_path, Storage.root_path())
- |> assign(:multilang_enabled, multilang_enabled)
- |> assign(:primary_language, primary_language)
- |> assign(:current_lang, primary_language)
- |> assign(:language_tabs, language_tabs)
|> assign(:sort_mode, Entities.get_sort_mode(entity))
+ |> mount_multilang()
socket =
if connected?(socket) do
@@ -183,25 +175,8 @@ defmodule PhoenixKit.Modules.Entities.Web.EntityForm do
end
end
- defp switch_lang_js(lang_code, current_lang) do
- if lang_code == current_lang do
- # Already on this tab — no-op to prevent skeleton ghosts
- %JS{}
- else
- JS.push("switch_language", value: %{lang: lang_code})
- |> JS.add_class("hidden", to: "[data-translatable=fields]")
- |> JS.remove_class("hidden", to: "[data-translatable=skeletons]")
- end
- end
-
def handle_event("switch_language", %{"lang" => lang_code}, socket) do
- enabled_langs = Multilang.enabled_languages()
-
- if lang_code in enabled_langs do
- {:noreply, assign(socket, :current_lang, lang_code)}
- else
- {:noreply, socket}
- end
+ {:noreply, handle_switch_language(socket, lang_code)}
end
@impl true
diff --git a/lib/modules/entities/web/entity_form.html.heex b/lib/modules/entities/web/entity_form.html.heex
index d4e923b8e..e75178e30 100644
--- a/lib/modules/entities/web/entity_form.html.heex
+++ b/lib/modules/entities/web/entity_form.html.heex
@@ -62,10 +62,13 @@
)}
- <% translations = (@entity.settings || %{})["translations"] || %{} %>
- <% is_secondary = @multilang_enabled && @current_lang != @primary_language %>
<% lang_translations =
- if is_secondary, do: translations[@current_lang] || %{}, else: %{} %>
+ if @multilang_enabled && @current_lang != @primary_language do
+ translations = (@entity.settings || %{})["translations"] || %{}
+ translations[@current_lang] || %{}
+ else
+ %{}
+ end %>
<%!-- Language tabs --%>
<%= if @multilang_enabled && length(@language_tabs) > 1 do %>
@@ -77,187 +80,110 @@
)}
-
- <% compact = length(@language_tabs) > 5 %>
-
- <%= if compact do %>
- <%= for {tab, idx} <- Enum.with_index(@language_tabs) do %>
- <%= if idx > 0 do %>
- |
- <% end %>
-
- <%= if tab.flag do %>
- {tab.flag}
- <% end %>
- {tab.short_code}
- <%= if tab.is_primary do %>
- <.icon name="hero-star-solid" class="w-3 h-3 text-primary" />
- <% end %>
-
- <% end %>
- <% else %>
- <%= for tab <- @language_tabs do %>
- <%= if tab.is_primary do %>
-
- <%= if tab.flag do %>
- {tab.flag}
- <% end %>
- {tab.name}
- <.icon name="hero-star-solid" class="w-3 h-3 text-primary" />
-
-
- <% else %>
-
- <%= if tab.flag do %>
- {tab.flag}
- <% end %>
- {tab.name}
-
- <% end %>
- <% end %>
- <% end %>
-
<% end %>
- <%!-- Skeleton placeholders (shown instantly on tab click).
- IMPORTANT: IDs MUST include @current_lang so morphdom treats them as new
- elements on language switch. Static IDs would preserve JS-modified classes
- across LiveView patches, causing skeletons to stick. --%>
-
+
+ <.multilang_fields_wrapper
+ multilang_enabled={@multilang_enabled}
+ current_lang={@current_lang}
+ skeleton_class="space-y-6"
+ fields_class="space-y-6"
>
-
-
-
-
+
- <%!-- Translatable fields --%>
-
<%!-- Slug (translatable) --%>
-
- <.label for="entity_name">
- {gettext("Slug")}{unless is_secondary, do: " *"}
- <%= unless is_secondary do %>
+ <.translatable_field
+ field_name="name"
+ form_prefix="entities"
+ changeset={@changeset}
+ schema_field={:name}
+ multilang_enabled={@multilang_enabled}
+ current_lang={@current_lang}
+ primary_language={@primary_language}
+ lang_data={lang_translations}
+ secondary_name={"entities[translations][#{@current_lang}][name]"}
+ lang_data_key="name"
+ label={gettext("Slug")}
+ placeholder={gettext("brand")}
+ required
+ disabled={@readonly?}
+ class="w-full"
+ hint={gettext("snake_case identifier used in the system")}
+ >
+ <:label_extra>
+ <%= if !@multilang_enabled || @current_lang == @primary_language do %>
{gettext("Generate")}
<% end %>
-
- <%= if is_secondary do %>
-
- <% else %>
- <.input
- field={f[:name]}
- type="text"
- placeholder={gettext("brand")}
- phx-debounce="300"
- disabled={@readonly?}
- />
- <% end %>
- <.label class="label">
-
- {gettext("snake_case identifier used in the system")}
-
-
-
+
+
<%!-- Description (translatable) --%>
-
- <.label for="entity_description">{gettext("Description (Optional)")}
- <%= if is_secondary do %>
-
- <% else %>
-
- <% end %>
-
-
+ <.translatable_field
+ field_name="description"
+ form_prefix="entities"
+ changeset={@changeset}
+ schema_field={:description}
+ multilang_enabled={@multilang_enabled}
+ current_lang={@current_lang}
+ primary_language={@primary_language}
+ lang_data={lang_translations}
+ secondary_name={"entities[translations][#{@current_lang}][description]"}
+ lang_data_key="description"
+ label={gettext("Description (Optional)")}
+ placeholder={gettext("Describe what this entity represents...")}
+ type="textarea"
+ rows={3}
+ disabled={@readonly?}
+ class="w-full"
+ />
+
diff --git a/lib/modules/publishing/web/components/language_switcher.ex b/lib/modules/publishing/web/components/language_switcher.ex
deleted file mode 100644
index 728303f53..000000000
--- a/lib/modules/publishing/web/components/language_switcher.ex
+++ /dev/null
@@ -1,442 +0,0 @@
-defmodule PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher do
- @moduledoc """
- Unified language switcher component for publishing posts.
-
- Displays available languages as a compact inline list with status indicators.
- Flexible enough for admin interfaces (with status dots) and public pages (links only).
-
- ## Display Format
-
- Admin mode: ● EN | ● FR | ○ ES
- - Green dot (●): Published
- - Yellow dot (●): Draft
- - Gray dot (●): Archived
- - Empty dot (○): No translation exists
-
- Public mode: EN | FR | ES
- - No status indicators
- - Only shows languages with published translations
-
- ## Examples
-
- # Admin: publishing listing with edit links and status indicators
- <.publishing_language_switcher
- languages={@languages}
- current_language="en"
- on_click="switch_language"
- show_status={true}
- />
-
- # Admin: editor with switch functionality
- <.publishing_language_switcher
- languages={@languages}
- current_language={@current_language}
- on_click="switch_language"
- show_status={true}
- show_add={true}
- />
-
- # Public: post page with translation links
- <.publishing_language_switcher
- languages={@translations}
- current_language={@current_language}
- show_status={false}
- />
- """
-
- use Phoenix.Component
- use Gettext, backend: PhoenixKitWeb.Gettext
-
- @doc """
- Renders a compact inline language switcher.
-
- ## Attributes
-
- - `languages` - List of language maps with keys: `code`, `name`, `status`, `url`, `exists`
- - `current_language` - Currently active language code
- - `show_status` - Show status indicator dots (default: true)
- - `show_add` - Show "add" option for missing languages (default: false)
- - `show_flags` - Show flag emojis (default: false)
- - `on_click` - Event name for click handler (optional, uses href if not provided)
- - `class` - Additional CSS classes
- - `size` - Size variant: :xs, :sm, :md (default: :sm)
-
- ## Language Map Structure
-
- Each language in the list should have:
- - `code` - Language code (e.g., "en", "fr")
- - `name` - Display name (e.g., "English", "French") - optional
- - `flag` - Flag emoji (e.g., "🇺🇸") - optional
- - `status` - Post status: "published", "draft", "archived", or nil if not exists
- - `url` - URL to navigate to (for public mode or href navigation)
- - `exists` - Boolean, whether translation exists (default: inferred from status)
- - `enabled` - Boolean, whether the language is enabled in the Languages module (default: true)
- - `known` - Boolean, whether the language code is recognized (default: true)
- """
- attr :languages, :list, required: true
- attr :current_language, :string, default: nil
- attr :show_status, :boolean, default: true
- attr :show_add, :boolean, default: false
- attr :show_flags, :boolean, default: false
- attr :on_click, :string, default: nil
- attr :on_click_js, :any, default: nil, doc: "fn(lang_code) -> %JS{} for custom click behavior"
- attr :phx_target, :any, default: nil
- attr :class, :string, default: ""
- attr :size, :atom, default: :sm, values: [:xs, :sm, :md]
-
- def publishing_language_switcher(assigns) do
- # Filter languages for public mode (only show existing/published)
- languages =
- if assigns.show_status do
- assigns.languages
- else
- Enum.filter(assigns.languages, fn lang ->
- lang_exists?(lang) && lang[:status] == "published"
- end)
- end
-
- assigns = assign(assigns, :filtered_languages, languages)
-
- ~H"""
-
- <%= for {lang, index} <- Enum.with_index(@filtered_languages) do %>
- <%= if index > 0 do %>
- |
- <% end %>
- <.language_item
- lang={lang}
- current_language={@current_language}
- show_status={@show_status}
- show_add={@show_add}
- show_flags={@show_flags}
- on_click={@on_click}
- on_click_js={@on_click_js}
- phx_target={@phx_target}
- size={@size}
- />
- <% end %>
-
- """
- end
-
- attr :lang, :map, required: true
- attr :current_language, :string, default: nil
- attr :show_status, :boolean, default: true
- attr :show_add, :boolean, default: false
- attr :show_flags, :boolean, default: false
- attr :on_click, :string, default: nil
- attr :on_click_js, :any, default: nil
- attr :phx_target, :any, default: nil
- attr :size, :atom, default: :sm
-
- defp language_item(assigns) do
- lang = assigns.lang
- is_current = assigns.current_language == lang[:code]
- exists = lang_exists?(lang)
- status = lang[:status]
- enabled = lang_enabled?(lang)
- known = lang_known?(lang)
- is_primary = lang_is_primary?(lang)
-
- assigns =
- assigns
- |> assign(:is_current, is_current)
- |> assign(:exists, exists)
- |> assign(:status, status)
- |> assign(:enabled, enabled)
- |> assign(:known, known)
- |> assign(:is_primary, is_primary)
-
- ~H"""
- <%= if @on_click_js || @on_click do %>
-
- <.language_content
- lang={@lang}
- exists={@exists}
- status={@status}
- show_status={@show_status}
- show_add={@show_add}
- show_flags={@show_flags}
- is_current={@is_current}
- size={@size}
- enabled={@enabled}
- known={@known}
- is_primary={@is_primary}
- />
-
- <% else %>
- <%= if @lang[:url] do %>
- <.link
- navigate={@lang[:url]}
- class={item_classes(@is_current, @exists, @show_add, @size, @enabled, @known)}
- title={language_title(@lang, @exists, @status, @show_status, @enabled, @known)}
- >
- <.language_content
- lang={@lang}
- exists={@exists}
- status={@status}
- show_status={@show_status}
- show_add={@show_add}
- show_flags={@show_flags}
- is_current={@is_current}
- size={@size}
- enabled={@enabled}
- known={@known}
- is_primary={@is_primary}
- />
-
- <% else %>
-
- <.language_content
- lang={@lang}
- exists={@exists}
- status={@status}
- show_status={@show_status}
- show_add={@show_add}
- show_flags={@show_flags}
- is_current={@is_current}
- size={@size}
- enabled={@enabled}
- known={@known}
- is_primary={@is_primary}
- />
-
- <% end %>
- <% end %>
- """
- end
-
- attr :lang, :map, required: true
- attr :exists, :boolean, required: true
- attr :status, :string, default: nil
- attr :show_status, :boolean, default: true
- attr :show_add, :boolean, default: false
- attr :show_flags, :boolean, default: false
- attr :is_current, :boolean, default: false
- attr :size, :atom, default: :sm
- attr :enabled, :boolean, default: true
- attr :known, :boolean, default: true
- attr :is_primary, :boolean, default: false
-
- defp language_content(assigns) do
- ~H"""
-
- <%= if @show_status do %>
-
-
- <% end %>
- <%= if @show_flags && @lang[:flag] do %>
- {@lang[:flag]}
- <% end %>
-
- {get_display_code(@lang)}
-
-
- """
- end
-
- # Check if language translation 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?(%{"exists" => exists}) when is_boolean(exists), do: exists
- defp lang_exists?(%{"status" => status}) when is_binary(status), do: true
- defp lang_exists?(_), do: false
-
- defp lang_enabled?(%{enabled: enabled}) when is_boolean(enabled), do: enabled
- defp lang_enabled?(%{"enabled" => enabled}) when is_boolean(enabled), do: enabled
- defp lang_enabled?(_), do: true
-
- defp lang_known?(%{known: known}) when is_boolean(known), do: known
- defp lang_known?(%{"known" => known}) when is_boolean(known), do: known
- defp lang_known?(_), do: true
-
- defp lang_is_primary?(%{is_primary: is_primary}) when is_boolean(is_primary), do: is_primary
- defp lang_is_primary?(%{"is_primary" => is_primary}) when is_boolean(is_primary), do: is_primary
- defp lang_is_primary?(_), do: false
-
- # Get display code for a language
- # Uses display_code if provided (for dialect-aware display), otherwise formats code
- defp get_display_code(%{display_code: display_code}) when is_binary(display_code) do
- String.upcase(display_code)
- end
-
- defp get_display_code(%{"display_code" => display_code}) when is_binary(display_code) do
- String.upcase(display_code)
- end
-
- defp get_display_code(%{code: code}) when is_binary(code) do
- format_code(code)
- end
-
- defp get_display_code(%{"code" => code}) when is_binary(code) do
- format_code(code)
- end
-
- defp get_display_code(_), do: ""
-
- # Format language code for display (base code only)
- defp format_code(nil), do: ""
-
- defp format_code(code) when is_binary(code) do
- code
- |> String.split("-")
- |> List.first()
- |> String.upcase()
- end
-
- # Status dot styling
- # Dot color follows status regardless of enabled/known state
- # (strikethrough on text indicates disabled/unknown, not the dot)
- defp status_dot_classes(exists, status, size, _enabled, _known, _is_primary) do
- base = ["rounded-full", "inline-block", dot_size_class(size)]
-
- color =
- cond do
- !exists -> "bg-base-content/20"
- status == "published" -> "bg-success"
- status == "draft" -> "bg-warning"
- status == "archived" -> "bg-base-content/40"
- true -> "bg-base-content/20"
- end
-
- base ++ [color]
- end
-
- # Item container classes
- defp item_classes(is_current, exists, show_add, size, enabled, known) do
- base = [
- "inline-flex items-center rounded transition-colors",
- size_padding_class(size)
- ]
-
- state =
- cond do
- # Disabled or unknown languages: grey styling but still clickable
- is_current and (!enabled or !known) ->
- "bg-base-content/30 text-base-content/50 font-semibold cursor-pointer"
-
- (!enabled or !known) and exists ->
- "text-base-content/40 hover:bg-base-200/50 cursor-pointer"
-
- is_current ->
- "bg-primary/30 text-primary font-semibold"
-
- !exists && show_add ->
- "text-success hover:bg-success/10 cursor-pointer"
-
- exists ->
- "hover:bg-base-200 cursor-pointer"
-
- true ->
- "text-base-content/40"
- end
-
- base ++ [state]
- end
-
- # Code text classes
- # Text color matches the status dot color
- defp code_classes(exists, status, is_current, size, enabled, known, is_primary) do
- base = [size_text_class(size)]
-
- # Primary language gets bold, current gets semibold, others get medium
- weight =
- cond do
- is_primary -> "font-bold"
- is_current -> "font-semibold"
- true -> "font-medium"
- end
-
- # Add line-through for disabled or unknown languages
- decoration = if !enabled or !known, do: "line-through", else: nil
-
- # Text color matches the dot color
- color =
- cond do
- !exists -> "text-base-content/20"
- status == "published" -> "text-success"
- status == "draft" -> "text-warning"
- status == "archived" -> "text-base-content/40"
- true -> "text-base-content/20"
- end
-
- Enum.filter(base ++ [weight, decoration, color], & &1)
- end
-
- # Size-based 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 size_separator_class(:xs), do: "text-xs"
- defp size_separator_class(:sm), do: "text-sm"
- defp size_separator_class(:md), do: "text-base"
-
- defp flag_size_class(:xs), do: "text-sm"
- defp flag_size_class(:sm), do: "text-base"
- defp flag_size_class(:md), do: "text-lg"
-
- # Generate title/tooltip text
- # Only show status in tooltip when show_status is true (admin mode)
- defp language_title(lang, exists, status, show_status, enabled, known) do
- name = lang[:name] || format_code(lang[:code])
-
- if show_status do
- build_admin_title(name, exists, status, enabled, known)
- else
- name
- end
- end
-
- # Build title for admin mode with status indicators
- defp build_admin_title(name, exists, status, enabled, known) do
- cond do
- !known and exists -> gettext("%{language} (Unknown language)", language: name)
- !enabled and exists -> build_disabled_title(name, status)
- !exists -> gettext("Add %{language} translation", language: name)
- true -> build_status_title(name, status)
- end
- end
-
- defp build_disabled_title(name, status) do
- status_text = status_label(status)
- gettext("%{language} (Disabled - %{status})", language: name, status: status_text)
- end
-
- defp build_status_title(name, "published"),
- do: gettext("%{language} (Published)", language: name)
-
- defp build_status_title(name, "draft"), do: gettext("%{language} (Draft)", language: name)
- defp build_status_title(name, "archived"), do: gettext("%{language} (Archived)", language: name)
- 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")
-end
diff --git a/lib/modules/publishing/web/editor.ex b/lib/modules/publishing/web/editor.ex
index 87dedc024..1fe86f437 100644
--- a/lib/modules/publishing/web/editor.ex
+++ b/lib/modules/publishing/web/editor.ex
@@ -42,7 +42,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do
alias PhoenixKit.Utils.Date, as: UtilsDate
# Import publishing-specific components
- import PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher
+ import PhoenixKitWeb.Components.LanguageSwitcher
import PhoenixKit.Modules.Publishing.Web.Components.VersionSwitcher
require Logger
diff --git a/lib/modules/publishing/web/editor.html.heex b/lib/modules/publishing/web/editor.html.heex
index fa8c51bd5..b363aff4a 100644
--- a/lib/modules/publishing/web/editor.html.heex
+++ b/lib/modules/publishing/web/editor.html.heex
@@ -204,7 +204,7 @@
{gettext("Language:")}
- <.publishing_language_switcher
+ <.language_switcher
languages={other_languages}
current_language={@current_language}
show_status={true}
diff --git a/lib/modules/publishing/web/html.ex b/lib/modules/publishing/web/html.ex
index fe608e4c3..51d27c6ce 100644
--- a/lib/modules/publishing/web/html.ex
+++ b/lib/modules/publishing/web/html.ex
@@ -15,8 +15,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.HTML do
@timestamp_modes Constants.timestamp_modes()
@slug_modes Constants.slug_modes()
- # Import publishing-specific components for templates
- import PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher
+ import PhoenixKitWeb.Components.LanguageSwitcher
embed_templates("templates/*")
diff --git a/lib/modules/publishing/web/index.ex b/lib/modules/publishing/web/index.ex
index db1f65ebd..624aa4f31 100644
--- a/lib/modules/publishing/web/index.ex
+++ b/lib/modules/publishing/web/index.ex
@@ -13,12 +13,15 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do
alias PhoenixKit.Modules.Publishing.Web.Editor.Helpers
@group_statuses Constants.group_statuses()
+ alias PhoenixKit.Modules.Publishing.LanguageHelpers
alias PhoenixKit.Modules.Publishing.ListingCache
alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Date, as: UtilsDate
alias PhoenixKit.Utils.Routes
+ import PhoenixKitWeb.Components.LanguageSwitcher
+
@impl true
def mount(_params, _session, socket) do
# Load date/time format settings once for performance
@@ -404,4 +407,17 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do
end
defp extract_endpoint_url(_), do: ""
+
+ defp build_language_pills(language_codes) when is_list(language_codes) do
+ Enum.map(language_codes, fn lang ->
+ info = LanguageHelpers.get_language_info(lang)
+
+ %{
+ code: lang,
+ short_code: String.upcase(lang),
+ name: if(info, do: info.name, else: lang),
+ exists: true
+ }
+ end)
+ end
end
diff --git a/lib/modules/publishing/web/index.html.heex b/lib/modules/publishing/web/index.html.heex
index 0627b95fb..8ea90b02b 100644
--- a/lib/modules/publishing/web/index.html.heex
+++ b/lib/modules/publishing/web/index.html.heex
@@ -164,14 +164,13 @@
<%= if insight.languages != [] do %>
-
- <%= for language <- insight.languages do %>
-
- {language
- |> PhoenixKit.Modules.Languages.DialectMapper.extract_base()
- |> String.upcase()}
-
- <% end %>
+
+ <.language_switcher
+ languages={build_language_pills(insight.languages)}
+ show_status={false}
+ variant={:pills}
+ size={:xs}
+ />
<% else %>
{gettext("None")}
diff --git a/lib/modules/publishing/web/listing.ex b/lib/modules/publishing/web/listing.ex
index ae11c89c3..f3bffa48a 100644
--- a/lib/modules/publishing/web/listing.ex
+++ b/lib/modules/publishing/web/listing.ex
@@ -17,8 +17,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do
alias PhoenixKit.Utils.Date, as: UtilsDate
alias PhoenixKit.Utils.Routes
- # Import publishing-specific components
- import PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher
+ import PhoenixKitWeb.Components.LanguageSwitcher
@impl true
def mount(params, _session, socket) do
diff --git a/lib/modules/publishing/web/listing.html.heex b/lib/modules/publishing/web/listing.html.heex
index 3e011fabe..683ffa0c8 100644
--- a/lib/modules/publishing/web/listing.html.heex
+++ b/lib/modules/publishing/web/listing.html.heex
@@ -385,7 +385,7 @@
<%!-- Other translations (excluding primary) --%>
<%= if @post_view_mode == "trashed" do %>
- <.publishing_language_switcher
+ <.language_switcher
languages={other_languages}
show_status={true}
show_add={false}
@@ -393,7 +393,7 @@
/>
<% else %>
- <.publishing_language_switcher
+ <.language_switcher
languages={other_languages}
show_status={true}
show_add={true}
diff --git a/lib/modules/publishing/web/preview.ex b/lib/modules/publishing/web/preview.ex
index 914d6c7bb..9074d8003 100644
--- a/lib/modules/publishing/web/preview.ex
+++ b/lib/modules/publishing/web/preview.ex
@@ -20,7 +20,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Preview do
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Routes
- import PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher
+ import PhoenixKitWeb.Components.LanguageSwitcher
@impl true
def mount(params, _session, socket) do
diff --git a/lib/modules/publishing/web/preview.html.heex b/lib/modules/publishing/web/preview.html.heex
index 03c7f2c26..dc7b6eebb 100644
--- a/lib/modules/publishing/web/preview.html.heex
+++ b/lib/modules/publishing/web/preview.html.heex
@@ -70,7 +70,7 @@
<%!-- Language Switcher (links to preview URLs) --%>
<%= if length(@translations) > 1 do %>
- <.publishing_language_switcher
+ <.language_switcher
languages={build_preview_translations(@translations, @post, @group_slug)}
current_language={@current_language}
show_status={false}
diff --git a/lib/modules/publishing/web/templates/index.html.heex b/lib/modules/publishing/web/templates/index.html.heex
index fa8a16962..3133660cb 100644
--- a/lib/modules/publishing/web/templates/index.html.heex
+++ b/lib/modules/publishing/web/templates/index.html.heex
@@ -27,7 +27,7 @@
<%!-- Language Switcher --%>
<%= if length(@translations) > 1 do %>
- <.publishing_language_switcher
+ <.language_switcher
languages={build_public_translations(@translations, @current_language)}
current_language={@current_language}
show_status={false}
diff --git a/lib/modules/publishing/web/templates/show.html.heex b/lib/modules/publishing/web/templates/show.html.heex
index 29d53eead..b76d32cfb 100644
--- a/lib/modules/publishing/web/templates/show.html.heex
+++ b/lib/modules/publishing/web/templates/show.html.heex
@@ -41,7 +41,7 @@
<%!-- Language Switcher --%>
<%= if length(@translations) > 1 do %>
- <.publishing_language_switcher
+ <.language_switcher
languages={build_public_translations(@translations, @current_language)}
current_language={@current_language}
show_status={false}
diff --git a/lib/phoenix_kit_web/components/language_switcher.ex b/lib/phoenix_kit_web/components/language_switcher.ex
new file mode 100644
index 000000000..375b5eed9
--- /dev/null
+++ b/lib/phoenix_kit_web/components/language_switcher.ex
@@ -0,0 +1,1025 @@
+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 `
`
+
+ ## 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)
+
+ assigns =
+ assigns
+ |> assign(:filtered_languages, languages)
+ |> assign(:use_full_names, use_full_names)
+ |> assign(:last_idx, last_idx)
+
+ ~H"""
+
+ <%= for {lang, idx} <- Enum.with_index(@filtered_languages) do %>
+ <% prev_lang = if idx > 0, do: Enum.at(@filtered_languages, idx - 1) %>
+ <% prev_had_divider =
+ prev_lang && show_divider?(@primary_divider, prev_lang, idx - 1, @last_idx) %>
+ 0 && !prev_had_divider && show_separator?(@variant, @use_full_names)}
+ class={["text-base-content/30", size_text_class(@size)]}
+ >
+ |
+
+ <.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}
+ />
+
+ <% end %>
+
+ """
+ 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 %>
+
+ <.item_content {item_content_assigns(assigns)} />
+
+ <% 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)} />
+
+ <% else %>
+
+ <.item_content {item_content_assigns(assigns)} />
+
+ <% 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"""
+
+
+
+ {@flag}
+
+
+ {display_label(@lang, @use_full_names)}
+
+ <.primary_star :if={@show_primary && @is_primary} size={@size} />
+
+ {gettext("Primary")}
+
+
+ """
+ end
+
+ attr :size, :atom, required: true
+
+ defp primary_star(assigns) do
+ ~H"""
+
+
+
+ """
+ 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?(false, _lang, _idx, _last_idx), 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
diff --git a/lib/phoenix_kit_web/components/multilang_form.ex b/lib/phoenix_kit_web/components/multilang_form.ex
new file mode 100644
index 000000000..474147f5c
--- /dev/null
+++ b/lib/phoenix_kit_web/components/multilang_form.ex
@@ -0,0 +1,798 @@
+defmodule PhoenixKitWeb.Components.MultilangForm do
+ @moduledoc """
+ Shared multilang form components and helpers for PhoenixKit modules.
+
+ Provides the language tab switcher UI, skeleton loading placeholders,
+ translatable field components, and Elixir-side helpers for merging
+ multilang data in LiveView forms.
+
+ Designed for two main use cases:
+
+ 1. **Whole-form translation** — wrap all translatable fields in a card with
+ language tabs. The tab bar, skeleton placeholders, and field wrappers
+ are handled automatically.
+
+ 2. **Single-field translation** — drop a `<.translatable_field>` into any
+ form to make one field translatable, with no tab UI required (the caller
+ manages `current_lang` however they like).
+
+ ## Usage in a LiveView
+
+ ### Mount
+
+ import PhoenixKitWeb.Components.MultilangForm
+
+ def mount(params, session, socket) do
+ # ... load your record and changeset ...
+ {:ok, mount_multilang(socket)}
+ end
+
+ ### Events
+
+ def handle_event("switch_language", %{"lang" => lang_code}, socket) do
+ {:noreply, handle_switch_language(socket, lang_code)}
+ end
+
+ def handle_event("validate", %{"record" => params}, socket) do
+ params = merge_translatable_params(params, socket, ["name", "description"],
+ changeset: socket.assigns.changeset)
+ changeset = MySchema.changeset(socket.assigns.record, params)
+ {:noreply, assign(socket, :changeset, changeset)}
+ end
+
+ ### Template — whole-form translation
+
+ <.multilang_tabs
+ multilang_enabled={@multilang_enabled}
+ language_tabs={@language_tabs}
+ current_lang={@current_lang}
+ />
+
+ <.multilang_fields_wrapper
+ multilang_enabled={@multilang_enabled}
+ current_lang={@current_lang}
+ >
+ <.translatable_field
+ field_name="name"
+ form_prefix="catalogue"
+ changeset={@changeset}
+ schema_field={:name}
+ multilang_enabled={@multilang_enabled}
+ current_lang={@current_lang}
+ primary_language={@primary_language}
+ lang_data={@lang_data}
+ label={gettext("Name")}
+ required
+ />
+
+
+ ### Template — single-field translation (no tabs needed)
+
+ <.translatable_field
+ field_name="description"
+ form_prefix="product"
+ changeset={@changeset}
+ schema_field={:description}
+ multilang_enabled={@multilang_enabled}
+ current_lang={@current_lang}
+ primary_language={@primary_language}
+ lang_data={@lang_data}
+ label={gettext("Description")}
+ type="textarea"
+ rows={5}
+ />
+ """
+
+ use Phoenix.Component
+ use Gettext, backend: PhoenixKitWeb.Gettext
+
+ import PhoenixKitWeb.Components.Core.Icon
+ import PhoenixKitWeb.Components.Core.FormFieldError, only: [error: 1]
+ import PhoenixKitWeb.Components.Core.Input, only: [translate_error: 1]
+ import PhoenixKitWeb.Components.LanguageSwitcher, only: [language_switcher: 1]
+
+ alias Phoenix.LiveView.JS
+ alias PhoenixKit.Modules.Entities.Multilang
+
+ # ═══════════════════════════════════════════════════════════════════
+ # Mount & Event Helpers
+ # ═══════════════════════════════════════════════════════════════════
+
+ @doc """
+ Adds multilang assigns to the socket. Call from `mount/3`.
+
+ Adds: `:multilang_enabled`, `:primary_language`, `:current_lang`, `:language_tabs`
+ """
+ def mount_multilang(socket) do
+ multilang_enabled = multilang_enabled?()
+ primary_language = if multilang_enabled, do: safe_primary_language(), else: nil
+
+ Phoenix.Component.assign(socket,
+ multilang_enabled: multilang_enabled,
+ primary_language: primary_language,
+ current_lang: primary_language,
+ language_tabs: if(multilang_enabled, do: safe_build_language_tabs(), else: [])
+ )
+ end
+
+ @doc """
+ Refreshes multilang assigns after external changes (e.g. entity schema update).
+
+ Unlike `mount_multilang/1`, this preserves `current_lang` when it's still valid,
+ and resets it to the primary language if it was removed.
+ """
+ def refresh_multilang(socket) do
+ multilang_enabled = multilang_enabled?()
+ primary_language = if multilang_enabled, do: safe_primary_language(), else: nil
+ language_tabs = if multilang_enabled, do: safe_build_language_tabs(), else: []
+
+ current_lang = socket.assigns[:current_lang]
+ enabled_langs = if multilang_enabled, do: safe_enabled_languages(), else: []
+
+ current_lang =
+ cond do
+ not multilang_enabled -> nil
+ current_lang not in enabled_langs -> primary_language
+ true -> current_lang
+ end
+
+ Phoenix.Component.assign(socket,
+ multilang_enabled: multilang_enabled,
+ primary_language: primary_language,
+ current_lang: current_lang,
+ language_tabs: language_tabs
+ )
+ end
+
+ @doc """
+ Handles the `"switch_language"` event. Call from `handle_event/3`.
+
+ Returns the updated socket with the new `current_lang`.
+ Ignores unknown language codes.
+ """
+ def handle_switch_language(socket, lang_code) do
+ if lang_code in safe_enabled_languages() do
+ Phoenix.Component.assign(socket, :current_lang, lang_code)
+ else
+ socket
+ end
+ end
+
+ @doc """
+ Merges translatable field params into the multilang `data` JSONB structure.
+
+ Takes the raw form params, the socket, and a list of translatable field names
+ (the DB column names, e.g. `["name", "description"]`). Returns updated params
+ with the `"data"` key set to the merged multilang structure.
+
+ On primary language tabs, reads from `params["name"]`.
+ On secondary language tabs, reads from `params["lang_name"]`.
+
+ Also preserves primary language values for non-translatable fields when on
+ secondary tabs via the `preserve_fields` option.
+
+ ## Options
+
+ * `:changeset` — the current changeset (required)
+ * `:preserve_fields` — map of `%{"field_name" => :schema_field}` for fields
+ that should keep their primary language DB column value on secondary tabs.
+ Defaults to `%{}`.
+ """
+ def merge_translatable_params(params, socket, translatable_fields, opts \\ []) do
+ changeset = Keyword.fetch!(opts, :changeset)
+ preserve_fields = Keyword.get(opts, :preserve_fields, %{})
+ assigns = socket.assigns
+ current_lang = assigns[:current_lang]
+ primary = assigns[:primary_language]
+
+ params =
+ if assigns[:multilang_enabled] do
+ form_data = extract_translatable_data(params, translatable_fields, current_lang, primary)
+ final_data = do_merge_multilang_data(changeset, current_lang, form_data, assigns)
+ Map.put(params, "data", final_data)
+ else
+ params
+ end
+
+ do_preserve_primary_fields(params, changeset, assigns, preserve_fields)
+ end
+
+ @doc """
+ Injects a DB column value into the JSONB `data` field for multilang storage.
+
+ This handles the common pattern where a field exists both as a top-level DB
+ column (for queries/sorting) and inside the JSONB `data` (for translations).
+
+ On the primary language tab, reads from `params[field_name]` (the DB column input).
+ On secondary language tabs, reads from `params["lang_" <> field_name]` (the translation input).
+
+ The value is stored in the data map under `"_" <> field_name` (e.g. `"_title"`).
+
+ If no value is submitted (field not in form), preserves the existing value from
+ the changeset's JSONB data.
+
+ ## Requirements
+
+ `assigns` must contain:
+ - `:multilang_enabled` — boolean
+ - `:primary_language` — the primary language code
+ - `:changeset` — an `Ecto.Changeset` with a `:data` field (JSONB)
+
+ ## Examples
+
+ # In handle_event("validate", ...)
+ form_data =
+ form_data
+ |> inject_db_field_into_data("title", data_params, current_lang, socket.assigns)
+ |> inject_db_field_into_data("slug", data_params, current_lang, socket.assigns)
+ """
+ def inject_db_field_into_data(form_data, field_name, params, current_lang, assigns) do
+ if assigns[:multilang_enabled] == true do
+ primary = assigns[:primary_language]
+
+ value =
+ if current_lang == primary,
+ do: params[field_name],
+ else: params["lang_#{field_name}"]
+
+ data_key = "_#{field_name}"
+
+ if is_binary(value) do
+ Map.put(form_data, data_key, value)
+ else
+ # No value submitted — preserve existing from JSONB data
+ existing_data = safe_get_changeset_data(assigns.changeset)
+
+ case Multilang.get_raw_language_data(existing_data, current_lang) do
+ %{^data_key => existing} -> Map.put(form_data, data_key, existing)
+ _ -> form_data
+ end
+ end
+ else
+ form_data
+ end
+ end
+
+ @doc """
+ Merges language-specific validated data into the full multilang JSONB structure.
+
+ Reads existing data from the changeset's `:data` field, then merges the new
+ `validated_data` for the given `lang_code`.
+
+ Handles three cases:
+ - Multilang enabled: uses `Multilang.put_language_data/3`
+ - Multilang disabled but data has multilang structure: preserves translations
+ - Flat data, no multilang: passes through as-is
+
+ The `changeset` must be an `Ecto.Changeset` with a `:data` field.
+ `assigns` must contain `:multilang_enabled`.
+ """
+ def merge_multilang_data(changeset, lang_code, validated_data, assigns) do
+ do_merge_multilang_data(changeset, lang_code, validated_data, assigns)
+ end
+
+ @doc """
+ Gets the raw language data for the current language from a changeset.
+
+ Use this in templates to read override-only values for secondary language tabs.
+ Returns `%{}` when multilang is disabled.
+ """
+ def get_lang_data(changeset, current_lang, multilang_enabled) do
+ if multilang_enabled && changeset do
+ Multilang.get_raw_language_data(
+ Ecto.Changeset.get_field(changeset, :data),
+ current_lang
+ )
+ else
+ %{}
+ end
+ end
+
+ @doc """
+ Returns true when on the primary language tab (or multilang is disabled).
+ """
+ def primary_tab?(assigns) do
+ !assigns[:multilang_enabled] || assigns[:current_lang] == assigns[:primary_language]
+ end
+
+ @doc "Returns true when the Languages module is enabled with 2+ languages."
+ def multilang_enabled? do
+ Code.ensure_loaded?(Multilang) and Multilang.enabled?()
+ rescue
+ _ -> false
+ end
+
+ defp safe_primary_language do
+ Multilang.primary_language()
+ rescue
+ _ -> "en-US"
+ end
+
+ defp safe_enabled_languages do
+ Multilang.enabled_languages()
+ rescue
+ _ -> []
+ end
+
+ defp safe_build_language_tabs do
+ Multilang.build_language_tabs()
+ rescue
+ _ -> []
+ end
+
+ # Safely reads a field from a changeset, returning "" on any error.
+ defp safe_get_field(%Ecto.Changeset{} = changeset, field) when is_atom(field) do
+ Ecto.Changeset.get_field(changeset, field) || ""
+ rescue
+ _ -> ""
+ end
+
+ defp safe_get_field(_changeset, _field), do: ""
+
+ # Safely reads the :data field from a changeset, returning %{} on any error.
+ defp safe_get_changeset_data(%Ecto.Changeset{} = changeset) do
+ Ecto.Changeset.get_field(changeset, :data) || %{}
+ rescue
+ _ -> %{}
+ end
+
+ defp safe_get_changeset_data(_), do: %{}
+
+ # ── Private helpers ────────────────────────────────────────────
+
+ defp extract_translatable_data(params, fields, current_lang, primary) do
+ Enum.reduce(fields, %{}, fn field, acc ->
+ value =
+ if current_lang == primary,
+ do: params[field],
+ else: params["lang_#{field}"]
+
+ if is_binary(value), do: Map.put(acc, "_#{field}", value), else: acc
+ end)
+ end
+
+ defp do_merge_multilang_data(changeset, lang_code, validated_data, assigns) do
+ existing_data = safe_get_changeset_data(changeset)
+
+ cond do
+ assigns[:multilang_enabled] == true ->
+ Multilang.put_language_data(existing_data, lang_code, validated_data)
+
+ Multilang.multilang_data?(existing_data) ->
+ Multilang.put_language_data(existing_data, lang_code, validated_data)
+
+ true ->
+ validated_data
+ end
+ end
+
+ defp do_preserve_primary_fields(params, _changeset, assigns, preserve_fields)
+ when map_size(preserve_fields) == 0 or
+ not is_map_key(assigns, :multilang_enabled) do
+ params
+ end
+
+ defp do_preserve_primary_fields(params, changeset, assigns, preserve_fields) do
+ if assigns[:multilang_enabled] && assigns[:current_lang] != assigns[:primary_language] do
+ Enum.reduce(preserve_fields, params, fn {str_key, atom_key}, acc ->
+ preserve_field_value(acc, changeset, str_key, atom_key)
+ end)
+ else
+ params
+ end
+ end
+
+ defp preserve_field_value(params, changeset, str_key, atom_key) do
+ if Map.has_key?(params, str_key) do
+ params
+ else
+ case Ecto.Changeset.get_field(changeset, atom_key) do
+ nil -> params
+ value -> Map.put(params, str_key, value)
+ end
+ end
+ end
+
+ # ═══════════════════════════════════════════════════════════════════
+ # Components
+ # ═══════════════════════════════════════════════════════════════════
+
+ @doc """
+ Renders the language tab bar with compact/full mode.
+
+ Shows a header with language icon, an info alert explaining the translation
+ workflow, and the shared `<.language_switcher>` in `:tabs` variant with flags,
+ names, and primary star indicator.
+
+ Display mode:
+ - `compact: nil` (default) — auto: full names when ≤ 5 languages, short codes when more
+ - `compact: true` — always short codes
+ - `compact: false` — always full names
+
+ Delegates to `PhoenixKitWeb.Components.LanguageSwitcher.language_switcher/1`
+ for the tab bar rendering.
+
+ ## Attributes
+
+ * `multilang_enabled` — boolean, whether multilang is active
+ * `language_tabs` — list of tab maps from `Multilang.build_language_tabs/0`
+ * `current_lang` — the currently selected language code
+ * `compact` — force compact mode (short codes). Default: nil (auto)
+ * `show_header` — show the "Content Language" header. Default: true
+ * `show_info` — show the info alert. Default: true
+ """
+ attr :multilang_enabled, :boolean, required: true
+ attr :language_tabs, :list, required: true
+ attr :current_lang, :string, required: true
+ attr :compact, :boolean, default: nil
+ attr :show_header, :boolean, default: true
+ attr :show_info, :boolean, default: true
+ attr :class, :string, default: "card-body pb-0"
+
+ def multilang_tabs(assigns) do
+ display =
+ cond do
+ assigns.compact == true -> :compact
+ assigns.compact == false -> :full
+ true -> :auto
+ end
+
+ assigns = assign(assigns, :display, display)
+
+ ~H"""
+ 1} class={@class}>
+
+
+ <.icon name="hero-language" class="w-5 h-5 text-primary" />
+
{gettext("Content Language")}
+
+ <% primary_tab = Enum.find(@language_tabs, fn t -> t.is_primary end) %>
+
+ <.icon name="hero-star-solid" class="w-3.5 h-3.5 text-primary" />
+ {gettext("Primary: %{lang}", lang: primary_tab.name)}
+
+
+
+
+ <.icon name="hero-information-circle" class="w-4 h-4" />
+
+ {gettext(
+ "Use the language tabs below to translate this record's content. The primary language (marked with a star) is required. Other languages are optional — any empty fields will fall back to the primary language value."
+ )}
+
+
+
+
+ <.language_switcher
+ languages={@language_tabs}
+ current_language={@current_lang}
+ on_click_js={&switch_lang_js(&1, @current_lang)}
+ display={@display}
+ auto_threshold={5}
+ show_flags={true}
+ show_primary={true}
+ primary_divider={true}
+ variant={:tabs}
+ size={:sm}
+ />
+
+
+ """
+ end
+
+ @doc """
+ Renders skeleton loading placeholders and a content wrapper for translatable fields.
+
+ The skeleton is shown instantly on tab switch via JS, then hidden when LiveView
+ re-renders with the new language data.
+
+ Wrap your translatable form fields inside this component's inner block.
+
+ ## Customizing skeletons
+
+ Use the `:skeleton` slot to provide custom skeleton markup that matches
+ your form layout. If omitted, a default two-field skeleton is rendered.
+
+ ## Attributes
+
+ * `multilang_enabled` — boolean
+ * `current_lang` — current language code (used in element IDs for morphdom)
+ * `skeleton_class` — CSS class for the skeleton container. Default: `"card-body pt-4"`
+ * `fields_class` — CSS class for the fields container. Default: nil
+
+ ## Example — default skeleton (card context)
+
+ <.multilang_fields_wrapper multilang_enabled={@multilang_enabled} current_lang={@current_lang}>
+ <%!-- translatable fields here --%>
+
+
+ ## Example — custom skeleton and classes (non-card context)
+
+ <.multilang_fields_wrapper
+ multilang_enabled={@multilang_enabled}
+ current_lang={@current_lang}
+ skeleton_class="space-y-6"
+ fields_class="space-y-6"
+ >
+ <:skeleton>
+
+
+ <%!-- translatable fields here --%>
+
+ """
+ attr :multilang_enabled, :boolean, required: true
+ attr :current_lang, :string, required: true
+ attr :skeleton_class, :string, default: "card-body pt-4"
+ attr :fields_class, :string, default: nil
+ slot :skeleton
+ slot :inner_block, required: true
+
+ def multilang_fields_wrapper(assigns) do
+ ~H"""
+ <%!-- Skeleton placeholders (shown instantly on tab click).
+ IDs include current_lang so morphdom treats them as new elements. --%>
+
+ <%= if @skeleton != [] do %>
+ {render_slot(@skeleton)}
+ <% else %>
+
+ <% end %>
+
+
+
+ {render_slot(@inner_block)}
+
+ """
+ end
+
+ @doc """
+ Renders a translatable text input or textarea field.
+
+ On the primary language tab, renders a standard input reading from the changeset.
+ On secondary language tabs, renders with a language-specific name and uses the
+ primary language value as placeholder text.
+
+ Works both inside a `<.multilang_fields_wrapper>` (whole-form translation) and
+ standalone (single-field translation).
+
+ ## Translation models
+
+ Supports two naming patterns for secondary language inputs:
+
+ **Default (JSONB data column)** — used by entity data records:
+ - Secondary name: `form_prefix[lang_field_name]`
+ - Lang data key: `"_field_name"`
+
+ **Settings translations** — used by entity definitions and other models
+ that store translations in a `settings["translations"]` map. Set
+ `secondary_name` and `lang_data_key` to override the defaults:
+
+ <.translatable_field
+ field_name="display_name"
+ form_prefix="entities"
+ secondary_name={"entities[translations][\#{@current_lang}][display_name]"}
+ lang_data_key="display_name"
+ ...
+ />
+
+ ## Attributes
+
+ * `field_name` — the DB column name (e.g., "name")
+ * `form_prefix` — the form name prefix (e.g., "catalogue")
+ * `changeset` — the Ecto changeset
+ * `schema_field` — the schema field atom (e.g., `:name`)
+ * `multilang_enabled` — boolean
+ * `current_lang` — current language code
+ * `primary_language` — primary language code
+ * `lang_data` — raw language data map for the current language
+ * `label` — field label text
+ * `placeholder` — placeholder for primary language (optional)
+ * `type` — "input" or "textarea". Default: "input"
+ * `rows` — textarea rows (only for type="textarea"). Default: 3
+ * `required` — marks the primary language field as required. Default: false
+ * `disabled` — disables the field. Default: false
+ * `class` — additional CSS class(es) for the input element. Default: nil
+ * `pattern` — HTML pattern attribute for input validation. Default: nil
+ * `title` — HTML title attribute (for pattern validation message). Default: nil
+ * `hint` — hint text shown below the field. Default: nil
+ * `secondary_hint` — hint text shown only on secondary language tabs. Default: nil
+ * `secondary_name` — override the secondary tab input name. Default: `"form_prefix[lang_field_name]"`
+ * `lang_data_key` — key to look up in `lang_data` for secondary value.
+ Default: `"_field_name"`. Set to `"field_name"` for settings translations.
+ """
+ attr :field_name, :string, required: true
+ attr :form_prefix, :string, required: true
+ attr :changeset, :any, required: true
+ attr :schema_field, :atom, required: true
+ attr :multilang_enabled, :boolean, required: true
+ attr :current_lang, :string, required: true
+ attr :primary_language, :string, required: true
+ attr :lang_data, :map, required: true
+ attr :label, :string, required: true
+ attr :placeholder, :string, default: nil
+ attr :type, :string, default: "input"
+ attr :rows, :integer, default: 3
+ attr :required, :boolean, default: false
+ attr :disabled, :boolean, default: false
+ attr :class, :string, default: nil
+ attr :pattern, :string, default: nil
+ attr :title, :string, default: nil
+ attr :hint, :string, default: nil
+ attr :secondary_hint, :string, default: nil
+ attr :secondary_name, :string, default: nil
+ attr :lang_data_key, :string, default: nil
+ slot :label_extra
+
+ def translatable_field(assigns) do
+ # Coerce lang_data to map if nil/invalid
+ assigns =
+ if is_map(assigns.lang_data), do: assigns, else: assign(assigns, :lang_data, %{})
+
+ is_primary = !assigns.multilang_enabled || assigns.current_lang == assigns.primary_language
+
+ # Resolve the lang_data lookup key: custom or default "_field_name"
+ data_key = assigns.lang_data_key || "_#{assigns.field_name}"
+
+ # Resolve the secondary input name: custom or default "prefix[lang_field]"
+ sec_name =
+ assigns.secondary_name || "#{assigns.form_prefix}[lang_#{assigns.field_name}]"
+
+ # Extract changeset errors for this field (primary tab only)
+ errors = field_errors(assigns.changeset, assigns.schema_field, is_primary)
+
+ assigns =
+ assigns
+ |> assign(:is_primary, is_primary)
+ |> assign(:errors, errors)
+ |> assign_new(:primary_value, fn ->
+ safe_get_field(assigns.changeset, assigns.schema_field)
+ end)
+ |> assign_new(:lang_value, fn ->
+ Map.get(assigns.lang_data, data_key)
+ end)
+ |> assign(:secondary_input_name, sec_name)
+ |> assign_new(:input_id, fn ->
+ if is_primary do
+ "#{assigns.form_prefix}_#{assigns.field_name}"
+ else
+ "#{assigns.form_prefix}_#{assigns.field_name}_#{assigns.current_lang}"
+ end
+ end)
+ |> assign_new(:input_class, fn ->
+ base =
+ if assigns.type == "textarea",
+ do: "textarea textarea-bordered",
+ else: "input input-bordered"
+
+ base = if assigns.class, do: "#{base} #{assigns.class}", else: base
+ if errors != [], do: "#{base} input-error", else: base
+ end)
+
+ ~H"""
+
+
+
+ {@label}
+ <%= if @required && @is_primary do %>
+ *
+ <% end %>
+
+ {render_slot(@label_extra)}
+
+ <%= if @type == "textarea" do %>
+ <%= if @is_primary do %>
+
+ <% else %>
+
+ <% end %>
+ <% else %>
+ <%= if @is_primary do %>
+
+ <% else %>
+
+ <% end %>
+ <% end %>
+ <.error :for={msg <- @errors}>{msg}
+
+ {@hint}
+
+
+ {@secondary_hint}
+
+
+ """
+ end
+
+ # Extracts translated error messages for a field from the changeset.
+ # Only returns errors on the primary tab — secondary tabs don't validate DB columns.
+ defp field_errors(%Ecto.Changeset{action: action, errors: errors}, field, true = _is_primary)
+ when not is_nil(action) do
+ errors
+ |> Keyword.get_values(field)
+ |> Enum.map(&translate_error/1)
+ end
+
+ defp field_errors(_changeset, _field, _is_primary), do: []
+
+ # ── JS helper ──────────────────────────────────────────────────
+
+ @doc """
+ Returns a `Phoenix.LiveView.JS` command that switches languages.
+
+ Pushes the `"switch_language"` event, hides current field content,
+ and shows skeleton placeholders for instant visual feedback.
+
+ Returns a no-op when `lang_code == current_lang` to prevent skeleton ghosts.
+ """
+ def switch_lang_js(lang_code, current_lang) do
+ if lang_code == current_lang do
+ %JS{}
+ else
+ JS.push("switch_language", value: %{lang: lang_code})
+ |> JS.add_class("hidden", to: "[data-translatable=fields]")
+ |> JS.remove_class("hidden", to: "[data-translatable=skeletons]")
+ end
+ end
+end
diff --git a/test/phoenix_kit_web/components/language_switcher_test.exs b/test/phoenix_kit_web/components/language_switcher_test.exs
new file mode 100644
index 000000000..9739548f5
--- /dev/null
+++ b/test/phoenix_kit_web/components/language_switcher_test.exs
@@ -0,0 +1,740 @@
+defmodule PhoenixKitWeb.Components.LanguageSwitcherTest do
+ use ExUnit.Case, async: true
+
+ import Phoenix.LiveViewTest, only: [rendered_to_string: 1]
+ import Phoenix.Component, only: [sigil_H: 2]
+ import PhoenixKitWeb.Components.LanguageSwitcher
+
+ alias Phoenix.LiveView.JS
+
+ # ── Test data ────────────────────────────────────────────────
+
+ defp two_languages do
+ [
+ %{code: "en-US", name: "English (US)", short_code: "EN-US", flag: "🇺🇸", is_primary: true},
+ %{code: "es", name: "Spanish", short_code: "ES", flag: "🇪🇸", is_primary: false}
+ ]
+ end
+
+ defp six_languages do
+ [
+ %{code: "en", name: "English", short_code: "EN", is_primary: true},
+ %{code: "fr", name: "French", short_code: "FR", is_primary: false},
+ %{code: "de", name: "German", short_code: "DE", is_primary: false},
+ %{code: "es", name: "Spanish", short_code: "ES", is_primary: false},
+ %{code: "it", name: "Italian", short_code: "IT", is_primary: false},
+ %{code: "ja", name: "Japanese", short_code: "JA", is_primary: false}
+ ]
+ end
+
+ defp publishing_languages do
+ [
+ %{
+ code: "en",
+ name: "English",
+ display_code: "en",
+ status: "published",
+ exists: true,
+ is_primary: true
+ },
+ %{
+ code: "fr",
+ name: "French",
+ display_code: "fr",
+ status: "draft",
+ exists: true,
+ is_primary: false
+ },
+ %{
+ code: "de",
+ name: "German",
+ display_code: "de",
+ status: nil,
+ exists: false,
+ is_primary: false
+ }
+ ]
+ end
+
+ defp string_keyed_languages do
+ [
+ %{
+ "code" => "en",
+ "name" => "English",
+ "is_primary" => true,
+ "status" => "published",
+ "exists" => true
+ },
+ %{
+ "code" => "fr",
+ "name" => "French",
+ "is_primary" => false,
+ "status" => "draft",
+ "exists" => true
+ }
+ ]
+ end
+
+ defp html(assigns) do
+ rendered_to_string(assigns)
+ end
+
+ # ── Basic rendering ─────────────────────────────────────────
+
+ describe "basic rendering" do
+ test "renders nothing with empty list" do
+ assigns = %{}
+ result = html(~H"<.language_switcher languages={[]} />")
+ assert result =~ ""
+ end
+
+ test "renders nothing with single language for inline/tabs" do
+ assigns = %{langs: [%{code: "en", name: "English"}]}
+ result = html(~H"<.language_switcher languages={@langs} />")
+ refute result =~ "tablist"
+ end
+
+ test "renders two languages with inline variant" do
+ assigns = %{langs: two_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en-US" />
+ """)
+
+ assert result =~ "English (US)"
+ assert result =~ "Spanish"
+ assert result =~ "|"
+ end
+
+ test "renders with tabs variant" do
+ assigns = %{langs: two_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en-US" variant={:tabs} />
+ """)
+
+ assert result =~ "bg-base-200 rounded-box"
+ assert result =~ "role=\"tablist\""
+ end
+
+ test "renders with pills variant" do
+ assigns = %{langs: publishing_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en" variant={:pills} show_status={true} />
+ """)
+
+ assert result =~ "rounded-lg"
+ end
+
+ test "renders single language for pills variant" do
+ assigns = %{langs: [%{code: "en", name: "English", exists: true}]}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} variant={:pills} show_status={true} />
+ """)
+
+ # Single language (<=3) uses full names in auto mode
+ assert result =~ "English"
+ end
+ end
+
+ # ── Display modes ───────────────────────────────────────────
+
+ describe "display modes" do
+ test "auto mode shows full names when <= threshold" do
+ assigns = %{langs: two_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher
+ languages={@langs}
+ current_language="en-US"
+ display={:auto}
+ auto_threshold={3}
+ />
+ """)
+
+ assert result =~ "English (US)"
+ assert result =~ "Spanish"
+ end
+
+ test "auto mode shows short codes when > threshold" do
+ assigns = %{langs: six_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en" display={:auto} auto_threshold={3} />
+ """)
+
+ assert result =~ "EN"
+ assert result =~ "FR"
+ end
+
+ test "compact mode always shows short codes" do
+ assigns = %{langs: two_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en-US" display={:compact} />
+ """)
+
+ assert result =~ "EN-US"
+ assert result =~ "ES"
+ end
+
+ test "full mode always shows full names" do
+ assigns = %{langs: six_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en" display={:full} />
+ """)
+
+ assert result =~ "English"
+ assert result =~ "French"
+ assert result =~ "German"
+ end
+ end
+
+ # ── Filtering ───────────────────────────────────────────────
+
+ describe "filtering" do
+ test "show_status=false filters non-published languages with exists/status" do
+ assigns = %{langs: publishing_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en" />
+ """)
+
+ # Only English (published) passes the filter, but single language
+ # means inline variant renders nothing
+ assert result == ""
+ end
+
+ test "show_status=true shows all languages" do
+ assigns = %{langs: publishing_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en" show_status={true} />
+ """)
+
+ # Titles include status annotations
+ assert result =~ "title=\"English (Published)"
+ assert result =~ "title=\"French (Draft)"
+ assert result =~ "title=\"Add German translation"
+ end
+
+ test "languages without exists/status pass through when show_status=false" do
+ assigns = %{langs: two_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en-US" />
+ """)
+
+ # 2 languages <= 3 threshold => full names in auto mode
+ assert result =~ "English (US)"
+ assert result =~ "Spanish"
+ end
+
+ test "exclude_primary removes primary language" do
+ assigns = %{langs: publishing_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher
+ languages={@langs}
+ current_language="en"
+ show_status={true}
+ exclude_primary={true}
+ />
+ """)
+
+ refute result =~ "title=\"English"
+ # 2 languages <= 3 threshold => full names in auto mode
+ assert result =~ "French"
+ assert result =~ "German"
+ end
+
+ test "string-keyed maps work with filtering" do
+ assigns = %{langs: string_keyed_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en" show_status={true} />
+ """)
+
+ # 2 languages <= 3 threshold => full names in auto mode
+ assert result =~ "English"
+ assert result =~ "French"
+ end
+ end
+
+ # ── Status dots ─────────────────────────────────────────────
+
+ describe "status dots" do
+ test "renders dots when show_status=true" do
+ assigns = %{langs: publishing_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en" show_status={true} />
+ """)
+
+ assert result =~ "rounded-full"
+ assert result =~ "bg-success"
+ assert result =~ "bg-warning"
+ end
+
+ test "no dots when show_status=false" do
+ assigns = %{langs: two_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en-US" show_status={false} />
+ """)
+
+ refute result =~ "rounded-full"
+ end
+
+ test "custom dot_color overrides status" do
+ assigns = %{
+ langs: [
+ %{code: "en", name: "English", exists: true, dot_color: "error"},
+ %{code: "fr", name: "French", exists: true, dot_color: "info"}
+ ]
+ }
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en" show_status={true} />
+ """)
+
+ assert result =~ "bg-error"
+ assert result =~ "bg-info"
+ end
+
+ test "invalid dot_color falls back to status" do
+ langs = [
+ %{code: "en", name: "English", exists: true, status: "published", dot_color: "invalid"},
+ %{code: "fr", name: "French", exists: true, status: "draft"}
+ ]
+
+ assigns = %{langs: langs}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en" show_status={true} />
+ """)
+
+ assert result =~ "bg-success"
+ assert result =~ "bg-warning"
+ end
+ end
+
+ # ── Primary indicators ─────────────────────────────────────
+
+ describe "primary indicators" do
+ test "show_primary renders star SVG" do
+ assigns = %{langs: two_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en-US" show_primary={true} />
+ """)
+
+ assert result =~ "
+ """)
+
+ assert result =~ "Primary"
+ end
+
+ test "primary_divider renders vertical line" do
+ assigns = %{langs: two_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher
+ languages={@langs}
+ current_language="en-US"
+ show_primary={true}
+ primary_divider={true}
+ />
+ """)
+
+ assert result =~ "w-px h-4 bg-base-content/20"
+ end
+ end
+
+ # ── Separators ──────────────────────────────────────────────
+
+ describe "separators" do
+ test "inline always shows pipe separators" do
+ assigns = %{langs: two_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en-US" variant={:inline} />
+ """)
+
+ assert result =~ "|"
+ end
+
+ test "tabs compact shows pipe separators" do
+ assigns = %{langs: six_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en" variant={:tabs} display={:compact} />
+ """)
+
+ assert result =~ "|"
+ end
+
+ test "tabs full does not show pipe separators" do
+ assigns = %{langs: two_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en-US" variant={:tabs} display={:full} />
+ """)
+
+ refute result =~ "text-base-content/30"
+ end
+
+ test "pills never shows pipe separators" do
+ assigns = %{langs: publishing_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en" variant={:pills} show_status={true} />
+ """)
+
+ refute result =~ "text-base-content/30"
+ end
+ end
+
+ # ── Interaction modes ───────────────────────────────────────
+
+ describe "interaction modes" do
+ test "on_click renders buttons" do
+ assigns = %{langs: two_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en-US" on_click="switch_language" />
+ """)
+
+ assert result =~ " JS.push("switch", value: %{lang: code}) end
+ assigns = %{langs: two_languages(), js_fn: js_fn}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en-US" on_click_js={@js_fn} />
+ """)
+
+ assert result =~ "
+ """)
+
+ assert result =~ "
+ """)
+
+ assert result =~ "
+ """)
+
+ assert result =~ "aria-pressed=\"true\""
+ assert result =~ "aria-pressed=\"false\""
+ end
+
+ test "aria-current on links" do
+ langs = [
+ %{code: "en", name: "English", url: "/en", status: "published", exists: true},
+ %{code: "fr", name: "French", url: "/fr", status: "published", exists: true}
+ ]
+
+ assigns = %{langs: langs}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en" show_status={true} />
+ """)
+
+ assert result =~ "aria-current=\"true\""
+ end
+ end
+
+ # ── Flags ───────────────────────────────────────────────────
+
+ describe "flags" do
+ test "show_flags renders flag emojis" do
+ assigns = %{langs: two_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en-US" show_flags={true} />
+ """)
+
+ assert result =~ "🇺🇸"
+ assert result =~ "🇪🇸"
+ end
+
+ test "flags hidden by default" do
+ assigns = %{langs: two_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en-US" />
+ """)
+
+ refute result =~ "🇺🇸"
+ end
+ end
+
+ # ── Active styling ──────────────────────────────────────────
+
+ describe "active styling" do
+ test "current tab has primary styling in tabs" do
+ assigns = %{langs: two_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en-US" variant={:tabs} on_click="switch" />
+ """)
+
+ assert result =~ "bg-primary/20 text-primary"
+ end
+
+ test "current item has primary styling in inline" do
+ assigns = %{langs: two_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en-US" on_click="switch" />
+ """)
+
+ assert result =~ "bg-primary/30 text-primary"
+ end
+ end
+
+ # ── Coercion / safety ──────────────────────────────────────
+
+ describe "coercion and safety" do
+ test "non-map items filtered out" do
+ langs = [%{code: "en", name: "English"}, "not a map", 42, %{code: "fr", name: "French"}]
+ assigns = %{langs: langs}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en" />
+ """)
+
+ # 2 valid languages <= 3 threshold => full names in auto mode
+ assert result =~ "English"
+ assert result =~ "French"
+ end
+
+ test "language without code renders with ? in compact mode" do
+ langs = [%{name: "No Code"}, %{code: "en", name: "English"}]
+ assigns = %{langs: langs}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en" display={:compact} />
+ """)
+
+ assert result =~ "?"
+ end
+
+ test "on_click_js that raises is handled" do
+ bad_fn = fn _code -> raise "boom" end
+ assigns = %{langs: two_languages(), bad_fn: bad_fn}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en-US" on_click_js={@bad_fn} />
+ """)
+
+ assert result =~ "
+ """)
+
+ # 2 languages <= 3 threshold => full names in auto mode
+ assert result =~ "English"
+ assert result =~ "French"
+ assert result =~ "bg-success"
+ assert result =~ "bg-warning"
+ end
+ end
+
+ # ── Container attributes ────────────────────────────────────
+
+ describe "container attributes" do
+ test "id is set" do
+ assigns = %{langs: two_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en-US" id="my-switcher" />
+ """)
+
+ assert result =~ "id=\"my-switcher\""
+ end
+
+ test "custom class applied" do
+ assigns = %{langs: two_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en-US" class="mt-4" />
+ """)
+
+ assert result =~ "mt-4"
+ end
+ end
+
+ # ── Size variants ───────────────────────────────────────────
+
+ describe "sizes" do
+ test "xs uses smaller classes" do
+ assigns = %{langs: two_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en-US" size={:xs} />
+ """)
+
+ assert result =~ "text-xs"
+ end
+
+ test "md uses larger classes" do
+ assigns = %{langs: two_languages()}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en-US" size={:md} />
+ """)
+
+ assert result =~ "text-base"
+ end
+ end
+
+ # ── Short code uppercasing ──────────────────────────────────
+
+ describe "uppercasing" do
+ test "display_code is uppercased" do
+ langs = [
+ %{code: "en", name: "English", display_code: "en"},
+ %{code: "fr", name: "French", display_code: "fr"}
+ ]
+
+ assigns = %{langs: langs}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en" display={:compact} />
+ """)
+
+ assert result =~ "EN"
+ assert result =~ "FR"
+ end
+
+ test "auto-derived code is uppercased" do
+ langs = [
+ %{code: "en-US", name: "English"},
+ %{code: "fr-FR", name: "French"}
+ ]
+
+ assigns = %{langs: langs}
+
+ result =
+ html(~H"""
+ <.language_switcher languages={@langs} current_language="en-US" display={:compact} />
+ """)
+
+ assert result =~ "EN"
+ assert result =~ "FR"
+ end
+ end
+
+ # ── lang_code/1 ─────────────────────────────────────────────
+
+ describe "lang_code/1" do
+ test "reads atom key" do
+ assert lang_code(%{code: "en"}) == "en"
+ end
+
+ test "reads string key" do
+ assert lang_code(%{"code" => "fr"}) == "fr"
+ end
+
+ test "returns nil for missing key" do
+ assert lang_code(%{name: "English"}) == nil
+ end
+
+ test "returns nil for non-map" do
+ assert lang_code("en") == nil
+ end
+ end
+end
diff --git a/test/phoenix_kit_web/components/multilang_form_test.exs b/test/phoenix_kit_web/components/multilang_form_test.exs
new file mode 100644
index 000000000..a6ef1f8be
--- /dev/null
+++ b/test/phoenix_kit_web/components/multilang_form_test.exs
@@ -0,0 +1,684 @@
+defmodule PhoenixKitWeb.Components.MultilangFormTest do
+ use ExUnit.Case, async: true
+
+ import Phoenix.LiveViewTest, only: [rendered_to_string: 1]
+ import Phoenix.Component, only: [sigil_H: 2]
+ import PhoenixKitWeb.Components.MultilangForm
+
+ # ── Test helpers ─────────────────────────────────────────────
+
+ defp html(assigns) do
+ rendered_to_string(assigns)
+ end
+
+ defp make_changeset(data \\ %{}, changes \\ %{}) do
+ types = %{
+ title: :string,
+ slug: :string,
+ name: :string,
+ display_name: :string,
+ display_name_plural: :string,
+ description: :string,
+ status: :string,
+ data: :map
+ }
+
+ {data, types}
+ |> Ecto.Changeset.cast(changes, Map.keys(types))
+ end
+
+ defp make_error_changeset(field, message) do
+ make_changeset()
+ |> Ecto.Changeset.add_error(field, message)
+ |> Map.put(:action, :validate)
+ end
+
+ # ── multilang_enabled? ──────────────────────────────────────
+
+ describe "multilang_enabled?/0" do
+ test "returns boolean without crashing" do
+ result = multilang_enabled?()
+ assert is_boolean(result)
+ end
+ end
+
+ # ── primary_tab? ────────────────────────────────────────────
+
+ describe "primary_tab?/1" do
+ test "returns true when multilang disabled" do
+ assert primary_tab?(%{multilang_enabled: false, current_lang: nil, primary_language: nil})
+ end
+
+ test "returns true when on primary language" do
+ assert primary_tab?(%{multilang_enabled: true, current_lang: "en", primary_language: "en"})
+ end
+
+ test "returns false when on secondary language" do
+ refute primary_tab?(%{multilang_enabled: true, current_lang: "fr", primary_language: "en"})
+ end
+ end
+
+ # ── get_lang_data ───────────────────────────────────────────
+
+ describe "get_lang_data/3" do
+ test "returns empty map when multilang disabled" do
+ changeset = make_changeset()
+ assert get_lang_data(changeset, "en", false) == %{}
+ end
+
+ test "returns empty map when changeset is nil" do
+ assert get_lang_data(nil, "en", true) == %{}
+ end
+ end
+
+ # ── translatable_field primary tab ──────────────────────────
+
+ describe "translatable_field primary tab" do
+ test "renders input with primary name" do
+ changeset = make_changeset(%{title: "Hello"})
+ assigns = %{changeset: changeset}
+
+ result =
+ html(~H"""
+ <.translatable_field
+ field_name="title"
+ form_prefix="record"
+ changeset={@changeset}
+ schema_field={:title}
+ multilang_enabled={false}
+ current_lang="en"
+ primary_language="en"
+ lang_data={%{}}
+ label="Title"
+ />
+ """)
+
+ assert result =~ ~s(name="record[title]")
+ assert result =~ ~s(id="record_title")
+ assert result =~ "Title"
+ assert result =~ "Hello"
+ end
+
+ test "renders required marker" do
+ changeset = make_changeset()
+ assigns = %{changeset: changeset}
+
+ result =
+ html(~H"""
+ <.translatable_field
+ field_name="title"
+ form_prefix="record"
+ changeset={@changeset}
+ schema_field={:title}
+ multilang_enabled={true}
+ current_lang="en"
+ primary_language="en"
+ lang_data={%{}}
+ label="Title"
+ required
+ />
+ """)
+
+ assert result =~ "*"
+ assert result =~ "required"
+ end
+
+ test "renders textarea" do
+ changeset = make_changeset(%{description: "Some text"})
+ assigns = %{changeset: changeset}
+
+ result =
+ html(~H"""
+ <.translatable_field
+ field_name="description"
+ form_prefix="record"
+ changeset={@changeset}
+ schema_field={:description}
+ multilang_enabled={false}
+ current_lang="en"
+ primary_language="en"
+ lang_data={%{}}
+ label="Description"
+ type="textarea"
+ rows={5}
+ />
+ """)
+
+ assert result =~ "
+ """)
+
+ assert result =~ "Lowercase only"
+ end
+
+ test "renders label_extra slot" do
+ changeset = make_changeset()
+ assigns = %{changeset: changeset}
+
+ result =
+ html(~H"""
+ <.translatable_field
+ field_name="slug"
+ form_prefix="record"
+ changeset={@changeset}
+ schema_field={:slug}
+ multilang_enabled={true}
+ current_lang="en"
+ primary_language="en"
+ lang_data={%{}}
+ label="Slug"
+ >
+ <:label_extra>
+ Generate
+
+
+ """)
+
+ assert result =~ "Generate"
+ end
+ end
+
+ # ── translatable_field secondary tab ────────────────────────
+
+ describe "translatable_field secondary tab" do
+ test "renders with lang_ prefix by default" do
+ changeset = make_changeset(%{title: "Primary Title"})
+ assigns = %{changeset: changeset}
+
+ result =
+ html(~H"""
+ <.translatable_field
+ field_name="title"
+ form_prefix="record"
+ changeset={@changeset}
+ schema_field={:title}
+ multilang_enabled={true}
+ current_lang="fr"
+ primary_language="en"
+ lang_data={%{"_title" => "Titre"}}
+ label="Title"
+ />
+ """)
+
+ assert result =~ ~s(name="record[lang_title]")
+ assert result =~ ~s(id="record_title_fr")
+ assert result =~ ~s(value="Titre")
+ assert result =~ ~s(placeholder="Primary Title")
+ end
+
+ test "renders with custom secondary_name" do
+ changeset = make_changeset(%{display_name: "Brand"})
+ assigns = %{changeset: changeset}
+
+ result =
+ html(~H"""
+ <.translatable_field
+ field_name="display_name"
+ form_prefix="entities"
+ changeset={@changeset}
+ schema_field={:display_name}
+ multilang_enabled={true}
+ current_lang="es"
+ primary_language="en"
+ lang_data={%{"display_name" => "Marca"}}
+ secondary_name="entities[translations][es][display_name]"
+ lang_data_key="display_name"
+ label="Display Name"
+ />
+ """)
+
+ assert result =~ ~s(name="entities[translations][es][display_name]")
+ assert result =~ ~s(value="Marca")
+ end
+
+ test "no required marker on secondary" do
+ changeset = make_changeset()
+ assigns = %{changeset: changeset}
+
+ result =
+ html(~H"""
+ <.translatable_field
+ field_name="title"
+ form_prefix="record"
+ changeset={@changeset}
+ schema_field={:title}
+ multilang_enabled={true}
+ current_lang="fr"
+ primary_language="en"
+ lang_data={%{}}
+ label="Title"
+ required
+ />
+ """)
+
+ refute result =~ "*"
+ refute result =~ "required"
+ end
+
+ test "renders secondary_hint" do
+ changeset = make_changeset()
+ assigns = %{changeset: changeset}
+
+ result =
+ html(~H"""
+ <.translatable_field
+ field_name="slug"
+ form_prefix="record"
+ changeset={@changeset}
+ schema_field={:slug}
+ multilang_enabled={true}
+ current_lang="fr"
+ primary_language="en"
+ lang_data={%{}}
+ label="Slug"
+ hint="Auto-generated"
+ secondary_hint="Leave empty for primary slug"
+ />
+ """)
+
+ assert result =~ "Leave empty for primary slug"
+ refute result =~ "Auto-generated"
+ end
+ end
+
+ # ── Error display ───────────────────────────────────────────
+
+ describe "error display" do
+ test "shows errors on primary tab with action" do
+ changeset = make_error_changeset(:title, "can't be blank")
+ assigns = %{changeset: changeset}
+
+ result =
+ html(~H"""
+ <.translatable_field
+ field_name="title"
+ form_prefix="record"
+ changeset={@changeset}
+ schema_field={:title}
+ multilang_enabled={true}
+ current_lang="en"
+ primary_language="en"
+ lang_data={%{}}
+ label="Title"
+ required
+ />
+ """)
+
+ assert result =~ "input-error"
+ end
+
+ test "no errors on secondary tab" do
+ changeset = make_error_changeset(:title, "can't be blank")
+ assigns = %{changeset: changeset}
+
+ result =
+ html(~H"""
+ <.translatable_field
+ field_name="title"
+ form_prefix="record"
+ changeset={@changeset}
+ schema_field={:title}
+ multilang_enabled={true}
+ current_lang="fr"
+ primary_language="en"
+ lang_data={%{}}
+ label="Title"
+ />
+ """)
+
+ refute result =~ "input-error"
+ end
+
+ test "no errors without action" do
+ changeset =
+ make_changeset()
+ |> Ecto.Changeset.add_error(:title, "can't be blank")
+
+ assigns = %{changeset: changeset}
+
+ result =
+ html(~H"""
+ <.translatable_field
+ field_name="title"
+ form_prefix="record"
+ changeset={@changeset}
+ schema_field={:title}
+ multilang_enabled={true}
+ current_lang="en"
+ primary_language="en"
+ lang_data={%{}}
+ label="Title"
+ />
+ """)
+
+ refute result =~ "input-error"
+ end
+
+ test "hint hidden when errors present" do
+ changeset = make_error_changeset(:title, "can't be blank")
+ assigns = %{changeset: changeset}
+
+ result =
+ html(~H"""
+ <.translatable_field
+ field_name="title"
+ form_prefix="record"
+ changeset={@changeset}
+ schema_field={:title}
+ multilang_enabled={true}
+ current_lang="en"
+ primary_language="en"
+ lang_data={%{}}
+ label="Title"
+ hint="Enter a title"
+ />
+ """)
+
+ refute result =~ "Enter a title"
+ end
+ end
+
+ # ── Field attributes ────────────────────────────────────────
+
+ describe "field attributes" do
+ test "disabled" do
+ changeset = make_changeset()
+ assigns = %{changeset: changeset}
+
+ result =
+ html(~H"""
+ <.translatable_field
+ field_name="title"
+ form_prefix="record"
+ changeset={@changeset}
+ schema_field={:title}
+ multilang_enabled={false}
+ current_lang="en"
+ primary_language="en"
+ lang_data={%{}}
+ label="Title"
+ disabled
+ />
+ """)
+
+ assert result =~ "disabled"
+ end
+
+ test "pattern and title" do
+ changeset = make_changeset()
+ assigns = %{changeset: changeset}
+
+ result =
+ html(~H"""
+ <.translatable_field
+ field_name="slug"
+ form_prefix="record"
+ changeset={@changeset}
+ schema_field={:slug}
+ multilang_enabled={false}
+ current_lang="en"
+ primary_language="en"
+ lang_data={%{}}
+ label="Slug"
+ pattern="[a-z0-9-]+"
+ title="Lowercase only"
+ />
+ """)
+
+ assert result =~ ~s(pattern="[a-z0-9-]+")
+ assert result =~ "Lowercase only"
+ end
+
+ test "custom class" do
+ changeset = make_changeset()
+ assigns = %{changeset: changeset}
+
+ result =
+ html(~H"""
+ <.translatable_field
+ field_name="title"
+ form_prefix="record"
+ changeset={@changeset}
+ schema_field={:title}
+ multilang_enabled={false}
+ current_lang="en"
+ primary_language="en"
+ lang_data={%{}}
+ label="Title"
+ class="w-full"
+ />
+ """)
+
+ assert result =~ "input input-bordered w-full"
+ end
+ end
+
+ # ── Safety ──────────────────────────────────────────────────
+
+ describe "safety" do
+ test "nil lang_data coerced to empty map" do
+ changeset = make_changeset(%{title: "Test"})
+ assigns = %{changeset: changeset}
+
+ result =
+ html(~H"""
+ <.translatable_field
+ field_name="title"
+ form_prefix="record"
+ changeset={@changeset}
+ schema_field={:title}
+ multilang_enabled={true}
+ current_lang="fr"
+ primary_language="en"
+ lang_data={nil}
+ label="Title"
+ />
+ """)
+
+ assert result =~ "record[lang_title]"
+ end
+ end
+
+ # ── multilang_fields_wrapper ────────────────────────────────
+
+ describe "multilang_fields_wrapper" do
+ test "renders skeleton and fields containers" do
+ assigns = %{}
+
+ result =
+ html(~H"""
+ <.multilang_fields_wrapper multilang_enabled={true} current_lang="en">
+ Content
+
+ """)
+
+ assert result =~ "translatable-skeletons-en"
+ assert result =~ "translatable-fields-en"
+ assert result =~ "Content"
+ assert result =~ ~s(data-translatable="skeletons")
+ assert result =~ ~s(data-translatable="fields")
+ end
+
+ test "skeleton is hidden" do
+ assigns = %{}
+
+ result =
+ html(~H"""
+ <.multilang_fields_wrapper multilang_enabled={true} current_lang="en">
+ Content
+
+ """)
+
+ assert result =~ "hidden"
+ end
+
+ test "custom skeleton slot" do
+ assigns = %{}
+
+ result =
+ html(~H"""
+ <.multilang_fields_wrapper multilang_enabled={true} current_lang="en">
+ <:skeleton>
+ Loading...
+
+ Content
+
+ """)
+
+ assert result =~ "custom-skeleton"
+ assert result =~ "Loading..."
+ end
+
+ test "custom skeleton_class" do
+ assigns = %{}
+
+ result =
+ html(~H"""
+ <.multilang_fields_wrapper multilang_enabled={true} current_lang="en" skeleton_class="space-y-6">
+ Content
+
+ """)
+
+ assert result =~ "space-y-6"
+ end
+
+ test "custom fields_class" do
+ assigns = %{}
+
+ result =
+ html(~H"""
+ <.multilang_fields_wrapper multilang_enabled={true} current_lang="en" fields_class="space-y-6">
+ Content
+
+ """)
+
+ assert result =~ "space-y-6"
+ end
+
+ test "no skeleton when multilang disabled" do
+ assigns = %{}
+
+ result =
+ html(~H"""
+ <.multilang_fields_wrapper multilang_enabled={false} current_lang="en">
+ Content
+
+ """)
+
+ refute result =~ "translatable-skeletons"
+ assert result =~ "Content"
+ end
+
+ test "IDs change with language" do
+ assigns = %{}
+
+ en =
+ html(~H"""
+ <.multilang_fields_wrapper multilang_enabled={true} current_lang="en">
+ Content
+
+ """)
+
+ fr =
+ html(~H"""
+ <.multilang_fields_wrapper multilang_enabled={true} current_lang="fr">
+ Content
+
+ """)
+
+ assert en =~ "translatable-fields-en"
+ assert fr =~ "translatable-fields-fr"
+ refute en =~ "translatable-fields-fr"
+ end
+ end
+
+ # ── switch_lang_js ──────────────────────────────────────────
+
+ describe "switch_lang_js/2" do
+ test "no-op for same language" do
+ assert switch_lang_js("en", "en") == %Phoenix.LiveView.JS{}
+ end
+
+ test "returns JS commands for different language" do
+ refute switch_lang_js("fr", "en") == %Phoenix.LiveView.JS{}
+ end
+ end
+
+ # ── inject_db_field_into_data ───────────────────────────────
+
+ describe "inject_db_field_into_data/5" do
+ test "injects on primary tab" do
+ assigns = %{
+ multilang_enabled: true,
+ primary_language: "en",
+ changeset: make_changeset(%{data: %{}})
+ }
+
+ result = inject_db_field_into_data(%{}, "title", %{"title" => "Hello"}, "en", assigns)
+ assert result == %{"_title" => "Hello"}
+ end
+
+ test "injects lang_ prefixed on secondary tab" do
+ assigns = %{
+ multilang_enabled: true,
+ primary_language: "en",
+ changeset: make_changeset(%{data: %{}})
+ }
+
+ result =
+ inject_db_field_into_data(%{}, "title", %{"lang_title" => "Bonjour"}, "fr", assigns)
+
+ assert result == %{"_title" => "Bonjour"}
+ end
+
+ test "passes through when disabled" do
+ assigns = %{multilang_enabled: false}
+ result = inject_db_field_into_data(%{"existing" => "data"}, "title", %{}, "en", assigns)
+ assert result == %{"existing" => "data"}
+ end
+ end
+
+ # ── merge_translatable_params ───────────────────────────────
+
+ describe "merge_translatable_params/4" do
+ test "passes through when disabled" do
+ changeset = make_changeset()
+
+ socket = %Phoenix.LiveView.Socket{
+ assigns: %{
+ multilang_enabled: false,
+ primary_language: nil,
+ current_lang: nil,
+ __changed__: %{}
+ }
+ }
+
+ result =
+ merge_translatable_params(%{"name" => "Test"}, socket, ["name"], changeset: changeset)
+
+ refute Map.has_key?(result, "data")
+ assert result["name"] == "Test"
+ end
+ end
+end