fix(dialog): set aria-labelledby based on the md-dialog-title (#5178)

* [Based on the accessibility guidelines](https://www.w3.org/TR/wai-aria-practices-1.1/#dialog_modal), these changes add the `aria-labelledby` to dialog that use `md-dialog-title`, which causes the screen reader to read out the title. E.g. before NVDA would read out "Dialog", but now it reads out "Neptune dialog".
This commit is contained in:
Kristiyan Kostadinov
2017-06-20 02:31:12 +02:00
committed by Jeremy Elbourn
parent 61d979e66e
commit aee984a5e5
6 changed files with 77 additions and 24 deletions
+4
View File
@@ -66,6 +66,7 @@ export function throwMdDialogContentAlreadyAttachedError() {
host: {
'class': 'mat-dialog-container',
'[attr.role]': '_config?.role',
'[attr.aria-labelledby]': '_ariaLabelledBy',
'[@slideDialog]': '_state',
'(@slideDialog.done)': '_onAnimationDone($event)',
},
@@ -92,6 +93,9 @@ export class MdDialogContainer extends BasePortalHost {
/** Emits the current animation state whenever it changes. */
_onAnimationStateChange = new EventEmitter<AnimationEvent>();
/** ID of the element that should be considered as the dialog's label. */
_ariaLabelledBy: string | null = null;
constructor(
private _ngZone: NgZone,
private _elementRef: ElementRef,
+19 -3
View File
@@ -6,9 +6,12 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Directive, Input} from '@angular/core';
import {Directive, Input, Optional, OnInit} from '@angular/core';
import {MdDialogRef} from './dialog-ref';
import {MdDialogContainer} from './dialog-container';
/** Counter used to generate unique IDs for dialog elements. */
let dialogElementUid = 0;
/**
* Button that will close the current dialog.
@@ -40,9 +43,22 @@ export class MdDialogClose {
*/
@Directive({
selector: '[md-dialog-title], [mat-dialog-title], [mdDialogTitle], [matDialogTitle]',
host: {'class': 'mat-dialog-title'},
host: {
'class': 'mat-dialog-title',
'[id]': 'id',
},
})
export class MdDialogTitle { }
export class MdDialogTitle implements OnInit {
@Input() id = `md-dialog-title-${dialogElementUid++}`;
constructor(@Optional() private _container: MdDialogContainer) { }
ngOnInit() {
if (this._container && !this._container._ariaLabelledBy) {
Promise.resolve().then(() => this._container._ariaLabelledBy = this.id);
}
}
}
/**
+6 -10
View File
@@ -6,25 +6,21 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Injector, InjectionToken} from '@angular/core';
import {Injector} from '@angular/core';
import {MdDialogRef} from './dialog-ref';
export const MD_DIALOG_DATA = new InjectionToken<any>('MdDialogData');
import {MdDialogContainer} from './dialog-container';
/** Custom injector type specifically for instantiating components with a dialog. */
export class DialogInjector implements Injector {
constructor(
private _parentInjector: Injector,
private _dialogRef: MdDialogRef<any>,
private _data: any) { }
private _customTokens: WeakMap<any, any>) { }
get(token: any, notFoundValue?: any): any {
if (token === MdDialogRef) {
return this._dialogRef;
}
const value = this._customTokens.get(token);
if (token === MD_DIALOG_DATA) {
return this._data;
if (typeof value !== 'undefined') {
return value;
}
return this._parentInjector.get<any>(token, notFoundValue);
+12 -2
View File
@@ -21,11 +21,10 @@ import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {Location} from '@angular/common';
import {SpyLocation} from '@angular/common/testing';
import {MdDialogModule} from './index';
import {MdDialog} from './dialog';
import {MdDialog, MD_DIALOG_DATA} from './dialog';
import {MdDialogContainer} from './dialog-container';
import {OverlayContainer, ESCAPE} from '../core';
import {MdDialogRef} from './dialog-ref';
import {MD_DIALOG_DATA} from './dialog-injector';
import {dispatchKeyboardEvent} from '../core/testing/dispatch-events';
@@ -669,6 +668,17 @@ describe('MdDialog', () => {
});
}));
it('should set the aria-labelled by attribute to the id of the title', async(() => {
let title = overlayContainerElement.querySelector('[md-dialog-title]');
let container = overlayContainerElement.querySelector('md-dialog-container');
viewContainerFixture.whenStable().then(() => {
expect(title.id).toBeTruthy('Expected title element to have an id.');
expect(container.getAttribute('aria-labelledby'))
.toBe(title.id, 'Expected the aria-labelledby to match the title id.');
});
}));
});
});
+36 -8
View File
@@ -6,7 +6,15 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Injector, ComponentRef, Injectable, Optional, SkipSelf, TemplateRef} from '@angular/core';
import {
Injector,
InjectionToken,
ComponentRef,
Injectable,
Optional,
SkipSelf,
TemplateRef,
} from '@angular/core';
import {Location} from '@angular/common';
import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';
@@ -25,6 +33,8 @@ import {MdDialogRef} from './dialog-ref';
import {MdDialogContainer} from './dialog-container';
import {TemplatePortal} from '../core/portal/portal';
export const MD_DIALOG_DATA = new InjectionToken<any>('MdDialogData');
/**
* Service to open Material Design modal dialogs.
@@ -187,17 +197,12 @@ export class MdDialog {
});
}
// We create an injector specifically for the component we're instantiating so that it can
// inject the MdDialogRef. This allows a component loaded inside of a dialog to close itself
// and, optionally, to return a value.
let userInjector = config && config.viewContainerRef && config.viewContainerRef.injector;
let dialogInjector = new DialogInjector(userInjector || this._injector, dialogRef, config.data);
if (componentOrTemplateRef instanceof TemplateRef) {
dialogContainer.attachTemplatePortal(new TemplatePortal(componentOrTemplateRef, null));
} else {
let injector = this._createInjector<T>(config, dialogRef, dialogContainer);
let contentRef = dialogContainer.attachComponentPortal(
new ComponentPortal(componentOrTemplateRef, null, dialogInjector));
new ComponentPortal(componentOrTemplateRef, null, injector));
dialogRef.componentInstance = contentRef.instance;
}
@@ -208,6 +213,29 @@ export class MdDialog {
return dialogRef;
}
/**
* Creates a custom injector to be used inside the dialog. This allows a component loaded inside
* of a dialog to close itself and, optionally, to return a value.
* @param config Config object that is used to construct the dialog.
* @param dialogRef Reference to the dialog.
* @param container Dialog container element that wraps all of the contents.
* @returns The custom injector that can be used inside the dialog.
*/
private _createInjector<T>(
config: MdDialogConfig,
dialogRef: MdDialogRef<T>,
dialogContainer: MdDialogContainer): DialogInjector {
let userInjector = config && config.viewContainerRef && config.viewContainerRef.injector;
let injectionTokens = new WeakMap();
injectionTokens.set(MdDialogRef, dialogRef);
injectionTokens.set(MdDialogContainer, dialogContainer);
injectionTokens.set(MD_DIALOG_DATA, config.data);
return new DialogInjector(userInjector || this._injector, injectionTokens);
}
/**
* Removes a dialog from the array of open dialogs.
* @param dialogRef Dialog to be removed.
-1
View File
@@ -59,4 +59,3 @@ export * from './dialog-container';
export * from './dialog-content-directives';
export * from './dialog-config';
export * from './dialog-ref';
export {MD_DIALOG_DATA} from './dialog-injector';