Skip to content

fix(material/datepicker): announce the "to" when reading year range #24958

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/material/datepicker/calendar-header.html
Original file line number Diff line number Diff line change
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
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.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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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