defmodule Corex.DatePicker do @moduledoc ~S''' Phoenix implementation of [Zag.js Date Picker](https://zagjs.com/components/react/date-picker). ## Examples ### Basic Usage ```heex <.date_picker id="my-date-picker"> <:label>Select a date <:trigger> <.heroicon name="hero-calendar" /> <:prev_trigger> <.heroicon name="hero-chevron-left" class="icon" /> <:next_trigger> <.heroicon name="hero-chevron-right" class="icon" /> ``` ### Controlled Mode ```heex <.date_picker id="my-date-picker" controlled value={@date_value} on_value_change="date_changed"> <:label>Select a date <:trigger> <.heroicon name="hero-calendar" /> <:prev_trigger> <.heroicon name="hero-chevron-left" class="icon" /> <:next_trigger> <.heroicon name="hero-chevron-right" class="icon" /> ``` ```elixir def handle_event("date_changed", %{"value" => value}, socket) do {:noreply, assign(socket, :date_value, value)} end ``` Pass an ISO-8601 string, or a `Date` struct via `Date.to_iso8601/1` (for a single day use `~D[...]`): ```heex <.date_picker id="due" controlled value={@due && Date.to_iso8601(@due)} on_value_change="date_changed" > <:label>Due <:trigger> <.heroicon name="hero-calendar" /> <:prev_trigger> <.heroicon name="hero-chevron-left" class="icon" /> <:next_trigger> <.heroicon name="hero-chevron-right" class="icon" /> ``` ```elixir assign(socket, :due, ~D[2024-01-15]) ``` ## Phoenix Form Integration When using with Phoenix forms, set the form `id` in `to_form/2` (for example `to_form(changeset, as: :name, id: "my-form")`) and use `id={@form.id}` on `<.form>`. ### Controller Build the form from an Ecto changeset: ```elixir def form_page(conn, _params) do form = %MyApp.Form.DateForm{} |> MyApp.Form.DateForm.changeset(%{}) |> Phoenix.Component.to_form(as: :date_form, id: "date-form") render(conn, :form_page, form: form) end ``` ```heex <.form :let={f} for={@form} id={@form.id} action={@action} method="post"> <.date_picker field={f[:date]} class="date-picker" translation={%Corex.DatePicker.Translation{ open_calendar: "Select date", close_calendar: "Select date", input: "Select date" }} > <:label>Date <:trigger> <.heroicon name="hero-calendar" class="icon" /> <:prev_trigger> <.heroicon name="hero-chevron-left" class="icon" /> <:next_trigger> <.heroicon name="hero-chevron-right" class="icon" /> <:error :let={msg}> <.heroicon name="hero-exclamation-circle" class="icon" /> {msg} ``` ### Live View When using in a Live view you must add controlled mode. Prefer building the form from an Ecto changeset (see "With Ecto changeset" below). ### With Ecto changeset When using Ecto changeset for validation and inside a Live view you must enable the controlled mode. This allows the Live View to be the source of truth and the component to be in sync accordingly. First create your schema and changeset: ```elixir defmodule MyApp.Accounts.User do use Ecto.Schema import Ecto.Changeset schema "users" do field :name, :string field :birth_date, :date timestamps(type: :utc_datetime) end def changeset(user, attrs) do user |> cast(attrs, [:name, :birth_date]) |> validate_required([:name, :birth_date]) end end ``` ```elixir defmodule MyAppWeb.UserLive do use MyAppWeb, :live_view alias MyApp.Accounts.User def mount(_params, _session, socket) do {:ok, assign(socket, :form, to_form(User.changeset(%User{}, %{})))} end def handle_event("validate", %{"user" => user_params}, socket) do changeset = User.changeset(%User{}, user_params) {:noreply, assign(socket, form: to_form(changeset, action: :validate))} end def render(assigns) do ~H""" <.form for={@form} id={@form.id} phx-change="validate"> <.date_picker field={@form[:birth_date]} class="date-picker" controlled> <:label>Birth date <:trigger> <.heroicon name="hero-calendar" class="icon" /> <:prev_trigger> <.heroicon name="hero-chevron-left" class="icon" /> <:next_trigger> <.heroicon name="hero-chevron-right" class="icon" /> <:error :let={msg}> <.heroicon name="hero-exclamation-circle" class="icon" /> {msg} """ end end ``` ## API Control In order to use the API, you must use an id on the component ***Client-side*** ```heex ``` ***Server-side*** ```elixir def handle_event("set_date", _, socket) do {:noreply, Corex.DatePicker.set_value(socket, "my-date-picker", "2024-01-15")} end ``` ## Styling Use data attributes to target elements: ```css [data-scope="date-picker"][data-part="root"] {} [data-scope="date-picker"][data-part="label"] {} [data-scope="date-picker"][data-part="control"] {} [data-scope="date-picker"][data-part="input"] {} [data-scope="date-picker"][data-part="trigger"] {} [data-scope="date-picker"][data-part="positioner"] {} [data-scope="date-picker"][data-part="content"] {} ``` If you wish to use the default Corex styling, you can use the class `date-picker` on the component. This requires to install `Mix.Tasks.Corex.Design` first and import the component css file. ```css @import "../corex/main.css"; @import "../corex/tokens/themes/neo/light.css"; @import "../corex/components/date-picker.css"; ``` You can then use modifiers ```heex <.date_picker class="date-picker date-picker--accent date-picker--lg" id="my-date-picker"> ``` In `selection_mode` `"range"`, the control shows two fields with optional `range_start_label` and `range_end_label` (overrides the `translation` map’s range labels, defaulting to **From** / **To** with gettext). In `"multiple"`, a single field shows a comma‑separated list of the formatted selected dates. Use `max_selected_dates` to cap how many days can be selected in multiple mode; omit for no cap. ## Localization and `translation` Pass `translation={%Corex.DatePicker.Translation{}}` to override any string. The component merges with `Corex.DatePicker.default_translation/0` (Zag’s `translations` for open/close, prev/next, view, month/year, week, placeholders, and `input`). Use `open_calendar`, `close_calendar`, and `input` for the popover trigger and fields (SSR `aria-label` and client `data-translation` JSON). Without gettext, the defaults are English. With gettext, call `translation={%Corex.DatePicker.Translation{open_calendar: Corex.Gettext.gettext("Open calendar")}}` for partial overrides. ''' use Phoenix.Component alias Corex.DatePicker.Anatomy alias Corex.DatePicker.Connect alias Corex.DatePicker.Translation, as: DatePickerTranslation alias Corex.Gettext alias Phoenix.LiveView alias Phoenix.LiveView.JS @doc """ Renders a date picker component. """ attr(:id, :string, default: nil, doc: "The unique identifier for the date picker. Set automatically when using the field attr." ) attr(:value, :string, default: nil, doc: "The initial value or the controlled value (ISO date string)" ) attr(:controlled, :boolean, default: false, doc: "Whether the date picker is controlled. Only in LiveView, the on_value_change event is required" ) attr(:locale, :string, default: nil, doc: "The locale for date formatting" ) attr(:time_zone, :string, default: nil, doc: "The time zone for date operations" ) attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"], doc: "The direction of the date picker. When nil, derived from document (html lang + config :rtl_locales)" ) attr(:on_value_change, :string, default: nil, doc: "The server event name when the value changes" ) attr(:on_focus_change, :string, default: nil, doc: "The server event name when focus changes" ) attr(:on_view_change, :string, default: nil, doc: "The server event name when the view changes" ) attr(:name, :string, default: nil, doc: "The name attribute of the input element" ) attr(:disabled, :boolean, default: false, doc: "Whether the calendar is disabled" ) attr(:read_only, :boolean, default: false, doc: "Whether the calendar is read-only" ) attr(:required, :boolean, default: false, doc: "Whether the date picker is required" ) attr(:invalid, :boolean, default: false, doc: "Whether the date picker is invalid" ) attr(:outside_day_selectable, :boolean, default: false, doc: "Whether day outside the visible range can be selected" ) attr(:close_on_select, :boolean, default: false, doc: "If true, close the popover when selection is complete. For `selection_mode` :multiple or :range, the default false keeps the panel open until dismissed unless you set this to true." ) attr(:min, :string, default: nil, doc: "The minimum date that can be selected (ISO date string)" ) attr(:max, :string, default: nil, doc: "The maximum date that can be selected (ISO date string)" ) attr(:focused_value, :string, default: nil, doc: "The initial focused date when the calendar opens (ISO date string). Used as default in the picker." ) attr(:start_of_week, :integer, default: 0, doc: "The first day of the week (0=Sunday, 1=Monday, etc.)" ) attr(:fixed_weeks, :boolean, default: true, doc: "Whether the calendar should have a fixed number of weeks (6 weeks)" ) attr(:selection_mode, :string, default: "single", values: ["single", "multiple", "range"], doc: "The selection mode of the calendar" ) attr(:placeholder, :string, default: nil, doc: "The placeholder text to display in the input" ) attr(:translation, :any, default: nil, doc: "Merges with `default_translation/0` to override Zag and Corex strings; see the module section on localization." ) attr(:range_start_label, :string, default: nil, doc: "When `selection_mode` is \"range\", overrides `translation.range_start` for the first field side label (default in `default_translation/0` is **From**)." ) attr(:range_end_label, :string, default: nil, doc: "When `selection_mode` is \"range\", overrides `translation.range_end` (default **To**)." ) attr(:max_selected_dates, :integer, default: nil, doc: "When `selection_mode` is \"multiple\", limits how many days can be selected. Omit for no cap." ) attr(:view, :string, default: "day", values: ["day", "month", "year"], doc: "The initial view of the calendar (day, month, or year); passed to the client as the default view" ) attr(:min_view, :string, default: "day", values: ["day", "month", "year"], doc: "The minimum view of the calendar" ) attr(:max_view, :string, default: "year", values: ["day", "month", "year"], doc: "The maximum view of the calendar" ) attr(:positioning, Corex.Positioning, default: %Corex.Positioning{}, doc: "Positioning options for the date picker content" ) attr(:on_visible_range_change, :string, default: nil, doc: "The server event name when the visible range changes" ) attr(:on_open_change, :string, default: nil, doc: "The server event name when the calendar opens or closes" ) attr(:on_value_change_client, :string, default: nil, doc: "Fires a window-bubbling CustomEvent with this name when the value changes (optional; use with on_value_change for both)" ) attr(:on_open_change_client, :string, default: nil, doc: "Fires a window-bubbling CustomEvent with this name when the calendar opens or closes (optional; use with on_open_change for both)" ) attr(:errors, :list, default: [], doc: "List of error messages to display" ) attr(:field, Phoenix.HTML.FormField, doc: "A form field struct from the form, e.g. @form[:birth_date]. Sets id, name, value, and errors from the field; enables controlled mode for LiveView." ) attr(:rest, :global) slot :label, required: false do attr(:class, :string, required: false) end slot :error, required: false do attr(:class, :string, required: false) end slot :trigger, required: false do attr(:class, :string, required: false) end slot :prev_trigger, required: false do attr(:class, :string, required: false) end slot :next_trigger, required: false do attr(:class, :string, required: false) end def date_picker(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do errors = if Phoenix.Component.used_input?(field), do: field.errors, else: [] assigns |> assign(field: nil) |> assign(:errors, Enum.map(errors, &Gettext.translate_error(&1))) |> assign(:id, field.id) |> assign(:name, field.name) |> assign(:value, normalize_date_value(field.value)) |> date_picker() end def date_picker(assigns) do assigns = assigns |> assign(:id, assigns[:id] || "date-picker-#{System.unique_integer([:positive])}") |> merge_date_picker_assigns() ~H"""
<%= if @selection_mode == "range" do %>
{@range_start_label} "-range-start-label"} aria-label={@translation.input} /> {@range_end_label} "-range-end-label"} aria-label={@translation.input} />
<% else %> <% end %>
{render_slot(@error, msg)}
"-day-view"} data-scope="date-picker" data-part="day-view">
""" end @doc type: :api @doc """ Sets the date picker value from client-side. Returns a `Phoenix.LiveView.JS` command. ## Examples """ def set_value(date_picker_id, value) when is_binary(date_picker_id) do case normalize_date_value(value) do nil -> raise ArgumentError, "set_value/2 expected an ISO-8601 date string or a Date, got: #{inspect(value)}" iso -> JS.dispatch("corex:date-picker:set-value", to: "##{date_picker_id}", detail: %{value: iso}, bubbles: false ) end end @doc type: :api @doc """ Sets the date picker value from server-side. Pushes a LiveView event. ## Examples def handle_event("set_date", _params, socket) do socket = Corex.DatePicker.set_value(socket, "my-date-picker", "2024-01-15") {:noreply, socket} end def handle_event("set_birthdate", _params, socket) do socket = Corex.DatePicker.set_value(socket, "my-date-picker", ~D[2024-01-15]) {:noreply, socket} end """ def set_value(socket, date_picker_id, value) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(date_picker_id) do case normalize_date_value(value) do nil -> raise ArgumentError, "set_value/3 expected an ISO-8601 date string or a Date, got: #{inspect(value)}" iso -> LiveView.push_event(socket, "date_picker_set_value", %{ date_picker_id: date_picker_id, value: iso }) end end defp merge_date_picker_assigns(%{} = assigns) do default = default_translation() t = merge_date_picker_translation(Map.get(assigns, :translation), default) range_start = Map.get(assigns, :range_start_label) || t.range_start range_end = Map.get(assigns, :range_end_label) || t.range_end assign(assigns, translation: t, range_start_label: range_start, range_end_label: range_end) end defp merge_date_picker_translation(nil, default), do: default defp merge_date_picker_translation(%DatePickerTranslation{} = p, %DatePickerTranslation{} = d) do m_p = Map.from_struct(p) m_d = Map.from_struct(d) merged = for {k, fallback} <- Map.to_list(m_d), into: %{} do {k, take_translation_override(Map.get(m_p, k), fallback)} end struct!(DatePickerTranslation, merged) end defp take_translation_override(override, fallback) do if is_binary(override) and String.trim(override) != "" do override else fallback end end @doc """ Returns the merged default translatable strings. Uses gettext; override per call site with the `translation` attr. """ @spec default_translation() :: DatePickerTranslation.t() def default_translation do %DatePickerTranslation{ content: Gettext.gettext("calendar"), month_select: Gettext.gettext("Select month"), year_select: Gettext.gettext("Select year"), clear_trigger: Gettext.gettext("Clear selected dates"), week_column_header: Gettext.gettext("Wk"), open_calendar: Gettext.gettext("Open calendar"), close_calendar: Gettext.gettext("Close calendar"), view_trigger_year: Gettext.gettext("Switch to month view"), view_trigger_month: Gettext.gettext("Switch to day view"), view_trigger_day: Gettext.gettext("Switch to year view"), prev_trigger_year: Gettext.gettext("Switch to previous decade"), prev_trigger_month: Gettext.gettext("Switch to previous year"), prev_trigger_day: Gettext.gettext("Switch to previous month"), next_trigger_year: Gettext.gettext("Switch to next decade"), next_trigger_month: Gettext.gettext("Switch to next year"), next_trigger_day: Gettext.gettext("Switch to next month"), week_number: Gettext.gettext("Week __N__"), placeholder_day: Gettext.gettext("dd"), placeholder_month: Gettext.gettext("mm"), placeholder_year: Gettext.gettext("yyyy"), input: Gettext.gettext("Select date"), range_start: Gettext.gettext("From"), range_end: Gettext.gettext("To") } end defp normalize_date_value(nil), do: nil defp normalize_date_value(%Date{} = d), do: Date.to_iso8601(d) defp normalize_date_value(s) when is_binary(s), do: s defp normalize_date_value(_), do: nil end