Psst. It would be super cool if you could try the new Phoenix Boilerplate

Try now →

Tutorial

View on Github

Create a Calendar in Phoenix LiveView

calendarliveviewphoenix

In this tutorial I am creating a simple calendar in Phoenix LiveView. The calendar should be able to switch month, highlight current day and select a specific day. I might extend functionality in a future tutorial. And also, all styling are done with Tailwind.

And I do it in 163 lines of code and 0 lines of Javascript and 0 lines of CSS

STEP 1 - Install Timex

First off, I need to install the Timex library. It comes with some convenient helper functions that makes the rest of the work a lot easier.

# mix.exs
defp deps do
  [{:timex, "~> 3.5"}]
end

And then run:

mix deps.get

So, when that is installed, I can move on to the next part.

STEP 2 - Create Calendar component

Since I already know I want to do this in LiveView so I will go ahead and create a new component called TutorialWeb.CalendarLive.

The first step is so make it display current month and current date. I also want to make it configurable if the week starts with Sunday or Monday

# lib/tutorial_web/live/calendar_live.ex
defmodule TutorialWeb.CalendarLive do
  use Phoenix.LiveView
  use Timex

  @week_start_at :mon

  def mount(_session, socket) do
    current_date = Timex.now

    assigns = [
      conn: socket,
      current_date: current_date,
      day_names: day_names(@week_start_at),
      week_rows: week_rows(current_date)
    ]

    {:ok, assign(socket, assigns)}
  end

  def render(assigns) do
    TutorialWeb.PageView.render("calendar.html", assigns)
  end

  defp day_names(:sun), do:  [7, 1, 2, 3, 4, 5, 6] |> Enum.map(&Timex.day_shortname/1)
  defp day_names(_), do:  [1, 2, 3, 4, 5, 6, 7] |> Enum.map(&Timex.day_shortname/1)

  defp week_rows(current_date) do
    first =
      current_date
      |> Timex.beginning_of_month()
      |> Timex.beginning_of_week(@week_start_at)

    last =
      current_date
      |> Timex.end_of_month()
      |> Timex.end_of_week(@week_start_at)

    Interval.new(from: first, until: last)
    |> Enum.map(& &1)
    |> Enum.chunk_every(7)
  end
end

The template file is pretty basic. I iterate over the list of day names to get the headers and then iterate first over the list of weeks, and then within a week row, I iterate over the actual days.

<!-- calendar.html.leex -->
<table class="w-full mt-4 border border-gray-200 rounded-lg shadow-lg">
  <thead>
    <tr>
<%= for day_name <- @day_names do %>
      <th class="text-xs p-2 text-gray-600 border border-gray-200">
        <%= day_name %>
      </th>
<% end %>
    </tr>
  </thead>
  <tbody>
<%= for week <- @week_rows do %>
  <tr>
  <%= for day <- week do %>
    <%= Timex.format!(day, "%d", :strftime) %>
  <% end %>
  </tr>
<% end %>
  </tbody>
</table>

When that is done, I want to attach some interactivity to the calendar.

STEP 2 - Add month selection

The calendar are pretty useless without being able to switch months. So that would be the next thing to add. Open up the template again and in the top, add:

<!-- calendar.html.leex -->
<div class="flex items-baseline justify-between">
  <h3 class="ml-4 text-gray-600 text-lg">
    <%= Timex.format!(@current_date, "%B %Y", :strftime) %>
  </h3>
  <div>
    <a href="#" phx-click="prev-month" class="inline-block text-sm bg-white p-2 rounded shadow text-gray-600 border border-gray-200">&laquo; Prev</a>
    <a href="#" phx-click="next-month" class="inline-block text-sm bg-white p-2 rounded shadow text-gray-600 border border-gray-200">&raquo; Next</a>
  </div>
</div>

Note that I add phx-click="prev-month" to the links. So I also need to add handlers in the LiveView component.

So what I do is take current data and shift the month with either +1 or -1. Then I recalculate the weeks_rows based on the new date.

  # lib/tutorial_web/live/calendar_live.ex
  def handle_event("prev-month", _, socket) do
    current_date = Timex.shift(socket.assigns.current_date, months: -1)

    assigns = [
      current_date: current_date,
      week_rows: week_rows(current_date)
    ]

    {:noreply, assign(socket, assigns)}
  end

  def handle_event("next-month", _, socket) do
    current_date = Timex.shift(socket.assigns.current_date, months: 1)

    assigns = [
      current_date: current_date,
      week_rows: week_rows(current_date)
    ]

    {:noreply, assign(socket, assigns)}
  end

STEP 3 - Add functionality to a single day

Since the day cell will have more complex logic like:

  1. Display todays date with a specific color
  2. Display other months dates with a gray background
  3. Make a date selectable for future functionality
  4. Display events for this date (Not covered in this tutorial)

I think it make sense to extract it to its own LiveComponent.

# lib/tutorial_web/live/calendar_day_component.ex
defmodule TutorialWeb.CalendarDayComponent do
  use Phoenix.LiveComponent
  use Timex

  def render(assigns) do
    assigns = Map.put(assigns, :day_class, day_class(assigns))

    ~L"""
    <td phx-click="pick-date" phx-value-date="<%= Timex.format!(@day, "%Y-%m-%d", :strftime) %>" class="<%= @day_class %>">
      <%= Timex.format!(@day, "%d", :strftime) %>
    </td>
    """
  end

  defp day_class(assigns) do
    cond do
      today?(assigns) ->
        "text-xs p-2 text-gray-600 border border-gray-200 bg-green-200 hover:bg-green-300 cursor-pointer"
      current_date?(assigns) ->
        "text-xs p-2 text-gray-600 border border-gray-200 bg-blue-100 cursor-pointer"
      other_month?(assigns) ->
        "text-xs p-2 text-gray-400 border border-gray-200 bg-gray-200 cursor-not-allowed"
      true ->
        "text-xs p-2 text-gray-600 border border-gray-200 bg-white hover:bg-blue-100 cursor-pointer"
    end
  end

  defp current_date?(assigns) do
    Map.take(assigns.day, [:year, :month, :day]) == Map.take(assigns.current_date, [:year, :month, :day])
  end

  defp today?(assigns) do
    Map.take(assigns.day, [:year, :month, :day]) == Map.take(Timex.now, [:year, :month, :day])
  end

  defp other_month?(assigns) do
    Map.take(assigns.day, [:year, :month]) != Map.take(assigns.current_date, [:year, :month])
  end
end

Note that I want the component, or day cell, to have different styling depending of what day it is. I also want to be able to click on a day to set it as active. I do that with phx-click="pick-date" and the value that are being sent is phx-value-date="<%= Timex.format!(@day, "%Y-%m-%d", :strftime) %>"

That makes is easy to parse in the LiveView Calendar component. And also, that is the last thing to add. Open CalendarLive again and add:

  # lib/tutorial_web/live/calendar_live.ex
  def handle_event("pick-date", %{"date" => date}, socket) do
    current_date = Timex.parse!(date, "{YYYY}-{0M}-{D}")

    assigns = [
      current_date: current_date
    ]

    {:noreply, assign(socket, assigns)}
  end

So that is that. If you have any questions, you can reach out to me on Twitter and I will be happy to answer them.


Related Tutorials

Published 01 Apr

Create a reusable modal with LiveView Component

alpinejsliveviewmodalphoenixtailwind

To reduce duplicity and complexity in your apps, Phoenix LiveView comes with the possibility to use reusable components. Each component can have its..

Published 31 Mar

Combine Phoenix LiveView with Alpine.js

alpinejsliveviewphoenixtailwind

No matter how great Phoenix LiveView is, there is still some use case for sprinking some JS in your app to improve UX. For example, tabs, dropdowns,..