From 689c0ce9e059cbab91143f963981db96ff132532 Mon Sep 17 00:00:00 2001 From: Boyan Rakilovski Date: Wed, 13 Aug 2025 10:29:40 +0300 Subject: [PATCH 1/5] test(ui5-calendar): improve header announcements --- packages/main/src/Calendar.ts | 52 ++++++++++++++++--- packages/main/src/CalendarHeaderTemplate.tsx | 25 ++++++++- packages/main/src/DayPicker.ts | 32 +++++++++++- .../main/src/i18n/messagebundle.properties | 38 ++++++++++++-- 4 files changed, 133 insertions(+), 14 deletions(-) diff --git a/packages/main/src/Calendar.ts b/packages/main/src/Calendar.ts index e65249a7298a..48c8609275c1 100644 --- a/packages/main/src/Calendar.ts +++ b/packages/main/src/Calendar.ts @@ -46,7 +46,17 @@ 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_NEXT_MONTH_BUTTON, + CALENDAR_HEADER_PREVIOUS_MONTH_BUTTON, + CALENDAR_HEADER_NEXT_YEAR_BUTTON, + CALENDAR_HEADER_PREVIOUS_YEAR_BUTTON, + CALENDAR_HEADER_NEXT_YEAR_RANGE_BUTTON, + CALENDAR_HEADER_PREVIOUS_YEAR_RANGE_BUTTON, + CALENDAR_HEADER_MONTH_BUTTON, + CALENDAR_HEADER_YEAR_BUTTON, + CALENDAR_HEADER_YEAR_RANGE_BUTTON, +} from "./generated/i18n/i18n-defaults.js"; import type { YearRangePickerChangeEventDetail } from "./YearRangePicker.js"; interface ICalendarPicker { @@ -787,18 +797,46 @@ class Calendar extends CalendarPart { } get accInfo() { + const currentYearRange = this._currentYearRange; + const rangeStart = new CalendarDateComponent(this._calendarDate, this._primaryCalendarType); + const rangeEnd = new CalendarDateComponent(this._calendarDate, this._primaryCalendarType); + const yearFormat = DateFormat.getDateInstance({ format: "y", calendarType: this.primaryCalendarType }); + + rangeStart.setYear(currentYearRange.startYear); + rangeEnd.setYear(currentYearRange.endYear); + + const rangeStartText = yearFormat.format(rangeStart.toLocalJSDate()); + const rangeEndText = yearFormat.format(rangeEnd.toLocalJSDate()); + + const ariaLabelMonthButtonText = this.hasSecondaryCalendarType + ? `${this._headerMonthButtonText}, ${this.secondMonthButtonText}` : `${this._headerMonthButtonText}`; return { - ariaLabelMonthButton: this.hasSecondaryCalendarType - ? `${this._headerMonthButtonText}, ${this.secondMonthButtonText}` : `${this._headerMonthButtonText}`, + ariaLabelMonthButton: Calendar.i18nBundle?.getText(CALENDAR_HEADER_MONTH_BUTTON, ariaLabelMonthButtonText), + ariaLabelYearButton: Calendar.i18nBundle?.getText(CALENDAR_HEADER_YEAR_BUTTON, this._headerYearButtonText as string), + ariaLabelYearRangeButton: Calendar.i18nBundle?.getText(CALENDAR_HEADER_YEAR_RANGE_BUTTON, rangeStartText, rangeEndText), }; } - get headerPreviousButtonText() { - return Calendar.i18nBundle?.getText(CALENDAR_HEADER_PREVIOUS_BUTTON); + get headerPreviousButtonTitle() { + switch (this._currentPicker) { + case "day": + return Calendar.i18nBundle?.getText(CALENDAR_HEADER_PREVIOUS_MONTH_BUTTON); + case "month": + return Calendar.i18nBundle?.getText(CALENDAR_HEADER_PREVIOUS_YEAR_BUTTON); + case "year": + return Calendar.i18nBundle?.getText(CALENDAR_HEADER_PREVIOUS_YEAR_RANGE_BUTTON); + } } - get headerNextButtonText() { - return Calendar.i18nBundle?.getText(CALENDAR_HEADER_NEXT_BUTTON); + get headerNextButtonTitle() { + switch (this._currentPicker) { + case "day": + return Calendar.i18nBundle?.getText(CALENDAR_HEADER_NEXT_MONTH_BUTTON); + case "month": + return Calendar.i18nBundle?.getText(CALENDAR_HEADER_NEXT_YEAR_BUTTON); + case "year": + return Calendar.i18nBundle?.getText(CALENDAR_HEADER_NEXT_YEAR_RANGE_BUTTON); + } } get secondMonthButtonText() { diff --git a/packages/main/src/CalendarHeaderTemplate.tsx b/packages/main/src/CalendarHeaderTemplate.tsx index 6c3ea36c9701..dac5f973f82b 100644 --- a/packages/main/src/CalendarHeaderTemplate.tsx +++ b/packages/main/src/CalendarHeaderTemplate.tsx @@ -10,12 +10,17 @@ export default function CalendarTemplate(this: Calendar) {
@@ -29,6 +34,9 @@ export default function CalendarTemplate(this: Calendar) { tabindex={0} role="button" aria-label={this.accInfo.ariaLabelMonthButton} + aria-description={this.accInfo.ariaLabelMonthButton} + title="Select Month (F4)" + aria-keyshortcuts="F4" onClick={this.onHeaderShowMonthPress} onKeyDown={this.onMonthButtonKeyDown} onKeyUp={this.onMonthButtonKeyUp} @@ -46,9 +54,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="Select Year (Shift + F4)" + aria-keyshortcuts="SHIFT + F4" > {this._headerYearButtonText} {this.hasSecondaryCalendarType && @@ -62,6 +74,10 @@ export default function CalendarTemplate(this: Calendar) { hidden={this._isHeaderYearRangeButtonHidden} tabindex={0} role="button" + aria-label={this.accInfo.ariaLabelYearButton} + aria-description={this.accInfo.ariaLabelYearButton} + title="Select Year Range (Shift + F4)" + aria-keyshortcuts="SHIFT + F4" onClick={this.onHeaderShowYearRangePress} onKeyDown={this.onYearRangeButtonKeyDown} onKeyUp={this.onYearRangeButtonKeyUp} @@ -76,12 +92,17 @@ export default function CalendarTemplate(this: Calendar) {
diff --git a/packages/main/src/DayPicker.ts b/packages/main/src/DayPicker.ts index 3c9af1711ae0..0a6f096f84f9 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 @@ -55,6 +58,7 @@ import DayPickerTemplate from "./DayPickerTemplate.js"; // Styles import dayPickerCSS from "./generated/themes/DayPicker.css.js"; +import Calendar from "./Calendar.js"; const isBetween = (x: number, num1: number, num2: number) => x > Math.min(num1, num2) && x < Math.max(num1, num2); const DAYS_IN_WEEK = 7; @@ -266,10 +270,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 +460,22 @@ class DayPicker extends CalendarPart implements ICalendarPicker { return timestamp === this.selectedDates[0] || timestamp === this.selectedDates[this.selectedDates.length - 1]; } + _isRangeEndDate(timestamp: number): boolean { + if (this.selectionMode === CalendarSelectionMode.Range) { + return timestamp === this.selectedDates[1]; + } + + return false; + } + + _isRangeStartDate(timestamp: number): boolean { + if (this.selectionMode === CalendarSelectionMode.Range) { + return timestamp === this.selectedDates[0]; + } + + return false; + } + /** * 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 8c586cc6f69a..e0e32d2777ac 100644 --- a/packages/main/src/i18n/messagebundle.properties +++ b/packages/main/src/i18n/messagebundle.properties @@ -592,11 +592,41 @@ 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 +#XTOL: Tooltip text for 'Next' button in the Calendar Header +CALENDAR_HEADER_NEXT_MONTH_BUTTON = Next month (Pagedown) -#XBUT: Tooltip text for 'Previous' button in the Calendar Header -CALENDAR_HEADER_PREVIOUS_BUTTON = Previous +#XTOL: Tooltip text for 'Previous' button in the Calendar Header +CALENDAR_HEADER_PREVIOUS_MONTH_BUTTON = Previous month (Pageup) + +#XTOL: Tooltip text for 'Next' button in the Calendar Header +CALENDAR_HEADER_NEXT_YEAR_BUTTON = Next year (Pagedown) + +#XTOL: Tooltip text for 'Previous' button in the Calendar Header +CALENDAR_HEADER_PREVIOUS_YEAR_BUTTON = Previous year (Pageup) + +#XTOL: Tooltip text for 'Next' button in the Calendar Header +CALENDAR_HEADER_NEXT_YEAR_RANGE_BUTTON = Next year range (Pagedown) + +#XTOL: Tooltip text for 'Previous' button in the Calendar Header +CALENDAR_HEADER_PREVIOUS_YEAR_RANGE_BUTTON = Previous year range (Pageup) + +#XTOL: Tooltip text for 'Previous' button in the Calendar Header +CALENDAR_HEADER_MONTH_BUTTON = Month {0} + +#XTOL: Tooltip text for 'Previous' button in the Calendar Header +CALENDAR_HEADER_YEAR_BUTTON = Year {0} + +#XTOL: Tooltip text for 'Previous' button in the Calendar Header +CALENDAR_HEADER_YEAR_RANGE_BUTTON = Year range from {0} to {1} + +#XACT: ARIA label for slider tooltip input +DAY_PICKER_SELECTED_RANGE_START = {0} First date of range + +#XACT: ARIA label for slider tooltip input +DAY_PICKER_SELECTED_RANGE_BETWEEN = {0} in a selected range + +#XACT: ARIA label for slider tooltip input +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 From 5fe890acdda5801c42afda13e4d8ceca2551cec8 Mon Sep 17 00:00:00 2001 From: Boyan Rakilovski Date: Thu, 4 Sep 2025 03:09:10 +0100 Subject: [PATCH 2/5] fix(ui5-calendar): improve a11y announcemnts and tooltips --- packages/main/cypress/specs/Calendar.cy.tsx | 254 ++++++++++++++++++ packages/main/src/Calendar.ts | 103 +++---- packages/main/src/CalendarHeaderTemplate.tsx | 28 +- packages/main/src/DayPicker.ts | 1 - .../main/src/i18n/messagebundle.properties | 42 +-- 5 files changed, 339 insertions(+), 89 deletions(-) diff --git a/packages/main/cypress/specs/Calendar.cy.tsx b/packages/main/cypress/specs/Calendar.cy.tsx index f438ca2034b2..e1878d27cb30 100644 --- a/packages/main/cypress/specs/Calendar.cy.tsx +++ b/packages/main/cypress/specs/Calendar.cy.tsx @@ -957,3 +957,257 @@ 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)"); + }); +}); diff --git a/packages/main/src/Calendar.ts b/packages/main/src/Calendar.ts index 1ec23b853acd..b8e7280da534 100644 --- a/packages/main/src/Calendar.ts +++ b/packages/main/src/Calendar.ts @@ -47,15 +47,12 @@ import CalendarTemplate from "./CalendarTemplate.js"; import calendarCSS from "./generated/themes/Calendar.css.js"; import CalendarHeaderCss from "./generated/themes/CalendarHeader.css.js"; import { - CALENDAR_HEADER_NEXT_MONTH_BUTTON, - CALENDAR_HEADER_PREVIOUS_MONTH_BUTTON, - CALENDAR_HEADER_NEXT_YEAR_BUTTON, - CALENDAR_HEADER_PREVIOUS_YEAR_BUTTON, - CALENDAR_HEADER_NEXT_YEAR_RANGE_BUTTON, - CALENDAR_HEADER_PREVIOUS_YEAR_RANGE_BUTTON, 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"; @@ -516,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(); } @@ -604,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; @@ -800,45 +789,65 @@ class Calendar extends CalendarPart { get accInfo() { const currentYearRange = this._currentYearRange; - const rangeStart = new CalendarDateComponent(this._calendarDate, this._primaryCalendarType); - const rangeEnd = new CalendarDateComponent(this._calendarDate, this._primaryCalendarType); - const yearFormat = DateFormat.getDateInstance({ format: "y", calendarType: this.primaryCalendarType }); + const { rangeStartText, rangeEndText } = this._formatYearRangeText(currentYearRange); - rangeStart.setYear(currentYearRange.startYear); - rangeEnd.setYear(currentYearRange.endYear); + const headerMonthButtonText = this.hasSecondaryCalendarType + ? `${this._headerMonthButtonText}, ${this.secondMonthButtonText}` : `${this._headerMonthButtonText}`; - const rangeStartText = yearFormat.format(rangeStart.toLocalJSDate()); - const rangeEndText = yearFormat.format(rangeEnd.toLocalJSDate()); + // 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); - const ariaLabelMonthButtonText = this.hasSecondaryCalendarType - ? `${this._headerMonthButtonText}, ${this.secondMonthButtonText}` : `${this._headerMonthButtonText}`; return { - ariaLabelMonthButton: Calendar.i18nBundle?.getText(CALENDAR_HEADER_MONTH_BUTTON, ariaLabelMonthButtonText), - ariaLabelYearButton: Calendar.i18nBundle?.getText(CALENDAR_HEADER_YEAR_BUTTON, this._headerYearButtonText as string), - ariaLabelYearRangeButton: Calendar.i18nBundle?.getText(CALENDAR_HEADER_YEAR_RANGE_BUTTON, rangeStartText, rangeEndText), + 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 headerPreviousButtonTitle() { - switch (this._currentPicker) { - case "day": - return Calendar.i18nBundle?.getText(CALENDAR_HEADER_PREVIOUS_MONTH_BUTTON); - case "month": - return Calendar.i18nBundle?.getText(CALENDAR_HEADER_PREVIOUS_YEAR_BUTTON); - case "year": - return Calendar.i18nBundle?.getText(CALENDAR_HEADER_PREVIOUS_YEAR_RANGE_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 headerNextButtonTitle() { - switch (this._currentPicker) { - case "day": - return Calendar.i18nBundle?.getText(CALENDAR_HEADER_NEXT_MONTH_BUTTON); - case "month": - return Calendar.i18nBundle?.getText(CALENDAR_HEADER_NEXT_YEAR_BUTTON); - case "year": - return Calendar.i18nBundle?.getText(CALENDAR_HEADER_NEXT_YEAR_RANGE_BUTTON); - } + /** + * Helper method to format year range text + * @private + */ + _formatYearRangeText(yearRange: CalendarYearRangeT, calendarType?: string) { + const actualCalendarType = calendarType || this.primaryCalendarType; + const yearFormat = DateFormat.getDateInstance({ format: "y", calendarType: actualCalendarType as any }); + const { rangeStart, rangeEnd } = this._createYearRangeDates(yearRange, actualCalendarType); + + 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 e81e97244a72..bb8a039ffe44 100644 --- a/packages/main/src/CalendarHeaderTemplate.tsx +++ b/packages/main/src/CalendarHeaderTemplate.tsx @@ -10,18 +10,12 @@ export default function CalendarTemplate(this: Calendar) {
@@ -36,8 +30,8 @@ export default function CalendarTemplate(this: Calendar) { role="button" aria-label={this.accInfo.ariaLabelMonthButton} aria-description={this.accInfo.ariaLabelMonthButton} - title="Select Month (F4)" - aria-keyshortcuts="F4" + title={this.accInfo.tooltipMonthButton} + aria-keyshortcuts={this.accInfo.keyShortcutMonthButton} onClick={this.onHeaderShowMonthPress} onKeyDown={this.onMonthButtonKeyDown} onKeyUp={this.onMonthButtonKeyUp} @@ -60,8 +54,8 @@ export default function CalendarTemplate(this: Calendar) { onClick={this.onHeaderShowYearPress} onKeyDown={this.onYearButtonKeyDown} onKeyUp={this.onYearButtonKeyUp} - title="Select Year (Shift + F4)" - aria-keyshortcuts="SHIFT + F4" + title={this.accInfo.tooltipYearButton} + aria-keyshortcuts={this.accInfo.keyShortcutYearButton} > {this._headerYearButtonText} {this.hasSecondaryCalendarType && @@ -75,10 +69,10 @@ export default function CalendarTemplate(this: Calendar) { hidden={this._isHeaderYearRangeButtonHidden} tabindex={0} role="button" - aria-label={this.accInfo.ariaLabelYearButton} - aria-description={this.accInfo.ariaLabelYearButton} - title="Select Year Range (Shift + F4)" - aria-keyshortcuts="SHIFT + F4" + 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} @@ -93,18 +87,12 @@ export default function CalendarTemplate(this: Calendar) {
diff --git a/packages/main/src/DayPicker.ts b/packages/main/src/DayPicker.ts index 0a6f096f84f9..2a8cfa033564 100644 --- a/packages/main/src/DayPicker.ts +++ b/packages/main/src/DayPicker.ts @@ -58,7 +58,6 @@ import DayPickerTemplate from "./DayPickerTemplate.js"; // Styles import dayPickerCSS from "./generated/themes/DayPicker.css.js"; -import Calendar from "./Calendar.js"; const isBetween = (x: number, num1: number, num2: number) => x > Math.min(num1, num2) && x < Math.max(num1, num2); const DAYS_IN_WEEK = 7; diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties index 34b8522fde52..c73ee2bf8300 100644 --- a/packages/main/src/i18n/messagebundle.properties +++ b/packages/main/src/i18n/messagebundle.properties @@ -592,40 +592,40 @@ 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 -#XTOL: Tooltip text for 'Next' button in the Calendar Header -CALENDAR_HEADER_NEXT_MONTH_BUTTON = Next month (Pagedown) +#XACT: ARIA label for month button in the Calendar Header +CALENDAR_HEADER_MONTH_BUTTON = Month {0} -#XTOL: Tooltip text for 'Previous' button in the Calendar Header -CALENDAR_HEADER_PREVIOUS_MONTH_BUTTON = Previous month (Pageup) +#XTOL: Tooltip text for month button in the Calendar Header +CALENDAR_HEADER_MONTH_BUTTON_TOOLTIP = Month {0} -#XTOL: Tooltip text for 'Next' button in the Calendar Header -CALENDAR_HEADER_NEXT_YEAR_BUTTON = Next year (Pagedown) +#XACT: ARIA label for year button in the Calendar Header +CALENDAR_HEADER_YEAR_BUTTON = Year {0} -#XTOL: Tooltip text for 'Previous' button in the Calendar Header -CALENDAR_HEADER_PREVIOUS_YEAR_BUTTON = Previous year (Pageup) +#XTOL: Tooltip text for year button in the Calendar Header +CALENDAR_HEADER_YEAR_BUTTON_TOOLTIP = Year {0} -#XTOL: Tooltip text for 'Next' button in the Calendar Header -CALENDAR_HEADER_NEXT_YEAR_RANGE_BUTTON = Next year range (Pagedown) +#XACT: ARIA label for year range button in the Calendar Header +CALENDAR_HEADER_YEAR_RANGE_BUTTON = Year range from {0} to {1} -#XTOL: Tooltip text for 'Previous' button in the Calendar Header -CALENDAR_HEADER_PREVIOUS_YEAR_RANGE_BUTTON = Previous year range (Pageup) +#XTOL: Tooltip text for year range button in the Calendar Header +CALENDAR_HEADER_YEAR_RANGE_BUTTON_TOOLTIP = Year range from {0} to {1} -#XTOL: Tooltip text for 'Previous' button in the Calendar Header -CALENDAR_HEADER_MONTH_BUTTON = Month {0} +#XACT: Keyboard shortcut for month button in the Calendar Header +CALENDAR_HEADER_MONTH_BUTTON_SHORTCUT = F4 -#XTOL: Tooltip text for 'Previous' button in the Calendar Header -CALENDAR_HEADER_YEAR_BUTTON = Year {0} +#XACT: Keyboard shortcut for year button in the Calendar Header +CALENDAR_HEADER_YEAR_BUTTON_SHORTCUT = Shift + F4 -#XTOL: Tooltip text for 'Previous' button in the Calendar Header -CALENDAR_HEADER_YEAR_RANGE_BUTTON = Year range from {0} to {1} +#XACT: Keyboard shortcut for year range button in the Calendar Header +CALENDAR_HEADER_YEAR_RANGE_BUTTON_SHORTCUT = Shift + F4 -#XACT: ARIA label for slider tooltip input +#XACT: ARIA label for day picker selected range start DAY_PICKER_SELECTED_RANGE_START = {0} First date of range -#XACT: ARIA label for slider tooltip input +#XACT: ARIA label for day picker selected range between dates DAY_PICKER_SELECTED_RANGE_BETWEEN = {0} in a selected range -#XACT: ARIA label for slider tooltip input +#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 From f51efdb5ff02a3f06654f34e81437979dcbd3f1e Mon Sep 17 00:00:00 2001 From: Boyan Rakilovski Date: Thu, 4 Sep 2025 09:37:43 +0100 Subject: [PATCH 3/5] fix: add range selection labels test --- packages/main/cypress/specs/Calendar.cy.tsx | 43 +++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/main/cypress/specs/Calendar.cy.tsx b/packages/main/cypress/specs/Calendar.cy.tsx index e1878d27cb30..60db3bae7491 100644 --- a/packages/main/cypress/specs/Calendar.cy.tsx +++ b/packages/main/cypress/specs/Calendar.cy.tsx @@ -1210,4 +1210,47 @@ describe("Calendar accessibility", () => { .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"); + } + }); + }); }); From e1dc2d283ddb400d4c580668ceeb71efd862ec48 Mon Sep 17 00:00:00 2001 From: Boyan Rakilovski Date: Fri, 5 Sep 2025 11:58:05 +0100 Subject: [PATCH 4/5] fix: remove redundant message bundle keys --- packages/main/src/i18n/messagebundle.properties | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties index c73ee2bf8300..ebbfc33314a8 100644 --- a/packages/main/src/i18n/messagebundle.properties +++ b/packages/main/src/i18n/messagebundle.properties @@ -595,21 +595,12 @@ VALUE_STATE_LINKS_MAC=To go to the first link, press Cmd+Option+F8. To move to t #XACT: ARIA label for month button in the Calendar Header CALENDAR_HEADER_MONTH_BUTTON = Month {0} -#XTOL: Tooltip text for month button in the Calendar Header -CALENDAR_HEADER_MONTH_BUTTON_TOOLTIP = Month {0} - #XACT: ARIA label for year button in the Calendar Header CALENDAR_HEADER_YEAR_BUTTON = Year {0} -#XTOL: Tooltip text for year button in the Calendar Header -CALENDAR_HEADER_YEAR_BUTTON_TOOLTIP = Year {0} - #XACT: ARIA label for year range button in the Calendar Header CALENDAR_HEADER_YEAR_RANGE_BUTTON = Year range from {0} to {1} -#XTOL: Tooltip text for year range button in the Calendar Header -CALENDAR_HEADER_YEAR_RANGE_BUTTON_TOOLTIP = Year range from {0} to {1} - #XACT: Keyboard shortcut for month button in the Calendar Header CALENDAR_HEADER_MONTH_BUTTON_SHORTCUT = F4 From 7294f97afd5ba8dffcd43bb63bfbb0319a725af3 Mon Sep 17 00:00:00 2001 From: Boyan Rakilovski Date: Wed, 10 Sep 2025 23:34:36 +0300 Subject: [PATCH 5/5] fix: resolve comments --- packages/main/src/Calendar.ts | 7 +++---- packages/main/src/DayPicker.ts | 12 ++---------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/main/src/Calendar.ts b/packages/main/src/Calendar.ts index b8e7280da534..75fbb4a26221 100644 --- a/packages/main/src/Calendar.ts +++ b/packages/main/src/Calendar.ts @@ -839,10 +839,9 @@ class Calendar extends CalendarPart { * Helper method to format year range text * @private */ - _formatYearRangeText(yearRange: CalendarYearRangeT, calendarType?: string) { - const actualCalendarType = calendarType || this.primaryCalendarType; - const yearFormat = DateFormat.getDateInstance({ format: "y", calendarType: actualCalendarType as any }); - const { rangeStart, rangeEnd } = this._createYearRangeDates(yearRange, actualCalendarType); + _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()); diff --git a/packages/main/src/DayPicker.ts b/packages/main/src/DayPicker.ts index 2a8cfa033564..d8adcc0e7d0c 100644 --- a/packages/main/src/DayPicker.ts +++ b/packages/main/src/DayPicker.ts @@ -460,19 +460,11 @@ class DayPicker extends CalendarPart implements ICalendarPicker { } _isRangeEndDate(timestamp: number): boolean { - if (this.selectionMode === CalendarSelectionMode.Range) { - return timestamp === this.selectedDates[1]; - } - - return false; + return this.selectionMode === CalendarSelectionMode.Range && timestamp === this.selectedDates[1]; } _isRangeStartDate(timestamp: number): boolean { - if (this.selectionMode === CalendarSelectionMode.Range) { - return timestamp === this.selectedDates[0]; - } - - return false; + return this.selectionMode === CalendarSelectionMode.Range && timestamp === this.selectedDates[0]; } /**