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
endI 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
