diff --git a/packages/main/cypress/specs/Calendar.cy.tsx b/packages/main/cypress/specs/Calendar.cy.tsx index 522d5e83e77f..c5a21f3e88e0 100644 --- a/packages/main/cypress/specs/Calendar.cy.tsx +++ b/packages/main/cypress/specs/Calendar.cy.tsx @@ -8,6 +8,7 @@ import "@ui5/webcomponents-localization/dist/features/calendar/Islamic.js"; import "@ui5/webcomponents-localization/dist/features/calendar/Gregorian.js"; import { resetConfiguration } from "@ui5/webcomponents-base/dist/InitialConfiguration.js"; import { getFirstDayOfWeek } from "@ui5/webcomponents-base/dist/config/FormatSettings.js"; +import CalendarSelectionMode from "../../src/types/CalendarSelectionMode.js"; const getDefaultCalendar = (date: Date) => { const calDate = new Date(date); @@ -1765,3 +1766,467 @@ describe("Calendar Global Configuration", () => { .should("have.text", "Sat"); }); }); + +describe("Calendar - Multiple Months Mode", () => { + describe("Two Calendars Display", () => { + it("should display two calendars when _showTwoMonths is true", () => { + cy.mount( + + + + ); + + cy.get("#cal") + .shadow() + .find(".ui5-cal-month-container") + .should("have.length", 2); + }); + + it("should display only one calendar when _showTwoMonths is false", () => { + cy.mount( + + + + ); + + cy.get("#cal") + .shadow() + .find(".ui5-cal-month-container") + .should("not.exist"); + + cy.get("#cal") + .shadow() + .find("[id$='-daypicker']") + .should("exist"); + }); + + it("should display consecutive months in two calendar mode", () => { + cy.mount( + + + + ); + + // First calendar should show January + cy.get("#cal") + .shadow() + .find(".ui5-cal-month-header-container") + .first() + .find("[data-ui5-cal-header-btn-month]") + .should("contain.text", "January"); + + // Second calendar should show February + cy.get("#cal") + .shadow() + .find(".ui5-cal-month-header-container") + .last() + .find("[data-ui5-cal-header-btn-month]") + .should("contain.text", "February"); + }); + + it("should have correct CSS classes for multiple months mode", () => { + cy.mount( + + + + ); + + cy.get("#cal") + .shadow() + .find(".ui5-cal-root") + .should("have.class", "ui5-dt-cal--multiple"); + + cy.get("#cal") + .shadow() + .find(".ui5-cal-content") + .should("have.class", "ui5-cal-content-multiple"); + + cy.get("#cal") + .shadow() + .find(".ui5-cal-multiple-months-wrapper") + .should("exist"); + }); + }); + + describe("Navigation in Multiple Months Mode", () => { + it("should show prev button only in first calendar header", () => { + cy.mount( + + + + ); + + // First calendar should have prev button + cy.get("#cal") + .shadow() + .find(".ui5-cal-month-header-container") + .first() + .find("[data-ui5-cal-header-btn-prev]") + .should("exist"); + + // Second calendar should have spacer instead of prev button + cy.get("#cal") + .shadow() + .find(".ui5-cal-month-header-container") + .last() + .find("[data-ui5-cal-header-btn-prev]") + .should("not.exist"); + + cy.get("#cal") + .shadow() + .find(".ui5-cal-month-header-container") + .last() + .find(".ui5-calheader-spacer") + .should("exist"); + }); + + it("should show next button only in last calendar header (desktop)", () => { + cy.mount( + + + + ); + + // First calendar should have spacer instead of next button + cy.get("#cal") + .shadow() + .find(".ui5-cal-month-header-container") + .first() + .find("[data-ui5-cal-header-btn-next]") + .should("not.exist"); + + // Second calendar should have next button + cy.get("#cal") + .shadow() + .find(".ui5-cal-month-header-container") + .last() + .find("[data-ui5-cal-header-btn-next]") + .should("exist"); + }); + + it("should navigate both calendars when clicking prev button", () => { + cy.mount( + + + + ); + + // Click prev button + cy.get("#cal") + .shadow() + .find("[data-ui5-cal-header-btn-prev]") + .realClick(); + + // First calendar should show February + cy.get("#cal") + .shadow() + .find(".ui5-cal-month-header-container") + .first() + .find("[data-ui5-cal-header-btn-month]") + .should("contain.text", "February"); + + // Second calendar should show March + cy.get("#cal") + .shadow() + .find(".ui5-cal-month-header-container") + .last() + .find("[data-ui5-cal-header-btn-month]") + .should("contain.text", "March"); + }); + + it("should navigate both calendars when clicking next button", () => { + cy.mount( + + + + ); + + // Click next button + cy.get("#cal") + .shadow() + .find("[data-ui5-cal-header-btn-next]") + .realClick(); + + // First calendar should show February + cy.get("#cal") + .shadow() + .find(".ui5-cal-month-header-container") + .first() + .find("[data-ui5-cal-header-btn-month]") + .should("contain.text", "February"); + + // Second calendar should show March + cy.get("#cal") + .shadow() + .find(".ui5-cal-month-header-container") + .last() + .find("[data-ui5-cal-header-btn-month]") + .should("contain.text", "March"); + }); + }); + + describe("Picker Overlays in Multiple Months Mode", () => { + it("should show month picker as overlay when clicking month button", () => { + cy.mount( + + + + ); + + // Click month button in first calendar + cy.get("#cal") + .shadow() + .find(".ui5-cal-month-header-container") + .first() + .find("[data-ui5-cal-header-btn-month]") + .realClick(); + + // Month picker should be visible in overlay container + cy.get("#cal") + .shadow() + .find(".ui5-cal-overlay-container") + .should("not.have.class", "ui5-cal-overlay-hidden"); + + cy.get("#cal") + .shadow() + .find(".ui5-cal-overlay-container") + .find("[id$='-MP']") + .should("not.have.attr", "hidden"); + }); + + it("should show year picker as overlay when clicking year button", () => { + cy.mount( + + + + ); + + // Click year button in first calendar + cy.get("#cal") + .shadow() + .find(".ui5-cal-month-header-container") + .first() + .find("[data-ui5-cal-header-btn-year]") + .realClick(); + + // Year picker should be visible in overlay container + cy.get("#cal") + .shadow() + .find(".ui5-cal-overlay-container") + .should("not.have.class", "ui5-cal-overlay-hidden"); + + cy.get("#cal") + .shadow() + .find(".ui5-cal-overlay-container") + .find("[id$='-YP']") + .should("not.have.attr", "hidden"); + }); + + it("should show overlay effect on day pickers when picker is open", () => { + cy.mount( + + + + ); + + // Click month button to open month picker + cy.get("#cal") + .shadow() + .find("[data-ui5-cal-header-btn-month]") + .first() + .realClick(); + + // Overlay should be visible + cy.get("#cal") + .shadow() + .find(".ui5-cal-daypicker-overlay") + .should("be.visible"); + }); + + it("should hide overlay when selecting a month", () => { + cy.mount( + + + + ); + + // Open month picker + cy.get("#cal") + .shadow() + .find("[data-ui5-cal-header-btn-month]") + .first() + .realClick(); + + // Select a month + cy.get("#cal") + .shadow() + .find(".ui5-cal-overlay-container") + .find("[id$='-MP']") + .shadow() + .find("[data-sap-timestamp]") + .first() + .realClick(); + + // Overlay should be hidden + cy.get("#cal") + .shadow() + .find(".ui5-cal-overlay-container") + .should("have.class", "ui5-cal-overlay-hidden"); + }); + }); + + describe("Date Selection in Multiple Months Mode", () => { + it("should allow selecting dates from both calendars", () => { + cy.mount( + + + + ); + + // Click date in second calendar (February) + cy.get("#cal") + .shadow() + .find(".ui5-cal-month-container") + .last() + .find("[id$='-daypicker-1']") + .shadow() + .find("[data-sap-timestamp]") + .first() + .realClick(); + + // Selected date should be updated + cy.get("#cal") + .then($cal => { + const selectedDates = $cal[0].selectedDates; + expect(selectedDates).to.have.length(1); + }); + }); + + it("should support range selection across both calendars", () => { + cy.mount( + + + + ); + + // Select start date in January (first calendar) + cy.get("#cal") + .shadow() + .find(".ui5-cal-month-container") + .first() + .find("[id$='-daypicker-0']") + .shadow() + .find("[data-sap-timestamp]") + .eq(14) // Jan 15 + .realClick(); + + // Select end date in February (second calendar) + cy.get("#cal") + .shadow() + .find(".ui5-cal-month-container") + .last() + .find("[id$='-daypicker-1']") + .shadow() + .find("[data-sap-timestamp]") + .eq(13) // Feb 14 + .realClick(); + + // Should have range selected + cy.get("#cal") + .then($cal => { + const selectedDates = $cal[0].selectedDates; + expect(selectedDates).to.have.length.greaterThan(1); + }); + }); + }); + + describe("Header Buttons in Multiple Months Mode", () => { + it("should hide month button when year or year-range picker is shown", () => { + cy.mount( + + + + ); + + // Open year picker + cy.get("#cal") + .shadow() + .find("[data-ui5-cal-header-btn-year]") + .first() + .realClick(); + + // Month button should be hidden in unified header + cy.get("#cal") + .shadow() + .find(".ui5-calheader-default-multiple") + .find("[data-ui5-cal-header-btn-month]") + .should("have.attr", "hidden"); + }); + + it("should show all header buttons in default day picker mode", () => { + cy.mount( + + + + ); + + // Both calendars should show month and year buttons + cy.get("#cal") + .shadow() + .find(".ui5-cal-month-header-container") + .each(($container) => { + cy.wrap($container) + .find("[data-ui5-cal-header-btn-month]") + .should("not.have.attr", "hidden"); + + cy.wrap($container) + .find("[data-ui5-cal-header-btn-year]") + .should("not.have.attr", "hidden"); + }); + }); + }); + + describe("Accessibility in Multiple Months Mode", () => { + it("should have proper ARIA labels on navigation buttons", () => { + cy.mount( + + + + ); + + cy.get("#cal") + .shadow() + .find("[data-ui5-cal-header-btn-prev]") + .should("have.attr", "aria-label"); + + cy.get("#cal") + .shadow() + .find("[data-ui5-cal-header-btn-next]") + .should("have.attr", "aria-label"); + }); + + it("should have proper role attributes on header buttons", () => { + cy.mount( + + + + ); + + cy.get("#cal") + .shadow() + .find("[data-ui5-cal-header-btn-prev]") + .should("have.attr", "role", "button"); + + cy.get("#cal") + .shadow() + .find("[data-ui5-cal-header-btn-next]") + .should("have.attr", "role", "button"); + + cy.get("#cal") + .shadow() + .find("[data-ui5-cal-header-btn-month]") + .first() + .should("have.attr", "role", "button"); + }); + }); +}); diff --git a/packages/main/cypress/specs/DateRangePicker.cy.tsx b/packages/main/cypress/specs/DateRangePicker.cy.tsx index 2269b1337c75..a4f3754667ec 100644 --- a/packages/main/cypress/specs/DateRangePicker.cy.tsx +++ b/packages/main/cypress/specs/DateRangePicker.cy.tsx @@ -968,4 +968,475 @@ describe("Validation inside a form", () => { cy.get("#dateRangePicker:invalid") .should("not.exist"); }); -}); \ No newline at end of file +}); + +describe("DateRangePicker - Two Calendars Feature", () => { + describe("Basic Two Calendars Display", () => { + it("should display two calendars when showTwoMonths is true", () => { + cy.mount( + + ); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .shadow() + .find("[ui5-datetime-input]") + .realClick() + .should("be.focused"); + + cy.realPress("F4"); + + cy.get("@dateRangePicker") + .ui5DateRangePickerExpectToBeOpen() + .ui5DateRangePickerExpectMonthContainerCount(2); + }); + + it("should display one calendar when showTwoMonths is false", () => { + cy.mount( + + ); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .shadow() + .find("[ui5-datetime-input]") + .realClick() + .should("be.focused"); + + cy.realPress("F4"); + + cy.get("@dateRangePicker") + .ui5DateRangePickerExpectToBeOpen(); + + cy.get("@dateRangePicker") + .ui5DateRangePickerGetMonthContainers() + .should("not.exist"); + }); + + it("should show consecutive months in two calendars mode", () => { + cy.mount( + + ); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .shadow() + .find("[ui5-datetime-input]") + .realClick() + .should("be.focused"); + + cy.realPress("F4"); + + cy.get("@dateRangePicker") + .ui5DateRangePickerExpectToBeOpen(); + + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-calheader-middlebtn") + .should("have.length.greaterThan", 1); + }); + + it("should dynamically toggle showTwoMonths after initial render", () => { + cy.mount( + + ); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .ui5DateRangePickerOpen(); + + // Initially should show single calendar (no month containers in multi-month mode) + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-cal-month-container") + .should("not.exist"); + + // Verify single daypicker exists + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find("[ui5-daypicker]") + .should("exist") + .and("have.length", 1); + + // Close the picker + cy.realPress("Escape"); + + // Enable two calendars mode + cy.get("@dateRangePicker") + .then(($drp) => { + ($drp[0] as any).showTwoMonths = true; + }); + + // Reopen the picker + cy.get("@dateRangePicker") + .ui5DateRangePickerOpen(); + + // Should now show two calendars + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-cal-month-container") + .should("have.length", 2); + + // Close the picker + cy.realPress("Escape"); + + // Disable two calendars mode + cy.get("@dateRangePicker") + .then(($drp) => { + ($drp[0] as any).showTwoMonths = false; + }); + + // Reopen the picker + cy.get("@dateRangePicker") + .ui5DateRangePickerOpen(); + + // Should be back to single calendar + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-cal-month-container") + .should("not.exist"); + + // Verify single daypicker exists + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find("[ui5-daypicker]") + .should("exist") + .and("have.length", 1); + }); + }); + + describe("Date Range Selection with Two Calendars", () => { + it("should allow selecting range across both calendars", () => { + cy.mount( + + ); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .ui5DateRangePickerOpen(); + + // Select start date in first calendar + cy.get("@dateRangePicker") + .ui5DateRangePickerClickDateInCalendar(0, 14); + + // Select end date in second calendar + cy.get("@dateRangePicker") + .ui5DateRangePickerClickDateInCalendar(1, 9); + + cy.get("@dateRangePicker") + .invoke("attr", "value") + .should("exist") + .and("not.be.empty"); + }); + + it("should highlight selection across both calendars", () => { + cy.mount( + + ); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .ui5DateRangePickerOpen(); + + // First calendar should have selected dates + cy.get("@dateRangePicker") + .ui5DateRangePickerVerifySelectedDatesInCalendar(0); + + // Second calendar should have selected dates + cy.get("@dateRangePicker") + .ui5DateRangePickerVerifySelectedDatesInCalendar(1); + }); + + it("should update value when selecting new range", () => { + cy.mount( + + ); + + const changeSpy = cy.spy().as("changeSpy"); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .then($drp => { + $drp[0].addEventListener("change", changeSpy); + }) + .ui5DateRangePickerOpen(); + + // Select new start date + cy.get("@dateRangePicker") + .ui5DateRangePickerClickDateInCalendar(0, 9); + + // Select new end date + cy.get("@dateRangePicker") + .ui5DateRangePickerClickDateInCalendar(1, 14); + + cy.get("@changeSpy").should("have.been.called"); + }); + + it("should respect min/max date constraints with two calendars", () => { + cy.mount( + + ); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .ui5DateRangePickerOpen(); + + // Both calendars should show months within the valid range (Jan and Feb 2024) + cy.get("@dateRangePicker") + .ui5DateRangePickerExpectMonthContainerCount(2); + + // Check that dates before minDate are disabled (e.g., Jan 5, 2024) + // Jan 5, 2024 = timestamp 1704412800 + cy.get("@dateRangePicker") + .ui5DateRangePickerGetDayPicker(0) + .shadow() + .find("div[data-sap-timestamp='1704412800']") + .should("have.class", "ui5-dp-item--disabled"); + + // Check that dates within valid range are NOT disabled (e.g., Jan 15, 2024) + // Jan 15, 2024 = timestamp 1705276800 + cy.get("@dateRangePicker") + .ui5DateRangePickerGetDayPicker(0) + .shadow() + .find("div[data-sap-timestamp='1705276800']") + .should("not.have.class", "ui5-dp-item--disabled"); + }); + }); + + describe("Navigation in Two Calendars Mode", () => { + it("should navigate both calendars forward", () => { + cy.mount( + + ); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .ui5DateRangePickerOpen(); + + cy.get("@dateRangePicker") + .ui5DateRangePickerClickNavigationButton("next"); + + // First calendar should show February + cy.get("@dateRangePicker") + .ui5DateRangePickerVerifyMonthText(0, "February"); + + // Second calendar should show March + cy.get("@dateRangePicker") + .ui5DateRangePickerVerifyMonthText(1, "March"); + }); + + it("should navigate both calendars backward", () => { + cy.mount( + + ); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .ui5DateRangePickerOpen(); + + cy.get("@dateRangePicker") + .ui5DateRangePickerClickNavigationButton("prev"); + + // First calendar should show February + cy.get("@dateRangePicker") + .ui5DateRangePickerVerifyMonthText(0, "February"); + + // Second calendar should show March + cy.get("@dateRangePicker") + .ui5DateRangePickerVerifyMonthText(1, "March"); + }); + }); + + describe("Picker Overlays", () => { + it("should show month picker overlay when clicking month button", () => { + cy.mount( + + ); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .ui5DateRangePickerOpen(); + + cy.get("@dateRangePicker") + .ui5DateRangePickerGetCalendarHeaders() + .eq(0) + .find("[data-ui5-cal-header-btn-month]") + .realClick(); + + cy.get("@dateRangePicker") + .ui5DateRangePickerGetCalendar() + .shadow() + .find(".ui5-cal-overlay-container") + .should("not.have.class", "ui5-cal-overlay-hidden"); + + cy.get("@dateRangePicker") + .ui5DateRangePickerGetCalendar() + .shadow() + .find(".ui5-cal-overlay-container") + .find("[id$='-MP']") + .should("not.have.attr", "hidden"); + }); + + it("should show year picker overlay when clicking year button", () => { + cy.mount( + + ); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .ui5DateRangePickerOpen(); + + cy.get("@dateRangePicker") + .ui5DateRangePickerGetCalendarHeaders() + .eq(0) + .find("[data-ui5-cal-header-btn-year]") + .realClick(); + + cy.get("@dateRangePicker") + .ui5DateRangePickerGetCalendar() + .shadow() + .find(".ui5-cal-overlay-container") + .should("not.have.class", "ui5-cal-overlay-hidden"); + }); + + it("should return to day pickers after selecting from month picker", () => { + cy.mount( + + ); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .ui5DateRangePickerOpen(); + + cy.get("@dateRangePicker") + .ui5DateRangePickerGetCalendarHeaders() + .eq(0) + .find("[data-ui5-cal-header-btn-month]") + .realClick(); + + cy.get("@dateRangePicker") + .ui5DateRangePickerGetCalendar() + .shadow() + .find(".ui5-cal-overlay-container") + .find("[id$='-MP']") + .shadow() + .find("[data-sap-timestamp]") + .first() + .realClick(); + + cy.get("@dateRangePicker") + .ui5DateRangePickerGetCalendar() + .shadow() + .find(".ui5-cal-overlay-container") + .should("have.class", "ui5-cal-overlay-hidden"); + + cy.get("@dateRangePicker") + .ui5DateRangePickerExpectMonthContainerCount(2); + }); + }); + + describe("Keyboard Navigation", () => { + it("should allow keyboard navigation through header buttons", () => { + cy.mount( + + ); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .shadow() + .find("[ui5-datetime-input]") + .realClick() + .should("be.focused"); + + cy.realPress("F4"); + + cy.get("@dateRangePicker") + .ui5DateRangePickerExpectToBeOpen(); + + cy.get("@dateRangePicker") + .ui5DateRangePickerGetCalendarHeaders() + .should("have.length.greaterThan", 0); + }); + + it("should activate buttons with Space key", () => { + cy.mount( + + ); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .ui5DateRangePickerOpen(); + + cy.get("@dateRangePicker") + .ui5DateRangePickerGetCalendarHeaders() + .eq(0) + .find("[data-ui5-cal-header-btn-month]") + .focus(); + + cy.realPress("Space"); + + cy.get("@dateRangePicker") + .ui5DateRangePickerGetCalendar() + .shadow() + .find(".ui5-cal-overlay-container") + .should("not.have.class", "ui5-cal-overlay-hidden"); + }); + }); + + describe("Edge Cases", () => { + it("should handle year boundary correctly", () => { + cy.mount( + + ); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .shadow() + .find("[ui5-datetime-input]") + .realClick() + .should("be.focused"); + + cy.realPress("F4"); + + cy.get("@dateRangePicker") + .ui5DateRangePickerExpectToBeOpen(); + + cy.get("@dateRangePicker") + .ui5DateRangePickerGetCalendarHeaders() + .should("have.length.greaterThan", 0); + }); + + it("should handle empty initial value", () => { + cy.mount( + + ); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .ui5DateRangePickerOpen(); + + cy.get("@dateRangePicker") + .ui5DateRangePickerExpectMonthContainerCount(2); + }); + }); +}); + + diff --git a/packages/main/cypress/specs/DateRangePicker.mobile.cy.tsx b/packages/main/cypress/specs/DateRangePicker.mobile.cy.tsx new file mode 100644 index 000000000000..4b8fe46b1207 --- /dev/null +++ b/packages/main/cypress/specs/DateRangePicker.mobile.cy.tsx @@ -0,0 +1,236 @@ +import DateRangePicker from "../../src/DateRangePicker.js"; + +type DateTimePickerTemplateOptions = Partial<{ + formatPattern: string; + delimiter: string; + onChange: () => void; + value: string; + minDate: string; + maxDate: string; +}> + +function DateRangePickerTemplate(options: DateTimePickerTemplateOptions) { + return +} + +describe("DateRangePicker mobile footer interactions", () => { + beforeEach(() => { + cy.ui5SimulateDevice("phone"); + }); + + it("OK button is disabled when no dates are selected", () => { + cy.mount(); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .shadow() + .find("[ui5-datetime-input]") + .realClick() + .should("be.focused"); + + cy.realPress("F4"); + + cy.get("@dateRangePicker") + .ui5DateRangePickerExpectToBeOpen(); + + // Get the responsive popover and look for the button in its light DOM + cy.get("@dateRangePicker") + .ui5DateRangePickerGetPopover() + .within(() => { + cy.get("#ok") + .should("exist") + .and("have.attr", "disabled"); + }); + }); + + it("OK button is disabled when only one date is selected", () => { + cy.mount(); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .shadow() + .find("[ui5-datetime-input]") + .realClick() + .should("be.focused"); + + cy.realPress("F4"); + + cy.get("@dateRangePicker") + .ui5DateRangePickerExpectToBeOpen(); + + // Select first date + cy.get("@dateRangePicker") + .ui5DateRangePickerSelectRange(5); + + // OK button should still be disabled + cy.get("@dateRangePicker") + .ui5DateRangePickerGetPopover() + .within(() => { + cy.get("#ok") + .should("exist") + .and("have.attr", "disabled"); + }); + }); + + it("OK button is enabled when two dates are selected", () => { + cy.mount(); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .shadow() + .find("[ui5-datetime-input]") + .realClick() + .should("be.focused"); + + cy.realPress("F4"); + + cy.get("@dateRangePicker") + .ui5DateRangePickerExpectToBeOpen(); + + // Select date range + cy.get("@dateRangePicker") + .ui5DateRangePickerSelectRange(5, 15); + + cy.get("@dateRangePicker") + .ui5DateRangePickerGetPopover() + .within(() => { + cy.get("#ok") + .should("exist") + .and("not.have.attr", "disabled"); + }); + }); + + it("OK button confirms the selection and closes the picker", () => { + cy.mount(); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .shadow() + .find("[ui5-datetime-input]") + .realClick() + .should("be.focused"); + + cy.realPress("F4"); + + cy.get("@dateRangePicker") + .ui5DateRangePickerExpectToBeOpen(); + + // Select date range + cy.get("@dateRangePicker") + .ui5DateRangePickerSelectRange(5, 15); + + // Click OK button + cy.get("@dateRangePicker") + .ui5DateRangePickerGetPopover() + .within(() => { + cy.get("#ok").realClick(); + }); + + // Picker should be closed + cy.get("@dateRangePicker") + .should("have.prop", "open", false); + + // Change event should be fired + cy.get("@changeStub") + .should("be.calledOnce"); + + // Value should be set + cy.get("@dateRangePicker") + .should("have.attr", "value") + .and("match", /\d{2}\/\d{2}\/\d{4} - \d{2}\/\d{2}\/\d{4}/); + }); + + it("Cancel button clears the selection and closes the picker", () => { + cy.mount(); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .shadow() + .find("[ui5-datetime-input]") + .realClick() + .should("be.focused"); + + cy.realPress("F4"); + + cy.get("@dateRangePicker") + .ui5DateRangePickerExpectToBeOpen(); + + // Click Cancel button + cy.get("@dateRangePicker") + .ui5DateRangePickerGetPopover() + .within(() => { + cy.get("#cancel").realClick(); + }); + + // Picker should be closed + cy.get("@dateRangePicker") + .should("have.prop", "open", false); + + // Value should be cleared + cy.get("@dateRangePicker") + .should("have.attr", "value", ""); + }); + + it("Change event is not fired immediately on date selection in mobile mode", () => { + cy.mount(); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .shadow() + .find("[ui5-datetime-input]") + .realClick() + .should("be.focused"); + + cy.realPress("F4"); + + cy.get("@dateRangePicker") + .ui5DateRangePickerExpectToBeOpen(); + + // Select date range + cy.get("@dateRangePicker") + .ui5DateRangePickerSelectRange(5, 15); + + // Change event should not be fired yet (only value-changed is fired internally) + cy.get("@changeStub") + .should("not.be.called"); + + // Picker should still be open + cy.get("@dateRangePicker") + .ui5DateRangePickerExpectToBeOpen(); + }); + + it("Change event is fired only after OK button click on mobile", () => { + cy.mount(); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .shadow() + .find("[ui5-datetime-input]") + .realClick() + .should("be.focused"); + + cy.realPress("F4"); + + cy.get("@dateRangePicker") + .ui5DateRangePickerExpectToBeOpen(); + + // Select date range + cy.get("@dateRangePicker") + .ui5DateRangePickerSelectRange(5, 15); + + // Change event should not be fired yet + cy.get("@changeStub") + .should("not.be.called"); + + // Click OK button + cy.get("@dateRangePicker") + .ui5DateRangePickerGetPopover() + .within(() => { + cy.get("#ok").realClick(); + }); + + // Now change event should be fired + cy.get("@changeStub") + .should("be.calledOnce"); + }); +}); diff --git a/packages/main/cypress/support/commands/DateRangePicker.commands.ts b/packages/main/cypress/support/commands/DateRangePicker.commands.ts index bb88480b99fd..c174e353c7bb 100644 --- a/packages/main/cypress/support/commands/DateRangePicker.commands.ts +++ b/packages/main/cypress/support/commands/DateRangePicker.commands.ts @@ -7,14 +7,108 @@ Cypress.Commands.add("ui5DateRangePickerOpen", { prevSubject: true }, (subject: cy.wrap(subject).ui5DateRangePickerExpectToBeOpen() }); +Cypress.Commands.add("ui5DateRangePickerGetPopover", { prevSubject: true }, (subject: JQuery) => { + return cy.wrap(subject) + .shadow() + .find("[ui5-responsive-popover]"); +}); + Cypress.Commands.add("ui5DateRangePickerExpectToBeOpen", { prevSubject: true }, (subject: JQuery) => { cy.wrap(subject) .should("have.prop", "open", true); cy.wrap(subject) - .shadow() - .find("[ui5-responsive-popover]") + .ui5DateRangePickerGetPopover() .ui5ResponsivePopoverOpened(); + + return cy.wrap(subject); +}); + +Cypress.Commands.add("ui5DateRangePickerSelectRange", { prevSubject: true }, (subject: JQuery, startIndex: number, endIndex?: number) => { + cy.wrap(subject) + .shadow() + .find("[ui5-calendar]") + .shadow() + .find("[ui5-daypicker]") + .shadow() + .find(".ui5-dp-root .ui5-dp-content div > .ui5-dp-item") + .as("dateItems"); + + cy.get("@dateItems") + .eq(startIndex) + .realClick(); + + if (endIndex) { + cy.get("@dateItems") + .eq(endIndex) + .realClick(); + } +}); + +Cypress.Commands.add("ui5DateRangePickerGetCalendar", { prevSubject: true }, (subject: JQuery) => { + return cy.wrap(subject) + .shadow() + .find("[ui5-calendar]"); +}); + +Cypress.Commands.add("ui5DateRangePickerGetMonthContainers", { prevSubject: true }, (subject: JQuery) => { + return cy.wrap(subject) + .ui5DateRangePickerGetCalendar() + .shadow() + .find(".ui5-cal-month-container"); +}); + +Cypress.Commands.add("ui5DateRangePickerExpectMonthContainerCount", { prevSubject: true }, (subject: JQuery, count: number) => { + cy.wrap(subject) + .ui5DateRangePickerGetMonthContainers() + .should("have.length", count); +}); + +Cypress.Commands.add("ui5DateRangePickerGetDayPicker", { prevSubject: true }, (subject: JQuery, index: number) => { + return cy.wrap(subject) + .ui5DateRangePickerGetMonthContainers() + .eq(index) + .find(`[id$='-daypicker-${index}']`); +}); + +Cypress.Commands.add("ui5DateRangePickerGetCalendarHeaders", { prevSubject: true }, (subject: JQuery) => { + return cy.wrap(subject) + .ui5DateRangePickerGetCalendar() + .shadow() + .find(".ui5-calheader"); +}); + +Cypress.Commands.add("ui5DateRangePickerClickDateInCalendar", { prevSubject: true }, (subject: JQuery, calendarIndex: number, dateIndex: number) => { + cy.wrap(subject) + .ui5DateRangePickerGetDayPicker(calendarIndex) + .shadow() + .find("[data-sap-timestamp]") + .eq(dateIndex) + .realClick(); +}); + +Cypress.Commands.add("ui5DateRangePickerVerifySelectedDatesInCalendar", { prevSubject: true }, (subject: JQuery, calendarIndex: number) => { + cy.wrap(subject) + .ui5DateRangePickerGetDayPicker(calendarIndex) + .shadow() + .find(".ui5-dp-item--selected") + .should("exist"); +}); + +Cypress.Commands.add("ui5DateRangePickerClickNavigationButton", { prevSubject: true }, (subject: JQuery, button: "next" | "prev") => { + cy.wrap(subject) + .ui5DateRangePickerGetCalendar() + .shadow() + .find(`[data-ui5-cal-header-btn-${button}]`) + .realClick(); +}); + +Cypress.Commands.add("ui5DateRangePickerVerifyMonthText", { prevSubject: true }, (subject: JQuery, headerIndex: number, expectedText: string) => { + cy.wrap(subject) + .ui5DateRangePickerGetCalendarHeaders() + .eq(headerIndex) + .find("[data-ui5-cal-header-btn-month]") + .should("contain.text", expectedText); }); declare global { @@ -23,8 +117,51 @@ declare global { ui5DateRangePickerOpen( this: Chainable>, ): Chainable; + ui5DateRangePickerGetPopover( + this: Chainable>, + ): Chainable>; ui5DateRangePickerExpectToBeOpen( this: Chainable>, + ): Chainable>; + ui5DateRangePickerSelectRange( + this: Chainable>, + startIndex: number, + endIndex?: number, + ): Chainable; + ui5DateRangePickerGetCalendar( + this: Chainable>, + ): Chainable>; + ui5DateRangePickerGetMonthContainers( + this: Chainable>, + ): Chainable>; + ui5DateRangePickerExpectMonthContainerCount( + this: Chainable>, + count: number, + ): Chainable; + ui5DateRangePickerGetDayPicker( + this: Chainable>, + index: number, + ): Chainable>; + ui5DateRangePickerGetCalendarHeaders( + this: Chainable>, + ): Chainable>; + ui5DateRangePickerClickDateInCalendar( + this: Chainable>, + calendarIndex: number, + dateIndex: number, + ): Chainable; + ui5DateRangePickerVerifySelectedDatesInCalendar( + this: Chainable>, + calendarIndex: number, + ): Chainable; + ui5DateRangePickerClickNavigationButton( + this: Chainable>, + button: "next" | "prev", + ): Chainable; + ui5DateRangePickerVerifyMonthText( + this: Chainable>, + headerIndex: number, + expectedText: string, ): Chainable; } } diff --git a/packages/main/src/Calendar.ts b/packages/main/src/Calendar.ts index 7089649be053..7c25fe1cda02 100644 --- a/packages/main/src/Calendar.ts +++ b/packages/main/src/Calendar.ts @@ -36,6 +36,9 @@ import type CalendarLegend from "./CalendarLegend.js"; import type { CalendarLegendItemSelectionChangeEventDetail } from "./CalendarLegend.js"; import type SpecialCalendarDate from "./SpecialCalendarDate.js"; import type CalendarLegendItemType from "./types/CalendarLegendItemType.js"; +import { isPhone } from "@ui5/webcomponents-base/dist/Device.js"; +import type { ResizeObserverCallback } from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js"; +import ResizeHandler from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js"; // Default calendar for bundling import "@ui5/webcomponents-localization/dist/features/calendar/Gregorian.js"; @@ -63,6 +66,10 @@ import { CALENDAR_HEADER_YEAR_RANGE_PREVIOUS_BUTTON_TITLE, } from "./generated/i18n/i18n-defaults.js"; import type { YearRangePickerChangeEventDetail } from "./YearRangePicker.js"; +import getEffectiveContentDensity from "@ui5/webcomponents-base/dist/util/getEffectiveContentDensity.js"; +import modifyDateBy from "@ui5/webcomponents-localization/dist/dates/modifyDateBy.js"; + +const PHONE_MODE_BREAKPOINT = 640; // px interface ICalendarPicker extends HTMLElement { _showPreviousPage: () => void, @@ -280,6 +287,18 @@ class Calendar extends CalendarPart { @property({ type: Boolean }) hideWeekNumbers = false; + /** + * Defines whether the component displays two months side by side in the picker popup. + * @default false + * @private + * @since 2.21.0 + */ + @property({ type: Boolean }) + _showTwoMonths = false; + + @property({ type: Boolean }) + stretch = false; + /** * Which picker is currently visible to the user: day/month/year/yearRange * @private @@ -357,6 +376,14 @@ class Calendar extends CalendarPart { @property() _selectedItemType: `${CalendarLegendItemType}` = "None"; + @property({ type: Boolean, noAttribute: true }) + _phoneMode = false; + + @property({ type: Boolean, noAttribute: true }) + _portraitMode = false; + + _handleResizeBound: ResizeObserverCallback; + @i18n("@ui5/webcomponents") static i18nBundle: I18nBundle; @@ -364,6 +391,98 @@ class Calendar extends CalendarPart { super(); this._valueIsProcessed = false; + this._handleResizeBound = this._handleResize.bind(this); + } + + onEnterDOM() { + ResizeHandler.register(document.body, this._handleResizeBound); + this._handleResize(); + } + + get _phoneView() { + return isPhone() || this._phoneMode; + } + + get _portraitView() { + return this._portraitMode; + } + + /** + * Handles document resize to switch between `phoneMode` and `portraitMode`. + * - `_phoneMode`: Only when it's an actual phone device (isPhone() returns true) + * - `_portraitMode`: When resolution is under PHONE_MODE_BREAKPOINT (regardless of device type) + */ + _handleResize() { + if (!this._showTwoMonths) { + return; + } + + const documentWidth = document.body.offsetWidth; + const underBreakpoint = documentWidth <= PHONE_MODE_BREAKPOINT; + + // Phone mode: only when it's an actual phone device + const phoneModeChange = (underBreakpoint && !this._phoneMode) || (!underBreakpoint && this._phoneMode); + + if (phoneModeChange) { + this._phoneMode = underBreakpoint; + } + + // Portrait mode: when resolution is under breakpoint (can be tablet, desktop in narrow window, etc.) + const toPortraitMode = underBreakpoint; + const portraitModeChange = (toPortraitMode && !this._portraitMode) || (!toPortraitMode && this._portraitMode); + + if (portraitModeChange) { + this._portraitMode = toPortraitMode; + } + } + + onExitDOM() { + ResizeHandler.deregister(document.body, this._handleResizeBound); + } + + /** + * Returns the timestamp for a specific month index when displaying multiple months + * @private + */ + _getMonthTimestamp(monthIndex: number): number { + if (monthIndex === 0) { + return this._timestamp; + } + + const calendarDate = CalendarDateComponent.fromTimestamp(this._timestamp * 1000, this._primaryCalendarType); + const modifiedDate = modifyDateBy(calendarDate, monthIndex, "month", false); + + return modifiedDate.valueOf() / 1000; + } + + /** + * Generates header button text (month and year) for a specific month timestamp + * @private + */ + _getHeaderTextForMonth(monthTimestamp: number): { monthText: string, yearText: string, secondMonthText?: string, secondYearText?: string } { + const calendarDate = CalendarDateComponent.fromTimestamp(monthTimestamp * 1000, this._primaryCalendarType); + const localeData = getCachedLocaleDataInstance(getLocale()); + const yearFormat = DateFormat.getDateInstance({ format: "y", calendarType: this.primaryCalendarType }); + + const monthText = localeData.getMonthsStandAlone("wide", this.primaryCalendarType)[calendarDate.getMonth()]; + const localDate = calendarDate.toLocalJSDate(); + const yearText = String(yearFormat.format(localDate, true)); + + const result: { monthText: string, yearText: string, secondMonthText?: string, secondYearText?: string } = { + monthText, + yearText, + }; + + if (this.hasSecondaryCalendarType) { + const secondaryDate = transformDateToSecondaryType(this.primaryCalendarType, this._secondaryCalendarType, monthTimestamp, true); + const secondaryCalendarDate = secondaryDate.firstDate || secondaryDate.lastDate; + const secondaryLocaleData = getCachedLocaleDataInstance(getLocale()); + result.secondMonthText = secondaryLocaleData.getMonthsStandAlone("wide", this._secondaryCalendarType)[secondaryCalendarDate.getMonth()]; + const secondaryYearFormat = DateFormat.getDateInstance({ format: "y", calendarType: this._secondaryCalendarType }); + result.secondYearText = String(secondaryYearFormat.format(secondaryCalendarDate.toLocalJSDate(), true)); + } + + return result; } /** @@ -675,12 +794,21 @@ class Calendar extends CalendarPart { }; } + get _isCompactMode() { + return getEffectiveContentDensity(this) === "compact"; + } + + get _monthsToShow() { + const monthsToShow = this._showTwoMonths && !isPhone() ? 2 : 1; + return monthsToShow; + } + /** * The month button is hidden when the month picker or year picker is shown * @private */ get _isHeaderMonthButtonHidden(): boolean { - return this._currentPicker !== "day"; + return this._showTwoMonths ? this._currentPicker === "yearrange" || this._currentPicker === "year" : this._currentPicker !== "day"; } /** @@ -700,6 +828,10 @@ class Calendar extends CalendarPart { } get _isDayPickerHidden() { + // In multi-month mode (monthsToShow > 1), keep day pickers visible even when other pickers are shown + if (this._showTwoMonths) { + return false; + } return this._currentPicker !== "day"; } @@ -715,6 +847,18 @@ class Calendar extends CalendarPart { return this._currentPicker !== "yearrange"; } + get _isDefaultHeaderModeInMultipleMonths() { + return !this._isDayPickerHidden && this._isYearPickerHidden; + } + + get _shouldShowOnePickerHeaderButtonInMultipleMonths() { + return !this._isDayPickerHidden && !this._isYearPickerHidden; + } + + get _areDayPickersInert() { + return this._showTwoMonths && (!this._isMonthPickerHidden || !this._isYearPickerHidden || !this._isYearRangePickerHidden); + } + get _currentYearRange(): CalendarYearRangeT { const rangeSize = this.hasSecondaryCalendarType ? 8 : 20; const yearsOffset = this.hasSecondaryCalendarType ? 2 : 9; diff --git a/packages/main/src/CalendarHeaderTemplate.tsx b/packages/main/src/CalendarHeaderTemplate.tsx index f5a53839efcb..898c80906e28 100644 --- a/packages/main/src/CalendarHeaderTemplate.tsx +++ b/packages/main/src/CalendarHeaderTemplate.tsx @@ -4,91 +4,155 @@ import Icon from "./Icon.js"; import slimArowLeft from "@ui5/webcomponents-icons/dist/slim-arrow-left.js"; import slimArowRight from "@ui5/webcomponents-icons/dist/slim-arrow-right.js"; -export default function CalendarTemplate(this: Calendar) { +interface CalendarHeaderOptions { + headerText?: { + monthText: string; + yearText: string; + secondMonthText?: string; + secondYearText?: string; + }; + isFirst?: boolean; + isLast?: boolean; + isMultiple?: boolean; +} + +export default function CalendarHeaderTemplate(this: Calendar, options?: CalendarHeaderOptions) { + const headerText = options?.headerText; + const isFirst = options?.isFirst ?? true; + const isLast = options?.isLast ?? true; + const isMultiple = options?.isMultiple ?? false; + + const monthText = headerText?.monthText ?? this._headerMonthButtonText; + const yearText = headerText?.yearText ?? this._headerYearButtonText; + const secondMonthText = headerText?.secondMonthText ?? this.secondMonthButtonText; + const secondYearText = headerText?.secondYearText ?? this._headerYearButtonTextSecType; + return ( -
+
+ {renderPrevButton.call(this, isFirst, isMultiple)} + {renderMiddleButtons.call(this, { + monthText: monthText || "", + yearText: yearText || "", + secondMonthText: secondMonthText || "", + secondYearText: secondYearText || "", + })} + {renderNextButton.call(this, isFirst, isLast, isMultiple)} +
+ ); +} + +function renderPrevButton(this: Calendar, isFirst: boolean, isMultiple: boolean) { + if (!isFirst && isMultiple) { + return
; + } + + return ( +
+ +
+ ); +} + +function renderMiddleButtons( + this: Calendar, + headerText: { + monthText: string; + yearText: string; + secondMonthText?: string; + secondYearText?: string; + } +) { + return ( +
-
- + - - + +
+ ); +} +function renderNextButton(this: Calendar, isFirst: boolean, isLast: boolean, isMultiple: boolean) { + // In portrait or compact mode, show next button only on first calendar + // In landscape mode, show next button only on last calendar + const isVertical = this._portraitView || this._isCompactMode; + const shouldShowNextButton = !isMultiple || (isVertical ? isFirst : isLast); + const shouldShowSpacer = isMultiple && (isVertical ? isLast : !isLast); + + if (shouldShowNextButton) { + return (
-
); + ); + } + + if (shouldShowSpacer) { + return
; + } + + return null; } diff --git a/packages/main/src/CalendarTemplate.tsx b/packages/main/src/CalendarTemplate.tsx index 9c7b798181fa..51873f4646a7 100644 --- a/packages/main/src/CalendarTemplate.tsx +++ b/packages/main/src/CalendarTemplate.tsx @@ -7,18 +7,148 @@ import CalendarHeaderTemplate from "./CalendarHeaderTemplate.js"; import CalendarSelectionMode from "./types/CalendarSelectionMode.js"; export default function CalendarTemplate(this: Calendar) { + const showMultipleMonths = this._monthsToShow > 1 && !this._isDayPickerHidden; + const shouldRenderSeparateHeaders = this._isDefaultHeaderModeInMultipleMonths && !this._portraitMode && !this._isCompactMode; + const shouldRenderInlineHeaders = this._isDefaultHeaderModeInMultipleMonths && (this._portraitMode || this._isCompactMode); + return ( <>
-
- { CalendarHeaderTemplate.call(this) } + {!showMultipleMonths && ( +
+ { CalendarHeaderTemplate.call(this) } +
+ )} +
+ {showMultipleMonths ? ( + <> + {/* When pickers are active, show standard calendar header */} + {this._shouldShowOnePickerHeaderButtonInMultipleMonths && ( +
+ { CalendarHeaderTemplate.call(this) } +
+ )} + +
+ + {/* Render headers in separate loop when in horizontal layout (cozy mode, not portrait, not compact) */} + {shouldRenderSeparateHeaders && ( +
+ {renderMonthHeaders.call(this)} +
+ )} + + {/* Render day pickers (with inline headers in vertical layout) */} +
+ {renderMonthPickers.call(this, shouldRenderInlineHeaders)} +
+ + ) : ( + <> +
-
+ + {showMultipleMonths && ( +
+ {renderMonthPicker.call(this)} + {renderYearPicker.call(this)} + {renderYearRangePicker.call(this)} +
+ )} +
+ +
+ +
+ ); +} + +/** + * Renders month headers in a separate loop (horizontal layout) + */ +function renderMonthHeaders(this: Calendar) { + return Array.from({ length: this._monthsToShow }, (_, index) => { + const monthTimestamp = this._getMonthTimestamp(index); + const isFirst = index === 0; + const isLast = index === this._monthsToShow - 1; + + return ( +
+ {CalendarHeaderTemplate.call(this, { + headerText: this._getHeaderTextForMonth(monthTimestamp), + isFirst, + isLast, + isMultiple: true, + })} +
+ ); + }); +} + +/** + * Renders month pickers (with optional inline headers for vertical layout) + */ +function renderMonthPickers(this: Calendar, shouldRenderInlineHeaders: boolean) { + return Array.from({ length: this._monthsToShow }, (_, index) => { + const monthTimestamp = this._getMonthTimestamp(index); + const isFirst = index === 0; + const isLast = index === this._monthsToShow - 1; + + return ( +
+ {/* Render header inline when in vertical layout (portrait OR compact) */} + {shouldRenderInlineHeaders && + CalendarHeaderTemplate.call(this, { + headerText: this._getHeaderTextForMonth(monthTimestamp), + isFirst, + isLast, + isMultiple: true, + }) + } +
+ ); + }); +} -
- -
- ); +function renderMonthPicker(this: Calendar) { + return ( +