diff --git a/packages/main/cypress/specs/Calendar.cy.tsx b/packages/main/cypress/specs/Calendar.cy.tsx index cc967a909e5d..84125cab1bb7 100644 --- a/packages/main/cypress/specs/Calendar.cy.tsx +++ b/packages/main/cypress/specs/Calendar.cy.tsx @@ -51,47 +51,62 @@ const getCalendarWithDisabledDates = (id, formatPattern, ranges, props = {}) => ); describe("Calendar general interaction", () => { - it("Focus goes into the current day item of the day picker", () => { - const date = new Date(Date.UTC(2000, 10, 22, 0, 0, 0)); - cy.mount(getDefaultCalendar(date)); - - cy.ui5CalendarGetDay("#calendar1", "974851200") - .as("selectedDay"); - - cy.get("@selectedDay") - .realClick(); - - cy.get("@selectedDay") - .should("have.focus") - .realPress("Tab"); + it("Focus goes into the header items and then to the current day item of the day picker", () => { + const calendarTestDate = new Date(Date.UTC(2000, 10, 22, 0, 0, 0)); + cy.mount(getDefaultCalendar(calendarTestDate)); cy.get("#calendar1") .shadow() .find(".ui5-calheader") .as("calheader"); + + cy.ui5CalendarGetDay("#calendar1", "974851200").as("selectedDay"); + + cy.get("#calendar1") + .realClick(); + + cy.realPress("Tab"); cy.get("@calheader") - .find("[data-ui5-cal-header-btn-month]") - .as("monthBtn"); + .find("[data-ui5-cal-header-btn-prev]") + .as("prevBtn") + .should("have.attr", "tabindex", "0"); + + cy.get("@prevBtn") + .should("be.focused"); + cy.realPress("Tab"); + + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-month]") + .as("monthBtn") + .should("have.attr", "tabindex", "0");; + cy.get("@monthBtn") - .should("have.focus") - .realPress("Tab"); + .should("be.focused"); + + cy.realPress("Tab"); cy.get("@calheader") .find("[data-ui5-cal-header-btn-year]") .as("yearBtn"); - + cy.get("@yearBtn") - .should("have.focus") - .realPress(["Shift", "Tab"]); + .should("be.focused"); + + cy.realPress("Tab"); - cy.get("@monthBtn") - .should("have.focus") - .realPress(["Shift", "Tab"]); + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-next]") + .as("nextBtn"); + + cy.get("@nextBtn") + .should("be.focused"); + + cy.realPress("Tab"); cy.get("@selectedDay") - .should("have.focus"); + .should("be.focused"); }); it("Calendar focuses the selected year when yearpicker is opened", () => { @@ -121,11 +136,32 @@ describe("Calendar general interaction", () => { const date = new Date(Date.UTC(2000, 10, 22, 0, 0, 0)); cy.mount(getDefaultCalendar(date)); - cy.ui5CalendarGetDay("#calendar1", "974851200") + cy.get("#calendar1") + .shadow() + .find(".ui5-calheader") + .as("calheader"); + + cy.ui5CalendarGetDay("#calendar1", "974851200").as("selectedDay"); + + cy.get("#calendar1") .realClick(); - cy.focused().realPress("Tab"); - cy.focused().realPress("Space"); + cy.realPress("Tab"); + + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-prev]") + .as("prevBtn"); + + cy.get("@prevBtn") + .should("be.focused"); + + cy.realPress("Tab"); + + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-month]") + .as("monthBtn"); + + cy.realPress("Space"); cy.get("#calendar1") .shadow() @@ -148,12 +184,46 @@ describe("Calendar general interaction", () => { const date = new Date(Date.UTC(2000, 10, 22, 0, 0, 0)); cy.mount(getDefaultCalendar(date)); - cy.ui5CalendarGetDay("#calendar1", "974851200") - .realClick(); + + cy.get("#calendar1") + .shadow() + .find(".ui5-calheader") + .as("calheader"); - cy.focused().realPress("Tab"); - cy.focused().realPress("Tab"); - cy.focused().realPress("Space"); + cy.ui5CalendarGetDay("#calendar1", "974851200").as("selectedDay"); + + cy.get("#calendar1") + .realClick(); + + cy.realPress("Tab"); + + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-prev]") + .as("prevBtn"); + + cy.get("@prevBtn") + .should("be.focused"); + + cy.realPress("Tab"); + + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-month]") + .as("monthBtn"); + + cy.get("@monthBtn") + .should("be.focused"); + + + cy.realPress("Tab"); + + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-year]") + .as("yearBtn"); + + cy.get("@yearBtn") + .should("be.focused"); + + cy.realPress("Space"); cy.get("#calendar1") .shadow() @@ -427,7 +497,7 @@ describe("Calendar general interaction", () => { .should("have.focus"); cy.focused().realPress(["Shift", "F4"]); - + // Wait for focus to settle before proceeding cy.get("#calendar1") .shadow() @@ -435,7 +505,7 @@ describe("Calendar general interaction", () => { .shadow() .find("[tabindex='0']") .should("have.focus"); - + cy.focused().realPress("PageUp"); cy.get("#calendar1") @@ -1160,6 +1230,26 @@ describe("Calendar general interaction", () => { }); describe("Calendar accessibility", () => { + it("Header prev/next buttons have correct title and tabindex", () => { + const date = new Date(Date.UTC(2025, 0, 15, 0, 0, 0)); + cy.mount(getDefaultCalendar(date)); + + cy.get("#calendar1") + .shadow() + .find(".ui5-calheader") + .as("calheader"); + + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-prev]") + .should("have.attr", "title") + .and("contain", "Previous Month (Pagedown)"); + + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-next]") + .should("have.attr", "title") + .and("contain", "Next Month (Pageup)"); + }); + it("Should have proper aria-label attributes on header buttons", () => { const date = new Date(Date.UTC(2000, 10, 22, 0, 0, 0)); cy.mount(getDefaultCalendar(date)); @@ -1435,7 +1525,7 @@ describe("Calendar accessibility", () => { // Get the selected days and verify their aria-labels cy.get("@selectedDays").each(($day, index) => { cy.wrap($day).should("have.attr", "aria-label"); - + if (index === 0) { // First day should contain "First date of range" cy.wrap($day) @@ -1457,22 +1547,22 @@ describe("Calendar accessibility", () => { }); describe("Day Picker Tests", () => { - it.skip("Select day with Space", () => { + it.skip("Select day with Space", () => { cy.mount(); - + cy.get("#calendar1") .shadow() .find("[ui5-daypicker]") .shadow() .find(".ui5-dp-item--now") .as("today"); - + cy.get("@today") .realClick() .should("be.focused") .realPress("ArrowRight") .realPress("Space"); - + cy.focused() .invoke("attr", "data-sap-timestamp") .then(timestampAttr => { @@ -1481,7 +1571,7 @@ describe("Day Picker Tests", () => { const expectedDate = new Date(Date.now() + 24 * 3600 * 1000).getDate(); expect(selectedDate).to.eq(expectedDate); }); - + cy.get("#calendar1") .should(($calendar) => { const selectedDates = $calendar.prop("selectedDates"); @@ -1494,7 +1584,7 @@ describe("Day Picker Tests", () => { const tomorrow = Math.floor(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate() + 1, 0, 0, 0, 0) / 1000); cy.mount(); - + cy.get("#calendar1") .shadow() .find("[ui5-daypicker]") @@ -1533,7 +1623,7 @@ describe("Day Picker Tests", () => { it("Day names are correctly displayed", () => { cy.mount(); - + cy.get("#calendar1") .shadow() .find("[ui5-daypicker]") @@ -1593,7 +1683,6 @@ describe("Day Picker Tests", () => { const timestamp = parseInt(timestampAttr!); const todayFromTimestamp = new Date(timestamp * 1000); const actualToday = new Date(); - expect(todayFromTimestamp.getDate()).to.equal(actualToday.getDate()); expect(todayFromTimestamp.getMonth()).to.equal(actualToday.getMonth()); expect(todayFromTimestamp.getFullYear()).to.equal(actualToday.getFullYear()); diff --git a/packages/main/cypress/specs/DateTimePicker.cy.tsx b/packages/main/cypress/specs/DateTimePicker.cy.tsx index 962182101cf2..4bfbbec6f65c 100644 --- a/packages/main/cypress/specs/DateTimePicker.cy.tsx +++ b/packages/main/cypress/specs/DateTimePicker.cy.tsx @@ -119,6 +119,9 @@ describe("DateTimePicker general interaction", () => { cy.get("[ui5-calendar]") .shadow() .as("calendar"); + + cy.realPress("Tab"); + cy.realPress("Tab"); cy.get("@calendar") .find("[ui5-daypicker]") @@ -174,7 +177,7 @@ describe("DateTimePicker general interaction", () => { cy.realPress("Tab"); - // Simulate keyboard interactions + //Simulate keyboard interactions cy.get("@dtp") .shadow() .find("[ui5-datetime-input]") @@ -300,7 +303,7 @@ describe("DateTimePicker general interaction", () => { .ui5DateTimePickerClose(); }); - // Unstable test, needs investigation + //Unstable test, needs investigation it("tests selection of 12:34:56 AM", () => { setAnimationMode(AnimationMode.None); @@ -324,8 +327,8 @@ describe("DateTimePicker general interaction", () => { cy.get("@daypicker") .find(".ui5-dp-item--selected") - .should("be.focused") - .realClick(); + .realClick() + .should("be.focused"); cy.get("[ui5-time-selection-clocks]") .shadow() @@ -389,18 +392,18 @@ describe("DateTimePicker general interaction", () => { .find("ui5-daypicker") .as("daypicker"); - // act: open the picker + //act: open the picker cy.get("@dtp") .ui5DateTimePickerOpen(); - // act: click today's date + //act: click today's date cy.get("@daypicker") .shadow() .find("[data-sap-focus-ref]") - .should("be.focused") - .realClick(); + .realClick() + .should("be.focused"); - // act: confirm selection + //act: confirm selection cy.get("@dtp") .ui5DateTimePickerGetSubmitButton() .should("have.prop", "disabled", false); @@ -412,7 +415,7 @@ describe("DateTimePicker general interaction", () => { cy.get("@dtp") .ui5DateTimePickerExpectToBeClosed(); - // assert: the value is not changed + //assert: the value is not changed cy.get("@input") .should("be.focused") .and("have.attr", "value", ""); @@ -488,7 +491,7 @@ describe("DateTimePicker general interaction", () => { .should("have.text", "Invalid entry"); }); - // Unstable test, needs investigation + //Unstable test, needs investigation it("tests change event is fired on submit", () => { cy.mount(); @@ -525,10 +528,10 @@ describe("DateTimePicker general interaction", () => { cy.get("@dtp") .ui5DateTimePickerExpectToBeClosed(); - // Assert the change event was fired once + //Assert the change event was fired once cy.get("@changeStub").should("have.been.calledOnce"); - // Re-open the picker and submit without making a change + //Re-open the picker and submit without making a change cy.get("@dtp") .ui5DateTimePickerOpen(); @@ -540,11 +543,11 @@ describe("DateTimePicker general interaction", () => { .ui5DateTimePickerGetSubmitButton() .realClick(); - // Verify the picker is closed + //Verify the picker is closed cy.get("@dtp") .ui5DateTimePickerExpectToBeClosed(); - // The change event should not have been fired a second time. + //The change event should not have been fired a second time. cy.get("@changeStub").should("have.been.calledOnce"); }); }); diff --git a/packages/main/cypress/specs/DynamicDateRange.cy.tsx b/packages/main/cypress/specs/DynamicDateRange.cy.tsx index a511950fc6bb..003cdca803fd 100644 --- a/packages/main/cypress/specs/DynamicDateRange.cy.tsx +++ b/packages/main/cypress/specs/DynamicDateRange.cy.tsx @@ -150,32 +150,8 @@ describe("DynamicDateRange Component", () => { cy.get("@calendar") .should("exist"); - - cy.realPress("Tab"); - cy.realPress("Tab"); + cy.realPress("Tab"); - - cy.get("@calendar") - .shadow() - .find("[data-ui5-cal-header-btn-year='true']") - .as("yearButton"); - - cy.get("@yearButton") - .should("be.focused"); - - cy.realPress("Space"); - - cy.get("@calendar") - .shadow() - .find("ui5-yearpicker") - .as("yearPicker"); - - cy.get("@yearPicker") - .shadow() - .find(".ui5-dp-yeartext") - .contains("2035") - .realClick(); - cy.realPress("Tab"); cy.get("@calendar") @@ -184,7 +160,8 @@ describe("DynamicDateRange Component", () => { .as("monthButton"); cy.get("@monthButton") - .should("be.focused"); + .should("exist") + .should("have.focus"); cy.realPress("Space"); @@ -199,6 +176,24 @@ describe("DynamicDateRange Component", () => { .contains("May") .realClick(); + cy.get("@calendar") + .shadow() + .find("[data-ui5-cal-header-btn-year='true']") + .as("yearButton") + .should("exist") + .realClick(); + + cy.get("@calendar") + .shadow() + .find("ui5-yearpicker") + .as("yearPicker"); + + cy.get("@yearPicker") + .shadow() + .find(".ui5-dp-yeartext") + .contains("2035") + .realClick(); + cy.get("@calendar") .shadow() .find("ui5-daypicker") diff --git a/packages/main/src/Calendar.ts b/packages/main/src/Calendar.ts index 686373f355f0..449e2630385b 100644 --- a/packages/main/src/Calendar.ts +++ b/packages/main/src/Calendar.ts @@ -53,6 +53,14 @@ import { CALENDAR_HEADER_YEAR_BUTTON_SHORTCUT, CALENDAR_HEADER_YEAR_RANGE_BUTTON, CALENDAR_HEADER_YEAR_RANGE_BUTTON_SHORTCUT, + CALENDAR_HEADER_MONTH_NEXT_BUTTON_TITLE, + CALENDAR_HEADER_MONTH_NEXT_BUTTON_SHORTCUT, + CALENDAR_HEADER_MONTH_PREVIOUS_BUTTON_TITLE, + CALENDAR_HEADER_MONTH_PREVIOUS_BUTTON_SHORTCUT, + CALENDAR_HEADER_YEAR_NEXT_BUTTON_TITLE, + CALENDAR_HEADER_YEAR_PREVIOUS_BUTTON_TITLE, + CALENDAR_HEADER_YEAR_RANGE_NEXT_BUTTON_TITLE, + CALENDAR_HEADER_YEAR_RANGE_PREVIOUS_BUTTON_TITLE, } from "./generated/i18n/i18n-defaults.js"; import type { YearRangePickerChangeEventDetail } from "./YearRangePicker.js"; @@ -838,26 +846,44 @@ class Calendar extends CalendarPart { const monthLabel = Calendar.i18nBundle?.getText(CALENDAR_HEADER_MONTH_BUTTON, headerMonthButtonText); const yearLabel = Calendar.i18nBundle?.getText(CALENDAR_HEADER_YEAR_BUTTON, this._headerYearButtonText as string); const yearRangeLabel = Calendar.i18nBundle?.getText(CALENDAR_HEADER_YEAR_RANGE_BUTTON, rangeStartText, rangeEndText); + let nextBtnLabel = Calendar.i18nBundle?.getText(CALENDAR_HEADER_MONTH_NEXT_BUTTON_TITLE); + let prevBtnLabel = Calendar.i18nBundle?.getText(CALENDAR_HEADER_MONTH_PREVIOUS_BUTTON_TITLE); + + if (this._currentPicker === "month") { + nextBtnLabel = Calendar.i18nBundle?.getText(CALENDAR_HEADER_YEAR_NEXT_BUTTON_TITLE); + prevBtnLabel = Calendar.i18nBundle?.getText(CALENDAR_HEADER_YEAR_PREVIOUS_BUTTON_TITLE); + } else if (this._currentPicker === "year" || this._currentPicker === "yearrange") { + nextBtnLabel = Calendar.i18nBundle?.getText(CALENDAR_HEADER_YEAR_RANGE_NEXT_BUTTON_TITLE); + prevBtnLabel = Calendar.i18nBundle?.getText(CALENDAR_HEADER_YEAR_RANGE_PREVIOUS_BUTTON_TITLE); + } // Get shortcuts const monthShortcut = Calendar.i18nBundle?.getText(CALENDAR_HEADER_MONTH_BUTTON_SHORTCUT); const yearShortcut = Calendar.i18nBundle?.getText(CALENDAR_HEADER_YEAR_BUTTON_SHORTCUT); const yearRangeShortcut = Calendar.i18nBundle?.getText(CALENDAR_HEADER_YEAR_RANGE_BUTTON_SHORTCUT); + const nextBtnShortcut = Calendar.i18nBundle?.getText(CALENDAR_HEADER_MONTH_NEXT_BUTTON_SHORTCUT); + const prevBtnShortcut = Calendar.i18nBundle?.getText(CALENDAR_HEADER_MONTH_PREVIOUS_BUTTON_SHORTCUT); return { ariaLabelMonthButton: monthLabel, ariaLabelYearButton: yearLabel, ariaLabelYearRangeButton: yearRangeLabel, + ariaLabelNextButton: nextBtnLabel, + ariaLabelPrevButton: prevBtnLabel, // Keyboard shortcuts for aria-keyshortcuts keyShortcutMonthButton: monthShortcut, keyShortcutYearButton: yearShortcut, keyShortcutYearRangeButton: yearRangeShortcut, + keyShortcutNextButton: nextBtnShortcut, + keyShortcutPrevButton: prevBtnShortcut, // Tooltips combining label and shortcut tooltipMonthButton: `${monthLabel} (${monthShortcut})`, tooltipYearButton: `${yearLabel} (${yearShortcut})`, tooltipYearRangeButton: `${yearRangeLabel} (${yearRangeShortcut})`, + tooltipNextButton: `${nextBtnLabel} (${nextBtnShortcut})`, + tooltipPrevButton: `${prevBtnLabel} (${prevBtnShortcut})`, }; } @@ -950,26 +976,28 @@ class Calendar extends CalendarPart { } } - onPrevButtonClick(e: MouseEvent) { - if (this._previousButtonDisabled) { + _handleNavigationButtonKeyDown(e: MouseEvent, isDisabled: boolean, action: () => void) { + if (isDisabled) { e.preventDefault(); return; } - this.onHeaderPreviousPress(); - e.preventDefault(); - } - - onNextButtonClick(e: MouseEvent) { - if (this._nextButtonDisabled) { - e.preventDefault(); + if (e.button !== 0) { return; } - this.onHeaderNextPress(); + action(); e.preventDefault(); } + onPrevButtonClick(e: MouseEvent) { + this._handleNavigationButtonKeyDown(e, this._previousButtonDisabled, () => this.onHeaderPreviousPress()); + } + + onNextButtonClick(e: MouseEvent) { + this._handleNavigationButtonKeyDown(e, this._nextButtonDisabled, () => this.onHeaderNextPress()); + } + /** * Returns an array of UTC timestamps, representing the selected dates. * @protected diff --git a/packages/main/src/CalendarHeaderTemplate.tsx b/packages/main/src/CalendarHeaderTemplate.tsx index c3193e95c41d..f5a53839efcb 100644 --- a/packages/main/src/CalendarHeaderTemplate.tsx +++ b/packages/main/src/CalendarHeaderTemplate.tsx @@ -16,6 +16,11 @@ export default function CalendarTemplate(this: Calendar) { part="calendar-header-arrow-button" role="button" onMouseDown={this.onPrevButtonClick} + tabindex={this._previousButtonDisabled ? -1 : 0} + title={this.accInfo.tooltipPrevButton} + aria-label={this.accInfo.ariaLabelPrevButton} + aria-description={this.accInfo.ariaLabelPrevButton} + aria-keyshortcuts={this.accInfo.keyShortcutPrevButton} > @@ -93,6 +98,11 @@ export default function CalendarTemplate(this: Calendar) { part="calendar-header-arrow-button" role="button" onMouseDown={this.onNextButtonClick} + tabindex={this._nextButtonDisabled ? -1 : 0} + title={this.accInfo.tooltipNextButton} + aria-label={this.accInfo.ariaLabelNextButton} + aria-description={this.accInfo.ariaLabelNextButton} + aria-keyshortcuts={this.accInfo.keyShortcutNextButton} > diff --git a/packages/main/src/CalendarTemplate.tsx b/packages/main/src/CalendarTemplate.tsx index edae4e593207..9c7b798181fa 100644 --- a/packages/main/src/CalendarTemplate.tsx +++ b/packages/main/src/CalendarTemplate.tsx @@ -13,6 +13,9 @@ export default function CalendarTemplate(this: Calendar) { class="ui5-cal-root" onKeyDown={this._onkeydown} > +
+ { CalendarHeaderTemplate.call(this) } +
- -
- { CalendarHeaderTemplate.call(this) } -