if Code.ensure_loaded?(Phoenix.Component) and Code.ensure_loaded?(Gettext.Backend) do defmodule Localize.Inputs.Date.Components do @moduledoc """ HEEx components for locale-aware date form input. Provides `date_input/1`, `date_range_input/1`, and `date_range_picker/1`. Built on `calendrical` for multi-calendar parsing (Gregorian, Buddhist, Japanese, Islamic, Persian, Hebrew, ROC, …). ## Setup Add the JS hooks in your `assets/js/app.js`: import Hooks from "localize_datetime_inputs" let liveSocket = new LiveSocket("/live", Socket, { hooks: { DatePicker: Hooks.DatePicker, DateRangePicker: Hooks.DateRangePicker } }) """ use Phoenix.Component use Localize.Message.Sigils, backend: Localize.Inputs.Gettext # ── date_input + date_picker + date_range_input ────────── @doc """ Locale-aware date input with a popup calendar grid. Renders a text input that accepts the locale's CLDR date patterns plus ISO-8601, paired with a calendar-icon trigger that opens a Gregorian month grid for picking. Selecting a day fills the text input (locale-formatted) and a hidden sibling input (ISO wire format). On submit the form receives `params[field]` as `"YYYY-MM-DD"`. Server-side, parse with `Localize.Inputs.Date.Parser.parse_date/2` or `Calendrical.Date.parse/2`. Multi-calendar parsing works (Buddhist, Islamic, Japanese, etc.) — the user can type in their locale's calendar representation and the server parses correctly. The popup grid renders in Gregorian; non-Gregorian grid rendering is a follow-on enhancement. ### Attributes * `:form` — the `Phoenix.HTML.Form` the field belongs to. * `:field` — the form field as an atom. * `:value` — explicit ISO date string; otherwise pulled from `@form[@field]`. * `:locale` — display locale. Defaults to `Localize.get_locale/0`. * `:min`, `:max` — ISO date strings or `Date` structs. * `:placeholder` — placeholder text for the text input. * `:display_format` — one of `:short`, `:medium` (default), `:long`, `:full`. Controls the locale-formatted display shape; the wire value is always ISO. * `:js` — set to `false` to skip the `phx-hook` attribute. * `:class`, `:input_class`, `:button_class`, `:overlay_class` — customisation hooks. ### Examples <.date_input form={@form} field={:dob} /> <.date_input form={@form} field={:start_date} min={~D[2026-01-01]} max={~D[2026-12-31]} display_format={:long} /> """ attr(:form, Phoenix.HTML.Form, required: true) attr(:field, :atom, required: true) attr(:value, :any, default: nil) attr(:locale, :any, default: nil) attr(:min, :any, default: nil) attr(:max, :any, default: nil) attr(:placeholder, :string, default: nil) attr(:display_format, :atom, default: :medium, values: [:short, :medium, :long, :full]) attr(:calendar, :atom, default: :gregorian) attr(:variant, :atom, default: :auto, values: [:auto, :dropdown, :sheet]) attr(:js, :boolean, default: true) attr(:class, :string, default: nil) attr(:input_class, :string, default: nil) attr(:button_class, :string, default: nil) attr(:overlay_class, :string, default: nil) attr(:rest, :global, include: ~w(disabled readonly required autofocus)) def date_input(assigns) do assigns = assign_date_common(assigns) ~H"""
<%!-- Canonical ISO carrier: the JS hook writes the picker's current selection here on every day click. It SUBMITS alongside the visible text input (as `_iso`) so the server can prefer this lossless wire value over whatever the browser's `Intl.DateTimeFormat` rendered into the visible field (which varies by browser and isn't always parseable for non-Gregorian calendars). --%>
""" end @doc """ Locale-aware date-range input. Renders two paired text inputs (from / to) inside a single grouped wrapper. Each field is independently editable; the pair submits as `params[field] = %{"from" => "YYYY-MM-DD", "to" => "YYYY-MM-DD"}`. Server-side, parse with `Calendrical.Date.parse_range/2` passing the `{from, to}` tuple from `params[field]`. ### Attributes * `:form` — the `Phoenix.HTML.Form` the field belongs to. * `:field` — the form field as an atom; sub-fields submit under `params[field][from]` and `params[field][to]`. * `:locale`, `:min`, `:max`, `:display_format`, `:variant`, `:js` — passed through to both inputs. * `:class`, `:input_class`, `:button_class`, `:overlay_class` — customisation hooks. ### Examples <.date_range_input form={@form} field={:stay} /> <.date_range_input form={@form} field={:trip} min={~D[2026-01-01]} max={~D[2026-12-31]} /> """ attr(:form, Phoenix.HTML.Form, required: true) attr(:field, :atom, required: true) attr(:locale, :any, default: nil) attr(:min, :any, default: nil) attr(:max, :any, default: nil) attr(:placeholder_from, :string, default: nil) attr(:placeholder_to, :string, default: nil) attr(:display_format, :atom, default: :medium, values: [:short, :medium, :long, :full]) attr(:calendar, :atom, default: :gregorian) attr(:variant, :atom, default: :auto, values: [:auto, :dropdown, :sheet]) attr(:js, :boolean, default: true) attr(:class, :string, default: nil) attr(:input_class, :string, default: nil) attr(:button_class, :string, default: nil) attr(:overlay_class, :string, default: nil) def date_range_input(assigns) do assigns = assign_date_range_common(assigns) ~H"""
<.date_input form={@form} field={String.to_atom("#{@field}_from")} locale={@locale} min={@min} max={@max} display_format={@display_format} calendar={@calendar} variant={@variant} placeholder={@placeholder_from} js={@js} input_class={@input_class} button_class={@button_class} overlay_class={@overlay_class} /> <.date_input form={@form} field={String.to_atom("#{@field}_to")} locale={@locale} min={@min} max={@max} display_format={@display_format} calendar={@calendar} variant={@variant} placeholder={@placeholder_to} js={@js} input_class={@input_class} button_class={@button_class} overlay_class={@overlay_class} />
""" end @doc """ Locale-aware date-range input with a unified popup calendar (click start, then click end inside the same grid). Pairs with `RangePicker` JS hook. Renders two text inputs (visible "from" and "to") plus a single shared trigger and overlay. The user clicks the trigger to open the popup, clicks once for the start, hovers to preview, clicks again for the end. Both text inputs and both hidden ISO inputs populate. Submits as `params[field] = %{"from" => "YYYY-MM-DD", "to" => "YYYY-MM-DD"}`. Server-side, parse with `Calendrical.Date.parse_range/2` passing the `{from, to}` tuple. ### Attributes Same shape as `date_range_input/1`: `:form`, `:field`, `:locale`, `:min`, `:max`, `:display_format`, `:calendar`, `:variant`, `:js`, `:class`, etc. ### Examples <.date_range_picker form={@form} field={:stay} /> """ attr(:form, Phoenix.HTML.Form, required: true) attr(:field, :atom, required: true) attr(:locale, :any, default: nil) attr(:min, :any, default: nil) attr(:max, :any, default: nil) attr(:placeholder_from, :string, default: nil) attr(:placeholder_to, :string, default: nil) attr(:display_format, :atom, default: :medium, values: [:short, :medium, :long, :full]) attr(:calendar, :atom, default: :gregorian) attr(:variant, :atom, default: :auto, values: [:auto, :dropdown, :sheet]) attr(:js, :boolean, default: true) attr(:class, :string, default: nil) attr(:input_class, :string, default: nil) attr(:button_class, :string, default: nil) attr(:overlay_class, :string, default: nil) def date_range_picker(assigns) do assigns = assign_date_range_picker_common(assigns) ~H"""
""" end # ── Internal: date_input assigns ────────────────────────── defp assign_date_common(assigns) do locale = assigns[:locale] || Localize.get_locale() field_struct = assigns.form[assigns.field] name = field_struct.name id = field_struct.id field_value = field_struct.value formatted = format_date_for_display( assigns.value || field_value, locale, assigns.display_format, Map.get(assigns, :calendar, :gregorian) ) assigns |> assign(:locale, locale) |> assign(:name, name) |> assign(:id, id) |> assign(:field_value, field_value) |> assign(:formatted_value, formatted) |> assign_new(:placeholder, fn -> nil end) |> assign_new(:class, fn -> nil end) |> assign_new(:input_class, fn -> nil end) |> assign_new(:button_class, fn -> nil end) |> assign_new(:overlay_class, fn -> nil end) end defp assign_date_range_common(assigns) do field_struct = assigns.form[assigns.field] id = field_struct.id assigns |> assign(:id, id) |> assign_new(:placeholder_from, fn -> nil end) |> assign_new(:placeholder_to, fn -> nil end) |> assign_new(:class, fn -> nil end) |> assign_new(:input_class, fn -> nil end) |> assign_new(:button_class, fn -> nil end) |> assign_new(:overlay_class, fn -> nil end) end defp assign_date_range_picker_common(assigns) do locale = assigns[:locale] || Localize.get_locale() field_struct = assigns.form[assigns.field] base_name = field_struct.name id = field_struct.id # Map-shaped field value: %{"from" => ..., "to" => ...}. {from_value, to_value} = case field_struct.value do %{"from" => f, "to" => t} -> {f, t} %{from: f, to: t} -> {f, t} _ -> {nil, nil} end assigns |> assign(:locale, locale) |> assign(:base_name, base_name) |> assign(:id, id) |> assign(:from_value, from_value) |> assign(:to_value, to_value) |> assign( :formatted_from, format_date_for_display(from_value, locale, assigns.display_format, assigns.calendar) ) |> assign( :formatted_to, format_date_for_display(to_value, locale, assigns.display_format, assigns.calendar) ) |> assign_new(:placeholder_from, fn -> nil end) |> assign_new(:placeholder_to, fn -> nil end) |> assign_new(:class, fn -> nil end) |> assign_new(:input_class, fn -> nil end) |> assign_new(:button_class, fn -> nil end) |> assign_new(:overlay_class, fn -> nil end) end defp format_date_for_display(nil, _locale, _format, _calendar), do: "" defp format_date_for_display("", _locale, _format, _calendar), do: "" defp format_date_for_display(%Date{} = date, locale, format, cldr_calendar) do # `Localize.Date.to_string/2` dispatches its CLDR pattern # lookup on `date.calendar`, so a `Calendar.ISO` date # always renders under `:gregorian` even when the # component received `calendar: :japanese`. Ensure the # date is in the requested calendar before formatting. display_date = ensure_calendar(date, cldr_calendar) case Localize.Date.to_string(display_date, locale: locale, format: format) do {:ok, string} -> string _ -> Date.to_iso8601(date) end end defp format_date_for_display(string, locale, format, cldr_calendar) when is_binary(string) do # Pass `:calendar` so the parser interprets the input # under the requested calendar (e.g. `"令和8年5月17日"` # under `:japanese`) AND returns a `Date` already tagged # with the right calendar module — no second-stage # conversion needed. case Calendrical.Date.parse(string, locale: locale, calendar: cldr_calendar) do {:ok, date} -> case Localize.Date.to_string(date, locale: locale, format: format) do {:ok, formatted} -> formatted _ -> string end _ -> string end end defp format_date_for_display(_, _, _, _), do: "" # No-op when the date is already in the requested # calendar; convert otherwise. `:calendrical` is a hard # dep of this library, so no runtime `ensure_loaded?` # check is needed. defp ensure_calendar(%Date{calendar: Calendar.ISO} = date, :gregorian), do: date defp ensure_calendar(%Date{} = date, cldr_calendar) when is_atom(cldr_calendar) do case Calendrical.calendar_from_cldr_calendar_type(cldr_calendar) do {:ok, module} when date.calendar == module -> date {:ok, module} -> Date.convert!(date, module) _ -> date end end defp ensure_calendar(date, _), do: date defp date_attr(nil), do: nil defp date_attr(%Date{} = d), do: Date.to_iso8601(d) defp date_attr(string) when is_binary(string), do: string defp date_attr(_), do: nil defp iso_attr(explicit, _field_value) when is_binary(explicit) and explicit != "", do: explicit defp iso_attr(%Date{} = d, _field_value), do: Date.to_iso8601(d) defp iso_attr(_explicit, %Date{} = d), do: Date.to_iso8601(d) defp iso_attr(_explicit, string) when is_binary(string) and string != "", do: string defp iso_attr(_, _), do: "" # Map a CLDR calendar key (the `Localize.Calendar` / # `Calendrical` convention) to the corresponding BCP-47 # `Intl.DateTimeFormat` calendar identifier so the JS # hook's `Intl.DateTimeFormat({ calendar: ... })` call # produces correctly-labelled month/year strings. Only # the identifiers Intl recognises are returned; anything # else falls through to "gregory" (the Intl default). @intl_calendar_map %{ gregorian: "gregory", buddhist: "buddhist", chinese: "chinese", coptic: "coptic", dangi: "dangi", ethiopic: "ethiopic", ethiopic_amete_alem: "ethioaa", hebrew: "hebrew", indian: "indian", islamic: "islamic", islamic_civil: "islamic-civil", islamic_rgsa: "islamic-rgsa", islamic_tbla: "islamic-tbla", islamic_umalqura: "islamic-umalqura", japanese: "japanese", persian: "persian", roc: "roc" } defp cldr_to_intl_calendar(atom) when is_atom(atom), do: Map.get(@intl_calendar_map, atom, "gregory") end end