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.
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"><</button> <span class="text-lg font-semibold">${selectedDate.format('MMMM YYYY')}</span> <button data-action="click->dates#nextMonth" class="btn btn-sm btn-ghost">></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: 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