Skip to content

Commit

Permalink
fix(dialog): set aria-labelledby based on the md-dialog-title (#5178)
Browse files Browse the repository at this point in the history
* [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".
  • Loading branch information
crisbeto authored and jelbourn committed Jun 20, 2017
1 parent 61d979e commit aee984a
Show file tree
Hide file tree
Showing 6 changed files with 77 additions and 24 deletions.
4 changes: 4 additions & 0 deletions src/lib/dialog/dialog-container.ts
Expand Up @@ -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)',
},
Expand All @@ -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,
Expand Down
22 changes: 19 additions & 3 deletions src/lib/dialog/dialog-content-directives.ts
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
}
}


/**
Expand Down
16 changes: 6 additions & 10 deletions src/lib/dialog/dialog-injector.ts
Expand Up @@ -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);
Expand Down
14 changes: 12 additions & 2 deletions src/lib/dialog/dialog.spec.ts
Expand Up @@ -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';


Expand Down Expand Up @@ -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.');
});
}));

});
});

Expand Down
44 changes: 36 additions & 8 deletions src/lib/dialog/dialog.ts
Expand Up @@ -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';
Expand All @@ -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.
Expand Down Expand Up @@ -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;
}

Expand All @@ -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.
Expand Down
1 change: 0 additions & 1 deletion src/lib/dialog/index.ts
Expand Up @@ -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';

0 comments on commit aee984a

Please sign in to comment.