Packages

phoenix_kit

1.7.113
1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web components multilang_form.ex
Raw

lib/phoenix_kit_web/components/multilang_form.ex

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
/>
</.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}
/>
"""
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
# PhoenixKit.Utils.Multilang is in an external package — referenced by full name
# with Code.ensure_loaded?/rescue guards throughout this module.
# ═══════════════════════════════════════════════════════════════════
# Mount & Event Helpers
# ═══════════════════════════════════════════════════════════════════
@doc """
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.
"""
def mount_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: [])
socket
|> Phoenix.Component.assign(
multilang_enabled: multilang_enabled,
primary_language: primary_language,
current_lang: primary_language,
language_tabs: language_tabs,
show_multilang_tabs: multilang_enabled and length(language_tabs) > 1,
# Kept for backwards compat with consumer templates that still pass
# `switching_lang={@switching_lang}` to `<.multilang_fields_wrapper>`.
# The wrapper no longer reads it — visibility is client-side JS.
switching_lang: false
)
|> attach_multilang_hook()
end
# Attaches a `:handle_info` hook that intercepts the internal
# `{:__multilang_apply_lang__, lang}` message. The hook lives on the
# consumer's socket but is invisible to them — no `handle_info/2`
# clause needed. Returning `{:halt, socket}` prevents the message
# from reaching the consumer's own handle_info (and suppresses the
# "unhandled message" warning). Other messages pass through with
# `{:cont, socket}`.
#
# `attach_hook/4` is idempotent-by-name within a process — re-running
# `mount_multilang/1` (e.g. across reconnects) with the same hook id
# simply replaces the prior callback, so we don't need a guard.
defp attach_multilang_hook(socket) do
Phoenix.LiveView.attach_hook(
socket,
:__phoenix_kit_multilang_apply_lang__,
:handle_info,
fn
{:__multilang_apply_lang__, lang_code}, socket ->
{:halt, handle_multilang_apply_lang(socket, lang_code)}
_msg, socket ->
{:cont, socket}
end
)
rescue
# `attach_hook/4` only works on LiveView sockets, not LiveComponent
# sockets. Multilang consumers are all `Phoenix.LiveView` today, but
# if someone wires it into a component in the future, fall back
# silently — they'll need to add the `handle_info` themselves.
ArgumentError -> socket
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,
show_multilang_tabs: multilang_enabled and length(language_tabs) > 1
)
end
@doc """
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.
"""
@multilang_debounce_ms 150
# The timer ref lives in `socket.private` (not `socket.assigns`) so
# that storing it doesn't trigger a render+diff cycle that would fight
# the client-side skeleton/fields visibility toggles. Private state
# is per-socket — visible only inside the LV process — and survives
# the LV's lifecycle the same way assigns do, but with zero diff cost.
@multilang_timer_private_key :__phoenix_kit_multilang_timer__
def handle_switch_language(socket, lang_code) do
if lang_code in safe_enabled_languages() do
socket = cancel_multilang_timer(socket)
timer_ref =
Process.send_after(
self(),
{:__multilang_apply_lang__, lang_code},
@multilang_debounce_ms
)
# Skeleton/fields visibility is driven entirely by
# `switch_lang_js/2`'s client-side class toggles. The server just
# holds off applying the new `current_lang` until the debounce
# fires, which is what causes the final morphdom swap that
# restores the fields (with new-lang content) and hides the
# skeleton again.
Phoenix.LiveView.put_private(socket, @multilang_timer_private_key, timer_ref)
else
socket
end
end
@doc """
Applies a debounced language change. Call from `handle_info/2` when
`{:__multilang_apply_lang__, lang_code}` is received.
"""
def handle_multilang_apply_lang(socket, lang_code) do
socket
|> Phoenix.LiveView.put_private(@multilang_timer_private_key, nil)
|> Phoenix.Component.assign(:current_lang, lang_code)
end
defp cancel_multilang_timer(socket) do
case Map.get(socket.private, @multilang_timer_private_key) do
ref when is_reference(ref) ->
Process.cancel_timer(ref)
Phoenix.LiveView.put_private(socket, @multilang_timer_private_key, nil)
_ ->
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 PhoenixKit.Utils.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 `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`.
"""
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
PhoenixKit.Utils.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 """
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.
"""
def preserve_primary_fields(params, changeset, assigns, preserve_fields) do
do_preserve_primary_fields(params, changeset, assigns, preserve_fields)
end
@doc "Returns true when the Languages module is enabled with 2+ languages."
def multilang_enabled? do
Code.ensure_loaded?(PhoenixKit.Utils.Multilang) and PhoenixKit.Utils.Multilang.enabled?()
rescue
_ -> false
end
defp safe_primary_language do
PhoenixKit.Utils.Multilang.primary_language()
rescue
_ -> "en-US"
end
defp safe_enabled_languages do
PhoenixKit.Utils.Multilang.enabled_languages()
rescue
_ -> []
end
defp safe_build_language_tabs do
PhoenixKit.Utils.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 ->
PhoenixKit.Utils.Multilang.put_language_data(existing_data, lang_code, validated_data)
PhoenixKit.Utils.Multilang.multilang_data?(existing_data) ->
PhoenixKit.Utils.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 `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
"""
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"""
<div :if={@multilang_enabled && match?([_, _ | _], @language_tabs)} class={@class}>
<div :if={@show_header} class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<.icon name="hero-language" class="w-5 h-5 text-primary" />
<h2 class="card-title text-lg m-0">{gettext("Content Language")}</h2>
</div>
<% primary_tab = Enum.find(@language_tabs, fn t -> t.is_primary end) %>
<div :if={primary_tab} class="flex items-center gap-1.5 text-xs text-base-content/60">
<.icon name="hero-star-solid" class="w-3.5 h-3.5 text-primary" />
<span>{gettext("Primary: %{lang}", lang: primary_tab.name)}</span>
</div>
</div>
<div :if={@show_info} class="alert alert-info py-2 text-xs mb-4">
<.icon name="hero-information-circle" class="w-4 h-4" />
<span>
{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."
)}
</span>
</div>
<div class="mb-4">
<.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}
/>
</div>
</div>
"""
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 --%>
</.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>
"""
attr :multilang_enabled, :boolean, required: true
attr :current_lang, :string, required: true
attr :switching_lang, :boolean,
default: false,
doc:
"accepted for backwards compatibility but no longer used — skeleton/fields visibility is client-side via `switch_lang_js/2`."
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 via JS).
IDs include current_lang so that when the debounce fires and
current_lang changes, morphdom treats these as new elements
and replaces them — which resets the skeleton back to `hidden`
and inserts the fields with new-language content. --%>
<div
:if={@multilang_enabled}
id={"translatable-skeletons-#{@current_lang}"}
data-translatable="skeletons"
class={["hidden", @skeleton_class]}
aria-busy="true"
aria-label={gettext("Loading language content")}
>
<%= if @skeleton != [] do %>
{render_slot(@skeleton)}
<% else %>
<div class="space-y-4">
<div class="space-y-2">
<div class="skeleton h-4 w-24"></div>
<div class="skeleton h-12 w-full"></div>
</div>
<div class="space-y-2">
<div class="skeleton h-4 w-32"></div>
<div class="skeleton h-24 w-full"></div>
</div>
</div>
<% end %>
</div>
<div
id={if @multilang_enabled, do: "translatable-fields-#{@current_lang}", else: "form-fields"}
data-translatable="fields"
class={@fields_class}
>
{render_slot(@inner_block)}
</div>
"""
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 ->
# `w-full` for parity with the regular `<.input>` core component
# — daisyUI 5's `.input` / `.textarea` classes don't include
# full-width by default, so without this the field shrinks to
# the input's intrinsic content width.
base =
if assigns.type == "textarea",
do: "textarea textarea-bordered w-full",
else: "input input-bordered w-full"
base = if assigns.class, do: "#{base} #{assigns.class}", else: base
if errors != [], do: "#{base} input-error", else: base
end)
~H"""
<%!-- `flex flex-col` because daisyUI 5's `.label` is `inline-flex`
and `.input`/`.textarea` are `inline-block` — without forcing
column direction here they sit on the same row. The `gap-1`
puts a small breathing room between label and field that
matches the `<.input>` core component's `mb-2`. --%>
<div
class="form-control flex flex-col gap-1"
phx-feedback-for={if @is_primary, do: "#{@form_prefix}[#{@field_name}]"}
>
<label for={@input_id} class="label">
<span class="label-text font-semibold">
{@label}
<%= if @required && @is_primary do %>
*
<% end %>
</span>
{render_slot(@label_extra)}
</label>
<%= if @type == "textarea" do %>
<%= if @is_primary do %>
<textarea
name={"#{@form_prefix}[#{@field_name}]"}
id={@input_id}
class={@input_class}
rows={@rows}
phx-debounce="300"
required={@required}
disabled={@disabled}
>{@primary_value}</textarea>
<% else %>
<textarea
name={@secondary_input_name}
id={@input_id}
class={@input_class}
rows={@rows}
placeholder={@primary_value}
phx-debounce="300"
disabled={@disabled}
>{@lang_value || ""}</textarea>
<% end %>
<% else %>
<%= if @is_primary do %>
<input
type="text"
name={"#{@form_prefix}[#{@field_name}]"}
id={@input_id}
value={@primary_value}
class={@input_class}
placeholder={@placeholder}
phx-debounce="300"
required={@required}
disabled={@disabled}
pattern={@pattern}
title={@title}
/>
<% else %>
<input
type="text"
name={@secondary_input_name}
id={@input_id}
value={@lang_value}
placeholder={@primary_value}
class={@input_class}
phx-debounce="300"
disabled={@disabled}
pattern={@pattern}
title={@title}
/>
<% end %>
<% end %>
<.error :for={msg <- @errors}>{msg}</.error>
<label :if={@hint && @is_primary && @errors == []} class="label">
<span class="label-text-alt">{@hint}</span>
</label>
<label :if={@secondary_hint && !@is_primary} class="label">
<span class="label-text-alt">{@secondary_hint}</span>
</label>
</div>
"""
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.
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.
"""
def switch_lang_js(lang_code, current_lang) do
if lang_code == current_lang do
# Same-lang click during an in-flight debounce: we need to cancel
# the pending server timer (which would otherwise still fire and
# flip to the wrong lang) AND revert the skeleton/fields toggles
# on the client (so the UI doesn't sit stuck in skeleton-visible
# state after the no-op server render). Pushing with the current
# lang hits `handle_switch_language/2`'s `cancel_multilang_timer`
# and reschedules a no-op `current_lang` set.
JS.push("switch_language", value: %{lang: lang_code})
|> JS.remove_class("hidden", to: "[data-translatable=fields]")
|> JS.add_class("hidden", to: "[data-translatable=skeletons]")
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