-
Notifications
You must be signed in to change notification settings - Fork 279
feat(ui5-calendar): add disabled dates functionality #12691
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
1bcb6fa
11564ac
8c7ad66
8c5a832
33fef50
2e28efc
fc93ee4
343304a
68f9479
284dc14
71a2180
51bb17c
57e74a9
d9960c7
8d66860
6d4c853
138dc54
0c444cb
a62fe8b
fff4dfe
41899dd
666825c
f30b9e4
e143612
555c611
5decd79
9d30dc5
e601fb8
3e02970
058b171
7da49e3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -934,6 +934,154 @@ describe("Calendar general interaction", () => { | |
| .should("have.length", 1); | ||
| }); | ||
|
|
||
| it("Disabled date range prevents selection of dates within the range", () => { | ||
| cy.mount( | ||
| <Calendar id="calendar1" formatPattern="yyyy-MM-dd"> | ||
| <CalendarDateRange slot="disabledDates" startValue="2024-11-10" endValue="2024-11-15"></CalendarDateRange> | ||
| </Calendar> | ||
| ); | ||
|
|
||
| const timestamp = new Date(Date.UTC(2024, 10, 10, 0, 0, 0)).valueOf() / 1000; | ||
| cy.get<Calendar>("#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.get<Calendar>("#calendar1") | ||
| .invoke("prop", "selectedDates") | ||
| .should("have.length", 0); | ||
| }); | ||
|
|
||
| it("Disabled date range with only start date disables dates from start onwards", () => { | ||
GDamyanov marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| cy.mount( | ||
| <Calendar id="calendar1" formatPattern="yyyy-MM-dd" maxDate="2024-11-20"> | ||
| <CalendarDateRange slot="disabledDates" startValue="2024-11-15"></CalendarDateRange> | ||
| </Calendar> | ||
| ); | ||
|
|
||
| const timestamp = new Date(Date.UTC(2024, 10, 10, 0, 0, 0)).valueOf() / 1000; | ||
| cy.get<Calendar>("#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("Disabled date range with only end date disables dates up to end", () => { | ||
GDamyanov marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| cy.mount( | ||
| <Calendar id="calendar1" formatPattern="yyyy-MM-dd" minDate="2024-11-01"> | ||
| <CalendarDateRange slot="disabledDates" endValue="2024-11-10"></CalendarDateRange> | ||
| </Calendar> | ||
| ); | ||
|
|
||
| const timestamp = new Date(Date.UTC(2024, 10, 10, 0, 0, 0)).valueOf() / 1000; | ||
| cy.get<Calendar>("#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( | ||
| <Calendar id="calendar1" formatPattern="yyyy-MM-dd"> | ||
| <CalendarDateRange slot="disabledDates" startValue="2024-11-05" endValue="2024-11-07"></CalendarDateRange> | ||
| <CalendarDateRange slot="disabledDates" startValue="2024-11-15" endValue="2024-11-17"></CalendarDateRange> | ||
| </Calendar> | ||
| ); | ||
|
|
||
| const timestamp = new Date(Date.UTC(2024, 10, 10, 0, 0, 0)).valueOf() / 1000; | ||
| cy.get<Calendar>("#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( | ||
| <Calendar id="calendar1" formatPattern="dd/MM/yyyy"> | ||
| <CalendarDateRange slot="disabledDates" startValue="10/11/2024" endValue="15/11/2024"></CalendarDateRange> | ||
| </Calendar> | ||
| ); | ||
|
|
||
| const timestamp = new Date(Date.UTC(2024, 10, 10, 0, 0, 0)).valueOf() / 1000; | ||
| cy.get<Calendar>("#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( | ||
| <Calendar id="calendar1" formatPattern="yyyy-MM-dd" selectionMode="Range"> | ||
| <CalendarDateRange slot="disabledDates" startValue="2024-11-10" endValue="2024-11-15"></CalendarDateRange> | ||
| </Calendar> | ||
| ); | ||
|
|
||
| const timestamp = new Date(Date.UTC(2024, 10, 5, 0, 0, 0)).valueOf() / 1000; | ||
| cy.get<Calendar>("#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(); | ||
|
|
||
| // The disabled date should not be selectable | ||
| cy.get<Calendar>("#calendar1") | ||
|
||
| .invoke("prop", "selectedDates") | ||
| .should("have.length", 1); // Only the first date should be selected | ||
| }); | ||
|
|
||
| it("Check calendar week numbers with specific CalendarWeekNumbering configuration", () => { | ||
| cy.mount(getCalendarsWithWeekNumbers()); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<SpecialCalendarDate>; | ||
|
|
||
| /** | ||
| * 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<CalendarDateRange>; | ||
|
|
||
| /** | ||
| * 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._disabledDateRanges.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 => { | ||
|
|
@@ -778,6 +806,10 @@ class Calendar extends CalendarPart { | |
| return this.getSlottedNodes<SpecialCalendarDate>("specialDates"); | ||
| } | ||
|
|
||
| get _disabledDateRanges() { | ||
|
||
| return this.getSlottedNodes<CalendarDateRange>("disabledDates"); | ||
| } | ||
|
|
||
| get classes() { | ||
| return { | ||
| prevButton: { | ||
|
|
@@ -963,4 +995,5 @@ export type { | |
| ICalendarSelectedDates, | ||
| CalendarSelectionChangeEventDetail, | ||
| SpecialCalendarDateT, | ||
| DisabledDateRangeT, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,6 +39,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"; | ||
|
|
@@ -195,6 +196,14 @@ class DayPicker extends CalendarPart implements ICalendarPicker { | |
| @property({ type: Array }) | ||
| specialCalendarDates: Array<SpecialCalendarDateT> = []; | ||
|
|
||
| /** | ||
| * 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<DisabledDateRangeT> = []; | ||
|
|
||
| @query("[data-sap-focus-ref]") | ||
| _focusableDay!: HTMLElement; | ||
|
|
||
|
|
@@ -230,8 +239,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; | ||
|
|
||
|
|
@@ -254,7 +261,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._isDisabledDate(tempDate); | ||
| const isToday = tempDate.isSame(todayDate); | ||
| const isFirstDayOfWeek = tempDate.getDay() === firstDayOfWeek; | ||
|
|
||
|
|
@@ -817,6 +824,63 @@ class DayPicker extends CalendarPart implements ICalendarPicker { | |
| || (iWeekendEnd < iWeekendStart && (iWeekDay >= iWeekendStart || iWeekDay <= iWeekendEnd)); | ||
| } | ||
|
|
||
| /** | ||
| * Checks if a given date is disabled (not 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 (inclusive of start and end dates) | ||
|
||
| * @param date - The date to check | ||
| * @returns `true` if the date is enabled (selectable), `false` if disabled | ||
| * @private | ||
| */ | ||
| _isDisabledDate(date: CalendarDate): boolean { | ||
| if ((this._minDate && date.valueOf() < this._minDate.valueOf()) | ||
| || (this._maxDate && date.valueOf() > this._maxDate.valueOf())) { | ||
| 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; | ||
| } | ||
|
|
||
| if (startTimestamp && !endTimestamp) { | ||
| return dateTimestamp === startTimestamp; | ||
| } | ||
|
|
||
| return false; | ||
|
||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are we sure that either of the methods throw an error to be caught? If not we can just go for: const jsDate = this.getValueFormat().parse(dateValue); And if needed to check if there's calendarDate before returning it.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. fromLocalJSDate method throws exception. |
||
| 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")); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why don't we just check if the day that we are clicking on does not have specific class, but we need to invoke a property and check there?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 5decd79