Tutorial
Create a Reusable Calendar in Phoenix LiveView
This post was updated 29 Nov - 2022
In this updated tutorial for Phoenix 1.7, I am creating a reusable 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 ~100 lines of code and 0 lines of Javascript and 0 lines of CSS
STEP 1 - 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.Live.CalendarComponent
.
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
defmodule TutorialWeb.Live.CalendarComponent do
use Phoenix.LiveComponent
@week_start_at :monday
def render(assigns) do
~H"""
<div>
<table>
<thead>
<tr>
<th :for={week_day <- List.first(@week_rows)}>
<%= Calendar.strftime(week_day, "%a") %>
</th>
</tr>
</thead>
<tbody>
<tr :for={week <- @week_rows}>
<td :for={day <- week}>
<time datetime={Calendar.strftime(day, "%Y-%m-%d")}><%= Calendar.strftime(day, "%d") %></time>
</td>
</tr>
</tbody>
</table>
</div>
"""
end
def mount(socket) do
current_date = Date.utc_today()
assigns = [
current_date: current_date,
selected_date: nil,
week_rows: week_rows(current_date)
]
{:ok, assign(socket, assigns)}
end
defp week_rows(current_date) do
first =
current_date
|> Date.beginning_of_month()
|> Date.beginning_of_week(@week_start_at)
last =
current_date
|> Date.end_of_month()
|> Date.end_of_week(@week_start_at)
Date.range(first, last)
|> Enum.map(& &1)
|> Enum.chunk_every(7)
end
end
The most complex part here is the week_rows/1
function. It takes the current_date
as an argument, which is the first day of the current month. And from there take the start and end date for the month as well as the start date and end date for those weeks. That gives the dates for the calendar that works for previous and next month.
That ends with taking a a date range and split it up in parts of seven to get the all the dates in a list.
After that, the rendering of the table in the template 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.
<br>
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. Go to the render function again and in the top, add:
<div>
<h3><%= Calendar.strftime(@current_date, "%B %Y") %></h3>
<div>
<button type="button" phx-target={@myself} phx-click="prev-month">« Prev</button>
<button type="button" phx-target={@myself} phx-click="next-month">Next »</button>
</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.
def handle_event("prev-month", _, socket) do
new_date = socket.assigns.current_date |> Date.beginning_of_month() |> Date.add(-1)
assigns = [
current_date: new_date,
week_rows: week_rows(new_date)
]
{:noreply, assign(socket, assigns)}
end
def handle_event("next-month", _, socket) do
new_date = socket.assigns.current_date |> Date.end_of_month() |> Date.add(1)
assigns = [
current_date: new_date,
week_rows: week_rows(new_date)
]
{:noreply, assign(socket, assigns)}
end
STEP 3 - Add functionality to a single day
A single day should have some conditional logic. It should be able to indicate and handle:
- Display todays date with a specific color
- Display other months dates with a gray background
- Make a date selectable for future functionality
- Display events for this date (Not covered in this tutorial)
The specific HEEX markup for a day look like this:
<td :for={day <- week} class={[
"text-center",
today?(day) && "bg-green-100",
other_month?(day, @current_date) && "bg-gray-100",
selected_date?(day, @selected_date) && "bg-blue-100"
]}>
<button type="button" phx-target={@myself} phx-click="pick-date" phx-value-date={Calendar.strftime(day, "%Y-%m-%d")}>
<time datetime={Calendar.strftime(day, "%Y-%m-%d")}><%= Calendar.strftime(day, "%d") %></time>
</button>
</td>
And the helper methods for figuring out if the day is selected, today or belongs to another month than the current one.
defp selected_date?(day, selected_date), do: day == selected_date
defp today?(day), do: day == Date.utc_today()
defp other_month?(day, current_date), do: Date.beginning_of_month(day) != Date.beginning_of_month(current_date)
Now that each day has a button, the desired behaviour is to make the day selected when the button is clicked.
That code looks like phx-target={@myself} phx-click="pick-date" phx-value-date={Calendar.strftime(day, "%Y-%m-%d")}
The code that handles that in the Live Component looks like this:
def handle_event("pick-date", %{"date" => date}, socket) do
{:noreply, assign(socket, :selected_date, Date.from_iso8601!(date))}
end
This expect date
to have the format of "2022-02-02"
.
Final code
The final example looks like th
defmodule TutorialWeb.Live.CalendarComponent do
use Phoenix.LiveComponent
@week_start_at :monday
def render(assigns) do
~H"""
<div>
<div>
<h3><%= Calendar.strftime(@current_date, "%B %Y") %></h3>
<div>
<button type="button" phx-target={@myself} phx-click="prev-month">« Prev</button>
<button type="button" phx-target={@myself} phx-click="next-month">Next »</button>
</div>
</div>
<table>
<thead>
<tr>
<th :for={week_day <- List.first(@week_rows)}>
<%= Calendar.strftime(week_day, "%a") %>
</th>
</tr>
</thead>
<tbody>
<tr :for={week <- @week_rows}>
<td :for={day <- week} class={[
"text-center",
today?(day) && "bg-green-100",
other_month?(day, @current_date) && "bg-gray-100",
selected_date?(day, @selected_date) && "bg-blue-100"
]}>
<button type="button" phx-target={@myself} phx-click="pick-date" phx-value-date={Calendar.strftime(day, "%Y-%m-%d")}>
<time datetime={Calendar.strftime(day, "%Y-%m-%d")}><%= Calendar.strftime(day, "%d") %></time>
</button>
</td>
</tr>
</tbody>
</table>
</div>
"""
end
def mount(socket) do
current_date = Date.utc_today()
assigns = [
current_date: current_date,
selected_date: nil,
week_rows: week_rows(current_date)
]
{:ok, assign(socket, assigns)}
end
defp week_rows(current_date) do
first =
current_date
|> Date.beginning_of_month()
|> Date.beginning_of_week(@week_start_at)
last =
current_date
|> Date.end_of_month()
|> Date.end_of_week(@week_start_at)
Date.range(first, last)
|> Enum.map(& &1)
|> Enum.chunk_every(7)
end
def handle_event("prev-month", _, socket) do
new_date = socket.assigns.current_date |> Date.beginning_of_month() |> Date.add(-1)
assigns = [
current_date: new_date,
week_rows: week_rows(new_date)
]
{:noreply, assign(socket, assigns)}
end
def handle_event("next-month", _, socket) do
new_date = socket.assigns.current_date |> Date.end_of_month() |> Date.add(1)
assigns = [
current_date: new_date,
week_rows: week_rows(new_date)
]
{:noreply, assign(socket, assigns)}
end
def handle_event("pick-date", %{"date" => date}, socket) do
{:noreply, assign(socket, :selected_date, Date.from_iso8601!(date))}
end
defp selected_date?(day, selected_date), do: day == selected_date
defp today?(day), do: day == Date.utc_today()
defp other_month?(day, current_date), do: Date.beginning_of_month(day) != Date.beginning_of_month(current_date)
end
From this, it should be easy to get going with a customized real world example.