Skip to content

Commit cc7c174

Browse files
authored
feat(ui5-calendar): add disabled dates functionality
This PR introduces support for disabling specific date ranges in the ui5-calendar component via the new disabledDates slot. Applications can now pass one or more ui5-date-range elements to the calendar, specifying startValue and/or endValue to mark dates as non-selectable. Key Changes Added disabledDates slot to ui5-calendar for passing disabled date ranges. Implemented logic in DayPicker to prevent selection of dates within disabled ranges. Updated internal date parsing to respect the calendar’s formatPattern. Added comprehensive JSDoc documentation for new properties and methods. Created Cypress tests to verify disabled date functionality, including edge cases and format pattern support. Updated sample and test pages to demonstrate usage. Motivation This feature allows applications to restrict user selection to valid dates only, improving UX for scenarios like booking, scheduling, or compliance.
1 parent b7e9352 commit cc7c174

File tree

9 files changed

+312
-5
lines changed

9 files changed

+312
-5
lines changed

packages/main/cypress/specs/Calendar.cy.tsx

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,19 @@ const getCalendarsWithWeekNumbers = () => (<>
3737
</Calendar>
3838
</>);
3939

40+
const getCalendarWithDisabledDates = (id, formatPattern, ranges, props = {}) => (
41+
<Calendar id={id} formatPattern={formatPattern} {...props}>
42+
{ranges.map((range, idx) => (
43+
<CalendarDateRange
44+
slot="disabledDates"
45+
key={idx}
46+
startValue={range.startValue}
47+
endValue={range.endValue}
48+
/>
49+
))}
50+
</Calendar>
51+
);
52+
4053
describe("Calendar general interaction", () => {
4154
it("Focus goes into the current day item of the day picker", () => {
4255
const date = new Date(Date.UTC(2000, 10, 22, 0, 0, 0));
@@ -936,6 +949,157 @@ describe("Calendar general interaction", () => {
936949
.should("have.length", 1);
937950
});
938951

952+
it("Disabled date range prevents selection of dates within the range", () => {
953+
cy.mount(getCalendarWithDisabledDates(
954+
"calendar1",
955+
"yyyy-MM-dd",
956+
[{ startValue: "2024-11-10", endValue: "2024-11-15" }]
957+
));
958+
959+
const timestamp = new Date(Date.UTC(2024, 10, 10, 0, 0, 0)).valueOf() / 1000;
960+
cy.get<Calendar>("#calendar1").invoke("prop", "timestamp", timestamp);
961+
962+
// Check that disabled dates have the correct class and aria-disabled attribute
963+
const disabledDate = new Date(Date.UTC(2024, 10, 12, 0, 0, 0)).valueOf() / 1000;
964+
965+
cy.ui5CalendarGetDay("#calendar1", disabledDate.toString())
966+
.should("have.class", "ui5-dp-item--disabled")
967+
.should("have.attr", "aria-disabled", "true");
968+
969+
// Try to click on a disabled date
970+
cy.ui5CalendarGetDay("#calendar1", disabledDate.toString())
971+
.realClick();
972+
973+
// Verify the date was not selected
974+
cy.ui5CalendarGetDay("#calendar1", disabledDate.toString())
975+
.should("not.have.class", "ui5-dp-item--selected");
976+
});
977+
978+
it("Disables a single date equal to start date when end date is not defined", () => {
979+
cy.mount(getCalendarWithDisabledDates(
980+
"calendar1",
981+
"yyyy-MM-dd",
982+
[{ startValue: "2024-11-15" }],
983+
{ maxDate: "2024-11-20" }
984+
));
985+
986+
const timestamp = new Date(Date.UTC(2024, 10, 10, 0, 0, 0)).valueOf() / 1000;
987+
cy.get<Calendar>("#calendar1").invoke("prop", "timestamp", timestamp);
988+
989+
// Date before start should be enabled
990+
const enabledDate = new Date(Date.UTC(2024, 10, 14, 0, 0, 0)).valueOf() / 1000;
991+
cy.ui5CalendarGetDay("#calendar1", enabledDate.toString())
992+
.should("not.have.class", "ui5-dp-item--disabled");
993+
994+
// Date at start should be disabled
995+
const startDate = new Date(Date.UTC(2024, 10, 15, 0, 0, 0)).valueOf() / 1000;
996+
cy.ui5CalendarGetDay("#calendar1", startDate.toString())
997+
.should("have.class", "ui5-dp-item--disabled");
998+
999+
// Date after start should not be disabled
1000+
const afterStartDate = new Date(Date.UTC(2024, 10, 17, 0, 0, 0)).valueOf() / 1000;
1001+
cy.ui5CalendarGetDay("#calendar1", afterStartDate.toString())
1002+
.should("not.have.class", "ui5-dp-item--disabled");
1003+
});
1004+
1005+
it("Disables all dates before end date when start date is not defined", () => {
1006+
cy.mount(getCalendarWithDisabledDates(
1007+
"calendar1",
1008+
"yyyy-MM-dd",
1009+
[{ endValue: "2024-11-10" }],
1010+
{ minDate: "2024-11-01" }
1011+
));
1012+
1013+
const timestamp = new Date(Date.UTC(2024, 10, 10, 0, 0, 0)).valueOf() / 1000;
1014+
cy.get<Calendar>("#calendar1").invoke("prop", "timestamp", timestamp);
1015+
1016+
// Date after end should be enabled
1017+
const enabledDate = new Date(Date.UTC(2024, 10, 11, 0, 0, 0)).valueOf() / 1000;
1018+
cy.ui5CalendarGetDay("#calendar1", enabledDate.toString())
1019+
.should("not.have.class", "ui5-dp-item--disabled");
1020+
1021+
// Date at end should not be disabled
1022+
const endDate = new Date(Date.UTC(2024, 10, 10, 0, 0, 0)).valueOf() / 1000;
1023+
cy.ui5CalendarGetDay("#calendar1", endDate.toString())
1024+
.should("not.have.class", "ui5-dp-item--disabled");
1025+
1026+
// Date before end should be disabled
1027+
const beforeEndDate = new Date(Date.UTC(2024, 10, 8, 0, 0, 0)).valueOf() / 1000;
1028+
cy.ui5CalendarGetDay("#calendar1", beforeEndDate.toString())
1029+
.should("have.class", "ui5-dp-item--disabled");
1030+
});
1031+
1032+
it("Multiple disabled date ranges work correctly", () => {
1033+
cy.mount(getCalendarWithDisabledDates(
1034+
"calendar1",
1035+
"yyyy-MM-dd",
1036+
[
1037+
{ startValue: "2024-11-05", endValue: "2024-11-07" },
1038+
{ startValue: "2024-11-15", endValue: "2024-11-17" }
1039+
]
1040+
));
1041+
1042+
const timestamp = new Date(Date.UTC(2024, 10, 10, 0, 0, 0)).valueOf() / 1000;
1043+
cy.get<Calendar>("#calendar1").invoke("prop", "timestamp", timestamp);
1044+
1045+
// First range - should be disabled
1046+
const firstRangeDate = new Date(Date.UTC(2024, 10, 6, 0, 0, 0)).valueOf() / 1000;
1047+
cy.ui5CalendarGetDay("#calendar1", firstRangeDate.toString())
1048+
.should("have.class", "ui5-dp-item--disabled");
1049+
1050+
// Between ranges - should be enabled
1051+
const betweenDate = new Date(Date.UTC(2024, 10, 10, 0, 0, 0)).valueOf() / 1000;
1052+
cy.ui5CalendarGetDay("#calendar1", betweenDate.toString())
1053+
.should("not.have.class", "ui5-dp-item--disabled");
1054+
1055+
// Second range - should be disabled
1056+
const secondRangeDate = new Date(Date.UTC(2024, 10, 16, 0, 0, 0)).valueOf() / 1000;
1057+
cy.ui5CalendarGetDay("#calendar1", secondRangeDate.toString())
1058+
.should("have.class", "ui5-dp-item--disabled");
1059+
});
1060+
1061+
it("Disabled dates respect format pattern", () => {
1062+
cy.mount(getCalendarWithDisabledDates(
1063+
"calendar1",
1064+
"dd/MM/yyyy",
1065+
[{ startValue: "10/11/2024", endValue: "15/11/2024" }]
1066+
));
1067+
1068+
const timestamp = new Date(Date.UTC(2024, 10, 10, 0, 0, 0)).valueOf() / 1000;
1069+
cy.get<Calendar>("#calendar1").invoke("prop", "timestamp", timestamp);
1070+
1071+
// Check disabled date
1072+
const disabledDate = new Date(Date.UTC(2024, 10, 12, 0, 0, 0)).valueOf() / 1000;
1073+
cy.ui5CalendarGetDay("#calendar1", disabledDate.toString())
1074+
.should("have.class", "ui5-dp-item--disabled");
1075+
});
1076+
1077+
it("Disabled dates work with range selection mode", () => {
1078+
cy.mount(getCalendarWithDisabledDates(
1079+
"calendar1",
1080+
"yyyy-MM-dd",
1081+
[{ startValue: "2024-11-10", endValue: "2024-11-15" }],
1082+
{ selectionMode: "Range" }
1083+
));
1084+
1085+
const timestamp = new Date(Date.UTC(2024, 10, 5, 0, 0, 0)).valueOf() / 1000;
1086+
cy.get<Calendar>("#calendar1").invoke("prop", "timestamp", timestamp);
1087+
1088+
// Try to select a range that includes disabled dates
1089+
const validStartDate = new Date(Date.UTC(2024, 10, 8, 0, 0, 0)).valueOf() / 1000;
1090+
cy.ui5CalendarGetDay("#calendar1", validStartDate.toString())
1091+
.realClick();
1092+
1093+
// Try to select an end date in the disabled range
1094+
const disabledEndDate = new Date(Date.UTC(2024, 10, 12, 0, 0, 0)).valueOf() / 1000;
1095+
cy.ui5CalendarGetDay("#calendar1", disabledEndDate.toString())
1096+
.realClick();
1097+
1098+
// Verify the date was not selected
1099+
cy.ui5CalendarGetDay("#calendar1", disabledEndDate.toString())
1100+
.should("not.have.class", "ui5-dp-item--selected");
1101+
});
1102+
9391103
it("Check calendar week numbers with specific CalendarWeekNumbering configuration", () => {
9401104
cy.mount(getCalendarsWithWeekNumbers());
9411105

packages/main/src/Calendar.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ type CalendarYearRangeT = {
9393
endYear: number,
9494
}
9595

96+
type DisabledDateRangeT = {
97+
startValue?: string,
98+
endValue?: string
99+
}
100+
96101
/**
97102
* @class
98103
*
@@ -327,6 +332,16 @@ class Calendar extends CalendarPart {
327332
@slot({ type: HTMLElement, invalidateOnChildChange: true })
328333
specialDates!: Array<SpecialCalendarDate>;
329334

335+
/**
336+
* Defines the disabled date ranges that cannot be selected in the calendar.
337+
* Use `ui5-date-range` elements to specify ranges of disabled dates.
338+
* Each range can define a start date, an end date, or both.
339+
* @public
340+
* @since 2.16.0
341+
*/
342+
@slot({ type: HTMLElement, invalidateOnChildChange: true })
343+
disabledDates!: Array<CalendarDateRange>;
344+
330345
/**
331346
* Defines the selected item type of the calendar legend item (if such exists).
332347
* @private
@@ -431,6 +446,19 @@ class Calendar extends CalendarPart {
431446
return !!date;
432447
}
433448

449+
get _disabledDates() {
450+
const validDisabledDateRanges = this.disabledDates.filter(dateRange => {
451+
const startValue = dateRange.startValue;
452+
const endValue = dateRange.endValue;
453+
return (startValue && this._isValidCalendarDate(startValue)) || (endValue && this._isValidCalendarDate(endValue));
454+
});
455+
456+
return validDisabledDateRanges.map(dateRange => ({
457+
startValue: dateRange.startValue,
458+
endValue: dateRange.endValue,
459+
}));
460+
}
461+
434462
get _specialCalendarDates() {
435463
const hasSelectedType = this._specialDates.some(date => date.type === this._selectedItemType);
436464
const validSpecialDates = this._specialDates.filter(date => {
@@ -963,4 +991,5 @@ export type {
963991
ICalendarSelectedDates,
964992
CalendarSelectionChangeEventDetail,
965993
SpecialCalendarDateT,
994+
DisabledDateRangeT,
966995
};

packages/main/src/CalendarTemplate.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export default function CalendarTemplate(this: Calendar) {
2020
formatPattern={this._formatPattern}
2121
selectedDates={this._selectedDatesTimestamps}
2222
specialCalendarDates={this._specialCalendarDates}
23+
disabledDates={this._disabledDates}
2324
_hidden={this._isDayPickerHidden}
2425
primaryCalendarType={this._primaryCalendarType}
2526
secondaryCalendarType={this._secondaryCalendarType}

packages/main/src/DayPicker.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import DateFormat from "@ui5/webcomponents-localization/dist/DateFormat.js";
4040
import CalendarSelectionMode from "./types/CalendarSelectionMode.js";
4141
import CalendarPart from "./CalendarPart.js";
4242
import type {
43+
DisabledDateRangeT,
4344
ICalendarPicker,
4445
SpecialCalendarDateT,
4546
} from "./Calendar.js";
@@ -196,6 +197,14 @@ class DayPicker extends CalendarPart implements ICalendarPicker {
196197
@property({ type: Array })
197198
specialCalendarDates: Array<SpecialCalendarDateT> = [];
198199

200+
/**
201+
* Array of disabled date ranges that cannot be selected.
202+
* Each range can have a start and/or end date value.
203+
* @private
204+
*/
205+
@property({ type: Array })
206+
disabledDates: Array<DisabledDateRangeT> = [];
207+
199208
@query("[data-sap-focus-ref]")
200209
_focusableDay!: HTMLElement;
201210

@@ -231,8 +240,6 @@ class DayPicker extends CalendarPart implements ICalendarPicker {
231240
const tempDate = this._getFirstDay(); // date that will be changed by 1 day 42 times
232241
const todayDate = CalendarDate.fromLocalJSDate(UI5Date.getInstance(), this._primaryCalendarType); // current day date - calculate once
233242
const calendarDate = this._calendarDate; // store the _calendarDate value as this getter is expensive and degrades IE11 perf
234-
const minDate = this._minDate; // store the _minDate (expensive getter)
235-
const maxDate = this._maxDate; // store the _maxDate (expensive getter)
236243

237244
const tempSecondDate = this.hasSecondaryCalendarType ? this._getSecondaryDay(tempDate) : undefined;
238245

@@ -255,7 +262,7 @@ class DayPicker extends CalendarPart implements ICalendarPicker {
255262
const isSelectedBetween = this._isDayInsideSelectionRange(timestamp);
256263
const isOtherMonth = tempDate.getMonth() !== calendarDate.getMonth();
257264
const isWeekend = this._isWeekend(tempDate);
258-
const isDisabled = tempDate.valueOf() < minDate.valueOf() || tempDate.valueOf() > maxDate.valueOf();
265+
const isDisabled = !this._isDateEnabled(tempDate);
259266
const isToday = tempDate.isSame(todayDate);
260267
const isFirstDayOfWeek = tempDate.getDay() === firstDayOfWeek;
261268

@@ -818,6 +825,59 @@ class DayPicker extends CalendarPart implements ICalendarPicker {
818825
|| (iWeekendEnd < iWeekendStart && (iWeekDay >= iWeekendStart || iWeekDay <= iWeekendEnd));
819826
}
820827

828+
/**
829+
* Checks if a given date is enabled (selectable).
830+
* A date is considered disabled if:
831+
* - It falls outside the min/max date range defined by the component
832+
* - It matches a single disabled date
833+
* - It falls within a disabled date range (exclusive of start and end dates)
834+
* @param date - The date to check
835+
* @returns `true` if the date is enabled (selectable), `false` if disabled
836+
* @private
837+
*/
838+
_isDateEnabled(date: CalendarDate): boolean {
839+
if ((this._minDate && date.isBefore(this._minDate))
840+
|| (this._maxDate && date.isAfter(this._maxDate))) {
841+
return false;
842+
}
843+
844+
const dateTimestamp = date.valueOf() / 1000;
845+
846+
return !this.disabledDates.some(range => {
847+
const startTimestamp = this._getTimestampFromDateValue(range.startValue);
848+
const endTimestamp = this._getTimestampFromDateValue(range.endValue);
849+
850+
if (endTimestamp) {
851+
return dateTimestamp > startTimestamp && dateTimestamp < endTimestamp;
852+
}
853+
854+
return startTimestamp && dateTimestamp === startTimestamp;
855+
});
856+
}
857+
858+
/**
859+
* Converts a date value string to a timestamp.
860+
* @param dateValue - Date string to convert
861+
* @returns timestamp in seconds, or 0 if invalid
862+
* @private
863+
*/
864+
_getTimestampFromDateValue(dateValue?: string): number {
865+
if (!dateValue) {
866+
return 0;
867+
}
868+
869+
try {
870+
const jsDate = this.getValueFormat().parse(dateValue) as Date;
871+
const calendarDate = CalendarDate.fromLocalJSDate(
872+
jsDate,
873+
this._primaryCalendarType,
874+
);
875+
return calendarDate.valueOf() / 1000;
876+
} catch {
877+
return 0;
878+
}
879+
}
880+
821881
_isDayPressed(target: HTMLElement): boolean {
822882
const targetParent = target.parentNode as HTMLElement;
823883
return (target.className.indexOf("ui5-dp-item") > -1) || (targetParent && targetParent.classList && targetParent.classList.contains("ui5-dp-item"));

packages/main/test/pages/Calendar.html

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@
8686
</style>
8787
</head>
8888
<body class="calendar1auto">
89-
9089
<section>
9190
<ui5-label id="selectLabel" label-for="selectionType">Selection type for the first calendar:</ui5-label>
9291
<ui5-select id="selectionType">
@@ -133,6 +132,8 @@
133132
</ui5-calendar>
134133
</section>
135134

135+
136+
136137
<section>
137138
<ui5-title> Calendar with no format pattern & ISO min-max dates</ui5-title>
138139
<ui5-calendar id="calendar6" min-date="2020-10-20" max-date="2023-10-20"></ui5-calendar>
@@ -178,6 +179,22 @@
178179
<ui5-calendar class="header-parts-demo"></ui5-calendar>
179180
</section>
180181

182+
<section>
183+
<ui5-title>Calendar with Disabled Dates</ui5-title>
184+
<ui5-calendar id="calendar-disabled-dates">
185+
<ui5-date-range slot="disabledDates" start-value="15 Nov 2025" end-value="20 Nov 2025"></ui5-date-range>
186+
<ui5-date-range slot="disabledDates" start-value="25 Nov 2025" end-value="27 Nov 2025"></ui5-date-range>
187+
</ui5-calendar>
188+
</section>
189+
190+
<section>
191+
<ui5-title>Calendar with Disabled Dates using a format pattern</ui5-title>
192+
<ui5-calendar id="calendar-disabled-dates" format-pattern="dd/MM/yyyy">
193+
<ui5-date-range slot="disabledDates" start-value="15/11/2025" end-value="20/11/2025"></ui5-date-range>
194+
<ui5-date-range slot="disabledDates" start-value="25/11/2025" end-value="27/11/2025"></ui5-date-range>
195+
</ui5-calendar>
196+
</section>
197+
181198
</body>
182199

183200
<script>

0 commit comments

Comments
 (0)