Skip to content

Commit d7114ad

Browse files
committed
fix(material/datepicker): announce the "to" when reading year range
Create period button's `aria-description` using `formatYearRangeLabel` method. Format year range in a TTS friendly way (e.g. "2019 to 2020"). Previously, some screen readers would announce the range as "2019 2020". Fixes #23467.
1 parent 3761275 commit d7114ad

File tree

5 files changed

+72
-35
lines changed

5 files changed

+72
-35
lines changed

src/material/datepicker/calendar-header.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@
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-describedby]="_periodButtonLabelId" aria-live="polite">
6+
<span aria-hidden="true">{{periodButtonText}}</span>
87
<svg class="mat-calendar-arrow" [class.mat-calendar-invert]="calendar.currentView !== 'month'"
9-
viewBox="0 0 10 5" focusable="false">
8+
viewBox="0 0 10 5" focusable="false" aria-hidden="true">
109
<polygon points="0,0 5,5 10,0"/>
1110
</svg>
1211
</button>
@@ -26,3 +25,4 @@
2625
</button>
2726
</div>
2827
</div>
28+
<label [id]="_periodButtonLabelId" class="mat-calendar-hidden-label">{{periodButtonDescription}}</label>

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

Lines changed: 8 additions & 2 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);
201+
expect(periodButton.getAttribute('aria-label')).toMatch(/^[a-z0-9\s]+$/i);
196202
expect(periodButton.hasAttribute('aria-describedby')).toBe(true);
197-
expect(periodButton.getAttribute('aria-describedby')).toBe(description?.getAttribute('id')!);
203+
expect(periodButton.getAttribute('aria-describedby')).toMatch(/mat-calendar-header-[0-9]+/i);
198204
});
199205
});
200206

src/material/datepicker/calendar.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,8 @@ $calendar-next-icon-transform: translateX(-2px) rotate(45deg);
139139
.mat-calendar-body-cell:focus .mat-focus-indicator::before {
140140
content: '';
141141
}
142+
143+
// Label that is not rendered and removed from the accessibility tree.
144+
.mat-calendar-hidden-label {
145+
display: none;
146+
}

src/material/datepicker/calendar.ts

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,14 @@ import {
4141
import {MatYearView} from './year-view';
4242
import {MAT_SINGLE_DATE_SELECTION_MODEL_PROVIDER, DateRange} from './date-selection-model';
4343

44+
let calendarHeaderId = 1;
45+
4446
/**
4547
* Possible views for the calendar.
4648
* @docs-private
4749
*/
4850
export type MatCalendarView = 'month' | 'year' | 'multi-year';
4951

50-
/** Counter used to generate unique IDs. */
51-
let uniqueId = 0;
52-
5352
/** Default header for MatCalendar */
5453
@Component({
5554
selector: 'mat-calendar-header',
@@ -59,8 +58,6 @@ let uniqueId = 0;
5958
changeDetection: ChangeDetectionStrategy.OnPush,
6059
})
6160
export class MatCalendarHeader<D> {
62-
_buttonDescriptionId = `mat-calendar-button-${uniqueId++}`;
63-
6461
constructor(
6562
private _intl: MatDatepickerIntl,
6663
@Inject(forwardRef(() => MatCalendar)) public calendar: MatCalendar<D>,
@@ -71,7 +68,7 @@ export class MatCalendarHeader<D> {
7168
this.calendar.stateChanges.subscribe(() => changeDetectorRef.markForCheck());
7269
}
7370

74-
/** The label for the current calendar view. */
71+
/** The display text for the current calendar view. */
7572
get periodButtonText(): string {
7673
if (this.calendar.currentView == 'month') {
7774
return this._dateAdapter
@@ -82,28 +79,26 @@ export class MatCalendarHeader<D> {
8279
return this._dateAdapter.getYearName(this.calendar.activeDate);
8380
}
8481

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-
);
104-
return this._intl.formatYearRange(minYearName, maxYearName);
82+
return this._intl.formatYearRange(...this._formatMinAndMaxYearLabels());
10583
}
10684

85+
/** The aria description for the current calendar view. */
86+
get periodButtonDescription(): string {
87+
if (this.calendar.currentView == 'month') {
88+
return this._dateAdapter
89+
.format(this.calendar.activeDate, this._dateFormats.display.monthYearLabel)
90+
.toLocaleUpperCase();
91+
}
92+
if (this.calendar.currentView == 'year') {
93+
return this._dateAdapter.getYearName(this.calendar.activeDate);
94+
}
95+
96+
// Format a label for the window of years displayed in the multi-year calendar view. Use
97+
// `formatYearRangeLabel` because it is TTS friendly.
98+
return this._intl.formatYearRangeLabel(...this._formatMinAndMaxYearLabels());
99+
}
100+
101+
/** The `aria-label` for changing the calendar view. */
107102
get periodButtonLabel(): string {
108103
return this.calendar.currentView == 'month'
109104
? this._intl.switchToMultiYearViewLabel
@@ -192,6 +187,39 @@ export class MatCalendarHeader<D> {
192187
this.calendar.maxDate,
193188
);
194189
}
190+
191+
/**
192+
* Format two individual labels for the minimum year and maximum year available in the multi-year
193+
* calendar view. Returns an array of two strings where the first string is the formatted label
194+
* for the minimum year, and the second string is the formatted label for the maximum year.
195+
*/
196+
private _formatMinAndMaxYearLabels(): [minYearLabel: string, maxYearLabel: string] {
197+
// The offset from the active year to the "slot" for the starting year is the
198+
// *actual* first rendered year in the multi-year view, and the last year is
199+
// just yearsPerPage - 1 away.
200+
const activeYear = this._dateAdapter.getYear(this.calendar.activeDate);
201+
const minYearOfPage =
202+
activeYear -
203+
getActiveOffset(
204+
this._dateAdapter,
205+
this.calendar.activeDate,
206+
this.calendar.minDate,
207+
this.calendar.maxDate,
208+
);
209+
const maxYearOfPage = minYearOfPage + yearsPerPage - 1;
210+
const minYearLabel = this._dateAdapter.getYearName(
211+
this._dateAdapter.createDate(minYearOfPage, 0, 1),
212+
);
213+
const maxYearLabel = this._dateAdapter.getYearName(
214+
this._dateAdapter.createDate(maxYearOfPage, 0, 1),
215+
);
216+
217+
return [minYearLabel, maxYearLabel];
218+
}
219+
220+
private _id = `mat-calendar-header-${calendarHeaderId++}`;
221+
222+
_periodButtonLabelId = `${this._id}-period-label`;
195223
}
196224

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

tools/public_api_guard/material/datepicker.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,14 +291,12 @@ export type MatCalendarCellCssClasses = string | string[] | Set<string> | {
291291
export class MatCalendarHeader<D> {
292292
constructor(_intl: MatDatepickerIntl, calendar: MatCalendar<D>, _dateAdapter: DateAdapter<D>, _dateFormats: MatDateFormats, changeDetectorRef: ChangeDetectorRef);
293293
// (undocumented)
294-
_buttonDescriptionId: string;
295-
// (undocumented)
296294
calendar: MatCalendar<D>;
297295
currentPeriodClicked(): void;
298296
get nextButtonLabel(): string;
299297
nextClicked(): void;
300298
nextEnabled(): boolean;
301-
// (undocumented)
299+
get periodButtonDescription(): string;
302300
get periodButtonLabel(): string;
303301
get periodButtonText(): string;
304302
get prevButtonLabel(): string;

0 commit comments

Comments
 (0)