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
…24958)

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.
  • Loading branch information
zarend committed Sep 19, 2022
1 parent e1c0c55 commit 2704c31
Show file tree
Hide file tree
Showing 5 changed files with 74 additions and 35 deletions.
8 changes: 4 additions & 4 deletions src/material/datepicker/calendar-header.html
Expand Up @@ -2,11 +2,10 @@
<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-describedby]="_periodButtonLabelId" 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 All @@ -26,3 +25,4 @@
</button>
</div>
</div>
<label [id]="_periodButtonLabelId" class="mat-calendar-hidden-label">{{periodButtonDescription}}</label>
10 changes: 8 additions & 2 deletions src/material/datepicker/calendar-header.spec.ts
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.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);
});
});

Expand Down
5 changes: 5 additions & 0 deletions src/material/datepicker/calendar.scss
Expand Up @@ -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;
}
80 changes: 54 additions & 26 deletions src/material/datepicker/calendar.ts
Expand Up @@ -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',
Expand All @@ -59,8 +58,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 +68,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 +79,26 @@ 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),
);
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
Expand Down Expand Up @@ -192,6 +187,39 @@ export class MatCalendarHeader<D> {
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. */
Expand Down
6 changes: 3 additions & 3 deletions tools/public_api_guard/material/datepicker.md
Expand Up @@ -291,15 +291,15 @@ 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;
get periodButtonLabel(): string;
// (undocumented)
_periodButtonLabelId: string;
get periodButtonText(): string;
get prevButtonLabel(): string;
previousClicked(): void;
Expand Down

0 comments on commit 2704c31

Please sign in to comment.