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;