diff --git a/packages/main/cypress/specs/Calendar.cy.tsx b/packages/main/cypress/specs/Calendar.cy.tsx index f438ca2034b2..60db3bae7491 100644 --- a/packages/main/cypress/specs/Calendar.cy.tsx +++ b/packages/main/cypress/specs/Calendar.cy.tsx @@ -957,3 +957,300 @@ describe("Calendar general interaction", () => { .should("have.text", "Sun"); }); }); + +describe("Calendar accessibility", () => { + 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)); + + cy.get("#calendar1") + .shadow() + .find(".ui5-calheader") + .as("calheader"); + + // Check month button aria-label + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-month]") + .should("have.attr", "aria-label") + .and("contain", "Month November"); + + // Check year button aria-label + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-year]") + .should("have.attr", "aria-label") + .and("contain", "Year 2000"); + }); + + it("Should have proper aria-description attributes on header buttons", () => { + const date = new Date(Date.UTC(2000, 10, 22, 0, 0, 0)); + cy.mount(getDefaultCalendar(date)); + + cy.get("#calendar1") + .shadow() + .find(".ui5-calheader") + .as("calheader"); + + // Check month button aria-description + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-month]") + .should("have.attr", "aria-description") + .and("contain", "Month November"); + + // Check year button aria-description + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-year]") + .should("have.attr", "aria-description") + .and("contain", "Year 2000"); + }); + + it("Should have proper title (tooltip) attributes on header buttons", () => { + const date = new Date(Date.UTC(2000, 10, 22, 0, 0, 0)); + cy.mount(getDefaultCalendar(date)); + + cy.get("#calendar1") + .shadow() + .find(".ui5-calheader") + .as("calheader"); + + // Check month button title includes both label and shortcut + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-month]") + .should("have.attr", "title") + .and("contain", "Month November") + .and("contain", "(F4)"); + + // Check year button title includes both label and shortcut + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-year]") + .should("have.attr", "title") + .and("contain", "Year 2000") + .and("contain", "(Shift + F4)"); + }); + + it("Should have proper aria-keyshortcuts attributes on header buttons", () => { + const date = new Date(Date.UTC(2000, 10, 22, 0, 0, 0)); + cy.mount(getDefaultCalendar(date)); + + cy.get("#calendar1") + .shadow() + .find(".ui5-calheader") + .as("calheader"); + + // Check month button keyboard shortcut + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-month]") + .should("have.attr", "aria-keyshortcuts", "F4"); + + // Check year button keyboard shortcut + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-year]") + .should("have.attr", "aria-keyshortcuts", "Shift + F4"); + }); + + it("Should have proper accessibility attributes on year range button when visible", () => { + const date = new Date(Date.UTC(2000, 10, 22, 0, 0, 0)); + cy.mount(getDefaultCalendar(date)); + + // Navigate to year picker first to make year range button visible + cy.get("#calendar1") + .shadow() + .find("[data-ui5-cal-header-btn-year]") + .realClick(); + + // Now check year range button accessibility attributes + cy.get("#calendar1") + .shadow() + .find(".ui5-calheader") + .find("[data-ui5-cal-header-btn-year-range]") + .as("yearRangeBtn"); + + // Check aria-label for year range + cy.get("@yearRangeBtn") + .should("have.attr", "aria-label") + .and("contain", "Year range from") + .and("contain", "1991") + .and("contain", "to") + .and("contain", "2010"); + + // Check aria-description for year range + cy.get("@yearRangeBtn") + .should("have.attr", "aria-description") + .and("contain", "Year range from") + .and("contain", "1991") + .and("contain", "to") + .and("contain", "2010"); + + // Check title includes both label and shortcut + cy.get("@yearRangeBtn") + .should("have.attr", "title") + .and("contain", "Year range from") + .and("contain", "1991") + .and("contain", "to") + .and("contain", "2010") + .and("contain", "(Shift + F4)"); + + // Check keyboard shortcut + cy.get("@yearRangeBtn") + .should("have.attr", "aria-keyshortcuts", "Shift + F4"); + }); + + it("Should update accessibility attributes when navigating between different months", () => { + const date = new Date(Date.UTC(2000, 10, 22, 0, 0, 0)); // November 2000 + cy.mount(getDefaultCalendar(date)); + + cy.get("#calendar1") + .shadow() + .find(".ui5-calheader") + .as("calheader"); + + // Initial check - November 2000 + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-month]") + .should("have.attr", "aria-label") + .and("contain", "Month November"); + + // Navigate to next month + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-next]") + .realClick(); + + // Check updated aria-label - December 2000 + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-month]") + .should("have.attr", "aria-label") + .and("contain", "Month December"); + + // Navigate to previous month twice + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-prev]") + .realClick() + .realClick(); + + // Check updated aria-label - October 2000 + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-month]") + .should("have.attr", "aria-label") + .and("contain", "Month October"); + }); + + it("Should update accessibility attributes when navigating between different years", () => { + const date = new Date(Date.UTC(2000, 10, 22, 0, 0, 0)); // November 2000 + cy.mount(getDefaultCalendar(date)); + + cy.get("#calendar1") + .shadow() + .find(".ui5-calheader") + .as("calheader"); + + // Initial check - Year 2000 + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-year]") + .should("have.attr", "aria-label") + .and("contain", "Year 2000"); + + // Navigate to day picker and use keyboard shortcuts to change year + cy.get("#calendar1") + .shadow() + .find("[ui5-daypicker]") + .shadow() + .find("[tabindex='0']") + .realClick() + .realPress(["Shift", "PageDown"]); // Next year + + // Check updated aria-label - Year 2001 + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-year]") + .should("have.attr", "aria-label") + .and("contain", "Year 2001"); + + // Navigate back one year + cy.focused() + .realPress(["Shift", "PageUp"]); // Previous year + + // Check updated aria-label - Year 2000 + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-year]") + .should("have.attr", "aria-label") + .and("contain", "Year 2000"); + }); + + it("Should maintain accessibility attributes consistency between primary and secondary calendar types", () => { + const date = new Date(Date.UTC(2000, 10, 22, 0, 0, 0)); + cy.mount( + + + + ); + + cy.get("#calendar1") + .shadow() + .find(".ui5-calheader") + .as("calheader"); + + // Check that month button still has proper aria-label with dual calendar + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-month]") + .should("have.attr", "aria-label") + .and("contain", "Month"); + + // Check that year button still has proper aria-label with dual calendar + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-year]") + .should("have.attr", "aria-label") + .and("contain", "Year"); + + // Verify tooltips still contain shortcuts + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-month]") + .should("have.attr", "title") + .and("contain", "(F4)"); + + cy.get("@calheader") + .find("[data-ui5-cal-header-btn-year]") + .should("have.attr", "title") + .and("contain", "(Shift + F4)"); + }); + + it("Should have proper aria-labels for range selection dates (First, Between, Last)", () => { + // Mount calendar with predefined range selection (Jan 20-22, 2021) similar to Calendar.html + cy.mount( + + + + ); + + // Find all selected day cells using the part attribute + cy.get("#calendar1") + .shadow() + .find("[ui5-daypicker]") + .shadow() + .find("[part*='day-cell-selected']") + .as("selectedDays"); + + // Should have exactly 3 selected days (Jan 20, 21, 22) + cy.get("@selectedDays") + .should("have.length", 3); + + // 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) + .should("have.attr", "aria-label") + .and("contain", "First date of range"); + } else if (index === 1) { + // Middle day should contain "in a selected range" + cy.wrap($day) + .should("have.attr", "aria-label") + .and("contain", "in a selected range"); + } else if (index === 2) { + // Last day should contain "Last date of range" + cy.wrap($day) + .should("have.attr", "aria-label") + .and("contain", "Last date of range"); + } + }); + }); +}); diff --git a/packages/main/src/Calendar.ts b/packages/main/src/Calendar.ts index fbfcba77d49d..75fbb4a26221 100644 --- a/packages/main/src/Calendar.ts +++ b/packages/main/src/Calendar.ts @@ -46,7 +46,14 @@ import CalendarTemplate from "./CalendarTemplate.js"; // Styles import calendarCSS from "./generated/themes/Calendar.css.js"; import CalendarHeaderCss from "./generated/themes/CalendarHeader.css.js"; -import { CALENDAR_HEADER_NEXT_BUTTON, CALENDAR_HEADER_PREVIOUS_BUTTON } from "./generated/i18n/i18n-defaults.js"; +import { + CALENDAR_HEADER_MONTH_BUTTON, + CALENDAR_HEADER_MONTH_BUTTON_SHORTCUT, + CALENDAR_HEADER_YEAR_BUTTON, + CALENDAR_HEADER_YEAR_BUTTON_SHORTCUT, + CALENDAR_HEADER_YEAR_RANGE_BUTTON, + CALENDAR_HEADER_YEAR_RANGE_BUTTON_SHORTCUT, +} from "./generated/i18n/i18n-defaults.js"; import type { YearRangePickerChangeEventDetail } from "./YearRangePicker.js"; interface ICalendarPicker { @@ -506,13 +513,8 @@ class Calendar extends CalendarPart { this._headerYearButtonText = String(yearFormat.format(this._localDate, true)); const currentYearRange = this._currentYearRange; - const rangeStart = new CalendarDateComponent(this._calendarDate, this._primaryCalendarType); - const rangeEnd = new CalendarDateComponent(this._calendarDate, this._primaryCalendarType); - - rangeStart.setYear(currentYearRange.startYear); - rangeEnd.setYear(currentYearRange.endYear); - - this._headerYearRangeButtonText = `${yearFormat.format(rangeStart.toLocalJSDate())} - ${yearFormat.format(rangeEnd.toLocalJSDate())}`; + const { rangeStartText, rangeEndText } = this._formatYearRangeText(currentYearRange); + this._headerYearRangeButtonText = `${rangeStartText} - ${rangeEndText}`; this._secondaryCalendarType && this._setSecondaryCalendarTypeButtonText(); } @@ -594,10 +596,7 @@ class Calendar extends CalendarPart { this._headerYearButtonTextSecType = String(yearFormatSecType.format(this._localDate, true)); const currentYearRange = this._currentYearRange; - const rangeStart = new CalendarDateComponent(this._calendarDate, this._primaryCalendarType); - const rangeEnd = new CalendarDateComponent(this._calendarDate, this._primaryCalendarType); - rangeStart.setYear(currentYearRange.startYear); - rangeEnd.setYear(currentYearRange.endYear); + const { rangeStart, rangeEnd } = this._createYearRangeDates(currentYearRange); const rangeStartSecType = transformDateToSecondaryType(this.primaryCalendarType, this._secondaryCalendarType, rangeStart.valueOf() / 1000, true) .firstDate; @@ -789,18 +788,65 @@ class Calendar extends CalendarPart { } get accInfo() { + const currentYearRange = this._currentYearRange; + const { rangeStartText, rangeEndText } = this._formatYearRangeText(currentYearRange); + + const headerMonthButtonText = this.hasSecondaryCalendarType + ? `${this._headerMonthButtonText}, ${this.secondMonthButtonText}` : `${this._headerMonthButtonText}`; + + // Get base labels + 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); + + // 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); + return { - ariaLabelMonthButton: this.hasSecondaryCalendarType - ? `${this._headerMonthButtonText}, ${this.secondMonthButtonText}` : `${this._headerMonthButtonText}`, + ariaLabelMonthButton: monthLabel, + ariaLabelYearButton: yearLabel, + ariaLabelYearRangeButton: yearRangeLabel, + + // Keyboard shortcuts for aria-keyshortcuts + keyShortcutMonthButton: monthShortcut, + keyShortcutYearButton: yearShortcut, + keyShortcutYearRangeButton: yearRangeShortcut, + + // Tooltips combining label and shortcut + tooltipMonthButton: `${monthLabel} (${monthShortcut})`, + tooltipYearButton: `${yearLabel} (${yearShortcut})`, + tooltipYearRangeButton: `${yearRangeLabel} (${yearRangeShortcut})`, }; } - get headerPreviousButtonText() { - return Calendar.i18nBundle?.getText(CALENDAR_HEADER_PREVIOUS_BUTTON); + /** + * Helper method to create CalendarDateComponent instances for year range + * @private + */ + _createYearRangeDates(yearRange: CalendarYearRangeT, calendarType: string = this._primaryCalendarType) { + const rangeStart = new CalendarDateComponent(this._calendarDate, calendarType); + const rangeEnd = new CalendarDateComponent(this._calendarDate, calendarType); + + rangeStart.setYear(yearRange.startYear); + rangeEnd.setYear(yearRange.endYear); + + return { rangeStart, rangeEnd }; } - get headerNextButtonText() { - return Calendar.i18nBundle?.getText(CALENDAR_HEADER_NEXT_BUTTON); + /** + * Helper method to format year range text + * @private + */ + _formatYearRangeText(yearRange: CalendarYearRangeT) { + const yearFormat = DateFormat.getDateInstance({ format: "y", calendarType: this.primaryCalendarType }); + const { rangeStart, rangeEnd } = this._createYearRangeDates(yearRange, this.primaryCalendarType); + + const rangeStartText = yearFormat.format(rangeStart.toLocalJSDate()); + const rangeEndText = yearFormat.format(rangeEnd.toLocalJSDate()); + + return { rangeStartText, rangeEndText }; } get secondMonthButtonText() { diff --git a/packages/main/src/CalendarHeaderTemplate.tsx b/packages/main/src/CalendarHeaderTemplate.tsx index c04590e8549d..bb8a039ffe44 100644 --- a/packages/main/src/CalendarHeaderTemplate.tsx +++ b/packages/main/src/CalendarHeaderTemplate.tsx @@ -16,7 +16,6 @@ export default function CalendarTemplate(this: Calendar) { part="calendar-header-arrow-button" role="button" onMouseDown={this.onPrevButtonClick} - title={this.headerPreviousButtonText} > @@ -30,6 +29,9 @@ export default function CalendarTemplate(this: Calendar) { tabindex={0} role="button" aria-label={this.accInfo.ariaLabelMonthButton} + aria-description={this.accInfo.ariaLabelMonthButton} + title={this.accInfo.tooltipMonthButton} + aria-keyshortcuts={this.accInfo.keyShortcutMonthButton} onClick={this.onHeaderShowMonthPress} onKeyDown={this.onMonthButtonKeyDown} onKeyUp={this.onMonthButtonKeyUp} @@ -47,9 +49,13 @@ export default function CalendarTemplate(this: Calendar) { hidden={this._isHeaderYearButtonHidden} tabindex={0} role="button" + aria-label={this.accInfo.ariaLabelYearButton} + aria-description={this.accInfo.ariaLabelYearButton} onClick={this.onHeaderShowYearPress} onKeyDown={this.onYearButtonKeyDown} onKeyUp={this.onYearButtonKeyUp} + title={this.accInfo.tooltipYearButton} + aria-keyshortcuts={this.accInfo.keyShortcutYearButton} > {this._headerYearButtonText} {this.hasSecondaryCalendarType && @@ -63,6 +69,10 @@ export default function CalendarTemplate(this: Calendar) { hidden={this._isHeaderYearRangeButtonHidden} tabindex={0} role="button" + aria-label={this.accInfo.ariaLabelYearRangeButton} + aria-description={this.accInfo.ariaLabelYearRangeButton} + title={this.accInfo.tooltipYearRangeButton} + aria-keyshortcuts={this.accInfo.keyShortcutYearRangeButton} onClick={this.onHeaderShowYearRangePress} onKeyDown={this.onYearRangeButtonKeyDown} onKeyUp={this.onYearRangeButtonKeyUp} @@ -83,7 +93,6 @@ export default function CalendarTemplate(this: Calendar) { part="calendar-header-arrow-button" role="button" onMouseDown={this.onNextButtonClick} - title={this.headerNextButtonText} > diff --git a/packages/main/src/DayPicker.ts b/packages/main/src/DayPicker.ts index 3c9af1711ae0..d8adcc0e7d0c 100644 --- a/packages/main/src/DayPicker.ts +++ b/packages/main/src/DayPicker.ts @@ -48,6 +48,9 @@ import { DAY_PICKER_NON_WORKING_DAY, DAY_PICKER_TODAY, LIST_ITEM_SELECTED, + DAY_PICKER_SELECTED_RANGE_START, + DAY_PICKER_SELECTED_RANGE_END, + DAY_PICKER_SELECTED_RANGE_BETWEEN, } from "./generated/i18n/i18n-defaults.js"; // Template @@ -266,10 +269,20 @@ class DayPicker extends CalendarPart implements ICalendarPicker { const tooltip = `${todayAriaLabel}${nonWorkingAriaLabel}${unnamedCalendarTypeLabel}`.trim(); - const ariaLabel = this.hasSecondaryCalendarType + let ariaLabel = this.hasSecondaryCalendarType ? `${monthsNames[tempDate.getMonth()]} ${tempDate.getDate()}, ${tempDate.getYear()}; ${secondaryMonthsNamesString} ${tempSecondDateNumber}, ${tempSecondYearNumber} ${tooltip}`.trim() : `${monthsNames[tempDate.getMonth()]} ${tempDate.getDate()}, ${tempDate.getYear()} ${tooltip}`.trim(); + if (this.selectionMode === CalendarSelectionMode.Range) { + if (isSelected && this._isRangeEndDate(timestamp)) { + ariaLabel = DayPicker.i18nBundle.getText(DAY_PICKER_SELECTED_RANGE_END, ariaLabel); + } else if (isSelected && this._isRangeStartDate(timestamp)) { + ariaLabel = DayPicker.i18nBundle.getText(DAY_PICKER_SELECTED_RANGE_START, ariaLabel); + } else if (isSelectedBetween) { + ariaLabel = DayPicker.i18nBundle.getText(DAY_PICKER_SELECTED_RANGE_BETWEEN, ariaLabel); + } + } + const day: Day = { timestamp: timestamp.toString(), focusRef: isFocused, @@ -446,6 +459,14 @@ class DayPicker extends CalendarPart implements ICalendarPicker { return timestamp === this.selectedDates[0] || timestamp === this.selectedDates[this.selectedDates.length - 1]; } + _isRangeEndDate(timestamp: number): boolean { + return this.selectionMode === CalendarSelectionMode.Range && timestamp === this.selectedDates[1]; + } + + _isRangeStartDate(timestamp: number): boolean { + return this.selectionMode === CalendarSelectionMode.Range && timestamp === this.selectedDates[0]; + } + /** * Tells if the day is inside a selection range (light blue). * @param timestamp diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties index bb9408a1aad9..ebbfc33314a8 100644 --- a/packages/main/src/i18n/messagebundle.properties +++ b/packages/main/src/i18n/messagebundle.properties @@ -592,11 +592,32 @@ VALUE_STATE_LINKS=To go to the first link, press Ctrl+Alt+F8. To move to the nex #XACT: ARIA announcement for keyboard shortcut to value state multiple links on Mac VALUE_STATE_LINKS_MAC=To go to the first link, press Cmd+Option+F8. To move to the next link, use Tab -#XBUT: Tooltip text for 'Next' button in the Calendar Header -CALENDAR_HEADER_NEXT_BUTTON = Next +#XACT: ARIA label for month button in the Calendar Header +CALENDAR_HEADER_MONTH_BUTTON = Month {0} -#XBUT: Tooltip text for 'Previous' button in the Calendar Header -CALENDAR_HEADER_PREVIOUS_BUTTON = Previous +#XACT: ARIA label for year button in the Calendar Header +CALENDAR_HEADER_YEAR_BUTTON = Year {0} + +#XACT: ARIA label for year range button in the Calendar Header +CALENDAR_HEADER_YEAR_RANGE_BUTTON = Year range from {0} to {1} + +#XACT: Keyboard shortcut for month button in the Calendar Header +CALENDAR_HEADER_MONTH_BUTTON_SHORTCUT = F4 + +#XACT: Keyboard shortcut for year button in the Calendar Header +CALENDAR_HEADER_YEAR_BUTTON_SHORTCUT = Shift + F4 + +#XACT: Keyboard shortcut for year range button in the Calendar Header +CALENDAR_HEADER_YEAR_RANGE_BUTTON_SHORTCUT = Shift + F4 + +#XACT: ARIA label for day picker selected range start +DAY_PICKER_SELECTED_RANGE_START = {0} First date of range + +#XACT: ARIA label for day picker selected range between dates +DAY_PICKER_SELECTED_RANGE_BETWEEN = {0} in a selected range + +#XACT: ARIA label for day picker selected range end +DAY_PICKER_SELECTED_RANGE_END = {0} Last date of range #XBUT: Text for 'Week number' in the DayPicker DAY_PICKER_WEEK_NUMBER_TEXT = Week number