Skip to content

Commit

Permalink
fix(material/datepicker): announce the "to" when reading year range
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
zarend committed May 25, 2022
1 parent 25dcb36 commit 869537a
Show file tree
Hide file tree
Showing 5 changed files with 64 additions and 32 deletions.
9 changes: 5 additions & 4 deletions src/material/datepicker/calendar-header.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
<div class="mat-calendar-controls">
<button mat-button type="button" class="mat-calendar-period-button"
(click)="currentPeriodClicked()" [attr.aria-label]="periodButtonLabel"
[attr.aria-describedby]="_buttonDescriptionId"
aria-live="polite">
<span [attr.id]="_buttonDescriptionId">{{periodButtonText}}</span>
[attr.aria-description]="periodButtonDescription" aria-live="polite">
<span aria-hidden="true">
{{periodButtonText}}
</span>
<svg class="mat-calendar-arrow" [class.mat-calendar-invert]="calendar.currentView !== 'month'"
viewBox="0 0 10 5" focusable="false">
viewBox="0 0 10 5" focusable="false" aria-hidden="true">
<polygon points="0,0 5,5 10,0"/>
</svg>
</button>
Expand Down
12 changes: 9 additions & 3 deletions src/material/datepicker/calendar-header.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.hasAttribute('aria-describedby')).toBe(true);
expect(periodButton.getAttribute('aria-describedby')).toBe(description?.getAttribute('id')!);
expect(periodButton.getAttribute('aria-label')).toMatch(/^[a-z0-9\s]+$/i);
expect(periodButton.hasAttribute('aria-description')).toBe(true);
expect(periodButton.getAttribute('aria-description')).toMatch(/^[a-z0-9\s]+$/i);
});
});

Expand Down
63 changes: 41 additions & 22 deletions src/material/datepicker/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,6 @@ let uniqueId = 0;
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MatCalendarHeader<D> {
_buttonDescriptionId = `mat-calendar-button-${uniqueId++}`;

constructor(
private _intl: MatDatepickerIntl,
@Inject(forwardRef(() => MatCalendar)) public calendar: MatCalendar<D>,
Expand All @@ -71,7 +69,7 @@ export class MatCalendarHeader<D> {
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
Expand All @@ -82,28 +80,25 @@ export class MatCalendarHeader<D> {
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),
);
const [minYearName, maxYearName] = this._getMinMaxYearNames();
return this._intl.formatYearRange(minYearName, maxYearName);
}

/* The aria desciprtion of 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);
}

const [minYearName, maxYearName] = this._getMinMaxYearNames();
return this._intl.formatYearRangeLabel(minYearName, maxYearName);
}

get periodButtonLabel(): string {
return this.calendar.currentView == 'month'
? this._intl.switchToMultiYearViewLabel
Expand Down Expand Up @@ -192,6 +187,30 @@ export class MatCalendarHeader<D> {
this.calendar.maxDate,
);
}

private _getMinMaxYearNames(): [string, 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 minYearName = this._dateAdapter.getYearName(
this._dateAdapter.createDate(minYearOfPage, 0, 1),
);
const maxYearName = this._dateAdapter.getYearName(
this._dateAdapter.createDate(maxYearOfPage, 0, 1),
);

return [minYearName, maxYearName];
}
}

/** A calendar that is used as part of the datepicker. */
Expand Down
7 changes: 6 additions & 1 deletion src/material/datepicker/datepicker-intl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,13 @@ export class MatDatepickerIntl {
/** A label for the 'switch to year view' button (used by screen readers). */
switchToMultiYearViewLabel: string = 'Choose month and year';

/** Formats a range of years. */
/** Formats a range of years (used only for visuals). */
formatYearRange(start: string, end: string): string {
return `${start} \u2013 ${end}`;
}

/** Formats a range of years (used by screen readers). */
formatYearRangeLabel(start: string, end: string): string {
return `${start} to ${end}`;
}
}
5 changes: 3 additions & 2 deletions tools/public_api_guard/material/datepicker.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,14 +282,14 @@ export type MatCalendarCellCssClasses = string | string[] | Set<string> | {
export class MatCalendarHeader<D> {
constructor(_intl: MatDatepickerIntl, calendar: MatCalendar<D>, _dateAdapter: DateAdapter<D>, _dateFormats: MatDateFormats, changeDetectorRef: ChangeDetectorRef);
// (undocumented)
_buttonDescriptionId: string;
// (undocumented)
calendar: MatCalendar<D>;
currentPeriodClicked(): void;
get nextButtonLabel(): string;
nextClicked(): void;
nextEnabled(): boolean;
// (undocumented)
get periodButtonDescription(): string;
// (undocumented)
get periodButtonLabel(): string;
get periodButtonText(): string;
get prevButtonLabel(): string;
Expand Down Expand Up @@ -526,6 +526,7 @@ export class MatDatepickerIntl {
readonly changes: Subject<void>;
closeCalendarLabel: string;
formatYearRange(start: string, end: string): string;
formatYearRangeLabel(start: string, end: string): string;
nextMonthLabel: string;
nextMultiYearLabel: string;
nextYearLabel: string;
Expand Down

0 comments on commit 869537a

Please sign in to comment.