A custom date picker

I have tried several date-picker libraries, but I have never found anything that is easy enough to style the way I want it to. After endless hours of trying to work with the Ruby on Rails date_field, third-party libraries like Flatpicker (which haven't been updated in years), and ending up disappointed, I finally decided to create my own.

I figured I could achieve something similar: https://mhenrixon.com/articles/taggable-array and extend my existing form builder with a date select field.
lang-ruby
class TailwindFormBuilder < ActionView::Helpers::FormBuilder
  include ActionView::Helpers::TagHelper

  def custom_date_field(method, options = {}) # rubocop:disable Metrics/MethodLength
    default_options = {
      class: "input input-bordered input-primary w-full",
      data: {
        dates_target: "input",
      },
    }
    merged_options = default_options.merge(options)

    tag.div(
      class: "dropdown dropdown-hover w-full",
      data: {
        controller: "dates",
        dates_target: "dropdown",
      },
    ) do
      tag.div(class: "relative") do
        text_field(method, value: @object.send(method)&.to_fs(:db), **merged_options)
      end +
        tag.div(
          class: "dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-64",
          data: { dates_target: "calendar" },
        )
    end
  end
end

ActiveSupport.on_load(:action_view) do
  ActionView::Base.default_form_builder = TailwindFormBuilder
end
I decided to use the dayjs library due to its size and simplicity:
lang-js
import { Controller } from "@hotwired/stimulus"
import dayjs from "dayjs"
import customParseFormat from "dayjs/plugin/customParseFormat"
import weekday from "dayjs/plugin/weekday"

dayjs.extend(customParseFormat)
dayjs.extend(weekday)

export default class extends Controller {
  static targets = ["input", "calendar", "dropdown"]
  static values = {
    selectedDate: String,
  }

  connect() {
    this.selectedDateValue = dayjs(this.selectedDateValue || undefined).format('YYYY-MM-DD')
    this.renderCalendar()
    this.inputTarget.addEventListener("change", this.handleInputChange.bind(this))
  }

  handleInputChange(event) {
    const inputDate = dayjs(event.target.value, 'YYYY-MM-DD', true)
    if (inputDate.isValid()) {
      this.selectedDateValue = inputDate.format('YYYY-MM-DD')
      this.renderCalendar()
    } else {
      console.warn("Invalid date entered:", event.target.value)
    }
  }

  renderCalendar() {
    const selectedDate = dayjs(this.selectedDateValue)
    const firstDay = selectedDate.startOf('month')
    const lastDay = selectedDate.endOf('month')

    let calendarHTML = `
      <div class="flex justify-between items-center mb-4">
        <button data-action="click->dates#previousMonth" class="btn btn-sm btn-ghost">&lt;</button>
        <span class="text-lg font-semibold">${selectedDate.format('MMMM YYYY')}</span>
        <button data-action="click->dates#nextMonth" class="btn btn-sm btn-ghost">&gt;</button>
      </div>
      <div class="grid grid-cols-7 gap-1 text-center">
        ${['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(day => `<div class="text-xs font-medium text-gray-500">${day}</div>`).join("")}
    `

    for (let i = 0; i < firstDay.day(); i++) {
      calendarHTML += "<div></div>"
    }

    for (let day = 1; day <= lastDay.date(); day++) {
      const currentDate = selectedDate.date(day)
      const isSelected = currentDate.format('YYYY-MM-DD') === this.selectedDateValue
      calendarHTML += `
        <div>
          <button data-action="click->dates#selectDate"
                  data-date="${currentDate.format('YYYY-MM-DD')}"
                  class="w-8 h-8 rounded-full ${isSelected ? "bg-primary text-primary-content" : "hover:bg-base-200"}">${day}</button>
        </div>
      `
    }

    calendarHTML += "</div>"
    this.calendarTarget.innerHTML = calendarHTML
  }

  selectDate(event) {
    this.selectedDateValue = event.currentTarget.dataset.date
    this.inputTarget.value = this.selectedDateValue
    this.renderCalendar()
    this.closeCalendar()
  }

  previousMonth() {
    this.selectedDateValue = dayjs(this.selectedDateValue).subtract(1, 'month').format('YYYY-MM-DD')
    this.renderCalendar()
  }

  nextMonth() {
    this.selectedDateValue = dayjs(this.selectedDateValue).add(1, 'month').format('YYYY-MM-DD')
    this.renderCalendar()
  }

  toggleCalendar() {
    this.dropdownTarget.classList.toggle("dropdown-open")
  }

  closeCalendar() {
    this.dropdownTarget.classList.remove("dropdown-open")
  }
}
This is a little demo of it all:
CleanShot 2024-08-18 at 18.30.34.gif 1.07 MB
It is amazing what one can do with minimal effort these days. To try it out, we can get away with something super simple:
lang-ruby
div(
  class: "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-neutral sm:pt-5"\
) do
  f.label :published_at, class: "sm:mt-px sm:pt-2"

  div(class: "mt-1 sm:mt-0 sm:col-span-2") do
    f.custom_date_field :published_at, tabindex: 3
  end
end

Comments

No comments yet. Be the first to comment!
Your email address will be verified before your first comment is posted. It will not be displayed publicly.
Legal Information
By commenting, you agree to our Privacy Policy and Terms of Service.