diff --git a/src/material/datepicker/calendar-header.html b/src/material/datepicker/calendar-header.html index 7570d82e9a02..c520c6507346 100644 --- a/src/material/datepicker/calendar-header.html +++ b/src/material/datepicker/calendar-header.html @@ -2,11 +2,10 @@
@@ -26,3 +25,4 @@
+ diff --git a/src/material/datepicker/calendar-header.spec.ts b/src/material/datepicker/calendar-header.spec.ts index 7c8ed1260d35..e619b2ea9ccf 100644 --- a/src/material/datepicker/calendar-header.spec.ts +++ b/src/material/datepicker/calendar-header.spec.ts @@ -191,10 +191,16 @@ describe('MatCalendarHeader', () => { }); it('should label and describe period button for assistive technology', () => { - const description = periodButton.querySelector('span[id]'); + expect(calendarInstance.currentView).toBe('month'); + + periodButton.click(); + fixture.detectChanges(); + + expect(calendarInstance.currentView).toBe('multi-year'); expect(periodButton.hasAttribute('aria-label')).toBe(true); + expect(periodButton.getAttribute('aria-label')).toMatch(/^[a-z0-9\s]+$/i); expect(periodButton.hasAttribute('aria-describedby')).toBe(true); - expect(periodButton.getAttribute('aria-describedby')).toBe(description?.getAttribute('id')!); + expect(periodButton.getAttribute('aria-describedby')).toMatch(/mat-calendar-header-[0-9]+/i); }); }); diff --git a/src/material/datepicker/calendar.scss b/src/material/datepicker/calendar.scss index dfc182b1a1e5..3c0e989e5784 100644 --- a/src/material/datepicker/calendar.scss +++ b/src/material/datepicker/calendar.scss @@ -139,3 +139,8 @@ $calendar-next-icon-transform: translateX(-2px) rotate(45deg); .mat-calendar-body-cell:focus .mat-focus-indicator::before { content: ''; } + +// Label that is not rendered and removed from the accessibility tree. +.mat-calendar-hidden-label { + display: none; +} diff --git a/src/material/datepicker/calendar.ts b/src/material/datepicker/calendar.ts index 1bf1423d5394..cad50e3721c8 100644 --- a/src/material/datepicker/calendar.ts +++ b/src/material/datepicker/calendar.ts @@ -41,15 +41,14 @@ import { import {MatYearView} from './year-view'; import {MAT_SINGLE_DATE_SELECTION_MODEL_PROVIDER, DateRange} from './date-selection-model'; +let calendarHeaderId = 1; + /** * Possible views for the calendar. * @docs-private */ export type MatCalendarView = 'month' | 'year' | 'multi-year'; -/** Counter used to generate unique IDs. */ -let uniqueId = 0; - /** Default header for MatCalendar */ @Component({ selector: 'mat-calendar-header', @@ -59,8 +58,6 @@ let uniqueId = 0; changeDetection: ChangeDetectionStrategy.OnPush, }) export class MatCalendarHeader { - _buttonDescriptionId = `mat-calendar-button-${uniqueId++}`; - constructor( private _intl: MatDatepickerIntl, @Inject(forwardRef(() => MatCalendar)) public calendar: MatCalendar, @@ -71,7 +68,7 @@ export class MatCalendarHeader { this.calendar.stateChanges.subscribe(() => changeDetectorRef.markForCheck()); } - /** The label for the current calendar view. */ + /** The display text for the current calendar view. */ get periodButtonText(): string { if (this.calendar.currentView == 'month') { return this._dateAdapter @@ -82,28 +79,26 @@ export class MatCalendarHeader { return this._dateAdapter.getYearName(this.calendar.activeDate); } - // The offset from the active year to the "slot" for the starting year is the - // *actual* first rendered year in the multi-year view, and the last year is - // just yearsPerPage - 1 away. - const activeYear = this._dateAdapter.getYear(this.calendar.activeDate); - const minYearOfPage = - activeYear - - getActiveOffset( - this._dateAdapter, - this.calendar.activeDate, - this.calendar.minDate, - this.calendar.maxDate, - ); - const maxYearOfPage = minYearOfPage + yearsPerPage - 1; - const minYearName = this._dateAdapter.getYearName( - this._dateAdapter.createDate(minYearOfPage, 0, 1), - ); - const maxYearName = this._dateAdapter.getYearName( - this._dateAdapter.createDate(maxYearOfPage, 0, 1), - ); - return this._intl.formatYearRange(minYearName, maxYearName); + return this._intl.formatYearRange(...this._formatMinAndMaxYearLabels()); } + /** The aria description for the current calendar view. */ + get periodButtonDescription(): string { + if (this.calendar.currentView == 'month') { + return this._dateAdapter + .format(this.calendar.activeDate, this._dateFormats.display.monthYearLabel) + .toLocaleUpperCase(); + } + if (this.calendar.currentView == 'year') { + return this._dateAdapter.getYearName(this.calendar.activeDate); + } + + // Format a label for the window of years displayed in the multi-year calendar view. Use + // `formatYearRangeLabel` because it is TTS friendly. + return this._intl.formatYearRangeLabel(...this._formatMinAndMaxYearLabels()); + } + + /** The `aria-label` for changing the calendar view. */ get periodButtonLabel(): string { return this.calendar.currentView == 'month' ? this._intl.switchToMultiYearViewLabel @@ -192,6 +187,39 @@ export class MatCalendarHeader { this.calendar.maxDate, ); } + + /** + * Format two individual labels for the minimum year and maximum year available in the multi-year + * calendar view. Returns an array of two strings where the first string is the formatted label + * for the minimum year, and the second string is the formatted label for the maximum year. + */ + private _formatMinAndMaxYearLabels(): [minYearLabel: string, maxYearLabel: string] { + // The offset from the active year to the "slot" for the starting year is the + // *actual* first rendered year in the multi-year view, and the last year is + // just yearsPerPage - 1 away. + const activeYear = this._dateAdapter.getYear(this.calendar.activeDate); + const minYearOfPage = + activeYear - + getActiveOffset( + this._dateAdapter, + this.calendar.activeDate, + this.calendar.minDate, + this.calendar.maxDate, + ); + const maxYearOfPage = minYearOfPage + yearsPerPage - 1; + const minYearLabel = this._dateAdapter.getYearName( + this._dateAdapter.createDate(minYearOfPage, 0, 1), + ); + const maxYearLabel = this._dateAdapter.getYearName( + this._dateAdapter.createDate(maxYearOfPage, 0, 1), + ); + + return [minYearLabel, maxYearLabel]; + } + + private _id = `mat-calendar-header-${calendarHeaderId++}`; + + _periodButtonLabelId = `${this._id}-period-label`; } /** A calendar that is used as part of the datepicker. */ diff --git a/tools/public_api_guard/material/datepicker.md b/tools/public_api_guard/material/datepicker.md index f6e846d768b5..4db59c131111 100644 --- a/tools/public_api_guard/material/datepicker.md +++ b/tools/public_api_guard/material/datepicker.md @@ -291,15 +291,15 @@ export type MatCalendarCellCssClasses = string | string[] | Set | { export class MatCalendarHeader { constructor(_intl: MatDatepickerIntl, calendar: MatCalendar, _dateAdapter: DateAdapter, _dateFormats: MatDateFormats, changeDetectorRef: ChangeDetectorRef); // (undocumented) - _buttonDescriptionId: string; - // (undocumented) calendar: MatCalendar; currentPeriodClicked(): void; get nextButtonLabel(): string; nextClicked(): void; nextEnabled(): boolean; - // (undocumented) + get periodButtonDescription(): string; get periodButtonLabel(): string; + // (undocumented) + _periodButtonLabelId: string; get periodButtonText(): string; get prevButtonLabel(): string; previousClicked(): void;