This guide adds date pickers to a Phoenix 1.8 app with LiveView 1.2 (a Phoenix 1.7 app with LiveView 1.1 works the same way). At the end, a LiveView form has a date picker and a range picker, the schema stores a date and a range of days, and the changeset checks the same rules as the pickers.

Your app does not run Node.js for Koyomi. The Hex package has the built bundle, and your esbuild profile imports it from deps/.

1. Add the dependency

In mix.exs:

def deps do
  [
    {:koyomi, "~> 0.2"}
  ]
end

Then run mix deps.get.

Koyomi needs Elixir 1.18 or later. The Koyomi.DateRange type of this guide stores a PostgreSQL daterange by default, which needs :postgrex; with SQLite or MySQL, give it storage: :string (see below).

2. Run the installer

mix koyomi.install --dry-run   # shows the changes as a diff
mix koyomi.install

The installer changes two files, and prints what it changed:

  • assets/js/app.js: it adds import { Koyomi } from "koyomi" and puts Koyomi in the hooks of the LiveSocket.
  • assets/css/app.css: it adds the Koyomi style sheet, and the Sumi theme import as a comment.

It does not add what a file already has, so you can run it again. When it cannot find the new LiveSocket(...) call, or its hooks are not an object or a variable, it changes nothing in app.js and prints the lines to add. After the edits it prints a note on NODE_PATH (see step 3) and how to start the pickers on a page without LiveView.

2b. Or make the changes by hand

// assets/js/app.js
import { Koyomi } from "koyomi"

const liveSocket = new LiveSocket("/live", Socket, {
  longPollFallbackMs: 2500,
  params: {_csrf_token: csrfToken},
  hooks: {...colocatedHooks, Koyomi},
})

Put the CSS lines below the other @import lines of app.css:

/* assets/css/app.css */
@import "../../deps/koyomi/priv/static/koyomi.css";
/* The Sumi theme, when you want it: */
/* @import "../../deps/koyomi/priv/static/koyomi-sumi.css"; */

3. Let esbuild find koyomi in deps/

The import from "koyomi" resolves through NODE_PATH. The Phoenix generators already give the esbuild profile a NODE_PATH with deps/. If your profile has no NODE_PATH, add it in config/config.exs:

config :esbuild,
  my_app: [
    args: ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js),
    cd: Path.expand("../assets", __DIR__),
    env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
  ]

The installer prints this note when the profile has no NODE_PATH.

A path dependency

Mix does not copy a path dependency ({:koyomi, path: "../koyomi"}) into deps/. The installer then points the @import lines of app.css at the dependency's directory, and prints the NODE_PATH entry that esbuild needs: the directory that holds the dependency, relative to config/:

env: %{
  "NODE_PATH" => [
    Path.expand("../deps", __DIR__),
    Path.expand("../..", __DIR__),
    Mix.Project.build_path()
  ]
}

A checkout of Koyomi has no built bundle until mix koyomi.build runs in it, so run that task in the Koyomi directory before the app's mix assets.build.

4. Choose the locale and the time zone

Each picker takes a locale and a time_zone. Without them, it reads the config, and then uses "en" and UTC:

# config/config.exs
config :koyomi, locale: "en-GB", time_zone: "Europe/Lisbon"

The time zone decides what "today" is, for the :today rule and the Today button. A zone other than UTC needs a time zone database on the server, such as tz or tzdata. See the Time zones guide.

5. Add the fields to a schema

# The migration
alter table(:bookings) do
  add :due, :date
  add :stay, :daterange
end
defmodule MyApp.Bookings.Booking do
  use Ecto.Schema
  import Ecto.Changeset

  schema "bookings" do
    field :due, :date
    field :stay, Koyomi.DateRange

    timestamps()
  end

  @due_rules Koyomi.Rules.new(min: :today, disabled_weekdays: [6, 7])
  @stay_rules Koyomi.Rules.new(min: :today, min_days: 2, max_days: 14)

  def due_rules, do: @due_rules
  def stay_rules, do: @stay_rules

  def changeset(booking, attrs) do
    booking
    |> cast(attrs, [:due, :stay])
    |> validate_required([:due, :stay])
    |> Koyomi.Changeset.validate_date(:due, @due_rules)
    |> Koyomi.Changeset.validate_range(:stay, @stay_rules)
  end
end

Koyomi.DateRange stores a Date.Range with both ends included. With SQLite or MySQL, store it in a text column, as an ISO 8601 interval ("2027-03-01/2027-03-07"):

# The migration
add :stay, :string

# The schema
field :stay, Koyomi.DateRange, storage: :string

Or keep the two ends in two :date fields, to query them as dates: see "Two fields for a range" in the Rules guide.

:today is read again on each check, so rules built once, in a module attribute, stay right the next day.

6. Put the pickers in a LiveView form

Import the components, for example in the html_helpers of your web module (lib/my_app_web.ex). The installer does not add this line:

import Koyomi.Components

Then a LiveView with the form:

defmodule MyAppWeb.BookingLive.New do
  use MyAppWeb, :live_view

  alias MyApp.Bookings.Booking
  alias MyApp.Repo

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, form: to_form(Booking.changeset(%Booking{}, %{})))}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <.form for={@form} id="booking-form" phx-change="validate" phx-submit="save">
      <label id="due-label">Due</label>
      <.date_picker field={@form[:due]} label_id="due-label" rules={Booking.due_rules()} />
      <p :for={msg <- errors(@form[:due])} class="error">{msg}</p>

      <label id="stay-label">Stay</label>
      <.date_range_picker
        field={@form[:stay]}
        label_id="stay-label"
        rules={Booking.stay_rules()}
        months={2}
        nights
      />
      <p :for={msg <- errors(@form[:stay])} class="error">{msg}</p>

      <.button>Book</.button>
    </.form>
    """
  end

  defp errors(field) do
    if used_input?(field), do: Enum.map(field.errors, &translate_error/1), else: []
  end

  @impl true
  def handle_event("validate", %{"booking" => params}, socket) do
    changeset = Booking.changeset(%Booking{}, params)
    {:noreply, assign(socket, form: to_form(changeset, action: :validate))}
  end

  def handle_event("save", %{"booking" => params}, socket) do
    case %Booking{} |> Booking.changeset(params) |> Repo.insert() do
      {:ok, _booking} -> {:noreply, put_flash(socket, :info, "Booked")}
      {:error, changeset} -> {:noreply, assign(socket, form: to_form(changeset))}
    end
  end
end

translate_error/1 is the function of your app's CoreComponents.

Give the same rules to the picker and to the changeset. The picker disables in the calendar what the rules refuse, and the changeset refuses it on the server, so the two never disagree.

The pickers post their values with the form, so the form's own phx-change and phx-submit events have them, and the LiveView needs no other event. The date picker posts due as 2027-03-15; the range picker posts stay[from] and stay[to], which Koyomi.DateRange casts. The Forms guide has the wire form of each picker.

To edit a booking, build the form from it (Booking.changeset(booking, %{})): each picker starts with the field's value.

7. Done

Start the server and open the page. Before the hook mounts, and with JavaScript off, each picker is the browser's own input of its kind (<input type="date">), with the same name and value, so the form still works.

Next