diff --git a/packages/main/cypress/specs/Calendar.cy.tsx b/packages/main/cypress/specs/Calendar.cy.tsx index 47141ef325ef..cc967a909e5d 100644 --- a/packages/main/cypress/specs/Calendar.cy.tsx +++ b/packages/main/cypress/specs/Calendar.cy.tsx @@ -37,6 +37,19 @@ const getCalendarsWithWeekNumbers = () => (<> ); +const getCalendarWithDisabledDates = (id, formatPattern, ranges, props = {}) => ( + + {ranges.map((range, idx) => ( + + ))} + +); + describe("Calendar general interaction", () => { it("Focus goes into the current day item of the day picker", () => { const date = new Date(Date.UTC(2000, 10, 22, 0, 0, 0)); @@ -936,6 +949,157 @@ describe("Calendar general interaction", () => { .should("have.length", 1); }); + it("Disabled date range prevents selection of dates within the range", () => { + cy.mount(getCalendarWithDisabledDates( + "calendar1", + "yyyy-MM-dd", + [{ startValue: "2024-11-10", endValue: "2024-11-15" }] + )); + + const timestamp = new Date(Date.UTC(2024, 10, 10, 0, 0, 0)).valueOf() / 1000; + cy.get("#calendar1").invoke("prop", "timestamp", timestamp); + + // Check that disabled dates have the correct class and aria-disabled attribute + const disabledDate = new Date(Date.UTC(2024, 10, 12, 0, 0, 0)).valueOf() / 1000; + + cy.ui5CalendarGetDay("#calendar1", disabledDate.toString()) + .should("have.class", "ui5-dp-item--disabled") + .should("have.attr", "aria-disabled", "true"); + + // Try to click on a disabled date + cy.ui5CalendarGetDay("#calendar1", disabledDate.toString()) + .realClick(); + + // Verify the date was not selected + cy.ui5CalendarGetDay("#calendar1", disabledDate.toString()) + .should("not.have.class", "ui5-dp-item--selected"); + }); + + it("Disables a single date equal to start date when end date is not defined", () => { + cy.mount(getCalendarWithDisabledDates( + "calendar1", + "yyyy-MM-dd", + [{ startValue: "2024-11-15" }], + { maxDate: "2024-11-20" } + )); + + const timestamp = new Date(Date.UTC(2024, 10, 10, 0, 0, 0)).valueOf() / 1000; + cy.get("#calendar1").invoke("prop", "timestamp", timestamp); + + // Date before start should be enabled + const enabledDate = new Date(Date.UTC(2024, 10, 14, 0, 0, 0)).valueOf() / 1000; + cy.ui5CalendarGetDay("#calendar1", enabledDate.toString()) + .should("not.have.class", "ui5-dp-item--disabled"); + + // Date at start should be disabled + const startDate = new Date(Date.UTC(2024, 10, 15, 0, 0, 0)).valueOf() / 1000; + cy.ui5CalendarGetDay("#calendar1", startDate.toString()) + .should("have.class", "ui5-dp-item--disabled"); + + // Date after start should not be disabled + const afterStartDate = new Date(Date.UTC(2024, 10, 17, 0, 0, 0)).valueOf() / 1000; + cy.ui5CalendarGetDay("#calendar1", afterStartDate.toString()) + .should("not.have.class", "ui5-dp-item--disabled"); + }); + + it("Disables all dates before end date when start date is not defined", () => { + cy.mount(getCalendarWithDisabledDates( + "calendar1", + "yyyy-MM-dd", + [{ endValue: "2024-11-10" }], + { minDate: "2024-11-01" } + )); + + const timestamp = new Date(Date.UTC(2024, 10, 10, 0, 0, 0)).valueOf() / 1000; + cy.get("#calendar1").invoke("prop", "timestamp", timestamp); + + // Date after end should be enabled + const enabledDate = new Date(Date.UTC(2024, 10, 11, 0, 0, 0)).valueOf() / 1000; + cy.ui5CalendarGetDay("#calendar1", enabledDate.toString()) + .should("not.have.class", "ui5-dp-item--disabled"); + + // Date at end should not be disabled + const endDate = new Date(Date.UTC(2024, 10, 10, 0, 0, 0)).valueOf() / 1000; + cy.ui5CalendarGetDay("#calendar1", endDate.toString()) + .should("not.have.class", "ui5-dp-item--disabled"); + + // Date before end should be disabled + const beforeEndDate = new Date(Date.UTC(2024, 10, 8, 0, 0, 0)).valueOf() / 1000; + cy.ui5CalendarGetDay("#calendar1", beforeEndDate.toString()) + .should("have.class", "ui5-dp-item--disabled"); + }); + + it("Multiple disabled date ranges work correctly", () => { + cy.mount(getCalendarWithDisabledDates( + "calendar1", + "yyyy-MM-dd", + [ + { startValue: "2024-11-05", endValue: "2024-11-07" }, + { startValue: "2024-11-15", endValue: "2024-11-17" } + ] + )); + + const timestamp = new Date(Date.UTC(2024, 10, 10, 0, 0, 0)).valueOf() / 1000; + cy.get("#calendar1").invoke("prop", "timestamp", timestamp); + + // First range - should be disabled + const firstRangeDate = new Date(Date.UTC(2024, 10, 6, 0, 0, 0)).valueOf() / 1000; + cy.ui5CalendarGetDay("#calendar1", firstRangeDate.toString()) + .should("have.class", "ui5-dp-item--disabled"); + + // Between ranges - should be enabled + const betweenDate = new Date(Date.UTC(2024, 10, 10, 0, 0, 0)).valueOf() / 1000; + cy.ui5CalendarGetDay("#calendar1", betweenDate.toString()) + .should("not.have.class", "ui5-dp-item--disabled"); + + // Second range - should be disabled + const secondRangeDate = new Date(Date.UTC(2024, 10, 16, 0, 0, 0)).valueOf() / 1000; + cy.ui5CalendarGetDay("#calendar1", secondRangeDate.toString()) + .should("have.class", "ui5-dp-item--disabled"); + }); + + it("Disabled dates respect format pattern", () => { + cy.mount(getCalendarWithDisabledDates( + "calendar1", + "dd/MM/yyyy", + [{ startValue: "10/11/2024", endValue: "15/11/2024" }] + )); + + const timestamp = new Date(Date.UTC(2024, 10, 10, 0, 0, 0)).valueOf() / 1000; + cy.get("#calendar1").invoke("prop", "timestamp", timestamp); + + // Check disabled date + const disabledDate = new Date(Date.UTC(2024, 10, 12, 0, 0, 0)).valueOf() / 1000; + cy.ui5CalendarGetDay("#calendar1", disabledDate.toString()) + .should("have.class", "ui5-dp-item--disabled"); + }); + + it("Disabled dates work with range selection mode", () => { + cy.mount(getCalendarWithDisabledDates( + "calendar1", + "yyyy-MM-dd", + [{ startValue: "2024-11-10", endValue: "2024-11-15" }], + { selectionMode: "Range" } + )); + + const timestamp = new Date(Date.UTC(2024, 10, 5, 0, 0, 0)).valueOf() / 1000; + cy.get("#calendar1").invoke("prop", "timestamp", timestamp); + + // Try to select a range that includes disabled dates + const validStartDate = new Date(Date.UTC(2024, 10, 8, 0, 0, 0)).valueOf() / 1000; + cy.ui5CalendarGetDay("#calendar1", validStartDate.toString()) + .realClick(); + + // Try to select an end date in the disabled range + const disabledEndDate = new Date(Date.UTC(2024, 10, 12, 0, 0, 0)).valueOf() / 1000; + cy.ui5CalendarGetDay("#calendar1", disabledEndDate.toString()) + .realClick(); + + // Verify the date was not selected + cy.ui5CalendarGetDay("#calendar1", disabledEndDate.toString()) + .should("not.have.class", "ui5-dp-item--selected"); + }); + it("Check calendar week numbers with specific CalendarWeekNumbering configuration", () => { cy.mount(getCalendarsWithWeekNumbers()); diff --git a/packages/main/src/Calendar.ts b/packages/main/src/Calendar.ts index ba14a1a2db4c..dcf6eae80d14 100644 --- a/packages/main/src/Calendar.ts +++ b/packages/main/src/Calendar.ts @@ -93,6 +93,11 @@ type CalendarYearRangeT = { endYear: number, } +type DisabledDateRangeT = { + startValue?: string, + endValue?: string +} + /** * @class * @@ -327,6 +332,16 @@ class Calendar extends CalendarPart { @slot({ type: HTMLElement, invalidateOnChildChange: true }) specialDates!: Array; + /** + * Defines the disabled date ranges that cannot be selected in the calendar. + * Use `ui5-date-range` elements to specify ranges of disabled dates. + * Each range can define a start date, an end date, or both. + * @public + * @since 2.16.0 + */ + @slot({ type: HTMLElement, invalidateOnChildChange: true }) + disabledDates!: Array; + /** * Defines the selected item type of the calendar legend item (if such exists). * @private @@ -431,6 +446,19 @@ class Calendar extends CalendarPart { return !!date; } + get _disabledDates() { + const validDisabledDateRanges = this.disabledDates.filter(dateRange => { + const startValue = dateRange.startValue; + const endValue = dateRange.endValue; + return (startValue && this._isValidCalendarDate(startValue)) || (endValue && this._isValidCalendarDate(endValue)); + }); + + return validDisabledDateRanges.map(dateRange => ({ + startValue: dateRange.startValue, + endValue: dateRange.endValue, + })); + } + get _specialCalendarDates() { const hasSelectedType = this._specialDates.some(date => date.type === this._selectedItemType); const validSpecialDates = this._specialDates.filter(date => { @@ -963,4 +991,5 @@ export type { ICalendarSelectedDates, CalendarSelectionChangeEventDetail, SpecialCalendarDateT, + DisabledDateRangeT, }; diff --git a/packages/main/src/CalendarTemplate.tsx b/packages/main/src/CalendarTemplate.tsx index dc96a6eccb24..edae4e593207 100644 --- a/packages/main/src/CalendarTemplate.tsx +++ b/packages/main/src/CalendarTemplate.tsx @@ -20,6 +20,7 @@ export default function CalendarTemplate(this: Calendar) { formatPattern={this._formatPattern} selectedDates={this._selectedDatesTimestamps} specialCalendarDates={this._specialCalendarDates} + disabledDates={this._disabledDates} _hidden={this._isDayPickerHidden} primaryCalendarType={this._primaryCalendarType} secondaryCalendarType={this._secondaryCalendarType} diff --git a/packages/main/src/DayPicker.ts b/packages/main/src/DayPicker.ts index c0306233661d..6b39305e959a 100644 --- a/packages/main/src/DayPicker.ts +++ b/packages/main/src/DayPicker.ts @@ -40,6 +40,7 @@ import DateFormat from "@ui5/webcomponents-localization/dist/DateFormat.js"; import CalendarSelectionMode from "./types/CalendarSelectionMode.js"; import CalendarPart from "./CalendarPart.js"; import type { + DisabledDateRangeT, ICalendarPicker, SpecialCalendarDateT, } from "./Calendar.js"; @@ -196,6 +197,14 @@ class DayPicker extends CalendarPart implements ICalendarPicker { @property({ type: Array }) specialCalendarDates: Array = []; + /** + * Array of disabled date ranges that cannot be selected. + * Each range can have a start and/or end date value. + * @private + */ + @property({ type: Array }) + disabledDates: Array = []; + @query("[data-sap-focus-ref]") _focusableDay!: HTMLElement; @@ -231,8 +240,6 @@ class DayPicker extends CalendarPart implements ICalendarPicker { const tempDate = this._getFirstDay(); // date that will be changed by 1 day 42 times const todayDate = CalendarDate.fromLocalJSDate(UI5Date.getInstance(), this._primaryCalendarType); // current day date - calculate once const calendarDate = this._calendarDate; // store the _calendarDate value as this getter is expensive and degrades IE11 perf - const minDate = this._minDate; // store the _minDate (expensive getter) - const maxDate = this._maxDate; // store the _maxDate (expensive getter) const tempSecondDate = this.hasSecondaryCalendarType ? this._getSecondaryDay(tempDate) : undefined; @@ -255,7 +262,7 @@ class DayPicker extends CalendarPart implements ICalendarPicker { const isSelectedBetween = this._isDayInsideSelectionRange(timestamp); const isOtherMonth = tempDate.getMonth() !== calendarDate.getMonth(); const isWeekend = this._isWeekend(tempDate); - const isDisabled = tempDate.valueOf() < minDate.valueOf() || tempDate.valueOf() > maxDate.valueOf(); + const isDisabled = !this._isDateEnabled(tempDate); const isToday = tempDate.isSame(todayDate); const isFirstDayOfWeek = tempDate.getDay() === firstDayOfWeek; @@ -818,6 +825,59 @@ class DayPicker extends CalendarPart implements ICalendarPicker { || (iWeekendEnd < iWeekendStart && (iWeekDay >= iWeekendStart || iWeekDay <= iWeekendEnd)); } + /** + * Checks if a given date is enabled (selectable). + * A date is considered disabled if: + * - It falls outside the min/max date range defined by the component + * - It matches a single disabled date + * - It falls within a disabled date range (exclusive of start and end dates) + * @param date - The date to check + * @returns `true` if the date is enabled (selectable), `false` if disabled + * @private + */ + _isDateEnabled(date: CalendarDate): boolean { + if ((this._minDate && date.isBefore(this._minDate)) + || (this._maxDate && date.isAfter(this._maxDate))) { + return false; + } + + const dateTimestamp = date.valueOf() / 1000; + + return !this.disabledDates.some(range => { + const startTimestamp = this._getTimestampFromDateValue(range.startValue); + const endTimestamp = this._getTimestampFromDateValue(range.endValue); + + if (endTimestamp) { + return dateTimestamp > startTimestamp && dateTimestamp < endTimestamp; + } + + return startTimestamp && dateTimestamp === startTimestamp; + }); + } + + /** + * Converts a date value string to a timestamp. + * @param dateValue - Date string to convert + * @returns timestamp in seconds, or 0 if invalid + * @private + */ + _getTimestampFromDateValue(dateValue?: string): number { + if (!dateValue) { + return 0; + } + + try { + const jsDate = this.getValueFormat().parse(dateValue) as Date; + const calendarDate = CalendarDate.fromLocalJSDate( + jsDate, + this._primaryCalendarType, + ); + return calendarDate.valueOf() / 1000; + } catch { + return 0; + } + } + _isDayPressed(target: HTMLElement): boolean { const targetParent = target.parentNode as HTMLElement; return (target.className.indexOf("ui5-dp-item") > -1) || (targetParent && targetParent.classList && targetParent.classList.contains("ui5-dp-item")); diff --git a/packages/main/test/pages/Calendar.html b/packages/main/test/pages/Calendar.html index e088e99d0e72..656df0e4a9d2 100644 --- a/packages/main/test/pages/Calendar.html +++ b/packages/main/test/pages/Calendar.html @@ -86,7 +86,6 @@ -
Selection type for the first calendar: @@ -133,6 +132,8 @@
+ +
Calendar with no format pattern & ISO min-max dates @@ -178,6 +179,22 @@
+
+ Calendar with Disabled Dates + + + + +
+ +
+ Calendar with Disabled Dates using a format pattern + + + + +
+ + + + +