if Code.ensure_loaded?(Phoenix.LiveComponent) and Code.ensure_loaded?(Gettext.Backend) do defmodule Localize.Inputs.Date.Components.DatePickerLive do @moduledoc """ Server-rendered date picker LiveComponent with full multi-calendar grid support. Where `<.date_input>` ships a Gregorian-structured grid re-labelled via `Intl.DateTimeFormat`, this component renders the grid **in the configured calendar's own month structure** — Hebrew months span Hebrew month boundaries, Islamic months wrap at the Islamic month end, Persian Esfand has 29 or 30 days depending on the 33-year cycle, and so on. Calendar arithmetic delegates to the Calendrical Calendar behaviour module (`Date.add/2`, `Date.day_of_week/1`, `Date.days_in_month/1`). Wire format on the form is the same as `<.date_input>`: ISO-8601 (`YYYY-MM-DD`, Gregorian) via the embedded hidden input. Server-side, parse with `Localize.Inputs.Date.Parser.parse_date/2` or just read `params[field]` directly. ## Usage <.live_component module={Localize.Inputs.Date.Components.DatePickerLive} id="event-date" form={@form} field={:date} calendar={:hebrew} locale={:"he-IL"} /> ## Attributes * `:id` — required, unique per LiveComponent instance. * `:form` — the `Phoenix.HTML.Form` the field belongs to. * `:field` — the form field as an atom. * `:calendar` — a CLDR calendar key (`:gregorian`, `:hebrew`, `:islamic_civil`, `:islamic_umalqura`, `:persian`, `:japanese`, `:buddhist`, `:roc`, etc.). Defaults to `:gregorian`. * `:locale` — display locale. Defaults to `Localize.get_locale/0`. * `:min`, `:max` — bounds as ISO strings or `Date` structs. Cells outside the bounds render as disabled. * `:display_format` — one of `:short`, `:medium` (default), `:long`, `:full`. Controls the visible text-input formatting (the wire value is always ISO). * `:class`, `:input_class`, `:button_class`, `:overlay_class` — customisation hooks. """ use Phoenix.LiveComponent use Localize.Message.Sigils, backend: Localize.Inputs.Gettext @impl true def mount(socket) do {:ok, assign(socket, open: false)} end @impl true def update(assigns, socket) do cldr_calendar = Map.get(assigns, :calendar, :gregorian) locale = Map.get(assigns, :locale) || Localize.get_locale() calendar_module = resolve_calendar_module(cldr_calendar) field_struct = assigns.form[assigns.field] iso_value = case field_struct.value do %Date{} = d -> Date.to_iso8601(d) s when is_binary(s) and s != "" -> s _ -> nil end selected_iso_date = case iso_value && Date.from_iso8601(iso_value) do {:ok, d} -> d _ -> nil end cursor = derive_cursor(socket.assigns, selected_iso_date, calendar_module) socket = socket |> assign(assigns) |> assign(:cldr_calendar, cldr_calendar) |> assign(:locale, locale) |> assign(:calendar_module, calendar_module) |> assign(:field_struct, field_struct) |> assign(:iso_value, iso_value || "") |> assign(:selected_iso_date, selected_iso_date) |> assign(:cursor, cursor) |> assign( :formatted_value, format_for_display(selected_iso_date, locale, calendar_module, assigns) ) |> assign_new(:display_format, fn -> :medium end) |> assign_new(:min, fn -> nil end) |> assign_new(:max, fn -> nil end) |> 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) {:ok, socket} end @impl true def handle_event("toggle", _params, socket) do {:noreply, assign(socket, open: !socket.assigns.open)} end def handle_event("close", _params, socket) do {:noreply, assign(socket, open: false)} end def handle_event("prev_month", _params, socket) do {:noreply, assign(socket, cursor: shift_cursor(socket.assigns.cursor, -1))} end def handle_event("next_month", _params, socket) do {:noreply, assign(socket, cursor: shift_cursor(socket.assigns.cursor, 1))} end def handle_event("select_day", %{"iso" => iso}, socket) do case Date.from_iso8601(iso) do {:ok, date} -> calendar = socket.assigns.calendar_module calendar_date = safe_convert(date, calendar) {:noreply, socket |> assign(:selected_iso_date, date) |> assign(:iso_value, iso) |> assign(:cursor, %{year: calendar_date.year, month: calendar_date.month}) |> assign( :formatted_value, format_for_display(date, socket.assigns.locale, calendar, socket.assigns) ) |> assign(:open, false)} _ -> {:noreply, socket} end end @impl true def render(assigns) do assigns = assign(assigns, :grid, build_grid(assigns)) ~H"""
""" end # ── Internals ────────────────────────────────────────── defp resolve_calendar_module(:gregorian), do: Calendar.ISO defp resolve_calendar_module(cldr_calendar) do # `:calendrical` is a hard dep of this library, so no # runtime `ensure_loaded?` check is needed. case Calendrical.calendar_from_cldr_calendar_type(cldr_calendar) do {:ok, module} -> module _ -> Calendar.ISO end end defp derive_cursor(prev_assigns, selected_iso_date, calendar_module) do cond do match?(%{cursor: %{year: _, month: _}}, prev_assigns) -> prev_assigns.cursor selected_iso_date -> calendar_date = safe_convert(selected_iso_date, calendar_module) %{year: calendar_date.year, month: calendar_date.month} true -> today_in_calendar = safe_convert(Date.utc_today(), calendar_module) %{year: today_in_calendar.year, month: today_in_calendar.month} end end defp shift_cursor(%{year: year, month: month}, delta) do total = year * 12 + (month - 1) + delta %{year: div(total, 12), month: rem(total, 12) + 1} end # Build a 6×7 grid for `cursor` in `calendar_module`. # Returns `%{month_label, weekday_names, weeks}` where each # cell is `%{iso, day_label, in_month, is_selected, # is_today, disabled}`. defp build_grid(assigns) do calendar = assigns.calendar_module cursor = assigns.cursor locale = assigns.locale first_of_month = safe_build_date(cursor.year, cursor.month, 1, calendar) first_dow = Date.day_of_week(first_of_month) first_day_of_week = first_day_for_locale(locale) offset = rem(first_dow - first_day_of_week + 7, 7) grid_start = Date.add(first_of_month, -offset) today_iso = today_iso() selected_iso = assigns[:iso_value] min_iso = to_iso_attr(assigns[:min]) max_iso = to_iso_attr(assigns[:max]) cells = Enum.map(0..41, fn i -> cell_date = Date.add(grid_start, i) iso_date = safe_convert(cell_date, Calendar.ISO) iso = Date.to_iso8601(iso_date) %{ iso: iso, day_label: format_day(cell_date, locale), in_month: cell_date.month == cursor.month, is_selected: iso == selected_iso, is_today: iso == today_iso, disabled: out_of_range?(iso, min_iso, max_iso) } end) weeks = cells |> Enum.chunk_every(7) %{ month_label: format_month_label(first_of_month, locale), weekday_names: build_weekday_names(first_day_of_week, locale), weeks: weeks } end # Library code never raises on render-path. If the # year/month/day combo isn't valid in `calendar`, fall # back to a known-good Gregorian today — the UI still # renders rather than 500ing. defp safe_build_date(year, month, day, Calendar.ISO) do case Date.new(year, month, day) do {:ok, d} -> d _ -> Date.utc_today() end end defp safe_build_date(year, month, day, module) do case Date.new(year, month, day, module) do {:ok, d} -> d _ -> # Fall back to today converted into the target # calendar; if that also fails, return ISO today. safe_convert(Date.utc_today(), module) end end # Tolerant `Date.convert`: returns the input untouched if # conversion fails (so callers always get back a valid # `%Date{}` for display arithmetic). defp safe_convert(%Date{calendar: target} = date, target), do: date defp safe_convert(%Date{} = date, target) do case Date.convert(date, target) do {:ok, converted} -> converted _ -> date end end defp first_day_for_locale(locale) do case Localize.Calendar.first_day_for_locale(locale) do n when is_integer(n) -> n _ -> 1 end end defp build_weekday_names(first_day, locale) do # `Localize.Calendar.days/2` returns localized day names # keyed 1..7 (1=Monday). Rotate starting from # `first_day`. case Localize.Calendar.days(locale, :gregorian) do {:ok, data} -> narrow = get_in(data, [:format, :narrow]) || %{} for i <- 0..6 do day = rem(first_day - 1 + i, 7) + 1 Map.get(narrow, day, "") end _ -> ["M", "T", "W", "T", "F", "S", "S"] end end defp format_month_label(%{year: _, month: _} = date, locale) do case Localize.Date.to_string(date, locale: locale, format: :yMMMM) do {:ok, formatted} -> formatted _ -> "#{date.year}-#{String.pad_leading(to_string(date.month), 2, "0")}" end end defp format_day(date, locale) do case Localize.Date.to_string(date, locale: locale, format: :d) do {:ok, formatted} -> formatted _ -> to_string(date.day) end end defp format_for_display(nil, _locale, _calendar_module, _assigns), do: "" defp format_for_display(%Date{} = date, locale, calendar_module, assigns) do format = Map.get(assigns, :display_format, :medium) # Convert the ISO Date into the display calendar before # formatting. `Localize.Date.to_string/2` reads # `date.calendar` and dispatches its CLDR pattern lookup # by that calendar — so a `Calendar.ISO` Date always # formats under `:gregorian` regardless of the # `:calendar` attr the component received. We convert # here so Japanese imperial / Buddhist / Hijri locales # render their own year and era markers. display_date = safe_convert(date, calendar_module) case Localize.Date.to_string(display_date, locale: locale, format: format) do {:ok, formatted} -> formatted _ -> Date.to_iso8601(date) end end defp today_iso, do: Date.to_iso8601(Date.utc_today()) defp to_iso_attr(nil), do: nil defp to_iso_attr(%Date{} = d), do: Date.to_iso8601(d) defp to_iso_attr(s) when is_binary(s), do: s defp to_iso_attr(_), do: nil defp out_of_range?(_iso, nil, nil), do: false defp out_of_range?(iso, min, nil), do: iso < min defp out_of_range?(iso, nil, max), do: iso > max defp out_of_range?(iso, min, max), do: iso < min or iso > max end end