Skip to content
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

fix(material/datepicker): add aria descriptions for comparison range #25667

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/material/datepicker/calendar-body.html
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,9 @@
<label [id]="_endDateLabelId" class="mat-calendar-body-hidden-label">
{{endDateAccessibleName}}
</label>
<label [id]="_comparisonStartLabelId" class="mat-calendar-body-hidden-label">
{{_getComparisonStartLabel()}}
</label>
<label [id]="_comparisonEndLabelId" class="mat-calendar-body-hidden-label">
{{_getComparisonEndLabel()}}
</label>
44 changes: 36 additions & 8 deletions src/material/datepicker/calendar-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> | {[key: string]: any};
Expand Down Expand Up @@ -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<HTMLElement>, private _ngZone: NgZone) {
_ngZone.runOutsideAngular(() => {
const element = _elementRef.nativeElement;
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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. */
Expand Down
147 changes: 143 additions & 4 deletions src/material/datepicker/date-range-input.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,7 @@ describe('MatDateRangeInput', () => {
.getAttribute('aria-describedby')!
.split(/\s+/g)
.map(x => `#${x}`)
.join(' '),
.join(','),
),
);
const rangeEndDescriptions = Array.from(
Expand All @@ -579,7 +579,7 @@ describe('MatDateRangeInput', () => {
.getAttribute('aria-describedby')!
.split(/\s+/g)
.map(x => `#${x}`)
.join(' '),
.join(','),
),
);

Expand All @@ -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');
}));
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions src/material/datepicker/datepicker-intl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
8 changes: 8 additions & 0 deletions tools/public_api_guard/material/datepicker.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,15 +209,21 @@ 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)
_endDateLabelId: string;
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;
Expand Down Expand Up @@ -537,6 +543,8 @@ export class MatDatepickerIntl {
calendarLabel: string;
readonly changes: Subject<void>;
closeCalendarLabel: string;
comparisonRangeEndLabel: string;
comparisonRangeStartLabel: string;
endDateLabel: string;
formatYearRange(start: string, end: string): string;
formatYearRangeLabel(start: string, end: string): string;
Expand Down