diff --git a/src/material/datepicker/calendar-body.html b/src/material/datepicker/calendar-body.html
index f66fc480740f..7cae1f078517 100644
--- a/src/material/datepicker/calendar-body.html
+++ b/src/material/datepicker/calendar-body.html
@@ -83,3 +83,9 @@
+
+
diff --git a/src/material/datepicker/calendar-body.ts b/src/material/datepicker/calendar-body.ts
index ceeaef2516c9..cab2eb28457e 100644
--- a/src/material/datepicker/calendar-body.ts
+++ b/src/material/datepicker/calendar-body.ts
@@ -19,8 +19,10 @@ import {
SimpleChanges,
OnDestroy,
AfterViewChecked,
+ inject,
} from '@angular/core';
import {take} from 'rxjs/operators';
+import {MatDatepickerIntl} from './datepicker-intl';
/** Extra CSS classes that can be associated with a calendar cell. */
export type MatCalendarCellCssClasses = string | string[] | Set | {[key: string]: any};
@@ -159,6 +161,8 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
/** Width of an individual cell. */
_cellWidth: string;
+ private _intl = inject(MatDatepickerIntl);
+
constructor(private _elementRef: ElementRef, private _ngZone: NgZone) {
_ngZone.runOutsideAngular(() => {
const element = _elementRef.nativeElement;
@@ -370,14 +374,36 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
return null;
}
- if (this.startValue === value && this.endValue === value) {
- return `${this._startDateLabelId} ${this._endDateLabelId}`;
- } else if (this.startValue === value) {
- return this._startDateLabelId;
- } else if (this.endValue === value) {
- return this._endDateLabelId;
+ let describedby = '';
+
+ // Add ids of relevant labels.
+ if (this.startValue === value) {
+ describedby += ` ${this._startDateLabelId}`;
}
- return null;
+ if (this.endValue === value) {
+ describedby += ` ${this._endDateLabelId}`;
+ }
+
+ if (this.comparisonStart === value) {
+ describedby += ` ${this._comparisonStartLabelId}`;
+ }
+ if (this.comparisonEnd === value) {
+ describedby += ` ${this._comparisonEndLabelId}`;
+ }
+
+ // Remove leading space character. Prefer passing null over empty string to avoid adding
+ // aria-describedby attribute with an empty value.
+ return describedby.trim() || null;
+ }
+
+ /** Gets the label for the start of comparison range (used by screen readers). */
+ _getComparisonStartLabel(): string | null {
+ return this._intl.comparisonRangeStartLabel;
+ }
+
+ /** Gets the label for the end of comparison range (used by screen readers). */
+ _getComparisonEndLabel(): string | null {
+ return this._intl.comparisonRangeEndLabel;
}
/**
@@ -441,8 +467,10 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
private _id = `mat-calendar-body-${calendarBodyId++}`;
_startDateLabelId = `${this._id}-start-date`;
-
_endDateLabelId = `${this._id}-end-date`;
+
+ _comparisonStartLabelId = `${this._id}-comparison-start`;
+ _comparisonEndLabelId = `${this._id}-comparison-end`;
}
/** Checks whether a node is a table cell element. */
diff --git a/src/material/datepicker/date-range-input.spec.ts b/src/material/datepicker/date-range-input.spec.ts
index 4418b27fb2e7..c6eb663d6edb 100644
--- a/src/material/datepicker/date-range-input.spec.ts
+++ b/src/material/datepicker/date-range-input.spec.ts
@@ -570,7 +570,7 @@ describe('MatDateRangeInput', () => {
.getAttribute('aria-describedby')!
.split(/\s+/g)
.map(x => `#${x}`)
- .join(' '),
+ .join(','),
),
);
const rangeEndDescriptions = Array.from(
@@ -579,7 +579,7 @@ describe('MatDateRangeInput', () => {
.getAttribute('aria-describedby')!
.split(/\s+/g)
.map(x => `#${x}`)
- .join(' '),
+ .join(','),
),
);
@@ -592,13 +592,13 @@ describe('MatDateRangeInput', () => {
expect(
rangeStartDescriptions
.map(x => x.textContent)
- .join(' ')
+ .join(',')
.trim(),
).toEqual('Start date');
expect(
rangeEndDescriptions
.map(x => x.textContent)
- .join(' ')
+ .join(',')
.trim(),
).toEqual('End date');
}));
@@ -636,6 +636,145 @@ describe('MatDateRangeInput', () => {
expect(rangeTexts).toEqual(['2', '3', '4', '5']);
}));
+ it('should provide aria descriptions for start and end of comparison range', fakeAsync(() => {
+ const fixture = createComponent(StandardRangePicker);
+ let overlayContainerElement: HTMLElement;
+
+ // Set startAt to guarantee that the calendar opens on the proper month.
+ fixture.componentInstance.comparisonStart = fixture.componentInstance.startAt = new Date(
+ 2020,
+ 1,
+ 2,
+ );
+ fixture.componentInstance.comparisonEnd = new Date(2020, 1, 5);
+ inject([OverlayContainer], (overlayContainer: OverlayContainer) => {
+ overlayContainerElement = overlayContainer.getContainerElement();
+ })();
+ fixture.detectChanges();
+
+ fixture.componentInstance.rangePicker.open();
+ fixture.detectChanges();
+ tick();
+
+ const comparisonStartDescribedBy = overlayContainerElement!
+ .querySelector('.mat-calendar-body-comparison-start')
+ ?.getAttribute('aria-describedby');
+ const comparisonEndDescribedBy = overlayContainerElement!
+ .querySelector('.mat-calendar-body-comparison-end')
+ ?.getAttribute('aria-describedby');
+
+ expect(comparisonStartDescribedBy)
+ .withContext(
+ 'epxected to find comparison start element with non-empty aria-describedby attribute',
+ )
+ .toBeTruthy();
+ expect(comparisonEndDescribedBy)
+ .withContext(
+ 'epxected to find comparison end element with non-empty aria-describedby attribute',
+ )
+ .toBeTruthy();
+
+ // query for targets of `aria-describedby`. Query from document instead of fixture.nativeElement as calendar UI is rendered in an overlay.
+ const comparisonStartDescriptions = Array.from(
+ document.querySelectorAll(
+ comparisonStartDescribedBy!
+ .split(/\s+/g)
+ .map(x => `#${x}`)
+ .join(','),
+ ),
+ );
+ const comparisonEndDescriptions = Array.from(
+ document.querySelectorAll(
+ comparisonEndDescribedBy!
+ .split(/\s+/g)
+ .map(x => `#${x}`)
+ .join(','),
+ ),
+ );
+
+ expect(comparisonStartDescriptions)
+ .withContext('target of aria-descriedby should exist')
+ .not.toBeNull();
+ expect(comparisonEndDescriptions)
+ .withContext('target of aria-descriedby should exist')
+ .not.toBeNull();
+ expect(
+ comparisonStartDescriptions
+ .map(x => x.textContent?.trim())
+ .join(' ')
+ .trim(),
+ ).toMatch(/start of comparison range/i);
+ expect(
+ comparisonEndDescriptions
+ .map(x => x.textContent?.trim())
+ .join(' ')
+ .trim(),
+ ).toMatch(/end of comparison range/i);
+ }));
+
+ // Validate that the correct aria description is applied when the start date, end date,
+ // comparison start date, comparison end date all fall on the same date.
+ it('should apply aria description to date cell that is the start date, end date, comparison start date and comparison end date', fakeAsync(() => {
+ const fixture = createComponent(StandardRangePicker);
+ let overlayContainerElement: HTMLElement;
+
+ const {start, end} = fixture.componentInstance.range.controls;
+ start.setValue(new Date(2020, 0, 15));
+ end.setValue(new Date(2020, 0, 15));
+
+ // Set startAt to guarantee that the calendar opens on the proper month.
+ fixture.componentInstance.comparisonStart = fixture.componentInstance.startAt = new Date(
+ 2020,
+ 0,
+ 15,
+ );
+ fixture.componentInstance.comparisonEnd = new Date(2020, 0, 15);
+ inject([OverlayContainer], (overlayContainer: OverlayContainer) => {
+ overlayContainerElement = overlayContainer.getContainerElement();
+ })();
+ fixture.detectChanges();
+
+ fixture.componentInstance.rangePicker.open();
+ fixture.detectChanges();
+ tick();
+
+ const activeCells = Array.from(
+ overlayContainerElement!.querySelectorAll(
+ '.mat-calendar-body-cell-container[data-mat-row="2"][data-mat-col="3"] .mat-calendar-body-cell',
+ ),
+ );
+
+ expect(activeCells.length).withContext('expected to find a single active date cell').toBe(1);
+
+ console.log('found it?', activeCells[0].outerHTML);
+
+ const dateCellDescribedby = activeCells[0].getAttribute('aria-describedby');
+
+ expect(dateCellDescribedby)
+ .withContext('expected active cell to have a non-empty aria-descriebedby attribute')
+ .toBeTruthy();
+
+ // query for targets of `aria-describedby`. Query from document instead of fixture.nativeElement as calendar UI is rendered in an overlay.
+ const dateCellDescriptions = Array.from(
+ document.querySelectorAll(
+ dateCellDescribedby!
+ .split(/\s+/g)
+ .map(x => `#${x}`)
+ .join(','),
+ ),
+ );
+
+ const dateCellDescription = dateCellDescriptions
+ .map(x => x.textContent?.trim())
+ .join(' ')
+ .trim();
+
+ expect(dateCellDescription).toMatch(/start of comparison range/i);
+ expect(dateCellDescription).toMatch(/end of comparison range/i);
+ expect(dateCellDescription).toMatch(/start date/i);
+ expect(dateCellDescription).toMatch(/end date/i);
+ }));
+
it('should preserve the preselected values when assigning through ngModel', fakeAsync(() => {
const start = new Date(2020, 1, 2);
const end = new Date(2020, 1, 2);
diff --git a/src/material/datepicker/datepicker-intl.ts b/src/material/datepicker/datepicker-intl.ts
index e23f98effcf5..50b9790c7c52 100644
--- a/src/material/datepicker/datepicker-intl.ts
+++ b/src/material/datepicker/datepicker-intl.ts
@@ -57,6 +57,12 @@ export class MatDatepickerIntl {
/** A label for the last date of a range of dates (used by screen readers). */
endDateLabel = 'End date';
+ /** A label for the first date of a comparison range (used by screen readers). */
+ comparisonRangeStartLabel = 'Start of comparison range';
+
+ /** A label for the last date of a comparison range (used by screen readers). */
+ comparisonRangeEndLabel = 'End of comparison range';
+
/** Formats a range of years (used for visuals). */
formatYearRange(start: string, end: string): string {
return `${start} \u2013 ${end}`;
diff --git a/tools/public_api_guard/material/datepicker.md b/tools/public_api_guard/material/datepicker.md
index 4db59c131111..a3a14c893fb6 100644
--- a/tools/public_api_guard/material/datepicker.md
+++ b/tools/public_api_guard/material/datepicker.md
@@ -209,8 +209,12 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
_cellPadding: string;
_cellWidth: string;
comparisonEnd: number | null;
+ // (undocumented)
+ _comparisonEndLabelId: string;
comparisonStart: number | null;
// (undocumented)
+ _comparisonStartLabelId: string;
+ // (undocumented)
_emitActiveDateChange(cell: MatCalendarCell, event: FocusEvent): void;
endDateAccessibleName: string | null;
// (undocumented)
@@ -218,6 +222,8 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
endValue: number;
_firstRowOffset: number;
_focusActiveCell(movePreview?: boolean): void;
+ _getComparisonEndLabel(): string | null;
+ _getComparisonStartLabel(): string | null;
_getDescribedby(value: number): string | null;
_isActiveCell(rowIndex: number, colIndex: number): boolean;
_isComparisonBridgeEnd(value: number, rowIndex: number, colIndex: number): boolean;
@@ -537,6 +543,8 @@ export class MatDatepickerIntl {
calendarLabel: string;
readonly changes: Subject;
closeCalendarLabel: string;
+ comparisonRangeEndLabel: string;
+ comparisonRangeStartLabel: string;
endDateLabel: string;
formatYearRange(start: string, end: string): string;
formatYearRangeLabel(start: string, end: string): string;