Calendar component using ViewComponent and Hotwire

Photo by Blessing Ri on Unsplash

Calendar component using ViewComponent and Hotwire

One possible way of rendering a simple calendar control

TailwindUI has a nice looking calendar component I can use for this task. Here is what it will look like:

image.png

And here is the component in action being used to set a value for a date input:

Let's start by getting the rendering of the component done first, using a ViewComponent of course.

app/components/calendar/component.html.erb

<%= turbo_frame_tag "#{id}", loading: :lazy do %>
  <div <%= 'hidden' if hidden %>
       id="<%= "#{id}_calendar" %>"
       class="absolute w-80 z-10 bg-sky-100 shadow-lg"
       data-controller="calendar--component"
       data-calendar--component-input-id-value="<%= input_id %>"
  >
    <div class="mt-2 text-center lg:col-start-8 lg:col-end-13 lg:row-start-1 xl:col-start-9">
      <div class="flex items-center text-gray-900">
        <a
          id="<%= "#{id}_calendar--previous_month_button" %>"
          href="<%= previous_calendar_url %>"
          class="-m-1.5 flex flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500"
          data-turbo-frame="_self"
        >
          <span class="sr-only">Previous month</span>
          <%= icon(name: :chevron_left, colour: :gray) %>
        </a>
        <div id="<%= "#{id}_calendar--current_year_month" %>" class="flex-auto font-semibold"><%= Date::MONTHNAMES[month] %> <%= year %></div>
        <a
          id="<%= "#{id}_calendar--next_month_button" %>"
          href="<%= next_calendar_url %>"
          class="-m-1.5 flex flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500"
        >
          <span class="sr-only">Next month</span>
          <%= icon(name: :chevron_right, colour: :gray) %>
        </a>
      </div>
      <div class="mt-6 grid grid-cols-7 text-xs leading-6 text-gray-500">
        <div>M</div>
        <div>T</div>
        <div>W</div>
        <div>T</div>
        <div>F</div>
        <div>S</div>
        <div>S</div>
      </div>
      <div class="isolate mt-2 grid grid-cols-7 gap-px rounded-lg bg-gray-200 text-sm shadow ring-1 ring-gray-200">
         <% six_week_date_range.each_with_index do |date, index| %>
          <button
            id="<%= "#{id}_calendar--button_#{date.strftime('%F')}" %>"
            type="button"
            class="<%= button_style(cell_date: date, index: index) %>"
            data-action="click->calendar--component#selectDate"
          >
            <time
              id="<%= "#{id}_calendar--time_#{date.strftime('%F')}" %>"
              datetime="<%= date.strftime('%F') %>"
              class="<%= day_style(cell_date: date) %>"
            >
              <%= date.day %>
            </time>
          </button>
        <% end %>
      </div>
    </div>
  </div>
<% end %>

The HTML renders a 7 x 6 grid for the days and a heading with controls for moving to the previous and next months. All of this is inserted into a Turbo Frame tag with the id of the calendar in question. I'll come back to how this is important a bit later, but first here is the component class:

app/components/calendar/component.rb

module Calendar
  class Component < ViewComponent::Base
    include Turbo::FramesHelper
    include IconsHelper

    # @return [String] a unique identifier for setting the id on HTML elements in the component
    attr_reader :id
    # @return [Integer] the year to display
    attr_reader :year
    # @return [Integer] the month to display
    attr_reader :month
    # @return [String] the HTML id of the input connected to this calendar
    attr_reader :input_id
    # @return [Date] a string that has been parsed to a date representing the selected date in the attached input
    attr_reader :selected_date
    # @return [Boolean] true if the calendar is hidden, false otherwise
    attr_reader :hidden
    # @return [Date] the first day of the month and year for the calendar
    attr_reader :calendar_date
    # @return [Date] the current date in the current Time.zone
    attr_reader :today

    def initialize(id:, year:, month:, input_id:, selected_date: nil, hidden: true)
      super
      @id = id
      @year = Integer((year.presence || Time.zone.now.year.to_s), 10)
      @month = Integer((month.presence || Time.zone.now.month.to_s), 10)
      @input_id = input_id
      @selected_date = Date.parse(selected_date) if selected_date.present?
      @hidden = hidden
      @calendar_date = Date.new(@year, @month, 1)
      @today = Time.zone.now.to_date
    end

    # @return [String] the URL to retrieve the calendar for the previous month
    def previous_calendar_url
      "/calendar?id=#{id}&input_id=#{input_id}&year=#{previous_month_date.year}&month=#{previous_month_date.month}"
    end

    # @return [String] the URL to retrieve the calendar for the next month
    def next_calendar_url
      "/calendar?id=#{id}&input_id=#{input_id}&year=#{next_month_date.year}&month=#{next_month_date.month}"
    end

    # @return [Date] the first day of the previous calendar month
    def previous_month_date
      calendar_date - 1.month
    end

    # @return [Date] the first day of the next calendar month
    def next_month_date
      calendar_date + 1.month
    end

    # @return [Range] a six week date range starting from the first_day_of_range to last_day_of_range
    def six_week_date_range
      (first_day_of_range..last_day_of_range)
    end

    # @return [Date] the date for the beginning of the week containing the calendar_date
    def first_day_of_range
      calendar_date.beginning_of_week
    end

    # @return [Date] the date 41 days after the first day of the range (this makes 42 days in total)
    def last_day_of_range
      first_day_of_range + 41.days
    end

    private 

    def button_style(cell_date:, index:)
      [
        button_style_default,
        button_text_colour(cell_date: cell_date),
        ('font-semibold' if cell_date == today || cell_date == selected_date),
        (cell_date.month == calendar_date.month ? 'bg-white' : 'bg-gray-50'),
        corner_style(index: index)
      ].compact.join(' ')
    end

    def button_text_colour(cell_date:)
      return 'text-white' if cell_date == selected_date
      return 'text-sky-600' if cell_date == today
      return 'text-gray-900' if cell_date.month == calendar_date.month

      'text-gray-400'
    end

    def button_style_default
      'py-1.5 hover:bg-gray-100 focus:z-10'
    end

    def corner_style(index:)
      case index
      when 0
        'rounded-tl-lg'
      when 6
        'rounded-tr-lg'
      when 35
        'rounded-bl-lg'
      when 41
        'rounded-br-lg'
      else
        ''
      end
    end

    def day_style(cell_date:)
      [day_style_default, selected_date_style(cell_date: cell_date)].compact.join(' ')
    end

    def day_style_default
      'mx-auto flex h-5 w-5 items-center justify-center rounded-full'
    end

    def selected_date_style(cell_date:)
      return 'bg-gray-900' if cell_date == selected_date
    end
  end
end

This component class has a reasonable amount going on in it. There are two methods for generating a URL to load a calendar for the previous and next months. And there are methods for working out the exact date ranges so that the calendar fits 42 days (7 days x 6 weeks) nicely into the table.

All the private methods are related to determining the correct CSS styles to apply based on the day within the calendar, taking into consideration the current date, which date has been selected (if any) and whether the day falls into the next or previous month.

There is also a Stimulus controller that takes care of two things; toggling whether the calendar component is visible; and setting the value of a date input based on what date the user clicked on:

app/components/calendar/component_controller.ts

import {ActionEvent, Controller} from "@hotwired/stimulus"
import cash from "cash-dom";

export default class extends Controller {
    static values = {
        inputId: String
    }

    declare readonly inputIdValue: string;

    toggle(event: ActionEvent) {
        event.preventDefault()
        const calendar: HTMLElement = cash(`#${event.params.toggleCalendarId}`)[0] as HTMLElement
        calendar.toggleAttribute('hidden')
    }

    selectDate(event: ActionEvent) {
        const targetEl: HTMLElement = event.target as HTMLElement
        const buttonEl: HTMLButtonElement = targetEl.closest('button') as HTMLButtonElement
        const timeEl: HTMLTimeElement = buttonEl.querySelector('time') as HTMLTimeElement
        const date: string | null = timeEl.getAttribute('datetime')
        const inputEl: HTMLInputElement | null = document.querySelector(`#${this.inputIdValue}`)

        if (inputEl && date) {
            inputEl.value = date
            inputEl.dispatchEvent(new Event('select'))
        }
    }
}

So I mentioned before there was a URL for rendering the previous and next months on the calendar, so I'll need a route for that:

config/routes.rb

  # Calendar
  get '/calendar' => 'calendar#index'

And also a controller that will respond to that route:

app/controllers/calendar_controller.rb

class CalendarController < ApplicationController
  include TurboFrameVariants

  # @param [Integer] id the unique identifier for the calendar container
  # @param [Integer] year the calendar year to display
  # @param [Integer] month the calendar month to display
  # @param [String] input_id the HTML id of the input that should receive calendar selection updates
  # @param [String] selected_date the date that is currently selected in YYYY-MM-DD format
  # @param [Boolean] hidden true to add the hidden attribute to the calendar container
  # @example Calendar for Year 2022, Month July
  #    /calendar?id=created_at_from&input_id=q_created_at_gteq&year=2022&month=7
  def index; end
end

This calendar does very little, it is only listening for an index action and if it receives that via a turbo frame this is what it will respond with:

app/views/calendar/index.html+turbo_frame.erb

<%= render Calendar::Component.new(
    id: params[:id],
    year: params[:year],
    month: params[:month],
    input_id: params[:input_id],
    selected_date: params[:selected_date],
    hidden: ActiveModel::Type::Boolean.new.cast(params[:hidden])) %>

In the erb the component being rendered using the parameters being passed in on the URL. Here is an example of how that looks in practice:

      <%= turbo_frame_tag "#{attribute}_from", src: "/calendar?id=created_at_from&amp;input_id=q_created_at_gteq&amp;year=2022&amp;month=12", loading: :lazy %>

This turbo frame has a src referring to the calendar endpoint and it is asking for a calendar for the year 2022 and month 12. When the controller returns this to the browser Turbo will replace the existing calendar content with the new content, without the need for any full page reloading. Every time the user clicks on the arrows for previous or next the same thing will occur, fetching a new calendar component with that year and month and replacing the content on the page.