Skip to content

Commit d737706

Browse files
authored
[Feature] Add Calendar minimum date support (#351) (#388)
1 parent 442321f commit d737706

7 files changed

Lines changed: 184 additions & 15 deletions

File tree

docs/app/javascript/controllers/ruby_ui/calendar_controller.js

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export default class extends Controller {
66
"calendar",
77
"title",
88
"weekdaysTemplate",
9+
"disabledDateTemplate",
910
"selectedDateTemplate",
1011
"todayDateTemplate",
1112
"currentMonthDateTemplate",
@@ -16,6 +17,10 @@ export default class extends Controller {
1617
type: String,
1718
default: null,
1819
},
20+
minDate: {
21+
type: String,
22+
default: null,
23+
},
1924
viewDate: {
2025
type: String,
2126
default: new Date().toISOString().slice(0, 10),
@@ -43,13 +48,21 @@ export default class extends Controller {
4348

4449
selectDay(e) {
4550
e.preventDefault();
51+
if (this.isDateDisabled(e.currentTarget.dataset.day)) return;
52+
4653
// Set the selected date value
4754
this.selectedDateValue = e.currentTarget.dataset.day;
4855
}
4956

5057
selectedDateValueChanged(value, prevValue) {
58+
const selectedDate = this.selectedDate();
59+
if (!selectedDate) {
60+
this.updateCalendar();
61+
return;
62+
}
63+
5164
// update the viewDateValue to the first day of month of the selected date (This will trigger updateCalendar() function)
52-
const newViewDate = new Date(this.selectedDateValue);
65+
const newViewDate = new Date(selectedDate);
5366
newViewDate.setDate(2); // set the day to the 2nd (to avoid issues with months with different number of days and timezones)
5467
this.viewDateValue = newViewDate.toISOString().slice(0, 10);
5568

@@ -58,7 +71,7 @@ export default class extends Controller {
5871

5972
// update the input value
6073
this.rubyUiCalendarInputOutlets.forEach((outlet) => {
61-
const formattedDate = this.formatDate(this.selectedDate());
74+
const formattedDate = this.formatDate(selectedDate);
6275
outlet.setValue(formattedDate);
6376
});
6477
}
@@ -101,10 +114,20 @@ export default class extends Controller {
101114

102115
renderDay(day) {
103116
const today = new Date();
117+
const selectedDate = this.selectedDate();
104118
let dateHTML = "";
105119
const data = { day: day, dayDate: day.getDate() };
106120

107-
if (day.toDateString() === this.selectedDate().toDateString()) {
121+
if (this.isDateDisabled(day)) {
122+
// disabledDate
123+
dateHTML = Mustache.render(
124+
this.disabledDateTemplateTarget.innerHTML,
125+
data,
126+
);
127+
} else if (
128+
selectedDate &&
129+
day.toDateString() === selectedDate.toDateString()
130+
) {
108131
// selectedDate
109132
// Render the selected date template target innerHTML with Mustache
110133
dateHTML = Mustache.render(
@@ -137,13 +160,13 @@ export default class extends Controller {
137160
}
138161

139162
selectedDate() {
140-
return new Date(this.selectedDateValue);
163+
return this.parseDate(this.selectedDateValue);
141164
}
142165

143166
viewDate() {
144-
return this.viewDateValue
145-
? new Date(this.viewDateValue)
146-
: this.selectedDate();
167+
return (
168+
this.parseDate(this.viewDateValue) || this.selectedDate() || new Date()
169+
);
147170
}
148171

149172
getFullWeeksStartAndEndInMonth() {
@@ -246,4 +269,40 @@ export default class extends Controller {
246269
return "th";
247270
}
248271
}
272+
273+
minDate() {
274+
return this.parseDate(this.minDateValue);
275+
}
276+
277+
isDateDisabled(date) {
278+
const minDate = this.minDate();
279+
const candidate = this.parseDate(date);
280+
281+
if (!minDate || !candidate) return false;
282+
283+
return this.startOfDay(candidate) < this.startOfDay(minDate);
284+
}
285+
286+
parseDate(value) {
287+
if (!value) return null;
288+
if (value instanceof Date) return new Date(value);
289+
290+
const isoDate = value.toString().match(/^(\d{4})-(\d{2})-(\d{2})/);
291+
if (isoDate) {
292+
return new Date(
293+
Number(isoDate[1]),
294+
Number(isoDate[2]) - 1,
295+
Number(isoDate[3]),
296+
);
297+
}
298+
299+
const date = new Date(value);
300+
return Number.isNaN(date.getTime()) ? null : date;
301+
}
302+
303+
startOfDay(date) {
304+
const normalizedDate = new Date(date);
305+
normalizedDate.setHours(0, 0, 0, 0);
306+
return normalizedDate;
307+
}
249308
}

docs/app/views/docs/calendar.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ def view_template
2626
RUBY
2727
end
2828

29+
render Docs::VisualCodeExample.new(title: "Minimum date", description: "Disable dates before a given date", context: self) do
30+
<<~RUBY
31+
div(class: 'space-y-4') do
32+
Input(type: 'string', placeholder: "Select a date", class: 'rounded-md border shadow', id: 'minimum-date', data_controller: 'ruby-ui--calendar-input')
33+
Calendar(input_id: '#minimum-date', min_date: Date.current, class: 'rounded-md border shadow')
34+
end
35+
RUBY
36+
end
37+
2938
render Components::ComponentSetup::Tabs.new(component_name: component)
3039

3140
render Docs::ComponentsTable.new(component_files(component))

gem/lib/ruby_ui/calendar/calendar.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
module RubyUI
44
class Calendar < Base
5-
def initialize(selected_date: nil, input_id: nil, date_format: "yyyy-MM-dd", **attrs)
5+
def initialize(selected_date: nil, min_date: nil, input_id: nil, date_format: "yyyy-MM-dd", **attrs)
66
@selected_date = selected_date
7+
@min_date = min_date
78
@input_id = input_id
89
@date_format = date_format
910
super(**attrs)
@@ -30,6 +31,7 @@ def default_attrs
3031
data: {
3132
controller: "ruby-ui--calendar",
3233
ruby_ui__calendar_selected_date_value: @selected_date&.to_s,
34+
ruby_ui__calendar_min_date_value: @min_date&.to_s,
3335
ruby_ui__calendar_format_value: @date_format,
3436
ruby_ui__calendar_ruby_ui__calendar_input_outlet: @input_id
3537
}

gem/lib/ruby_ui/calendar/calendar_controller.js

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export default class extends Controller {
66
"calendar",
77
"title",
88
"weekdaysTemplate",
9+
"disabledDateTemplate",
910
"selectedDateTemplate",
1011
"todayDateTemplate",
1112
"currentMonthDateTemplate",
@@ -16,6 +17,10 @@ export default class extends Controller {
1617
type: String,
1718
default: null,
1819
},
20+
minDate: {
21+
type: String,
22+
default: null,
23+
},
1924
viewDate: {
2025
type: String,
2126
default: new Date().toISOString().slice(0, 10),
@@ -43,13 +48,21 @@ export default class extends Controller {
4348

4449
selectDay(e) {
4550
e.preventDefault();
51+
if (this.isDateDisabled(e.currentTarget.dataset.day)) return;
52+
4653
// Set the selected date value
4754
this.selectedDateValue = e.currentTarget.dataset.day;
4855
}
4956

5057
selectedDateValueChanged(value, prevValue) {
58+
const selectedDate = this.selectedDate();
59+
if (!selectedDate) {
60+
this.updateCalendar();
61+
return;
62+
}
63+
5164
// update the viewDateValue to the first day of month of the selected date (This will trigger updateCalendar() function)
52-
const newViewDate = new Date(this.selectedDateValue);
65+
const newViewDate = new Date(selectedDate);
5366
newViewDate.setDate(2); // set the day to the 2nd (to avoid issues with months with different number of days and timezones)
5467
this.viewDateValue = newViewDate.toISOString().slice(0, 10);
5568

@@ -58,7 +71,7 @@ export default class extends Controller {
5871

5972
// update the input value
6073
this.rubyUiCalendarInputOutlets.forEach((outlet) => {
61-
const formattedDate = this.formatDate(this.selectedDate());
74+
const formattedDate = this.formatDate(selectedDate);
6275
outlet.setValue(formattedDate);
6376
});
6477
}
@@ -101,10 +114,20 @@ export default class extends Controller {
101114

102115
renderDay(day) {
103116
const today = new Date();
117+
const selectedDate = this.selectedDate();
104118
let dateHTML = "";
105119
const data = { day: day, dayDate: day.getDate() };
106120

107-
if (day.toDateString() === this.selectedDate().toDateString()) {
121+
if (this.isDateDisabled(day)) {
122+
// disabledDate
123+
dateHTML = Mustache.render(
124+
this.disabledDateTemplateTarget.innerHTML,
125+
data,
126+
);
127+
} else if (
128+
selectedDate &&
129+
day.toDateString() === selectedDate.toDateString()
130+
) {
108131
// selectedDate
109132
// Render the selected date template target innerHTML with Mustache
110133
dateHTML = Mustache.render(
@@ -137,13 +160,13 @@ export default class extends Controller {
137160
}
138161

139162
selectedDate() {
140-
return new Date(this.selectedDateValue);
163+
return this.parseDate(this.selectedDateValue);
141164
}
142165

143166
viewDate() {
144-
return this.viewDateValue
145-
? new Date(this.viewDateValue)
146-
: this.selectedDate();
167+
return (
168+
this.parseDate(this.viewDateValue) || this.selectedDate() || new Date()
169+
);
147170
}
148171

149172
getFullWeeksStartAndEndInMonth() {
@@ -246,4 +269,40 @@ export default class extends Controller {
246269
return "th";
247270
}
248271
}
272+
273+
minDate() {
274+
return this.parseDate(this.minDateValue);
275+
}
276+
277+
isDateDisabled(date) {
278+
const minDate = this.minDate();
279+
const candidate = this.parseDate(date);
280+
281+
if (!minDate || !candidate) return false;
282+
283+
return this.startOfDay(candidate) < this.startOfDay(minDate);
284+
}
285+
286+
parseDate(value) {
287+
if (!value) return null;
288+
if (value instanceof Date) return new Date(value);
289+
290+
const isoDate = value.toString().match(/^(\d{4})-(\d{2})-(\d{2})/);
291+
if (isoDate) {
292+
return new Date(
293+
Number(isoDate[1]),
294+
Number(isoDate[2]) - 1,
295+
Number(isoDate[3]),
296+
);
297+
}
298+
299+
const date = new Date(value);
300+
return Number.isNaN(date.getTime()) ? null : date;
301+
}
302+
303+
startOfDay(date) {
304+
const normalizedDate = new Date(date);
305+
normalizedDate.setHours(0, 0, 0, 0);
306+
return normalizedDate;
307+
}
249308
}

gem/lib/ruby_ui/calendar/calendar_days.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ class CalendarDays < Base
55
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"
66

77
def view_template
8+
render_disabled_date_template
89
render_selected_date_template
910
render_today_date_template
1011
render_current_month_date_template
@@ -13,6 +14,25 @@ def view_template
1314

1415
private
1516

17+
def render_disabled_date_template
18+
date_template("disabledDateTemplate") do
19+
button(
20+
data_day: "{{day}}",
21+
name: "day",
22+
class:
23+
[
24+
BASE_CLASS,
25+
"cursor-not-allowed bg-background text-muted-foreground hover:bg-background hover:text-muted-foreground"
26+
],
27+
disabled: true,
28+
role: "gridcell",
29+
tabindex: "-1",
30+
type: "button",
31+
aria_disabled: "true"
32+
) { "{{dayDate}}" }
33+
end
34+
end
35+
1636
def render_selected_date_template
1737
date_template("selectedDateTemplate") do
1838
button(

gem/lib/ruby_ui/calendar/calendar_docs.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ def view_template
2626
RUBY
2727
end
2828

29+
render Docs::VisualCodeExample.new(title: "Minimum date", description: "Disable dates before a given date", context: self) do
30+
<<~RUBY
31+
div(class: 'space-y-4') do
32+
Input(type: 'string', placeholder: "Select a date", class: 'rounded-md border shadow', id: 'minimum-date', data_controller: 'ruby-ui--calendar-input')
33+
Calendar(input_id: '#minimum-date', min_date: Date.current, class: 'rounded-md border shadow')
34+
end
35+
RUBY
36+
end
37+
2938
render Components::ComponentSetup::Tabs.new(component_name: component)
3039

3140
render Docs::ComponentsTable.new(component_files(component))

gem/test/ruby_ui/calendar_test.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,15 @@ def test_render_with_all_items
1111

1212
assert_match(/Select a date/, output)
1313
end
14+
15+
def test_render_with_min_date
16+
output = phlex do
17+
RubyUI.Calendar(min_date: "2026-05-07")
18+
end
19+
20+
assert_match(/data-ruby-ui--calendar-min-date-value="2026-05-07"/, output)
21+
assert_match(/data-ruby-ui--calendar-target="disabledDateTemplate"/, output)
22+
assert_match(/disabled/, output)
23+
assert_match(/aria-disabled="true"/, output)
24+
end
1425
end

0 commit comments

Comments
 (0)