lib/localize/inputs/date/components.ex

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
          }
        })

    ## Tolerance of invalid input

    These components sit on the render path and never raise on
    bad input — the page always renders. Specifically:

    * **Unknown `:locale`** — formatting falls back to
      whatever `Localize.Date.to_string/2` returns; on
      failure the cell renders the ISO-8601 form of the
      date (`2026-05-17`).

    * **Unknown `:calendar`** — date conversion uses a
      tolerant `Date.convert/2`; on failure the date is
      kept in its original calendar (typically
      `Calendar.ISO`) and rendered using whatever pattern
      lookup succeeds.

    * **Blank or unparseable `value`** — the visible text
      input renders empty; the hidden ISO carrier stays
      empty. `Localize.Inputs.Date.Parser.parse_date/2`
      returns `{:ok, nil}` for blanks and
      `{:error, %Calendrical.DateParseError{}}` for
      garbage, never raises.

    * **`date_range_input/1` child field atoms** — derives
      `{field}_from` and `{field}_to` via
      `String.to_existing_atom/1`. The atom must already
      exist (it does, because your changeset/schema defines
      it for form parsing); if it doesn't, an `ArgumentError`
      surfaces at render time and points at the missing field.

    * **`DatePickerLive` malformed cursor / month** — the
      server-rendered grid uses tolerant `safe_convert/2` and
      `safe_build_date/4` helpers. An invalid year/month
      combo for the target calendar falls back to today
      rather than 500ing the LiveView.

    """

    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"""
      <div
        class={["date-input-wrapper", @class]}
        id={"#{@id}-wrapper"}
        data-date-input
        data-locale={to_string(@locale)}
        data-display-format={Atom.to_string(@display_format)}
        data-calendar={cldr_to_intl_calendar(@calendar)}
        data-min={date_attr(@min)}
        data-max={date_attr(@max)}
        data-variant={to_string(@variant)}
        phx-hook={if @js, do: "DatePicker"}
      >
        <input
          type="text"
          inputmode="numeric"
          name={@name}
          id={@id}
          value={@formatted_value}
          class={["date-input-field", @input_class]}
          autocomplete="off"
          placeholder={@placeholder}
          aria-describedby={"#{@id}-hint"}
          {@rest}
        />
        <%!-- 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 `<name>_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). --%>
        <input
          type="hidden"
          name={"#{@name}_iso"}
          id={"#{@id}-iso"}
          value={iso_attr(@value, @field_value)}
          data-date-picker-value
        />
        <button
          type="button"
          class={["date-input-trigger", @button_class]}
          data-date-picker-trigger
          aria-haspopup="dialog"
          aria-expanded="false"
          aria-label={~t"Open calendar"}
        >
          <svg
            class="date-input-trigger-icon"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
            aria-hidden="true"
          >
            <path d="M8 2v4" /><path d="M16 2v4" />
            <rect x="3" y="4" width="18" height="18" rx="2" />
            <path d="M3 10h18" />
          </svg>
        </button>
        <div
          class={["date-picker-overlay", @overlay_class]}
          data-date-picker-overlay
          role="dialog"
          aria-label={~t"Choose date"}
          hidden
        >
          <div class="date-picker-header">
            <button
              type="button"
              class="date-picker-nav"
              data-date-picker-prev
              aria-label={~t"Previous month"}
            ></button>
            <span class="date-picker-month-label" data-date-picker-month-label></span>
            <button
              type="button"
              class="date-picker-nav"
              data-date-picker-next
              aria-label={~t"Next month"}
            ></button>
            <button
              type="button"
              class="date-picker-close"
              data-date-picker-close
              aria-label={~t"Close calendar"}
            >×</button>
          </div>
          <table class="date-picker-grid" data-date-picker-grid role="grid">
          </table>
        </div>
      </div>
      """
    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"""
      <div class={["date-range-input-wrapper", @class]} id={"#{@id}-wrapper"} data-date-range-input>
        <.date_input
          form={@form}
          field={String.to_existing_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}
        />
        <span class="date-range-separator" aria-hidden="true"></span>
        <.date_input
          form={@form}
          field={String.to_existing_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}
        />
      </div>
      """
    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"""
      <div
        class={["date-range-picker", @class]}
        id={"#{@id}-wrapper"}
        data-date-input
        data-range-picker
        data-locale={to_string(@locale)}
        data-display-format={Atom.to_string(@display_format)}
        data-calendar={cldr_to_intl_calendar(@calendar)}
        data-min={date_attr(@min)}
        data-max={date_attr(@max)}
        data-variant={to_string(@variant)}
        phx-hook={if @js, do: "RangePicker"}
      >
        <input
          type="text"
          name={"#{@base_name}[from]"}
          id={"#{@id}-from"}
          value={@formatted_from}
          class={["date-input-field", "range-from-field", @input_class]}
          autocomplete="off"
          placeholder={@placeholder_from}
        />
        <input
          type="hidden"
          name={"#{@base_name}[from_iso]"}
          id={"#{@id}-from-iso"}
          value={iso_attr(nil, @from_value)}
          data-range-picker-from
        />
        <span class="date-range-separator" aria-hidden="true"></span>
        <input
          type="text"
          name={"#{@base_name}[to]"}
          id={"#{@id}-to"}
          value={@formatted_to}
          class={["date-input-field", "range-to-field", @input_class]}
          autocomplete="off"
          placeholder={@placeholder_to}
        />
        <input
          type="hidden"
          name={"#{@base_name}[to_iso]"}
          id={"#{@id}-to-iso"}
          value={iso_attr(nil, @to_value)}
          data-range-picker-to
        />
        <button
          type="button"
          class={["date-input-trigger", @button_class]}
          data-date-picker-trigger
          aria-haspopup="dialog"
          aria-expanded="false"
          aria-label={~t"Open calendar"}
        >
          <svg
            class="date-input-trigger-icon"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
            aria-hidden="true"
          >
            <path d="M8 2v4" /><path d="M16 2v4" />
            <rect x="3" y="4" width="18" height="18" rx="2" />
            <path d="M3 10h18" />
          </svg>
        </button>
        <div
          class={["date-picker-overlay", @overlay_class]}
          data-date-picker-overlay
          role="dialog"
          aria-label={~t"Choose date range"}
          hidden
        >
          <div class="date-picker-header">
            <button type="button" class="date-picker-nav" data-date-picker-prev aria-label={~t"Previous month"}></button>
            <span class="date-picker-month-label" data-date-picker-month-label></span>
            <button type="button" class="date-picker-nav" data-date-picker-next aria-label={~t"Next month"}></button>
            <button type="button" class="date-picker-close" data-date-picker-close aria-label={~t"Close calendar"}>×</button>
          </div>
          <table class="date-picker-grid" data-date-picker-grid role="grid">
          </table>
        </div>
      </div>
      """
    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} ->
          case Date.convert(date, module) do
            {:ok, converted} -> converted
            _ -> date
          end

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