Skip to content

Commit 869537a

Browse files
committed
fix(material/datepicker): announce the "to" when reading year range
For the period button, announce the "to" when reading year range. When in multi-year view, some screen readers would announce the period button as "2019 2020". Add `formatYearRangeLabel` intl method to announce period button description as "2019 to 2020". Fixes #23467.
1 parent 25dcb36 commit 869537a

File tree

5 files changed

+64
-32
lines changed

5 files changed

+64
-32
lines changed

src/material/datepicker/calendar-header.html

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
<div class="mat-calendar-controls">
33
<button mat-button type="button" class="mat-calendar-period-button"
44
(click)="currentPeriodClicked()" [attr.aria-label]="periodButtonLabel"
5-
[attr.aria-describedby]="_buttonDescriptionId"
6-
aria-live="polite">
7-
<span [attr.id]="_buttonDescriptionId">{{periodButtonText}}</span>
5+
[attr.aria-description]="periodButtonDescription" aria-live="polite">
6+
<span aria-hidden="true">
7+
{{periodButtonText}}
8+
</span>
89
<svg class="mat-calendar-arrow" [class.mat-calendar-invert]="calendar.currentView !== 'month'"
9-
viewBox="0 0 10 5" focusable="false">
10+
viewBox="0 0 10 5" focusable="false" aria-hidden="true">
1011
<polygon points="0,0 5,5 10,0"/>
1112
</svg>
1213
</button>

src/material/datepicker/calendar-header.spec.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,16 @@ describe('MatCalendarHeader', () => {
191191
});
192192

193193
it('should label and describe period button for assistive technology', () => {
194-
const description = periodButton.querySelector('span[id]');
194+
expect(calendarInstance.currentView).toBe('month');
195+
196+
periodButton.click();
197+
fixture.detectChanges();
198+
199+
expect(calendarInstance.currentView).toBe('multi-year');
195200
expect(periodButton.hasAttribute('aria-label')).toBe(true);
196-
expect(periodButton.hasAttribute('aria-describedby')).toBe(true);
197-
expect(periodButton.getAttribute('aria-describedby')).toBe(description?.getAttribute('id')!);
201+
expect(periodButton.getAttribute('aria-label')).toMatch(/^[a-z0-9\s]+$/i);
202+
expect(periodButton.hasAttribute('aria-description')).toBe(true);
203+
expect(periodButton.getAttribute('aria-description')).toMatch(/^[a-z0-9\s]+$/i);
198204
});
199205
});
200206

src/material/datepicker/calendar.ts

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,6 @@ let uniqueId = 0;
5959
changeDetection: ChangeDetectionStrategy.OnPush,
6060
})
6161
export class MatCalendarHeader<D> {
62-
_buttonDescriptionId = `mat-calendar-button-${uniqueId++}`;
63-
6462
constructor(
6563
private _intl: MatDatepickerIntl,
6664
@Inject(forwardRef(() => MatCalendar)) public calendar: MatCalendar<D>,
@@ -71,7 +69,7 @@ export class MatCalendarHeader<D> {
7169
this.calendar.stateChanges.subscribe(() => changeDetectorRef.markForCheck());
7270
}
7371

74-
/** The label for the current calendar view. */
72+
/** The display text for the current calendar view. */
7573
get periodButtonText(): string {
7674
if (this.calendar.currentView == 'month') {
7775
return this._dateAdapter
@@ -82,28 +80,25 @@ export class MatCalendarHeader<D> {
8280
return this._dateAdapter.getYearName(this.calendar.activeDate);
8381
}
8482

85-
// The offset from the active year to the "slot" for the starting year is the
86-
// *actual* first rendered year in the multi-year view, and the last year is
87-
// just yearsPerPage - 1 away.
88-
const activeYear = this._dateAdapter.getYear(this.calendar.activeDate);
89-
const minYearOfPage =
90-
activeYear -
91-
getActiveOffset(
92-
this._dateAdapter,
93-
this.calendar.activeDate,
94-
this.calendar.minDate,
95-
this.calendar.maxDate,
96-
);
97-
const maxYearOfPage = minYearOfPage + yearsPerPage - 1;
98-
const minYearName = this._dateAdapter.getYearName(
99-
this._dateAdapter.createDate(minYearOfPage, 0, 1),
100-
);
101-
const maxYearName = this._dateAdapter.getYearName(
102-
this._dateAdapter.createDate(maxYearOfPage, 0, 1),
103-
);
83+
const [minYearName, maxYearName] = this._getMinMaxYearNames();
10484
return this._intl.formatYearRange(minYearName, maxYearName);
10585
}
10686

87+
/* The aria desciprtion of the current calendar view. */
88+
get periodButtonDescription(): string {
89+
if (this.calendar.currentView == 'month') {
90+
return this._dateAdapter
91+
.format(this.calendar.activeDate, this._dateFormats.display.monthYearLabel)
92+
.toLocaleUpperCase();
93+
}
94+
if (this.calendar.currentView == 'year') {
95+
return this._dateAdapter.getYearName(this.calendar.activeDate);
96+
}
97+
98+
const [minYearName, maxYearName] = this._getMinMaxYearNames();
99+
return this._intl.formatYearRangeLabel(minYearName, maxYearName);
100+
}
101+
107102
get periodButtonLabel(): string {
108103
return this.calendar.currentView == 'month'
109104
? this._intl.switchToMultiYearViewLabel
@@ -192,6 +187,30 @@ export class MatCalendarHeader<D> {
192187
this.calendar.maxDate,
193188
);
194189
}
190+
191+
private _getMinMaxYearNames(): [string, string] {
192+
// The offset from the active year to the "slot" for the starting year is the
193+
// *actual* first rendered year in the multi-year view, and the last year is
194+
// just yearsPerPage - 1 away.
195+
const activeYear = this._dateAdapter.getYear(this.calendar.activeDate);
196+
const minYearOfPage =
197+
activeYear -
198+
getActiveOffset(
199+
this._dateAdapter,
200+
this.calendar.activeDate,
201+
this.calendar.minDate,
202+
this.calendar.maxDate,
203+
);
204+
const maxYearOfPage = minYearOfPage + yearsPerPage - 1;
205+
const minYearName = this._dateAdapter.getYearName(
206+
this._dateAdapter.createDate(minYearOfPage, 0, 1),
207+
);
208+
const maxYearName = this._dateAdapter.getYearName(
209+
this._dateAdapter.createDate(maxYearOfPage, 0, 1),
210+
);
211+
212+
return [minYearName, maxYearName];
213+
}
195214
}
196215

197216
/** A calendar that is used as part of the datepicker. */

src/material/datepicker/datepicker-intl.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,13 @@ export class MatDatepickerIntl {
5151
/** A label for the 'switch to year view' button (used by screen readers). */
5252
switchToMultiYearViewLabel: string = 'Choose month and year';
5353

54-
/** Formats a range of years. */
54+
/** Formats a range of years (used only for visuals). */
5555
formatYearRange(start: string, end: string): string {
5656
return `${start} \u2013 ${end}`;
5757
}
58+
59+
/** Formats a range of years (used by screen readers). */
60+
formatYearRangeLabel(start: string, end: string): string {
61+
return `${start} to ${end}`;
62+
}
5863
}

tools/public_api_guard/material/datepicker.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,14 +282,14 @@ export type MatCalendarCellCssClasses = string | string[] | Set<string> | {
282282
export class MatCalendarHeader<D> {
283283
constructor(_intl: MatDatepickerIntl, calendar: MatCalendar<D>, _dateAdapter: DateAdapter<D>, _dateFormats: MatDateFormats, changeDetectorRef: ChangeDetectorRef);
284284
// (undocumented)
285-
_buttonDescriptionId: string;
286-
// (undocumented)
287285
calendar: MatCalendar<D>;
288286
currentPeriodClicked(): void;
289287
get nextButtonLabel(): string;
290288
nextClicked(): void;
291289
nextEnabled(): boolean;
292290
// (undocumented)
291+
get periodButtonDescription(): string;
292+
// (undocumented)
293293
get periodButtonLabel(): string;
294294
get periodButtonText(): string;
295295
get prevButtonLabel(): string;
@@ -526,6 +526,7 @@ export class MatDatepickerIntl {
526526
readonly changes: Subject<void>;
527527
closeCalendarLabel: string;
528528
formatYearRange(start: string, end: string): string;
529+
formatYearRangeLabel(start: string, end: string): string;
529530
nextMonthLabel: string;
530531
nextMultiYearLabel: string;
531532
nextYearLabel: string;

0 commit comments

Comments
 (0)