Returns true when on the primary language tab (or multilang is disabled).
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:
Whole-form translation — wrap all translatable fields in a card with language tabs. The tab bar, skeleton placeholders, and field wrappers are handled automatically.
Single-field translation — drop a
<.translatable_field>into any form to make one field translatable, with no tab UI required (the caller managescurrent_langhowever 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)}
endEvents
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)}
endTemplate — 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
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.
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.
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.
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— anEcto.Changesetwith a:datafield (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)
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
PhoenixKit.Utils.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.
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%{}.
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.
Returns true when the Languages module is enabled with 2+ languages.
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— booleancurrent_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 viaswitch_lang_js/2. Defaults tofalse.skeleton_class(:string) - Defaults to"card-body pt-4".fields_class(:string) - Defaults tonil.
Slots
skeletoninner_block(required)
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 morecompact: true— always short codescompact: false— always full names
Delegates to PhoenixKitWeb.Components.LanguageSwitcher.language_switcher/1
for the tab bar rendering.
Attributes
multilang_enabled— boolean, whether multilang is activelanguage_tabs— list of tab maps fromPhoenixKit.Utils.Multilang.build_language_tabs/0current_lang— the currently selected language codecompact— force compact mode (short codes). Default: nil (auto)show_header— show the "Content Language" header. Default: trueshow_info— show the info alert. Default: true
Attributes
multilang_enabled(:boolean) (required)language_tabs(:list) (required)current_lang(:string) (required)compact(:boolean) - Defaults tonil.show_header(:boolean) - Defaults totrue.show_info(:boolean) - Defaults totrue.class(:string) - Defaults to"card-body pb-0".
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.
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.
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.
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 changesetschema_field— the schema field atom (e.g.,:name)multilang_enabled— booleancurrent_lang— current language codeprimary_language— primary language codelang_data— raw language data map for the current languagelabel— field label textplaceholder— placeholder for primary language (optional)type— "input" or "textarea". Default: "input"rows— textarea rows (only for type="textarea"). Default: 3required— marks the primary language field as required. Default: falsedisabled— disables the field. Default: falseclass— additional CSS class(es) for the input element. Default: nilpattern— HTML pattern attribute for input validation. Default: niltitle— HTML title attribute (for pattern validation message). Default: nilhint— hint text shown below the field. Default: nilsecondary_hint— hint text shown only on secondary language tabs. Default: nilsecondary_name— override the secondary tab input name. Default:"form_prefix[lang_field_name]"lang_data_key— key to look up inlang_datafor 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 tonil.type(:string) - Defaults to"input".rows(:integer) - Defaults to3.required(:boolean) - Defaults tofalse.disabled(:boolean) - Defaults tofalse.class(:string) - Defaults tonil.pattern(:string) - Defaults tonil.title(:string) - Defaults tonil.hint(:string) - Defaults tonil.secondary_hint(:string) - Defaults tonil.secondary_name(:string) - Defaults tonil.lang_data_key(:string) - Defaults tonil.
Slots
label_extra