if Code.ensure_loaded?(Phoenix.Component) and Code.ensure_loaded?(Gettext.Backend) do defmodule Localize.Inputs.Number.Components do @moduledoc """ HEEx components for locale-aware form input. Today: `number_input/1`. More inputs (percentage, ratio, dimension, …) will land here over time under the same namespace. ## Setup Add the JS hook in your `assets/js/app.js`: import Hooks from "localize_inputs" let liveSocket = new LiveSocket("/live", Socket, { hooks: { NumberInput: Hooks.NumberInput } }) Install AutoNumeric as a peer dep: npm install autonumeric ## Tolerance of invalid input These components sit on the render path and never raise on bad input — the page always renders. Specifically: * **Unknown `:locale`** — falls back to `:en` locale data; if `:en` itself is unavailable (broken Localize install) falls back to an empty `%Localize.Inputs.Number.Symbols{}` so attribute reads still resolve to safe defaults. * **Unknown `:category`** on `unit_input/1` — falls back to an empty `%Localize.Inputs.Number.Unit{}`. The picker renders no rows rather than 500ing the page. * **Blank or non-numeric `value`** — formatted as `""` in the visible input; the hidden ISO carrier stays empty. The submit-side parser (`Localize.Inputs.Number.Parser.parse_number/2`) returns `{:ok, nil}` for blanks and `{:error, _}` for garbage, never raises. * **Malformed `:min` / `:max` / `:decimals` bounds** — `Localize.Inputs.Number.Validator.validate_number/2` uses `Decimal.parse/1` with a `Decimal.new(0)` fallback, so a typo in a bound silently passes the bound check rather than crashing the validation pipeline. Returns `{:error, %ValidationError{}}` for real out-of-range values. """ use Phoenix.Component use Localize.Message.Sigils, backend: Localize.Inputs.Gettext alias Localize.Inputs.Number.{Symbols, Unit} @doc """ Locale-aware plain-number input. Renders an `` wrapped in a `
` that carries `data-` attributes the JS hook reads (locale, separators, minus sign, min/max, decimals). With AutoNumeric loaded the input live-formats as the user types; without it, the server-side parser (`Localize.Inputs.Number.Parser.parse_number/2`) accepts whatever the user typed on submit. The form value submits in the user's *locale-formatted* shape — exactly what AutoNumeric was displaying. Parse it on the server with `Localize.Inputs.Number.Parser.parse_number/2` (or `Localize.Inputs.Number.Changeset.validate_number/3` for an Ecto-backed flow). One wire shape regardless of whether the JS hook is loaded — no canonical-vs-locale ambiguity. ### Arguments * `assigns` — see the per-attribute documentation below. ### Attributes * `:form` — the `Phoenix.HTML.Form` the field belongs to. * `:field` — the form field as an atom. * `:locale` — display locale. Defaults to `Localize.get_locale/0`. * `:integer` — when `true`, accept integers only and emit `inputmode="numeric"`. * `:min`, `:max` — value bounds. * `:decimals` — maximum fractional digits. * `:align` — `:left` (default), `:right`, or `:center`. * `:placeholder` — placeholder text. * `:js` — set to `false` to skip the `phx-hook` attribute. * `:class`, `:input_class` — extra classes for the wrapper and the input. ### Returns * A `Phoenix.LiveView.Rendered` struct containing the input markup. ### Examples <.number_input form={@form} field={:quantity} integer={true} min={1} max={999} /> <.number_input form={@form} field={:rating} min={0} max={5} decimals={1} /> """ attr(:form, Phoenix.HTML.Form, required: true) attr(:field, :atom, required: true) attr(:value, :any, default: nil) attr(:locale, :string, default: nil) attr(:integer, :boolean, default: false) attr(:min, :any, default: nil) attr(:max, :any, default: nil) attr(:decimals, :integer, default: nil) attr(:align, :atom, default: :left, values: [:left, :right, :center]) attr(:placeholder, :string, default: nil) attr(:js, :boolean, default: true) attr(:class, :string, default: nil) attr(:input_class, :string, default: nil) attr(:rest, :global, include: ~w(disabled readonly required autofocus)) def number_input(assigns) do assigns = assigns |> assign_common() |> assign_number_value() ~H"""
""" end # ── Internal: shared assigns ────────────────────────────── defp assign_common(assigns) do locale = assigns[:locale] || Localize.get_locale() # Components are render-path code — never raise on an # unknown locale. Fall back to `:en` so downstream # `locale_data.decimal` etc. still resolves. locale_data = safe_number_for_locale(locale) field_struct = assigns.form[assigns.field] name = field_struct.name id = field_struct.id assigns |> assign(:locale, locale) |> assign(:locale_data, locale_data) |> assign(:name, name) |> assign(:id, id) |> assign_new(:placeholder, fn -> nil end) |> assign_new(:class, fn -> nil end) |> assign_new(:input_class, fn -> nil end) end defp assign_number_value(assigns) do explicit = assigns.value form_value = (assigns.form[assigns.field] || %{}).value raw = explicit || form_value assign(assigns, :formatted_value, format_value(raw, assigns.locale)) end # Render the value into the input's `value=` attribute. Three # safe outcomes: nil/empty input → empty string; valid number # → locale-formatted string via Localize.Number.to_string; # unparseable input → empty string (the page still re-renders # without crashing). The user's raw text isn't preserved here # because the input only ever holds canonical-shape values # post-render; live editing is the JS hook's job. defp format_value(nil, _locale), do: "" defp format_value("", _locale), do: "" defp format_value(value, locale) when is_binary(value) do case Localize.Inputs.Number.Parser.parse_number(value, locale: locale) do {:ok, nil} -> "" {:ok, parsed} -> format_value(parsed, locale) {:error, _} -> value end end defp format_value(value, locale) do case Localize.Number.to_string(value, locale: locale) do {:ok, formatted} -> formatted {:error, _} -> "" end end defp value_attr(nil), do: nil defp value_attr(value) do # `to_string/1` raises for maps / tuples / structs # without `String.Chars`. The component is render-path # code so a typo'd attr like `min={%{}}` must NOT crash # the page — drop the attr entirely on un-stringifiable # values (Phoenix omits the attribute when the value # is nil). try do to_string(value) rescue Protocol.UndefinedError -> nil end end defp text_align_class(:left), do: "text-left" defp text_align_class(:center), do: "text-center" defp text_align_class(:right), do: "text-right" # ── unit_input + unit_picker ───────────────────────────── @doc """ Locale-aware number + unit-of-measure input. Renders a number input paired with a searchable `unit_picker/1` for a given measurement category (`"length"`, `"volume"`, `"mass"`, …). The picker is grouped into **Preferred** (units in the locale's measurement system — metric, US, or UK) and **All units** (every known unit in the category). Unit display names are localized via `Localize.Unit.display_name/2`. Submits as a nested map: params[field] = %{"amount" => "1.5", "unit" => "meter"} Parse on the server with the locale you already have. ### Attributes * `:form` — the `Phoenix.HTML.Form` the field belongs to. * `:field` — the form field as an atom. The amount and unit sub-fields submit under `params[field][amount]` and `params[field][unit]`. * `:category` — the unit category as a string (e.g. `"length"`). **Required.** See `Localize.Inputs.Number.Unit.known_categories/0`. * `:default_unit` — the unit selected by default. If `nil`, the first preferred unit for the locale is selected. * `:locale` — display locale. Defaults to `Localize.get_locale/0`. * `:integer`, `:min`, `:max`, `:decimals`, `:align`, `:placeholder`, `:js`, `:class`, `:input_class` — passed through to the underlying `number_input/1`. * `:include_prefixed` — when `true`, the All-units section includes SI-prefixed variants (e.g. `decimeter`). The default is `false` to keep the list manageable. ### Examples <.unit_input form={@form} field={:height} category="length" /> <.unit_input form={@form} field={:weight} category="mass" default_unit="kilogram" min={0} /> """ attr(:form, Phoenix.HTML.Form, required: true) attr(:field, :atom, required: true) attr(:category, :string, required: true) attr(:default_unit, :string, default: nil) attr(:locale, :string, default: nil) attr(:value, :any, default: nil) attr(:integer, :boolean, default: false) attr(:min, :any, default: nil) attr(:max, :any, default: nil) attr(:decimals, :integer, default: nil) attr(:align, :atom, default: :left, values: [:left, :right, :center]) attr(:placeholder, :string, default: nil) attr(:include_prefixed, :boolean, default: false) attr(:js, :boolean, default: true) attr(:class, :string, default: nil) attr(:input_class, :string, default: nil) attr(:picker_class, :string, default: nil) attr(:rest, :global, include: ~w(disabled readonly required autofocus)) def unit_input(assigns) do assigns = assigns |> assign_unit_common() |> assign_unit_value() ~H"""
<.unit_picker name={"#{@name_base}[unit]"} input_id={"#{@id}-unit"} current={@selected_unit} category={@category} locale={@locale} include_prefixed={@include_prefixed} class={@picker_class} id={"#{@id}-picker"} />
""" end @doc """ Standalone locale-aware unit picker. Searchable picker grouped into a **Preferred** section (units in the locale's measurement system) and an **All units** section (every known unit in the category). Selecting a row updates a hidden input that the picker serialises on form submission and emits a `localize-inputs:unit-change` `CustomEvent` so any enclosing `unit_input/1` can react. ### Attributes * `:current` — the currently-selected unit name (string). **Required.** * `:category` — the unit category. **Required.** * `:locale` — display locale. Defaults to `Localize.get_locale/0`. * `:form` + `:field` — when given, the hidden value input is named `Phoenix.HTML.Form.input_name(form, field)`. * `:name` — explicit hidden-input name. Overrides `:form`/`:field`. Used by `unit_input/1` to inject a nested name like `height[unit]`. * `:input_id` — explicit id for the hidden value input. * `:include_prefixed` — when `true`, the All-units section includes SI-prefixed variants. * `:variant` — `:auto` (default), `:dropdown`, or `:sheet`. * `:id`, `:class`, `:button_class`, `:overlay_class`, `:row_class` — customisation hooks. """ attr(:form, Phoenix.HTML.Form, default: nil) attr(:field, :atom, default: nil) attr(:name, :string, default: nil, doc: "Explicit hidden-input name. Overrides form+field. Used by `unit_input/1` to inject a nested name like `height[unit]`." ) attr(:input_id, :string, default: nil, doc: "Explicit id for the hidden value input.") attr(:current, :string, required: true) attr(:category, :string, required: true) attr(:locale, :any, default: nil) attr(:include_prefixed, :boolean, default: false) attr(:variant, :atom, default: :auto, values: [:auto, :dropdown, :sheet]) attr(:id, :string, default: nil) attr(:class, :string, default: nil) attr(:button_class, :string, default: nil) attr(:overlay_class, :string, default: nil) attr(:row_class, :string, default: nil) def unit_picker(assigns) do assigns = assign_unit_picker(assigns) ~H"""
<%= if @hidden_name do %> <% end %>
""" end # ── Internal: unit_input assigns ────────────────────────── defp assign_unit_common(assigns) do locale = assigns[:locale] || Localize.get_locale() locale_data = safe_number_for_locale(locale) unit_data = safe_unit_for_locale(locale, category: assigns.category, include_prefixed: assigns.include_prefixed ) field_struct = assigns.form[assigns.field] name_base = field_struct.name id = field_struct.id assigns |> assign(:locale, locale) |> assign(:locale_data, locale_data) |> assign(:unit_data, unit_data) |> assign(:name_base, name_base) |> assign(:id, id) |> assign_new(:placeholder, fn -> nil end) |> assign_new(:class, fn -> nil end) |> assign_new(:input_class, fn -> nil end) |> assign_new(:picker_class, fn -> nil end) end defp assign_unit_value(assigns) do explicit = assigns.value form_value = (assigns.form[assigns.field] || %{}).value {amount, unit} = extract_amount_and_unit(explicit || form_value) selected_unit = unit || assigns.default_unit || default_unit(assigns.unit_data) assigns |> assign(:formatted_amount, format_value(amount, assigns.locale)) |> assign(:selected_unit, selected_unit) end # unit_input form values come in three shapes: # nil/"" — no value # %{"amount" => ..., "unit" => ...} — fully-formed submit # a bare amount (binary/Decimal/integer) — value only defp extract_amount_and_unit(nil), do: {nil, nil} defp extract_amount_and_unit(""), do: {nil, nil} defp extract_amount_and_unit(%{} = map) do amount = Map.get(map, "amount") || Map.get(map, :amount) unit = Map.get(map, "unit") || Map.get(map, :unit) {blank_to_nil(amount), blank_to_nil(unit)} end defp extract_amount_and_unit(other), do: {other, nil} defp blank_to_nil(""), do: nil defp blank_to_nil(value), do: value # ── Defensive locale-data wrappers ──────────────────────── # Components must not raise on an unknown locale; render # the input under the `:en` fallback rather than crashing # the page. The fallback chain stops at `:en` — if even # `:en` errors, that's a Localize installation problem # worth surfacing. defp safe_number_for_locale(locale) do case Symbols.number_for_locale(locale) do {:ok, data} -> data _ -> case Symbols.number_for_locale(:en) do {:ok, data} -> data _ -> %Symbols{} end end end defp safe_unit_for_locale(locale, options) do case Unit.unit_for_locale(locale, options) do {:ok, data} -> data _ -> case Unit.unit_for_locale(:en, options) do {:ok, data} -> data _ -> # Defaults of `nil` would crash any downstream # `Enum.map(unit_data.preferred_units, ...)` with # `Protocol.UndefinedError`. Coalesce the # iterable fields to empty lists so the render # path never sees a non-enumerable. %Unit{preferred_units: [], all_units: []} end end end defp default_unit(%Unit{preferred_units: [{name, _} | _]}), do: name defp default_unit(%Unit{all_units: [{name, _} | _]}), do: name defp default_unit(_), do: nil defp assign_unit_picker(assigns) do locale = assigns[:locale] || Localize.get_locale() unit_data = safe_unit_for_locale(locale, category: assigns.category, include_prefixed: assigns.include_prefixed ) preferred_rows = Enum.map(unit_data.preferred_units, fn {name, display} -> %{code: name, name: display} end) all_rows = unit_data.all_units |> Enum.reject(fn {name, _} -> Enum.any?(unit_data.preferred_units, fn {pname, _} -> pname == name end) end) |> Enum.map(fn {name, display} -> %{code: name, name: display} end) sections = [ {~t"Preferred", preferred_rows}, {~t"All units", all_rows} ] id = assigns[:id] || "unit-picker-#{System.unique_integer([:positive])}" # Same three-priority naming scheme as currency_picker: # 1) an explicit `name=` attr (used when embedded in # unit_input to inject a nested name like `height[unit]`), # 2) a form+field pair (standalone use), # 3) nothing — the picker is purely client-side state. {hidden_name, hidden_id} = case {assigns[:name], assigns[:form], assigns[:field]} do {explicit, _, _} when is_binary(explicit) -> {explicit, assigns[:input_id] || "#{id}-value"} {_, form, field} when not is_nil(form) and not is_nil(field) -> {Phoenix.HTML.Form.input_name(form, field), "#{id}-value"} _ -> {nil, nil} end current_display = case Enum.find( unit_data.preferred_units ++ unit_data.all_units, fn {name, _} -> name == assigns.current end ) do {_, display} -> display _ -> assigns.current end assigns |> assign(:unit_data, unit_data) |> assign(:sections, sections) |> assign(:id, id) |> assign(:hidden_name, hidden_name) |> assign(:hidden_id, hidden_id) |> assign(:current_display, current_display) |> assign_new(:class, fn -> nil end) |> assign_new(:button_class, fn -> nil end) |> assign_new(:overlay_class, fn -> nil end) |> assign_new(:row_class, fn -> nil end) end end end