From fde47d8ce8b632211bfde23b065ef39c8e281fc8 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 7 May 2021 07:28:06 +0200 Subject: [PATCH] fix(material/datepicker): add label to dialog overlay (#22625) Fixes that the datepicker overlay element doesn't have a label which causes screen readers to read out "dialog". These changes point the overlay's `aria-labelledby` either to the label of the form field or the `aria-labelledby` of the input. (cherry picked from commit b8475350dd46ea1caec6628e294e894a0b84af64) --- .../datepicker/date-range-input.spec.ts | 17 ++++++++++ src/material/datepicker/date-range-input.ts | 5 +++ src/material/datepicker/datepicker-base.ts | 11 +++++-- src/material/datepicker/datepicker-input.ts | 11 ++++++- src/material/datepicker/datepicker.spec.ts | 32 +++++++++++++++++++ .../public_api_guard/material/datepicker.d.ts | 4 ++- 6 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/material/datepicker/date-range-input.spec.ts b/src/material/datepicker/date-range-input.spec.ts index 1b8c2c095770..617fac6ccde7 100644 --- a/src/material/datepicker/date-range-input.spec.ts +++ b/src/material/datepicker/date-range-input.spec.ts @@ -199,6 +199,23 @@ describe('MatDateRangeInput', () => { expect(end.nativeElement.getAttribute('aria-labelledby')).toBeFalsy(); }); + it('should set aria-labelledby of the overlay to the form field label', fakeAsync(() => { + const fixture = createComponent(StandardRangePicker); + fixture.detectChanges(); + + const label: HTMLElement = fixture.nativeElement.querySelector('.mat-form-field-label'); + expect(label).toBeTruthy(); + expect(label.getAttribute('id')).toBeTruthy(); + + fixture.componentInstance.rangePicker.open(); + fixture.detectChanges(); + tick(); + + const popup = document.querySelector('.cdk-overlay-pane')!; + expect(popup).toBeTruthy(); + expect(popup.getAttribute('aria-labelledby')).toBe(label.getAttribute('id')); + })); + it('should float the form field label when either input is focused', () => { const fixture = createComponent(StandardRangePicker); fixture.detectChanges(); diff --git a/src/material/datepicker/date-range-input.ts b/src/material/datepicker/date-range-input.ts index 5a4a7bc3493c..9e6e400fccce 100644 --- a/src/material/datepicker/date-range-input.ts +++ b/src/material/datepicker/date-range-input.ts @@ -324,6 +324,11 @@ export class MatDateRangeInput implements MatFormFieldControl>, return this._formField ? this._formField.getConnectedOverlayOrigin() : this._elementRef; } + /** Gets the ID of an element that should be used a description for the calendar overlay. */ + getOverlayLabelId(): string | null { + return this._formField ? this._formField.getLabelId() : null; + } + /** Gets the value that is used to mirror the state input. */ _getInputMirrorValue() { return this._startInput ? this._startInput.getMirrorValue() : ''; diff --git a/src/material/datepicker/datepicker-base.ts b/src/material/datepicker/datepicker-base.ts index bf5def8e8964..d40c793aa64e 100644 --- a/src/material/datepicker/datepicker-base.ts +++ b/src/material/datepicker/datepicker-base.ts @@ -243,6 +243,7 @@ export interface MatDatepickerControl { disabled: boolean; dateFilter: DateFilterFn; getConnectedOverlayOrigin(): ElementRef; + getOverlayLabelId(): string | null; stateChanges: Observable; } @@ -615,6 +616,7 @@ export abstract class MatDatepickerBase, S, this._destroyOverlay(); const isDialog = this.touchUi; + const labelId = this.datepickerInput.getOverlayLabelId(); const portal = new ComponentPortal>(MatDatepickerContent, this._viewContainerRef); const overlayRef = this._overlayRef = this._overlay.create(new OverlayConfig({ @@ -628,10 +630,15 @@ export abstract class MatDatepickerBase, S, scrollStrategy: isDialog ? this._overlay.scrollStrategies.block() : this._scrollStrategy(), panelClass: `mat-datepicker-${isDialog ? 'dialog' : 'popup'}`, })); - overlayRef.overlayElement.setAttribute('role', 'dialog'); + const overlayElement = overlayRef.overlayElement; + overlayElement.setAttribute('role', 'dialog'); + + if (labelId) { + overlayElement.setAttribute('aria-labelledby', labelId); + } if (isDialog) { - overlayRef.overlayElement.setAttribute('aria-modal', 'true'); + overlayElement.setAttribute('aria-modal', 'true'); } this._getCloseStream(overlayRef).subscribe(event => { diff --git a/src/material/datepicker/datepicker-input.ts b/src/material/datepicker/datepicker-input.ts index fbbaf3df43e8..73f2166c147a 100644 --- a/src/material/datepicker/datepicker-input.ts +++ b/src/material/datepicker/datepicker-input.ts @@ -134,7 +134,7 @@ export class MatDatepickerInput extends MatDatepickerInputBase elementRef: ElementRef, @Optional() dateAdapter: DateAdapter, @Optional() @Inject(MAT_DATE_FORMATS) dateFormats: MatDateFormats, - @Optional() @Inject(MAT_FORM_FIELD) private _formField: MatFormField) { + @Optional() @Inject(MAT_FORM_FIELD) private _formField?: MatFormField) { super(elementRef, dateAdapter, dateFormats); this._validator = Validators.compose(super._getValidators()); } @@ -147,6 +147,15 @@ export class MatDatepickerInput extends MatDatepickerInputBase return this._formField ? this._formField.getConnectedOverlayOrigin() : this._elementRef; } + /** Gets the ID of an element that should be used a description for the calendar overlay. */ + getOverlayLabelId(): string | null { + if (this._formField) { + return this._formField.getLabelId(); + } + + return this._elementRef.nativeElement.getAttribute('aria-labelledby'); + } + /** Returns the palette used by the input's form field, if any. */ getThemePalette(): ThemePalette { return this._formField ? this._formField.color : undefined; diff --git a/src/material/datepicker/datepicker.spec.ts b/src/material/datepicker/datepicker.spec.ts index 2bc7dd4c2edc..d73714623c06 100644 --- a/src/material/datepicker/datepicker.spec.ts +++ b/src/material/datepicker/datepicker.spec.ts @@ -249,6 +249,22 @@ describe('MatDatepicker', () => { expect(popup.getAttribute('role')).toBe('dialog'); })); + it('should set aria-labelledby to the one from the input, if not placed inside ' + + 'a mat-form-field', fakeAsync(() => { + expect(fixture.nativeElement.querySelector('mat-form-field')).toBeFalsy(); + + const input: HTMLInputElement = fixture.nativeElement.querySelector('input'); + input.setAttribute('aria-labelledby', 'test-label'); + + testComponent.datepicker.open(); + fixture.detectChanges(); + flush(); + + const popup = document.querySelector('.cdk-overlay-pane')!; + expect(popup).toBeTruthy(); + expect(popup.getAttribute('aria-labelledby')).toBe('test-label'); + })); + it('close should close dialog', fakeAsync(() => { testComponent.touch = true; fixture.detectChanges(); @@ -1357,6 +1373,21 @@ describe('MatDatepicker', () => { expect(contentEl.classList).toContain('mat-accent'); expect(contentEl.classList).not.toContain('mat-warn'); })); + + it('should set aria-labelledby of the overlay to the form field label', fakeAsync(() => { + const label: HTMLElement = fixture.nativeElement.querySelector('.mat-form-field-label'); + + expect(label).toBeTruthy(); + expect(label.getAttribute('id')).toBeTruthy(); + + testComponent.datepicker.open(); + fixture.detectChanges(); + flush(); + + const popup = document.querySelector('.cdk-overlay-pane')!; + expect(popup).toBeTruthy(); + expect(popup.getAttribute('aria-labelledby')).toBe(label.getAttribute('id')); + })); }); describe('datepicker with min and max dates and validation', () => { @@ -2385,6 +2416,7 @@ class DatepickerWithCustomIcon {} @Component({ template: ` + Pick a date diff --git a/tools/public_api_guard/material/datepicker.d.ts b/tools/public_api_guard/material/datepicker.d.ts index 6eb58bad1148..27aa473a6a0d 100644 --- a/tools/public_api_guard/material/datepicker.d.ts +++ b/tools/public_api_guard/material/datepicker.d.ts @@ -246,7 +246,7 @@ export declare class MatDatepickerInput extends MatDatepickerInputBase, dateAdapter: DateAdapter, dateFormats: MatDateFormats, _formField: MatFormField); + constructor(elementRef: ElementRef, dateAdapter: DateAdapter, dateFormats: MatDateFormats, _formField?: MatFormField | undefined); protected _assignValueToModel(value: D | null): void; protected _getDateFilter(): DateFilterFn; _getMaxDate(): D | null; @@ -255,6 +255,7 @@ export declare class MatDatepickerInput extends MatDatepickerInputBase): boolean; getConnectedOverlayOrigin(): ElementRef; + getOverlayLabelId(): string | null; getStartValue(): D | null; getThemePalette(): ThemePalette; ngOnDestroy(): void; @@ -360,6 +361,7 @@ export declare class MatDateRangeInput implements MatFormFieldControl