diff --git a/docs/app/javascript/controllers/ruby_ui/calendar_controller.js b/docs/app/javascript/controllers/ruby_ui/calendar_controller.js index e1b47c04..b355e936 100644 --- a/docs/app/javascript/controllers/ruby_ui/calendar_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/calendar_controller.js @@ -6,6 +6,7 @@ export default class extends Controller { "calendar", "title", "weekdaysTemplate", + "disabledDateTemplate", "selectedDateTemplate", "todayDateTemplate", "currentMonthDateTemplate", @@ -16,6 +17,10 @@ export default class extends Controller { type: String, default: null, }, + minDate: { + type: String, + default: null, + }, viewDate: { type: String, default: new Date().toISOString().slice(0, 10), @@ -43,13 +48,21 @@ export default class extends Controller { selectDay(e) { e.preventDefault(); + if (this.isDateDisabled(e.currentTarget.dataset.day)) return; + // Set the selected date value this.selectedDateValue = e.currentTarget.dataset.day; } selectedDateValueChanged(value, prevValue) { + const selectedDate = this.selectedDate(); + if (!selectedDate) { + this.updateCalendar(); + return; + } + // update the viewDateValue to the first day of month of the selected date (This will trigger updateCalendar() function) - const newViewDate = new Date(this.selectedDateValue); + const newViewDate = new Date(selectedDate); 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); @@ -58,7 +71,7 @@ export default class extends Controller { // update the input value this.rubyUiCalendarInputOutlets.forEach((outlet) => { - const formattedDate = this.formatDate(this.selectedDate()); + const formattedDate = this.formatDate(selectedDate); outlet.setValue(formattedDate); }); } @@ -101,10 +114,20 @@ export default class extends Controller { renderDay(day) { const today = new Date(); + const selectedDate = this.selectedDate(); let dateHTML = ""; const data = { day: day, dayDate: day.getDate() }; - if (day.toDateString() === this.selectedDate().toDateString()) { + if (this.isDateDisabled(day)) { + // disabledDate + dateHTML = Mustache.render( + this.disabledDateTemplateTarget.innerHTML, + data, + ); + } else if ( + selectedDate && + day.toDateString() === selectedDate.toDateString() + ) { // selectedDate // Render the selected date template target innerHTML with Mustache dateHTML = Mustache.render( @@ -137,13 +160,13 @@ export default class extends Controller { } selectedDate() { - return new Date(this.selectedDateValue); + return this.parseDate(this.selectedDateValue); } viewDate() { - return this.viewDateValue - ? new Date(this.viewDateValue) - : this.selectedDate(); + return ( + this.parseDate(this.viewDateValue) || this.selectedDate() || new Date() + ); } getFullWeeksStartAndEndInMonth() { @@ -246,4 +269,40 @@ export default class extends Controller { return "th"; } } + + minDate() { + return this.parseDate(this.minDateValue); + } + + isDateDisabled(date) { + const minDate = this.minDate(); + const candidate = this.parseDate(date); + + if (!minDate || !candidate) return false; + + return this.startOfDay(candidate) < this.startOfDay(minDate); + } + + parseDate(value) { + if (!value) return null; + if (value instanceof Date) return new Date(value); + + const isoDate = value.toString().match(/^(\d{4})-(\d{2})-(\d{2})/); + if (isoDate) { + return new Date( + Number(isoDate[1]), + Number(isoDate[2]) - 1, + Number(isoDate[3]), + ); + } + + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + + startOfDay(date) { + const normalizedDate = new Date(date); + normalizedDate.setHours(0, 0, 0, 0); + return normalizedDate; + } } diff --git a/docs/app/views/docs/calendar.rb b/docs/app/views/docs/calendar.rb index c8f9a519..096078ee 100644 --- a/docs/app/views/docs/calendar.rb +++ b/docs/app/views/docs/calendar.rb @@ -26,6 +26,15 @@ def view_template RUBY end + render Docs::VisualCodeExample.new(title: "Minimum date", description: "Disable dates before a given date", context: self) do + <<~RUBY + div(class: 'space-y-4') do + Input(type: 'string', placeholder: "Select a date", class: 'rounded-md border shadow', id: 'minimum-date', data_controller: 'ruby-ui--calendar-input') + Calendar(input_id: '#minimum-date', min_date: Date.current, class: 'rounded-md border shadow') + end + RUBY + end + render Components::ComponentSetup::Tabs.new(component_name: component) render Docs::ComponentsTable.new(component_files(component)) diff --git a/gem/lib/ruby_ui/calendar/calendar.rb b/gem/lib/ruby_ui/calendar/calendar.rb index e383fd0e..39b4652f 100644 --- a/gem/lib/ruby_ui/calendar/calendar.rb +++ b/gem/lib/ruby_ui/calendar/calendar.rb @@ -2,8 +2,9 @@ module RubyUI class Calendar < Base - def initialize(selected_date: nil, input_id: nil, date_format: "yyyy-MM-dd", **attrs) + def initialize(selected_date: nil, min_date: nil, input_id: nil, date_format: "yyyy-MM-dd", **attrs) @selected_date = selected_date + @min_date = min_date @input_id = input_id @date_format = date_format super(**attrs) @@ -30,6 +31,7 @@ def default_attrs data: { controller: "ruby-ui--calendar", ruby_ui__calendar_selected_date_value: @selected_date&.to_s, + ruby_ui__calendar_min_date_value: @min_date&.to_s, ruby_ui__calendar_format_value: @date_format, ruby_ui__calendar_ruby_ui__calendar_input_outlet: @input_id } diff --git a/gem/lib/ruby_ui/calendar/calendar_controller.js b/gem/lib/ruby_ui/calendar/calendar_controller.js index e1b47c04..b355e936 100644 --- a/gem/lib/ruby_ui/calendar/calendar_controller.js +++ b/gem/lib/ruby_ui/calendar/calendar_controller.js @@ -6,6 +6,7 @@ export default class extends Controller { "calendar", "title", "weekdaysTemplate", + "disabledDateTemplate", "selectedDateTemplate", "todayDateTemplate", "currentMonthDateTemplate", @@ -16,6 +17,10 @@ export default class extends Controller { type: String, default: null, }, + minDate: { + type: String, + default: null, + }, viewDate: { type: String, default: new Date().toISOString().slice(0, 10), @@ -43,13 +48,21 @@ export default class extends Controller { selectDay(e) { e.preventDefault(); + if (this.isDateDisabled(e.currentTarget.dataset.day)) return; + // Set the selected date value this.selectedDateValue = e.currentTarget.dataset.day; } selectedDateValueChanged(value, prevValue) { + const selectedDate = this.selectedDate(); + if (!selectedDate) { + this.updateCalendar(); + return; + } + // update the viewDateValue to the first day of month of the selected date (This will trigger updateCalendar() function) - const newViewDate = new Date(this.selectedDateValue); + const newViewDate = new Date(selectedDate); 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); @@ -58,7 +71,7 @@ export default class extends Controller { // update the input value this.rubyUiCalendarInputOutlets.forEach((outlet) => { - const formattedDate = this.formatDate(this.selectedDate()); + const formattedDate = this.formatDate(selectedDate); outlet.setValue(formattedDate); }); } @@ -101,10 +114,20 @@ export default class extends Controller { renderDay(day) { const today = new Date(); + const selectedDate = this.selectedDate(); let dateHTML = ""; const data = { day: day, dayDate: day.getDate() }; - if (day.toDateString() === this.selectedDate().toDateString()) { + if (this.isDateDisabled(day)) { + // disabledDate + dateHTML = Mustache.render( + this.disabledDateTemplateTarget.innerHTML, + data, + ); + } else if ( + selectedDate && + day.toDateString() === selectedDate.toDateString() + ) { // selectedDate // Render the selected date template target innerHTML with Mustache dateHTML = Mustache.render( @@ -137,13 +160,13 @@ export default class extends Controller { } selectedDate() { - return new Date(this.selectedDateValue); + return this.parseDate(this.selectedDateValue); } viewDate() { - return this.viewDateValue - ? new Date(this.viewDateValue) - : this.selectedDate(); + return ( + this.parseDate(this.viewDateValue) || this.selectedDate() || new Date() + ); } getFullWeeksStartAndEndInMonth() { @@ -246,4 +269,40 @@ export default class extends Controller { return "th"; } } + + minDate() { + return this.parseDate(this.minDateValue); + } + + isDateDisabled(date) { + const minDate = this.minDate(); + const candidate = this.parseDate(date); + + if (!minDate || !candidate) return false; + + return this.startOfDay(candidate) < this.startOfDay(minDate); + } + + parseDate(value) { + if (!value) return null; + if (value instanceof Date) return new Date(value); + + const isoDate = value.toString().match(/^(\d{4})-(\d{2})-(\d{2})/); + if (isoDate) { + return new Date( + Number(isoDate[1]), + Number(isoDate[2]) - 1, + Number(isoDate[3]), + ); + } + + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + + startOfDay(date) { + const normalizedDate = new Date(date); + normalizedDate.setHours(0, 0, 0, 0); + return normalizedDate; + } } diff --git a/gem/lib/ruby_ui/calendar/calendar_days.rb b/gem/lib/ruby_ui/calendar/calendar_days.rb index 83954bfb..cea64024 100644 --- a/gem/lib/ruby_ui/calendar/calendar_days.rb +++ b/gem/lib/ruby_ui/calendar/calendar_days.rb @@ -5,6 +5,7 @@ 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_disabled_date_template render_selected_date_template render_today_date_template render_current_month_date_template @@ -13,6 +14,25 @@ def view_template private + def render_disabled_date_template + date_template("disabledDateTemplate") do + button( + data_day: "{{day}}", + name: "day", + class: + [ + BASE_CLASS, + "cursor-not-allowed bg-background text-muted-foreground hover:bg-background hover:text-muted-foreground" + ], + disabled: true, + role: "gridcell", + tabindex: "-1", + type: "button", + aria_disabled: "true" + ) { "{{dayDate}}" } + end + end + def render_selected_date_template date_template("selectedDateTemplate") do button( diff --git a/gem/lib/ruby_ui/calendar/calendar_docs.rb b/gem/lib/ruby_ui/calendar/calendar_docs.rb index c8f9a519..096078ee 100644 --- a/gem/lib/ruby_ui/calendar/calendar_docs.rb +++ b/gem/lib/ruby_ui/calendar/calendar_docs.rb @@ -26,6 +26,15 @@ def view_template RUBY end + render Docs::VisualCodeExample.new(title: "Minimum date", description: "Disable dates before a given date", context: self) do + <<~RUBY + div(class: 'space-y-4') do + Input(type: 'string', placeholder: "Select a date", class: 'rounded-md border shadow', id: 'minimum-date', data_controller: 'ruby-ui--calendar-input') + Calendar(input_id: '#minimum-date', min_date: Date.current, class: 'rounded-md border shadow') + end + RUBY + end + render Components::ComponentSetup::Tabs.new(component_name: component) render Docs::ComponentsTable.new(component_files(component)) diff --git a/gem/test/ruby_ui/calendar_test.rb b/gem/test/ruby_ui/calendar_test.rb index 5c3d4745..9e102454 100644 --- a/gem/test/ruby_ui/calendar_test.rb +++ b/gem/test/ruby_ui/calendar_test.rb @@ -11,4 +11,15 @@ def test_render_with_all_items assert_match(/Select a date/, output) end + + def test_render_with_min_date + output = phlex do + RubyUI.Calendar(min_date: "2026-05-07") + end + + assert_match(/data-ruby-ui--calendar-min-date-value="2026-05-07"/, output) + assert_match(/data-ruby-ui--calendar-target="disabledDateTemplate"/, output) + assert_match(/disabled/, output) + assert_match(/aria-disabled="true"/, output) + end end