# `PhoenixKitWeb.Components.MultilangForm`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.22.15/lib/phoenix_kit_web/components/multilang_form.ex#L1)

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

`mount_multilang/2` auto-handles the `"switch_language"` event, so you do
NOT need a `handle_event("switch_language", …)` clause — you only wire your
own form events:

    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

## Storage caveat — the helper OWNS the `data` column

`merge_translatable_params/4` (and `put_language_data/3`) store translations
by RESTRUCTURING the schema's `data` JSONB into a multilang shape
(`%{"_primary_language" => "en", "en" => %{…}, "et" => %{…}}`). On a schema
whose `data` column already holds unrelated keys (e.g. per-record settings),
this MOVES those keys into the primary-language sub-map and every top-level
reader breaks. In that case do NOT point `merge_translatable_params` at
`data`: keep translations in an isolated sub-key and use `translatable_field`
in its settings-translations mode (`secondary_name` + `lang_data_key`) with
your own small merge. See the `translatable_field` docs.

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

# `get_lang_data`

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`

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

# `handle_switch_language`

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`

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`

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`.

# `merge_translatable_params`

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`

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 two internal LiveView hooks so consumers don't have to
wire the language-switching protocol by hand:

  * a `:handle_info` hook for the debounced language-switch timer, and
  * a `:handle_event` hook for the `"switch_language"` event that
    `<.multilang_tabs>` pushes — it calls `handle_switch_language/2`
    and halts, so **you no longer need to add**
    `def handle_event("switch_language", …)` yourself. Forgetting that
    clause used to crash the LiveView on the first tab click.

## Options

  * `:auto_switch_language` — when `false`, skips the `"switch_language"`
    event hook so you can handle the event yourself (e.g. to switch
    language immediately without the debounce). Default `true`.

# `multilang_enabled?`

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

# `multilang_fields_wrapper`

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`

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 row (title +
    info tooltip + "Primary: …" label). Default: **false** — removed as
    a deliberate product call (2026-08-15): the tab strip itself says
    "languages", and the primary language is already marked by the star
    on its tab, so the row was redundant chrome that pushed the actual
    fields down. The attr remains as an opt-in escape hatch.
  * `show_info` — show an info tooltip next to the header. Default:
    false (it anchors inside the header, so it follows the same call);
    requires `show_header: true` to have an anchor element.

## Attributes

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

# `preserve_primary_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?`

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

# `refresh_multilang`

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`

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`

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`.
* `debounce` (`:string`) - Value for `phx-debounce` on the field's input. Defaults to `"300"`, the
  delay every caller had baked in before this was configurable. Pass `nil`
  to omit the attribute entirely — do that when the server DERIVES
  something from this field as you type (a slug from a title, say), where
  waiting for a pause makes the derived value feel laggy.

  Defaults to `"300"`.
* `hint` (`:string`) - Defaults to `nil`.
* `secondary_hint` (`:string`) - Defaults to `nil`.
* `mentions` (`:boolean`) - offer `@` people and `#` records while typing (textarea only). Needs `use PhoenixKit.Mentions.Live` on the LiveView. Defaults to `false`.
* `secondary_name` (`:string`) - Defaults to `nil`.
* `lang_data_key` (`:string`) - Defaults to `nil`.
* Global attributes are accepted. Anything else lands on the input itself — `phx-hook`, `data-*`, `aria-*`.
  Without this the component silently swallowed such attributes: they
  reached its assigns and were never rendered, so a caller wiring a JS
  hook to a translated field got no hook and no warning.

  Don't pass `id` here — the input's id is derived internally from
  `field_name`/`form_prefix`/`current_lang`, and `id` is a global HTML
  attribute the compiler won't stop you from spreading; doing so renders
  two conflicting `id` attributes on the same tag.

## Slots

* `label_extra`

---

*Consult [api-reference.md](api-reference.md) for complete listing*
