diff --git a/packages/main/cypress/specs/Calendar.cy.tsx b/packages/main/cypress/specs/Calendar.cy.tsx index 522d5e83e77f..d9a3766656d2 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-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-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-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-container") + .last() + .find("[data-ui5-cal-header-btn-prev]") + .should("not.exist"); + + cy.get("#cal") + .shadow() + .find(".ui5-cal-month-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-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-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-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-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-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-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-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-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-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..04977181039c 100644 --- a/packages/main/cypress/specs/DateRangePicker.cy.tsx +++ b/packages/main/cypress/specs/DateRangePicker.cy.tsx @@ -968,4 +968,482 @@ 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(); + + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-cal-month-container") + .should("have.length", 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") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-cal-month-container") + .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); + }); + }); + + 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") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-cal-month-container") + .first() + .find("[id$='-daypicker-0']") + .shadow() + .find("[data-sap-timestamp]") + .eq(14) + .realClick(); + + // Select end date in second calendar + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-cal-month-container") + .last() + .find("[id$='-daypicker-1']") + .shadow() + .find("[data-sap-timestamp]") + .eq(9) + .realClick(); + + 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") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-cal-month-container") + .first() + .find("[id$='-daypicker-0']") + .shadow() + .find(".ui5-dp-item--selected") + .should("exist"); + + // Second calendar should have selected dates + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-cal-month-container") + .last() + .find("[id$='-daypicker-1']") + .shadow() + .find(".ui5-dp-item--selected") + .should("exist"); + }); + + 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") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-cal-month-container") + .first() + .find("[id$='-daypicker-0']") + .shadow() + .find("[data-sap-timestamp]") + .eq(9) + .realClick(); + + // Select new end date + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-cal-month-container") + .last() + .find("[id$='-daypicker-1']") + .shadow() + .find("[data-sap-timestamp]") + .eq(14) + .realClick(); + + cy.get("@changeSpy").should("have.been.called"); + }); + }); + + 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") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find("[data-ui5-cal-header-btn-next]") + .realClick(); + + // First calendar should show February + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-cal-month-container") + .first() + .find("[data-ui5-cal-header-btn-month]") + .should("contain.text", "February"); + + // Second calendar should show March + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-cal-month-container") + .last() + .find("[data-ui5-cal-header-btn-month]") + .should("contain.text", "March"); + }); + + it("should navigate both calendars backward", () => { + cy.mount( + + ); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .ui5DateRangePickerOpen(); + + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find("[data-ui5-cal-header-btn-prev]") + .realClick(); + + // First calendar should show February + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-cal-month-container") + .first() + .find("[data-ui5-cal-header-btn-month]") + .should("contain.text", "February"); + + // Second calendar should show March + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-cal-month-container") + .last() + .find("[data-ui5-cal-header-btn-month]") + .should("contain.text", "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") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find("[data-ui5-cal-header-btn-month]") + .first() + .realClick(); + + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-cal-overlay-container") + .should("not.have.class", "ui5-cal-overlay-hidden"); + + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .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") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find("[data-ui5-cal-header-btn-year]") + .first() + .realClick(); + + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .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") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find("[data-ui5-cal-header-btn-month]") + .first() + .realClick(); + + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-cal-overlay-container") + .find("[id$='-MP']") + .shadow() + .find("[data-sap-timestamp]") + .first() + .realClick(); + + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-cal-overlay-container") + .should("have.class", "ui5-cal-overlay-hidden"); + + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-cal-month-container") + .should("have.length", 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") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-calheader") + .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") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find("[data-ui5-cal-header-btn-month]") + .first() + .focus(); + + cy.realPress("Space"); + + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .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") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-calheader-middlebtn") + .should("have.length.greaterThan", 2); + }); + + it("should handle empty initial value", () => { + cy.mount( + + ); + + cy.get("[ui5-daterange-picker]") + .as("dateRangePicker") + .ui5DateRangePickerOpen(); + + cy.get("@dateRangePicker") + .shadow() + .find("[ui5-calendar]") + .shadow() + .find(".ui5-cal-month-container") + .should("have.length", 2); + }); + }); +}); + + diff --git a/packages/main/src/Calendar.ts b/packages/main/src/Calendar.ts index 7089649be053..205b3b9f0cc8 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,9 @@ 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"; + +const PHONE_MODE_BREAKPOINT = 640; // px interface ICalendarPicker extends HTMLElement { _showPreviousPage: () => void, @@ -280,6 +286,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 +375,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 +390,113 @@ class Calendar extends CalendarPart { super(); this._valueIsProcessed = false; + this._handleResizeBound = this._handleResize.bind(this); + } + + onEnterDOM() { + ResizeHandler.register(document.body, this._handleResizeBound); + // Initialize modes on first load + 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() { + 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); + + // Set day to 1 to avoid day-of-month overflow issues + // (e.g., Jan 31 + 1 month would overflow to March if Feb doesn't have 31 days) + calendarDate.setDate(1); + + // Add months one by one to handle month boundaries correctly + for (let i = 0; i < monthIndex; i++) { + const currentMonth = calendarDate.getMonth(); + const currentYear = calendarDate.getYear(); + + if (currentMonth === 11) { + // December -> January of next year + calendarDate.setYear(currentYear + 1); + calendarDate.setMonth(0); + } else { + // Just increment the month + calendarDate.setMonth(currentMonth + 1); + } + } + + return calendarDate.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 +808,21 @@ class Calendar extends CalendarPart { }; } + get _isCompactMode() { + return getEffectiveContentDensity(this) === "compact"; + } + + get _monthsToShow() { + const monthsToShow = this._showTwoMonths ? 2 : 1; + return isPhone() ? 1 : 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 +842,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 +861,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..4b6f2bd57d1b 100644 --- a/packages/main/src/CalendarHeaderTemplate.tsx +++ b/packages/main/src/CalendarHeaderTemplate.tsx @@ -4,91 +4,154 @@ 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, + 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 normal mode, show next button only on last calendar + const shouldShowNextButton = !isMultiple || (isLast && !this._portraitView && !this._isCompactMode) || (isFirst && (this._portraitView || this._isCompactMode)); + const shouldShowSpacer = isMultiple && ((!isLast && !this._portraitView && !this._isCompactMode) || (isLast && (this._portraitView || this._isCompactMode))); + + if (shouldShowNextButton) { + return (
-
); + ); + } + + if (shouldShowSpacer) { + return
; + } + + return null; } diff --git a/packages/main/src/CalendarTemplate.tsx b/packages/main/src/CalendarTemplate.tsx index 9c7b798181fa..b608763c0eac 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(this)} + {renderYearPicker(this)} + {renderYearRangePicker(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(calendar: Calendar) { + return ( +