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"""
"""
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