Calendar
A date field component that allows users to enter and edit date.
Usage
Connect to input
div(class: 'space-y-4') do Input(type: 'string', placeholder: "Select a date", class: 'rounded-md border shadow', id: 'date', data_controller: 'ruby-ui--calendar-input') Calendar(input_id: '#date', class: 'rounded-md border shadow') end
Format date
Format dates with date-fns
div(class: 'space-y-4') do Input(type: 'string', placeholder: "Select a date", class: 'rounded-md border shadow', id: 'formatted-date', data_controller: 'ruby-ui--calendar-input') Calendar(input_id: '#formatted-date', date_format: 'PPPP', class: 'rounded-md border shadow') end
Installation
Using RubyUI CLI
Run the install command
rails g ruby_ui:component Calendar
Manual installation
1
Add RubyUI::Calendar
to app/components/ruby_ui/calendar.rb
# frozen_string_literal: true module RubyUI class Calendar < Base def initialize(selected_date: nil, input_id: nil, date_format: "yyyy-MM-dd", **attrs) @selected_date = selected_date @input_id = input_id @date_format = date_format super(**attrs) end def view_template div(**attrs) do RubyUI.CalendarHeader do RubyUI.CalendarTitle RubyUI.CalendarPrev RubyUI.CalendarNext end RubyUI.CalendarBody # Where the calendar is rendered (Weekdays and Days) RubyUI.CalendarWeekdays # Template for the weekdays RubyUI.CalendarDays # Template for the days end end private def default_attrs { class: "p-3 space-y-4", data: { controller: "ruby-ui--calendar", ruby_ui__calendar_selected_date_value: @selected_date&.to_s, ruby_ui__calendar_format_value: @date_format, ruby_ui__calendar_ruby_ui__calendar_input_outlet: @input_id } } end end end
2
Add RubyUI::CalendarBody
to app/components/ruby_ui/calendar/calendar_body.rb
# frozen_string_literal: true module RubyUI class CalendarBody < Base def view_template table(**attrs) end private def default_attrs { data: { ruby_ui__calendar_target: "calendar" } } end end end
3
Add RubyUI::CalendarDays
to app/components/ruby_ui/calendar/calendar_days.rb
# frozen_string_literal: true module RubyUI class CalendarDays < Base BASE_CLASS = "inline-flex items-center justify-center rounded-md text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-8 w-8 p-0 font-normal aria-selected:opacity-100" def view_template render_selected_date_template render_today_date_template render_current_month_date_template render_other_month_date_template end private def render_selected_date_template date_template("selectedDateTemplate") do button( data_day: "{{day}}", data_action: "click->ruby-ui--calendar#selectDay", name: "day", class: [ BASE_CLASS, "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground" ], role: "gridcell", tabindex: "0", type: "button", aria_selected: "true" ) { "{{dayDate}}" } end end def render_today_date_template date_template("todayDateTemplate") do button( data_day: "{{day}}", data_action: "click->ruby-ui--calendar#selectDay", name: "day", class: [ BASE_CLASS, "bg-accent text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground" ], role: "gridcell", tabindex: "-1", type: "button" ) { "{{dayDate}}" } end end def render_current_month_date_template date_template("currentMonthDateTemplate") do button( data_day: "{{day}}", data_action: "click->ruby-ui--calendar#selectDay", name: "day", class: [ BASE_CLASS, "bg-background text-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground" ], role: "gridcell", tabindex: "-1", type: "button" ) { "{{dayDate}}" } end end def render_other_month_date_template date_template("otherMonthDateTemplate") do button( data_day: "{{day}}", data_action: " click->ruby-ui--calendar#selectDay", name: "day", class: [ BASE_CLASS, "bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground" ], role: "gridcell", tabindex: "-1", type: "button" ) { "{{dayDate}}" } end end def date_template(target, &block) template(data: {ruby_ui__calendar_target: target}) do td( class: "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected])]:rounded-md", role: "presentation", &block ) end end def default_attrs {} end end end
4
Add RubyUI::CalendarHeader
to app/components/ruby_ui/calendar/calendar_header.rb
# frozen_string_literal: true module RubyUI class CalendarHeader < Base def view_template(&) div(**attrs, &) end private def default_attrs { class: "flex justify-center pt-1 relative items-center" } end end end
5
Add RubyUI::CalendarNext
to app/components/ruby_ui/calendar/calendar_next.rb
# frozen_string_literal: true module RubyUI class CalendarNext < Base def view_template(&block) button(**attrs) do icon end end private def icon svg( width: "15", height: "15", viewbox: "0 0 15 15", fill: "none", xmlns: "http://www.w3.org/2000/svg", class: "h-4 w-4" ) do |s| s.path( d: "M6.1584 3.13508C6.35985 2.94621 6.67627 2.95642 6.86514 3.15788L10.6151 7.15788C10.7954 7.3502 10.7954 7.64949 10.6151 7.84182L6.86514 11.8418C6.67627 12.0433 6.35985 12.0535 6.1584 11.8646C5.95694 11.6757 5.94673 11.3593 6.1356 11.1579L9.565 7.49985L6.1356 3.84182C5.94673 3.64036 5.95694 3.32394 6.1584 3.13508Z", fill: "currentColor", fill_rule: "evenodd", clip_rule: "evenodd" ) end end def default_attrs { name: "next-month", aria_label: "Go to next month", class: "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input hover:bg-accent hover:text-accent-foreground h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-1", type: "button", data_action: "click->ruby-ui--calendar#nextMonth" } end end end
6
Add RubyUI::CalendarPrev
to app/components/ruby_ui/calendar/calendar_prev.rb
# frozen_string_literal: true module RubyUI class CalendarPrev < Base def view_template(&block) button(**attrs) do icon end end private def icon svg( width: "15", height: "15", viewbox: "0 0 15 15", fill: "none", xmlns: "http://www.w3.org/2000/svg", class: "h-4 w-4" ) do |s| s.path( d: "M8.84182 3.13514C9.04327 3.32401 9.05348 3.64042 8.86462 3.84188L5.43521 7.49991L8.86462 11.1579C9.05348 11.3594 9.04327 11.6758 8.84182 11.8647C8.64036 12.0535 8.32394 12.0433 8.13508 11.8419L4.38508 7.84188C4.20477 7.64955 4.20477 7.35027 4.38508 7.15794L8.13508 3.15794C8.32394 2.95648 8.64036 2.94628 8.84182 3.13514Z", fill: "currentColor", fill_rule: "evenodd", clip_rule: "evenodd" ) end end def default_attrs { name: "previous-month", aria_label: "Go to previous month", class: "rdp-button_reset rdp-button inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input hover:bg-accent hover:text-accent-foreground h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-1", type: "button", data_action: "click->ruby-ui--calendar#prevMonth" } end end end
7
Add RubyUI::CalendarTitle
to app/components/ruby_ui/calendar/calendar_title.rb
# frozen_string_literal: true module RubyUI class CalendarTitle < Base def initialize(default: "Month Year", **attrs) @default = default super(**attrs) end def view_template div(**attrs) { @default } end private def default_attrs { class: "text-sm font-medium", aria_live: "polite", role: "presentation", data: { ruby_ui__calendar_target: "title" } } end end end
8
Add RubyUI::CalendarWeekdays
to app/components/ruby_ui/calendar/calendar_weekdays.rb
# frozen_string_literal: true module RubyUI class CalendarWeekdays < Base DAYS = %w[Monday Tuesday Wednesday Thursday Friday Saturday Sunday].freeze def view_template template(data: {ruby_ui__calendar_target: "weekdaysTemplate"}) do thead(**attrs) do tr(class: "flex") do DAYS.each do |day| render_day(day) end end end end end private def render_day(day) th( scope: "col", class: "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]", aria_label: day ) { day[0..1] } end def default_attrs {} end end end
9
Add calendar_controller.js
to app/javascript/controllers/ruby_ui/calendar_controller.js
import { Controller } from "@hotwired/stimulus"; import Mustache from "mustache"; export default class extends Controller { static targets = [ "calendar", "title", "weekdaysTemplate", "selectedDateTemplate", "todayDateTemplate", "currentMonthDateTemplate", "otherMonthDateTemplate", ]; static values = { selectedDate: { type: String, default: null, }, viewDate: { type: String, default: new Date().toISOString().slice(0, 10), }, format: { type: String, default: "yyyy-MM-dd", // Default format }, }; static outlets = ["ruby-ui--calendar-input"]; initialize() { this.updateCalendar(); // Initial calendar render } nextMonth(e) { e.preventDefault(); this.viewDateValue = this.adjustMonth(1); } prevMonth(e) { e.preventDefault(); this.viewDateValue = this.adjustMonth(-1); } selectDay(e) { e.preventDefault(); // Set the selected date value this.selectedDateValue = e.currentTarget.dataset.day; } selectedDateValueChanged(value, prevValue) { // update the viewDateValue to the first day of month of the selected date (This will trigger updateCalendar() function) const newViewDate = new Date(this.selectedDateValue); newViewDate.setDate(2); // set the day to the 2nd (to avoid issues with months with different number of days and timezones) this.viewDateValue = newViewDate.toISOString().slice(0, 10); // Re-render the calendar this.updateCalendar(); // update the input value this.rubyUiCalendarInputOutlets.forEach((outlet) => { const formattedDate = this.formatDate(this.selectedDate()); outlet.setValue(formattedDate); }); } viewDateValueChanged(value, prevValue) { this.updateCalendar(); } adjustMonth(adjustment) { const date = this.viewDate(); date.setDate(2); // set the day to the 2nd (to avoid issues with months with different number of days and timezones) date.setMonth(date.getMonth() + adjustment); return date.toISOString().slice(0, 10); } updateCalendar() { // Update the title with month and year this.titleTarget.textContent = this.monthAndYear(); this.calendarTarget.innerHTML = this.calendarHTML(); } calendarHTML() { return this.weekdaysTemplateTarget.innerHTML + this.calendarDays(); } calendarDays() { return this.getFullWeeksStartAndEndInMonth() .map((week) => this.renderWeek(week)) .join(""); } renderWeek(week) { const days = week .map((day) => { return this.renderDay(day); }) .join(""); return `<tr class="flex w-full mt-2">${days}</tr>`; } renderDay(day) { const today = new Date(); let dateHTML = ""; const data = { day: day, dayDate: day.getDate() }; if (day.toDateString() === this.selectedDate().toDateString()) { // selectedDate // Render the selected date template target innerHTML with Mustache dateHTML = Mustache.render( this.selectedDateTemplateTarget.innerHTML, data, ); } else if (day.toDateString() === today.toDateString()) { // todayDate dateHTML = Mustache.render(this.todayDateTemplateTarget.innerHTML, data); } else if (day.getMonth() === this.viewDate().getMonth()) { // currentMonthDate dateHTML = Mustache.render( this.currentMonthDateTemplateTarget.innerHTML, data, ); } else { // otherMonthDate dateHTML = Mustache.render( this.otherMonthDateTemplateTarget.innerHTML, data, ); } return dateHTML; } monthAndYear() { const month = this.viewDate().toLocaleString("en-US", { month: "long" }); const year = this.viewDate().getFullYear(); return `${month} ${year}`; } selectedDate() { return new Date(this.selectedDateValue); } viewDate() { return this.viewDateValue ? new Date(this.viewDateValue) : this.selectedDate(); } getFullWeeksStartAndEndInMonth() { const month = this.viewDate().getMonth(); const year = this.viewDate().getFullYear(); let weeks = [], firstDate = new Date(year, month, 1), lastDate = new Date(year, month + 1, 0), numDays = lastDate.getDate(); let start = 1; let end; if (firstDate.getDay() === 1) { end = 7; } else if (firstDate.getDay() === 0) { let preMonthEndDay = new Date(year, month, 0); start = preMonthEndDay.getDate() - 6 + 1; end = 1; } else { let preMonthEndDay = new Date(year, month, 0); start = preMonthEndDay.getDate() + 1 - firstDate.getDay() + 1; end = 7 - firstDate.getDay() + 1; weeks.push({ start: start, end: end, }); start = end + 1; end = end + 7; } while (start <= numDays) { weeks.push({ start: start, end: end, }); start = end + 1; end = end + 7; end = start === 1 && end === 8 ? 1 : end; if (end > numDays && start <= numDays) { end = end - numDays; weeks.push({ start: start, end: end, }); break; } } // *** the magic starts here return weeks.map(({ start, end }, index) => { const sub = +(start > end && index === 0); return Array.from({ length: 7 }, (_, index) => { const date = new Date(year, month - sub, start + index); return date; }); }); } formatDate(date) { const format = this.formatValue; const day = date.getDate(); const month = date.getMonth() + 1; const year = date.getFullYear(); const hours = date.getHours(); const minutes = date.getMinutes(); const seconds = date.getSeconds(); const dayOfWeek = date.toLocaleString("en-US", { weekday: "long" }); const monthName = date.toLocaleString("en-US", { month: "long" }); const daySuffix = this.getDaySuffix(day); const map = { yyyy: year, MM: ("0" + month).slice(-2), dd: ("0" + day).slice(-2), HH: ("0" + hours).slice(-2), mm: ("0" + minutes).slice(-2), ss: ("0" + seconds).slice(-2), EEEE: dayOfWeek, MMMM: monthName, do: day + daySuffix, PPPP: `${dayOfWeek}, ${monthName} ${day}${daySuffix}, ${year}`, }; const formattedDate = format.replace( /yyyy|MM|dd|HH|mm|ss|EEEE|MMMM|do|PPPP/g, (matched) => map[matched], ); return formattedDate; } getDaySuffix(day) { if (day > 3 && day < 21) return "th"; switch (day % 10) { case 1: return "st"; case 2: return "nd"; case 3: return "rd"; default: return "th"; } } }
10
Add calendar_input_controller.js
to app/javascript/controllers/ruby_ui/calendar_input_controller.js
import { Controller } from "@hotwired/stimulus" // Connects to data-controller="input" export default class extends Controller { setValue(value) { this.element.value = value } }
11
Update the Stimulus controllers manifest file
Importmap!
rake stimulus:manifest:update
12
Install mustache
Javascript dependency
// with yarn yarn add mustache // with npm npm install mustache // with importmaps bin/importmap pin mustache
Components
Component | Built using | Source |
---|---|---|
Calendar | Phlex | |
CalendarBody | Phlex | |
CalendarDays | Phlex | |
CalendarHeader | Phlex | |
CalendarNext | Phlex | |
CalendarPrev | Phlex | |
CalendarTitle | Phlex | |
CalendarWeekdays | Phlex | |
CalendarController | Stimulus JS | |
CalendarInputController | Stimulus JS |