Skip to content

Commit 2c5e956

Browse files
committed
fix(material/datepicker): add aria descriptions to calendar for start/end dates
Add aria descriptions to calendar cells that are the first or last date of a date range. Describe first date of a date range as 'Start date', last date as 'End date'. Date ranges of exactly one day are described as 'Start and end date'. Fix accessibility issue where screen reader does not communicate if the selected cell is the start date or the end date (#23442). Fixes #23442
1 parent 799cf7c commit 2c5e956

File tree

5 files changed

+62
-2
lines changed

5 files changed

+62
-2
lines changed

src/material/datepicker/calendar-body.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
[class.mat-calendar-body-preview-end]="_isPreviewEnd(item.compareValue)"
6161
[class.mat-calendar-body-in-preview]="_isInPreview(item.compareValue)"
6262
[attr.aria-label]="item.ariaLabel"
63+
[attr.aria-description]="_getAriaDescription(item.compareValue)"
6364
[attr.aria-disabled]="!item.enabled || null"
6465
[attr.aria-pressed]="_isSelected(item.compareValue)"
6566
[attr.aria-current]="todayValue === item.compareValue ? 'date' : null"

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,13 @@ describe('MatCalendarBody', () => {
142142
it('should have a focus indicator', () => {
143143
expect(cellEls.every(element => !!element.querySelector('.mat-focus-indicator'))).toBe(true);
144144
});
145+
146+
it('should not have `aria-description` on cells', () => {
147+
expect(
148+
calendarBodyNativeElement.querySelectorAll('.mat-calendar-body-cell[aria-description]')
149+
.length,
150+
).toBe(0);
151+
});
145152
});
146153

147154
describe('range calendar body', () => {
@@ -167,6 +174,29 @@ describe('MatCalendarBody', () => {
167174
cells = Array.from(fixture.nativeElement.querySelectorAll('.mat-calendar-body-cell'));
168175
});
169176

177+
it('should have aria-description on start and end days of a 3-day range', () => {
178+
testComponent.startValue = 2;
179+
testComponent.endValue = 4;
180+
fixture.detectChanges();
181+
182+
expect(cells[1].getAttribute('aria-description')).toBe('Start date');
183+
expect(cells[3].getAttribute('aria-description')).toBe('End date');
184+
expect(
185+
fixture.nativeElement.querySelectorAll('.mat-calendar-body-cell[aria-description]').length,
186+
).toBe(2);
187+
});
188+
189+
it('should have aria-description on a cell that is both the first and last day of a date range', () => {
190+
testComponent.startValue = 3;
191+
testComponent.endValue = 3;
192+
fixture.detectChanges();
193+
194+
expect(cells[2].getAttribute('aria-description')).toBe('Start and end date');
195+
expect(
196+
fixture.nativeElement.querySelectorAll('.mat-calendar-body-cell[aria-description]').length,
197+
).toBe(1);
198+
});
199+
170200
it('should render a range', () => {
171201
testComponent.startValue = 1;
172202
testComponent.endValue = 5;

src/material/datepicker/calendar-body.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
AfterViewChecked,
2222
} from '@angular/core';
2323
import {take} from 'rxjs/operators';
24+
import {MatDatepickerIntl} from './datepicker-intl';
2425

2526
/** Extra CSS classes that can be associated with a calendar cell. */
2627
export type MatCalendarCellCssClasses = string | string[] | Set<string> | {[key: string]: any};
@@ -151,7 +152,11 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
151152
/** Width of an individual cell. */
152153
_cellWidth: string;
153154

154-
constructor(private _elementRef: ElementRef<HTMLElement>, private _ngZone: NgZone) {
155+
constructor(
156+
private _elementRef: ElementRef<HTMLElement>,
157+
private _ngZone: NgZone,
158+
private _intl: MatDatepickerIntl,
159+
) {
155160
_ngZone.runOutsideAngular(() => {
156161
const element = _elementRef.nativeElement;
157162
element.addEventListener('mouseenter', this._enterHandler, true);
@@ -174,6 +179,25 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
174179
}
175180
}
176181

182+
/**
183+
* Provides an aria-description for the cell to communicate if it is the
184+
* start or end of the selected date range.
185+
*/
186+
_getAriaDescription(value: number): string | null {
187+
if (!this.isRange) {
188+
return null;
189+
}
190+
191+
if (this.startValue === value && this.endValue === value) {
192+
return this._intl.startAndEndDateLabel;
193+
} else if (this.startValue === value) {
194+
return this._intl.startDateLabel;
195+
} else if (this.endValue === value) {
196+
return this._intl.endDateLabel;
197+
}
198+
return null;
199+
}
200+
177201
/** Returns whether a cell should be marked as selected. */
178202
_isSelected(value: number) {
179203
return this.startValue === value || this.endValue === value;

src/material/datepicker/datepicker-intl.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ export class MatDatepickerIntl {
5757
/** A label for the last date of a range of dates (used by screen readers). */
5858
endDateLabel = 'End date';
5959

60+
/** A label for a date range that starts and ends on the same day (used by screen readers). */
61+
startAndEndDateLabel = 'Start and end date';
62+
6063
/** Formats a range of years (used for visuals). */
6164
formatYearRange(start: string, end: string): string {
6265
return `${start} \u2013 ${end}`;

tools/public_api_guard/material/datepicker.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ export class MatCalendar<D> implements AfterContentInit, AfterViewChecked, OnDes
198198

199199
// @public
200200
export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
201-
constructor(_elementRef: ElementRef<HTMLElement>, _ngZone: NgZone);
201+
constructor(_elementRef: ElementRef<HTMLElement>, _ngZone: NgZone, _intl: MatDatepickerIntl);
202202
activeCell: number;
203203
// (undocumented)
204204
readonly activeDateChange: EventEmitter<MatCalendarUserEvent<number>>;
@@ -213,6 +213,7 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
213213
endValue: number;
214214
_firstRowOffset: number;
215215
_focusActiveCell(movePreview?: boolean): void;
216+
_getAriaDescription(value: number): string | null;
216217
_isActiveCell(rowIndex: number, colIndex: number): boolean;
217218
_isComparisonBridgeEnd(value: number, rowIndex: number, colIndex: number): boolean;
218219
_isComparisonBridgeStart(value: number, rowIndex: number, colIndex: number): boolean;
@@ -535,6 +536,7 @@ export class MatDatepickerIntl {
535536
prevMonthLabel: string;
536537
prevMultiYearLabel: string;
537538
prevYearLabel: string;
539+
startAndEndDateLabel: string;
538540
startDateLabel: string;
539541
switchToMonthViewLabel: string;
540542
switchToMultiYearViewLabel: string;

0 commit comments

Comments
 (0)