DEV Community

Cover image for Creating a Date Range Picker with Phoenix LiveView
Kathy
Kathy

Posted on

Creating a Date Range Picker with Phoenix LiveView

Tl;dr: You can use the full DateRangePicker component here. Please please PLEASE submit PRs and help me improve it!

Phoenix has built-in support for Tailwind, and the Tailwind forms plugin has a built-in datepicker when you set <input type="date">. With just a few lines of code you can have a beautiful, fully functional datepicker for your forms. What more could anyone want?!?

"But Kathy," you say, "My project requires a range of dates. I need to select more than one!"

You're in luck! You could just… add another input field!!! Then you can use two dates for a range!!!




That's it. That's the post.

















dog laughing

JUST KIDDING!!!

I tried that approach, and while it functioned properly and I was able to save multiple dates in a database for a range, the process doubled the number of clicks and cluttered forms.

A great goblin in World of Warcraft once said, "time is money, friend,"1 and I don't want anyone to feel frustrated with the ever-increasing micro-aggression of having to click through TWO datepicker components when it could be put in a single field. I also love Elixir and LiveView and wanted to work on a fun little project that would make people happy. Even better: after the obligatory initial Google research I couldn't find much information on making a date range picker in LiveView. Now I can share my learnings with everyone!! Wins all around!!!

Getting Started

This post assumes that you have Elixir, Phoenix, and Phoenix LiveView 2 installed on your system.

Note:
I will not be adding aliases during this post unless they are already available in generators. While those are great for cleaning up code, they can be confusing in tutorials and people might see errors if they copy-paste a line of code without the alias. I'll add a Clean Up section at the end to show different ways to improve what we're working on.

Like any good tutorial post, let's create a project to show more concrete examples of what we want to do. We're going to create an Event Management System!

That sounds super fancy, but really we're just going to have a form with two input fields: name and date_range.

We'll set up the infrastructure of our project with this phoenix generator:

mix archive.install hex phx_new
mix phx.new date_range_picker
cd date_range_picker/
mix phx.gen.live Events Event events name:string start_date:datetime end_date:datetime
Enter fullscreen mode Exit fullscreen mode

So fancy!

Our generator will make lots of files for us (many of which we won't need) and give us some handy instructions on how to set up our routes. Replace get "/", PageController, :home in the router.ex file with our live routes:

# lib/date_range_picker_web/router.ex

  scope "/", DateRangePickerWeb do
    pipe_through :browser

    live "/", EventLive.Index, :index
    live "/new", EventLive.Index, :new
    live "/:id/edit", EventLive.Index, :edit
  end
Enter fullscreen mode Exit fullscreen mode

Then we run mix ecto.setup to set up the events database table. Now we can start our server with iex -S mix phx.server and see our super fancy Event Management System!!

initial form

Wooooooowwwwww!!!! 😍

So, who doesn't love single page apps?! We're going to be super cool and have all of our functionality on this main page!!

In lib/date_range_picker_web/live/event_live/index.ex, replace the default defp apply_action(socket, :index, _params) do with:

# lib/date_range_picker_web/live/event_live/index.ex

defp apply_action(socket, :index, _params) do
  socket
  |> assign(:page_title, "Listing Events")
  |> assign(:event, %Event{})
end
Enter fullscreen mode Exit fullscreen mode

Then in the index.html.heex file, use the pre-built FormComponent instead of the "New Event" button:

# lib/date_range_picker_web/live/event_live/index.html.heex

<.header>
  Listing Events
  <.live_component
    id="event_form"
    action={:new}
    event={@event}
    title="Event"
    module={DateRangePickerWeb.EventLive.FormComponent}
  />
</.header>
Enter fullscreen mode Exit fullscreen mode

replace new button

Now open up the FormComponent. We don't want to push our changes to a different route, so remove |> push_patch(to: socket.assigns.patch) in save_event(socket, :new, event_params)

# lib/date_range_picker_web/live/event_live/form_component.ex

defp save_event(socket, :edit, event_params) do
  case Events.update_event(socket.assigns.event, event_params) do
    {:ok, event} ->
      notify_parent({:saved, event})

      {:noreply,
        socket
        |> put_flash(:info, "Event updated successfully")}

    {:error, %Ecto.Changeset{} = changeset} ->
      {:noreply, assign_form(socket, changeset)}
  end
end
Enter fullscreen mode Exit fullscreen mode

form

Neat! We have a working form! ✨

The Calendar

"But Kathy," you say. "I thought this post was about how to create a Date Range Picker with LiveView. This form is boring."

It sure is! And now that we have our form with our two datepicker inputs, we can really feel the struggle of having to go through all those clicks just to create an event. I don't know about you, but I hated every second of testing that form 😩

We're also going to do something REALLY crazy and create the DateRangePicker as a universal component. And then add that component to the FormComponent. That's right. Nested components in a form. Buckle up 😏

The FormComponent contains logic that is specific to the Event module. That's fine for smaller applications, but in general you want to add generic components wherever possible. We'll likely want to use a generic .date_range_picker anywhere in the app, so it should work with any module.

We'll start by adding a new file called date_range_picker.ex in the lib/date_range_picker_web/components directory.

# lib/date_range_picker_web/components/date_range_picker.ex

defmodule DateRangePickerWeb.Components.DateRangePicker do
  use DateRangePickerWeb, :live_component

  @impl true
  def render(assigns) do
    ~H"""
    <div>
      Cool things coming!
    </div>
    """
  end

end
Enter fullscreen mode Exit fullscreen mode

We'll keep Start Date as the default datepicker for reference and we'll have our End Date field use our fancy new component. Replace the .simple_form block with the following:

# lib/date_range_picker_web/live/event_live/form_component.ex

<.simple_form
  for={@form}
  id="event_form"
  phx-target={@myself}
  phx-change="validate"
  phx-submit="save"
>
  <.input field={@form[:name]} type="text" label="Name" />
  <.input field={@form[:start_date]} type="datetime-local" label="Start date" />
  <.live_component
    module={DateRangePickerWeb.Components.DateRangePicker}
    label="Date Range"
    id={@id}
    form={@form}
    start_date_field={@form[:start_date]}
    end_date_field={@form[:end_date]}
  />

  <:actions>
    <.button phx-disable-with="Saving...">Save Event</.button>
  </:actions>
</.simple_form>
Enter fullscreen mode Exit fullscreen mode

form with datepicker input

This next part is a bit "draw the owl" because we'll be creating the calendar section of the form. I closely followed the FullstackPhoenix Calendar Guide for this implementation, and I HIGHLY recommend checking out that post (and their other posts!) for more detailed information

Feel free to copy-paste this full code snippet into the date_range_picker.ex file:

defmodule DateRangePickerWeb.Components.DateRangePicker do
  use DateRangePickerWeb, :live_component

  @week_start_at :sunday

  @impl true
  def render(assigns) do
    ~H"""
    <div class="date-range-picker">
      <div class="fake-input-tag relative">
        <.input name={"#{@id}_display_value"} type="text" value="" />
      </div>

      <div id={"#{@id}_calendar"} class="absolute z-50 w-72 shadow">
        <div id="calendar_background" class="w-full bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none p-3">
          <div id="calendar_header" class="flex justify-between text-gray-900">
            <div id="button_left">
              <button type="button" class="p-1.5 text-gray-400 hover:text-gray-500">
                <.icon name="hero-arrow-left" />
              </button>
            </div>

            <div id="current_month_year" class="self-center">
              <%= Calendar.strftime(@current_date, "%B %Y") %>
            </div>

            <div id="button_right">
              <button type="button" class="p-1.5 text-gray-400 hover:text-gray-500">
                <.icon name="hero-arrow-right" />
              </button>
            </div>
          </div>

          <div id="click_today" class="text-gray-500 text-sm text-center cursor-pointer">
            Today
          </div>

          <div id="calendar_weekdays" class="text-center mt-6 grid grid-cols-7 text-xs leading-6 text-gray-500">
            <div :for={week_day <- List.first(@week_rows)}>
              <%= Calendar.strftime(week_day, "%a") %>
            </div>
          </div>

          <div id="calendar_days" class="isolate mt-2 grid grid-cols-7 gap-px text-sm">
            <button :for={day <- Enum.flat_map(@week_rows, fn week -> week end)} type="button">
              <time class="mx-auto flex h-6 w-6 items-center justify-center rounded-full" datetime={Calendar.strftime(day, "%Y-%m-%d")}>
                <%= Calendar.strftime(day, "%d") %>
              </time>
            </button>
          </div>
        </div>
      </div>
    </div>
    """
  end

  @impl true
  def mount(socket) do
    current_date = Date.utc_today()

    {
      :ok,
      socket
      |> assign(:current_date, current_date)
      |> assign(:week_rows, week_rows(current_date))
    } 
  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
Enter fullscreen mode Exit fullscreen mode

And now we have:

form with calendar

Ooooookay that's a lot of stuff. Again, if you'd like more details about everything going on here please check out the FullstackPhoenix Calendar Guide, otherwise we'll continue to adding click events and making the calendar more interactive.

Cool! So! We have a calendar! And it looks pretty awesome. So let's have it do some stuff!

We'll start with changing the months, and we'll handle that with handle_event/3 (😆 jokes! 😆)

We've got this button in <div id="button_left"> that could reeeeally use a phx-click event. Let's change that div and add phx-target={@myself} phx-click="prev-month":

# lib/date_range_picker_web/components/date_range_picker.ex

<div id="button_left">
  <button type="button" phx-target={@myself} phx-click="prev-month" class="p-1.5 text-gray-400 hover:text-gray-500">
    <.icon name="hero-arrow-left" />
  </button>
</div>
Enter fullscreen mode Exit fullscreen mode

Now when the user clicks on the arrow-left icon, LiveView will send a "prev-month" event that we'll need to catch. Let's add a handle_event/3 under mount/1:

# lib/date_range_picker_web/components/date_range_picker.ex

@impl true
def handle_event("prev-month", _, socket) do
  new_date =
    socket.assigns.current_date
    |> Date.beginning_of_month()
    |> Date.add(-1)

  {
    :noreply,
    socket
    |> assign(:current_date, new_date)
    |> assign(:week_rows, week_rows(new_date))
  }
end
Enter fullscreen mode Exit fullscreen mode

Go back to your form, click the left arrow, and OHMYGOSHWOW it switches to the previous month!! Let's do the same for "next-month" and click all over the place!! 🎉 🎉 🎉

Add phx-target={@myself} phx-click="next-month" to the "button_right" div:

# lib/date_range_picker_web/components/date_range_picker.ex

<div id="button_right">
  <button type="button" phx-target={@myself} phx-click="next-month" class="p-1.5 text-gray-400 hover:text-gray-500">
    <.icon name="hero-arrow-right" />
  </button>
</div>
Enter fullscreen mode Exit fullscreen mode

Then handle the "next-month" event:

# lib/date_range_picker_web/components/date_range_picker.ex

@impl true
def handle_event("next-month", _, socket) do
  new_date =
    socket.assigns.current_date
    |> Date.end_of_month()
    |> Date.add(1)

  {
    :noreply,
    socket
    |> assign(:current_date, new_date)
    |> assign(:week_rows, week_rows(new_date))
  }
end
Enter fullscreen mode Exit fullscreen mode

While we're at it, let's have that "Today" link reset the calendar back to the current month. As before, add phx-target={@myself} phx-click="today" to "click_today":

# lib/date_range_picker_web/components/date_range_picker.ex

<div id="click_today" phx-target={@myself} phx-click="today" class="text-gray-500 text-sm text-center cursor-pointer">
  Today
</div>
Enter fullscreen mode Exit fullscreen mode

Aaaaaand handle the "today" event:

# lib/date_range_picker_web/components/date_range_picker.ex

@impl true
def handle_event("today", _, socket) do
  current_date = Date.utc_today()

  {
    :noreply,
    socket
    |> assign(:current_date, current_date)
    |> assign(:week_rows, week_rows(current_date))
  }
end
Enter fullscreen mode Exit fullscreen mode

And now you can switch months! So exciting!! LiveView is the best!! 💖

Let's do a bit more work on the UX before we move on to actually saving the form fields. We'll start with having the calendar hidden at first and then shown on click.

To start off with the calendar hidden we'll add a :calendar? assign as a boolean in mount/1:

# lib/date_range_picker_web/components/date_range_picker.ex

@impl true
def mount(socket) do
  current_date = Date.utc_today()

  {
    :ok,
    socket
    |> assign(:calendar?, false)
    |> assign(:current_date, current_date)
    |> assign(:week_rows, week_rows(current_date))
  } 
end
Enter fullscreen mode Exit fullscreen mode

And then we'll check for that @calendar? assign in the main calendar div:

# lib/date_range_picker_web/components/date_range_picker.ex

<div :if={@calendar?} id={"#{@id}_calendar"} class="absolute z-50 w-72 shadow">
Enter fullscreen mode Exit fullscreen mode

Now when you refresh the page, you won't see the calendar. Next we'll add a phx-click event to that "fake-input-tag" and open the calendar with phx-click="open-calendar" phx-target={@myself}:

# lib/date_range_picker_web/components/date_range_picker.ex

<div class="fake-input-tag relative" phx-click="open-calendar" phx-target={@myself}>
Enter fullscreen mode Exit fullscreen mode

And we'll add the event handler for it near our other handle_event/3 functions:

# lib/date_range_picker_web/components/date_range_picker.ex

@impl true
def handle_event("open-calendar", _, socket) do
  {:noreply, socket |> assign(:calendar?, true)}
end
Enter fullscreen mode Exit fullscreen mode

Yay! It opens!! But... now we can't close it. Thankfully that's super easy with the phx-click-away event. We can add that to our main calendar div (where we added :if={@calendar?} so that the calendar will close whenever we click anywhere outside of it.

# lib/date_range_picker_web/components/date_range_picker.ex

<div :if={@calendar?} id={"#{@id}_calendar"} class="absolute z-50 w-72 shadow" phx-click-away="close-calendar" phx-target={@myself}>
Enter fullscreen mode Exit fullscreen mode

Add the event handler:

# lib/date_range_picker_web/components/date_range_picker.ex

@impl true
def handle_event("close-calendar", _, socket) do
  {:noreply, socket |> assign(:calendar?, false)}
end
Enter fullscreen mode Exit fullscreen mode

E voila! Your calendar now opens and closes! 💪

Next I'd like to add some styling for the current selected date and hovering over potential dates. This part involves a lot of Tailwind CSS hover magic, so I won't go too much into detail on the implementation. We'll be looking at the "calendar_days" div and adding some dynamic classes:

# lib/date_range_picker_web/components/date_range_picker.ex

<button
  :for={day <- Enum.flat_map(@week_rows, &(&1))}
  type="button"
  class={[
    "calendar-day overflow-hidden py-1.5 h-10 w-auto focus:z-10 w-full",
    today?(day) && "font-bold border border-black",
    "hover:bg-blue-300 hover:border hover:border-black",
    other_month?(day, @current_date) && "text-gray-400"
  ]}
>
Enter fullscreen mode Exit fullscreen mode

And then we'll add those today?/1 and other_month?/2 helper functions near the bottom of the date_range_picker.ex file:

# lib/date_range_picker_web/components/date_range_picker.ex

defp today?(day), do: day == Date.utc_today()

def other_month?(day, current_date) do
  Date.beginning_of_month(day) != Date.beginning_of_month(current_date)
end
Enter fullscreen mode Exit fullscreen mode

Look!!! Styles and hovering!!!! 😍

Days that are outside of the current month are a lighter gray, the current date has a border, and any date you hover over has a blue background! Yay team! 👏

prettier calendar

Persisting Data

Now to the exciting part: actually saving the dates!! We need to hook up the @form assign from the FormComponent to the DateRangePicker component in order to change that form data.

Back in FormComponent, add the form, start_date_field, and end_date_field arguments the .live_component function:

# lib/date_range_picker_web/live/event_live/form_component.ex

<.live_component
  module={DateRangePickerWeb.Components.DateRangePicker}
  label="Date Range"
  id="date-range-picker"
  form={@form}
  start_date_field={@form[:start_date]}
  end_date_field={@form[:end_date]}
/>
Enter fullscreen mode Exit fullscreen mode

Then back in DateRangePicker, initialize those new attributes as assigns in mount/1 and then populate them in a new update/2 callback. range_start and range_end will be the dates we use in the calendar. We'll update them in update/2 with the values from the start_date_field and end_date_field in the form.

# lib/date_range_picker_web/components/date_range_picker.ex

@impl true
def mount(socket) do
  current_date = Date.utc_today()

  {
    :ok,
    socket
    |> assign(:calendar?, false)
    |> assign(:current_date, current_date)
    |> assign(:range_start, nil)
    |> assign(:range_end, nil)
  }
end

@impl true
def update(assigns, socket) do
  range_start = assigns.start_date_field.value
  range_end = assigns.end_date_field.value
  current_date = socket.assigns.current_date

  {
    :ok,
    socket
    |> assign(assigns)
    |> assign(:current_date, current_date)
    |> assign(:range_start, range_start)
    |> assign(:range_end, range_end)
  }
end
Enter fullscreen mode Exit fullscreen mode

Next we'll add a click event to the date button that will select a date. Add the attributes phx-target={@myself} phx-click="pick-date" phx-value-date={Calendar.strftime(day, "%Y-%m-%d") <> "T00:00:00Z"} to the date button:

# lib/date_range_picker_web/components/date_range_picker.ex

<button
  :for={day <- Enum.flat_map(@week_rows, &(&1))}
  type="button"
  phx-target={@myself}
  phx-click="pick-date"
  phx-value-date={Calendar.strftime(day, "%Y-%m-%d") <> "T00:00:00Z"}
  class={[
    "calendar-day overflow-hidden py-1.5 h-10 w-auto focus:z-10 w-full",
    today?(day) && "font-bold border border-black",
    "hover:bg-blue-300 hover:border hover:border-black",
    other_month?(day, @current_date) && "text-gray-400"
  ]}
>
Enter fullscreen mode Exit fullscreen mode

And this, my friends, is where we get fancy with state machines! 🙌

Date ranges are tricky because we can't handle just ANY click; we need to know if it's the first click (start date), second click (end date), or third click (reset start date). Thankfully we don't have too many states to track, so it's not too complicated. Many many thanks to my wonderful colleague Dmitry Doronin for this suggestion!! My previous implementation was full of very complex case statements, and he made it so much simpler!! ⭐

Let's go back up to the top of the DateRangePicker and define our states. :set_start is followed by :set_end, :set_end is followed by :reset, and :reset goes back to :set_start:

# lib/date_range_picker_web/components/date_range_picker.ex

@fsm %{
  set_start: :set_end,
  set_end: :reset,
  reset: :set_start
}
@initial_state :set_start
Enter fullscreen mode Exit fullscreen mode

Then we need to go back to update/1 and set the :state assign:

# lib/date_range_picker_web/components/date_range_picker.ex

@impl true
def update(assigns, socket) do
  range_start = assigns.start_date_field.value
  range_end = assigns.end_date_field.value
  current_date = socket.assigns.current_date

  {
    :ok,
    socket
    |> assign(assigns)
    |> assign(:current_date, current_date)
    |> assign(:range_start, range_start)
    |> assign(:range_end, range_end)
    |> assign(:state, @initial_state)
  }
end
Enter fullscreen mode Exit fullscreen mode

Before we forget, let's also make sure to reset the state when we close the calendar by adding the :state assign in the close-calendar event handler:

# lib/date_range_picker_web/components/date_range_picker.ex

@impl true
def handle_event("close-calendar", _, socket) do
  {
    :noreply, 
    socket 
    |> assign(:calendar?, false)
    |> assign(:state, @initial_state)
  }
end
Enter fullscreen mode Exit fullscreen mode

Now we can work on our new pick-date event!

# lib/date_range_picker_web/components/date_range_picker.ex

@impl true
def handle_event("pick-date", %{"date" => date}, socket) do
  {:ok, date_time, _} = DateTime.from_iso8601(date)

  ranges = calculate_date_ranges(socket.assigns.state, date_time)
  state = @fsm[socket.assigns.state]

  {
    :noreply,
    socket
    |> assign(ranges)
    |> assign(:state, state)
  }
end

defp calculate_date_ranges(:set_start, date_time) do
  %{
    range_start: date_time,
    range_end: nil
  }
end

defp calculate_date_ranges(:set_end, date_time), do: %{range_end: date_time}

defp calculate_date_ranges(:reset, _date_time) do
  %{
    range_start: nil,
    range_end: nil
  }
end
Enter fullscreen mode Exit fullscreen mode

Oooooookay. So. What's going on here?? 😵

In handle_event/3 we get the date as a String value from the phx-click event. We convert that string to a DateTime type because we need to save the date as a DateTime for our postgresql database. Then we use some calculate_date_ranges/2 helper functions to set the :range_start and :range_end assigns. These helper functions allow us to manage the state of clicks:

  • If the state is :set_start, we only set the range_start assign.
  • If the state is :set_end, we already have the range_start assigned from the previous click, so we only set range_end.
  • If the state is :reset, we set both assigns back to nil.

After we set the ranges, we use our @fsm (finite state machine) to re-assign the :state and indicate what the next step will be.


"But Kathy," you say, "this is really cool and all, but what about, you know, actually saving the date???"

Great question! We're going to add that functionality to our "close-calendar" event! If you thought adding a state machine was tricky, be prepared to level up and send updates across LiveViews and LiveComponents!

I'll start with the new "close-calendar" event and then go through each line:

# lib/date_range_picker_web/components/date_range_picker.ex

@impl true
def handle_event("close-calendar", _, socket) do
  attrs = %{
    id: socket.assigns.id,
    start_date: socket.assigns.range_start,
    end_date: socket.assigns.range_end,
    form: socket.assigns.form
  }

  send(self(), {:updated_event, attrs})

  {
    :noreply,
    socket
    |> assign(:calendar?, false)
    |> assign(:end_date_field, set_field_value(socket.assigns, :end_date_field, attrs.end_date))
    |> assign(:start_date_field, set_field_value(socket.assigns, :start_date_field, attrs.start_date))
    |> assign(:state, @initial_state)
  }
end
Enter fullscreen mode Exit fullscreen mode

We'll need to update the original :form assign in the FormComponent in order to record the new dates. To do that, we need to notify the EventLive.Index LiveView of the changes. First we make a Map of the attributes we want to send, and then we call send(self(), {:updated_event, attrs}) to send them to EventLive.Index. This send function sends a message to the process that is running EventLive.Index with an "address" or "topic" of :updated_event along with the attributes we want to update.

Now we need to handle that message in EventLive.Index:

# lib/date_range_picker_web/live/event_live/index.ex

@impl true
def handle_info({:updated_event, attrs}, socket) do
  event = socket.assigns.event
  form = attrs.form

  new_form =
    Phoenix.HTML.FormData.to_form(
      %{
        "start_date" => attrs.start_date,
        "end_date" => attrs.end_date,
        "name" => form.params["name"]
      },
      id: form.id
    )

  updated_socket =
    socket
    |> assign(:event, event)
    |> assign(:id, attrs.id)
    |> assign(:new_form, new_form)

  send_update(
    DateRangePickerWeb.EventLive.FormComponent,
    updated_socket.assigns
    |> Map.delete(:flash)
    |> Map.delete(:streams)
  )

  {:noreply, updated_socket}
end
Enter fullscreen mode Exit fullscreen mode

This handle_info/2 callback will catch the :updated_event message along with the attributes we defined in DateRangePicker. We update the socket with the event, the form ID, and the new %Form struct. Then we call send_update/3 to the FormComponent to re-render the form with the new fields. Note: :flash and :streams are automatically assigned when calling send/2, but we don't want to use those in FormComponent so we remove them here.

Back in our FormComponent, we need to change our update/2 callback to use the new form data if it exists in the assigns. We can add some new_form_changeset/2helper functions to make that look a bit cleaner:

# lib/date_range_picker_web/live/event_live/form_component.ex

@impl true
def update(%{event: event} = assigns, socket) do
  changeset = new_form_changeset(event, assigns)

  {
    :ok,
    socket
    |> assign(assigns)
    |> assign_form(changeset)
  }
end

defp new_form_changeset(event, assigns) when is_map_key(assigns, :new_form) do
  Events.change_event(event, assigns.new_form.params)
end

defp new_form_changeset(event, _assigns) do
  Events.change_event(event)
end
Enter fullscreen mode Exit fullscreen mode

For the very last part we need to update the HTML in the DateRangePicker component and FormComponent to set the form fields appropriately.

Remove <.input field={@form[:start_date]} type="datetime-local" label="Start date" /> from FormComponent so that the .simple_form block only has the "Name" .input field and the .live_component field:

# lib/date_range_picker_web/live/event_live/form_component.ex

<.simple_form
  for={@form}
  id="event_form"
  phx-target={@myself}
  phx-change="validate"
  phx-submit="save"
>
  <.input field={@form[:name]} type="text" label="Name" />
  <.live_component
    module={DateRangePickerWeb.Components.DateRangePicker}
    label="Date Range"
    id={@id}
    form={@form}
    start_date_field={@form[:start_date]}
    end_date_field={@form[:end_date]}
  />

  <:actions>
    <.button phx-disable-with="Saving...">Save Event</.button>
  </:actions>
</.simple_form>
Enter fullscreen mode Exit fullscreen mode

Add the "start_date" and "end_date" .input fields to the DateRangePicker component instead as hidden inputs, and change the value of the "fake-input-tag" to show the selected range:

# lib/date_range_picker_web/components/date_range_picker.ex

<.input field={@start_date_field} type="hidden" />
<.input field={@end_date_field} type="hidden" />
<div class="fake-input-tag relative" phx-click="open-calendar" phx-target={@myself}>
  <.input
    name={"#{@id}_display_value"}
    type="text"
    label={@label}
    value={"#{@range_start} - #{@range_end}"}
  />
</div>
Enter fullscreen mode Exit fullscreen mode

With all of that hooked up correctly, you should be able to use your form and see both dates!

Working form

Cleaning up

There is A LOT more we can do with this, but you now have a very basic, functioning date range picker for your forms!! Congratulations!!

I have a more full-feature version of this component available for public use on my github. Full features include:

  • indicate that the input is range or a single date to use it as a range picker or a single date picker
  • styling for hovering over potential date ranges
  • add .date_range_picker and .date_picker functions in CoreComponents that can be used in forms instead of calling .live_component
  • support for a "minimum" date, ensuring that the start date cannot be before the minimum date.

  1. "Time is money, friend

  2. LiveView is included in Phoenix versions 1.5+, so you won't need to explicitly install it if you're using a newer version of Phoenix. 

Top comments (1)

Collapse
 
katafrakt profile image
Paweł Świątkowski

This is neat and useful. I don't have an Elixir project where I use date ranges currently, but bookmarking this, because I probably will and your solution looks cool!