PhoenixKitWeb.Components.MultilangForm (phoenix_kit v1.7.111)

Copy Markdown View Source

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
  />
</.multilang_fields_wrapper>

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}
/>

Summary

Functions

Gets the raw language data for the current language from a changeset.

Applies a debounced language change. Call from handle_info/2 when {:__multilang_apply_lang__, lang_code} is received.

Handles the "switch_language" event. Call from handle_event/3.

Injects a DB column value into the JSONB data field for multilang storage.

Merges language-specific validated data into the full multilang JSONB structure.

Merges translatable field params into the multilang data JSONB structure.

Adds multilang assigns to the socket. Call from mount/3.

Returns true when the Languages module is enabled with 2+ languages.

Renders skeleton loading placeholders and a content wrapper for translatable fields.

Renders the language tab bar with compact/full mode.

Preserves primary-language DB field values when on a secondary language tab.

Returns true when on the primary language tab (or multilang is disabled).

Refreshes multilang assigns after external changes (e.g. entity schema update).

Returns a Phoenix.LiveView.JS command that switches languages.

Renders a translatable text input or textarea field.

Functions

get_lang_data(changeset, current_lang, multilang_enabled)

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.

handle_multilang_apply_lang(socket, lang_code)

Applies a debounced language change. Call from handle_info/2 when {:__multilang_apply_lang__, lang_code} is received.

handle_switch_language(socket, lang_code)

Handles the "switch_language" event. Call from handle_event/3.

Returns a socket that defers applying :current_lang via a short trailing debounce (150 ms). Rapid click-through (EN → JA → FR → DE) keeps rescheduling the timer; only the last click actually updates :current_lang and triggers a content re-render. Without this, every intermediate event caused its own server render and the client briefly flashed each language's content before landing on the final one.

Nothing to do on the consumer's end — mount_multilang/1 attaches a :handle_info hook via Phoenix.LiveView.attach_hook/4 that intercepts the internal {:__multilang_apply_lang__, lang} message and applies the language transparently. Calling code never sees the message.

Ignores unknown language codes.

inject_db_field_into_data(form_data, field_name, params, current_lang, assigns)

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)

merge_multilang_data(changeset, lang_code, validated_data, assigns)

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:

The changeset must be an Ecto.Changeset with a :data field. assigns must contain :multilang_enabled.

merge_translatable_params(params, socket, translatable_fields, opts \\ [])

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 %{}.

mount_multilang(socket)

Adds multilang assigns to the socket. Call from mount/3.

Adds: :multilang_enabled, :primary_language, :current_lang, :language_tabs, :show_multilang_tabs, :switching_lang (no-op compat assign — kept because consumer templates pass it through to the wrapper). Also attaches an internal :handle_info hook that receives the debounced language-switch timer message.

multilang_enabled?()

Returns true when the Languages module is enabled with 2+ languages.

multilang_fields_wrapper(assigns)

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 --%>
</.multilang_fields_wrapper>

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>
    <div class="grid grid-cols-2 gap-6">
      <div class="skeleton h-12 w-full"></div>
      <div class="skeleton h-12 w-full"></div>
    </div>
  </:skeleton>
  <%!-- translatable fields here --%>
</.multilang_fields_wrapper>

Attributes

  • multilang_enabled (:boolean) (required)
  • current_lang (:string) (required)
  • switching_lang (:boolean) - accepted for backwards compatibility but no longer used — skeleton/fields visibility is client-side via switch_lang_js/2. Defaults to false.
  • skeleton_class (:string) - Defaults to "card-body pt-4".
  • fields_class (:string) - Defaults to nil.

Slots

  • skeleton
  • inner_block (required)

multilang_tabs(assigns)

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 PhoenixKit.Utils.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

Attributes

  • multilang_enabled (:boolean) (required)
  • language_tabs (:list) (required)
  • current_lang (:string) (required)
  • compact (:boolean) - Defaults to nil.
  • show_header (:boolean) - Defaults to true.
  • show_info (:boolean) - Defaults to true.
  • class (:string) - Defaults to "card-body pb-0".

preserve_primary_fields(params, changeset, assigns, preserve_fields)

Preserves primary-language DB field values when on a secondary language tab.

On secondary tabs, some fields (like title, slug) are absent from form params because they're replaced by lang_* inputs. This function fills in the missing values from the changeset so the DB columns keep their primary-language values.

preserve_fields is a map of %{"field_name" => :schema_field}.

No-ops when multilang is disabled or on the primary tab.

primary_tab?(assigns)

Returns true when on the primary language tab (or multilang is disabled).

refresh_multilang(socket)

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.

switch_lang_js(lang_code, current_lang)

Returns a Phoenix.LiveView.JS command that switches languages.

Toggles skeleton/fields visibility client-side, instantly (hides [data-translatable=fields], reveals [data-translatable=skeletons]) and pushes "switch_language" to the server. The server side holds the class state untouched — handle_switch_language/2 just schedules a 150 ms debounced timer that eventually updates :current_lang, which changes the wrapper div ids and makes morphdom replace both divs with their new-language versions (skeleton back to hidden, fields visible with new content). Nothing on the server renders hidden on the fields or removes hidden from the skeleton, so the JS toggles never fight a server diff.

Returns a no-op when lang_code == current_lang — clicking the already-active tab doesn't push and doesn't flash the skeleton.

translatable_field(assigns)

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.

Attributes

  • field_name (:string) (required)
  • form_prefix (:string) (required)
  • changeset (:any) (required)
  • schema_field (:atom) (required)
  • multilang_enabled (:boolean) (required)
  • current_lang (:string) (required)
  • primary_language (:string) (required)
  • lang_data (:map) (required)
  • label (:string) (required)
  • placeholder (:string) - Defaults to nil.
  • type (:string) - Defaults to "input".
  • rows (:integer) - Defaults to 3.
  • required (:boolean) - Defaults to false.
  • disabled (:boolean) - Defaults to false.
  • class (:string) - Defaults to nil.
  • pattern (:string) - Defaults to nil.
  • title (:string) - Defaults to nil.
  • hint (:string) - Defaults to nil.
  • secondary_hint (:string) - Defaults to nil.
  • secondary_name (:string) - Defaults to nil.
  • lang_data_key (:string) - Defaults to nil.

Slots

  • label_extra