diff --git a/src/lib/core/a11y/focus-trap.spec.ts b/src/lib/core/a11y/focus-trap.spec.ts
index 5cce6b61ebcc..279ebadb275d 100644
--- a/src/lib/core/a11y/focus-trap.spec.ts
+++ b/src/lib/core/a11y/focus-trap.spec.ts
@@ -26,7 +26,7 @@ describe('FocusTrap', () => {
// Because we can't mimic a real tab press focus change in a unit test, just call the
// focus event handler directly.
- focusTrapInstance.wrapFocus();
+ focusTrapInstance.focusFirstTabbableElement();
expect(document.activeElement.nodeName.toLowerCase())
.toBe('input', 'Expected input element to be focused');
@@ -38,7 +38,7 @@ describe('FocusTrap', () => {
// Because we can't mimic a real tab press focus change in a unit test, just call the
// focus event handler directly.
- focusTrapInstance.reverseWrapFocus();
+ focusTrapInstance.focusLastTabbableElement();
expect(document.activeElement.nodeName.toLowerCase())
.toBe('button', 'Expected button element to be focused');
diff --git a/src/lib/core/a11y/focus-trap.ts b/src/lib/core/a11y/focus-trap.ts
index 2196e5497e13..539a7f77bed7 100644
--- a/src/lib/core/a11y/focus-trap.ts
+++ b/src/lib/core/a11y/focus-trap.ts
@@ -15,9 +15,9 @@ import {InteractivityChecker} from './interactivity-checker';
selector: 'focus-trap',
// TODO(jelbourn): move this to a separate file.
template: `
-
+
- `,
+ `,
encapsulation: ViewEncapsulation.None,
})
export class FocusTrap {
@@ -25,16 +25,16 @@ export class FocusTrap {
constructor(private _checker: InteractivityChecker) { }
- /** Wrap focus from the end of the trapped region to the beginning. */
- wrapFocus() {
+ /** Focuses the first tabbable element within the focus trap region. */
+ focusFirstTabbableElement() {
let redirectToElement = this._getFirstTabbableElement(this.trappedContent.nativeElement);
if (redirectToElement) {
redirectToElement.focus();
}
}
- /** Wrap focus from the beginning of the trapped region to the end. */
- reverseWrapFocus() {
+ /** Focuses the last tabbable element within the focus trap region. */
+ focusLastTabbableElement() {
let redirectToElement = this._getLastTabbableElement(this.trappedContent.nativeElement);
if (redirectToElement) {
redirectToElement.focus();
diff --git a/src/lib/core/a11y/index.ts b/src/lib/core/a11y/index.ts
new file mode 100644
index 000000000000..197cd0fa28d2
--- /dev/null
+++ b/src/lib/core/a11y/index.ts
@@ -0,0 +1,22 @@
+import {NgModule, ModuleWithProviders} from '@angular/core';
+import {FocusTrap} from './focus-trap';
+import {MdLiveAnnouncer} from './live-announcer';
+import {InteractivityChecker} from './interactivity-checker';
+
+export {FocusTrap} from './focus-trap';
+export {MdLiveAnnouncer} from './live-announcer';
+export {InteractivityChecker} from './interactivity-checker';
+
+
+@NgModule({
+ declarations: [FocusTrap],
+ exports: [FocusTrap],
+})
+export class A11yModule {
+ static forRoot(): ModuleWithProviders {
+ return {
+ ngModule: A11yModule,
+ providers: [MdLiveAnnouncer, InteractivityChecker],
+ };
+ }
+}
diff --git a/src/lib/core/core.ts b/src/lib/core/core.ts
index c7362de69466..2e044b692dde 100644
--- a/src/lib/core/core.ts
+++ b/src/lib/core/core.ts
@@ -4,7 +4,8 @@ import {RtlModule} from './rtl/dir';
import {MdRippleModule} from './ripple/ripple';
import {PortalModule} from './portal/portal-directives';
import {OverlayModule} from './overlay/overlay-directives';
-import {MdLiveAnnouncer} from './a11y/live-announcer';
+import {A11yModule} from './a11y/index';
+
// RTL
export {Dir, LayoutDirection, RtlModule} from './rtl/dir';
@@ -77,14 +78,14 @@ export * from './keyboard/keycodes';
@NgModule({
- imports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule],
- exports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule],
+ imports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule, A11yModule],
+ exports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule, A11yModule],
})
export class MdCoreModule {
static forRoot(): ModuleWithProviders {
return {
ngModule: MdCoreModule,
- providers: [MdLiveAnnouncer]
+ providers: [A11yModule.forRoot().providers],
};
}
}
diff --git a/src/lib/dialog/dialog-container.html b/src/lib/dialog/dialog-container.html
index 4d5e533eef18..f1c4963b9627 100644
--- a/src/lib/dialog/dialog-container.html
+++ b/src/lib/dialog/dialog-container.html
@@ -1 +1,3 @@
-
+
+
+
diff --git a/src/lib/dialog/dialog-container.ts b/src/lib/dialog/dialog-container.ts
index 8f7345a87f28..cc38a229c550 100644
--- a/src/lib/dialog/dialog-container.ts
+++ b/src/lib/dialog/dialog-container.ts
@@ -1,12 +1,16 @@
-import {Component, ComponentRef, ViewChild, ViewEncapsulation} from '@angular/core';
import {
- BasePortalHost,
- ComponentPortal,
- PortalHostDirective,
- TemplatePortal
-} from '../core';
+ Component,
+ ComponentRef,
+ ViewChild,
+ ViewEncapsulation,
+ NgZone,
+ OnDestroy
+} from '@angular/core';
+import {BasePortalHost, ComponentPortal, PortalHostDirective, TemplatePortal} from '../core';
import {MdDialogConfig} from './dialog-config';
import {MdDialogContentAlreadyAttachedError} from './dialog-errors';
+import {FocusTrap} from '../core/a11y/focus-trap';
+import 'rxjs/add/operator/first';
/**
@@ -23,23 +27,52 @@ import {MdDialogContentAlreadyAttachedError} from './dialog-errors';
},
encapsulation: ViewEncapsulation.None,
})
-export class MdDialogContainer extends BasePortalHost {
+export class MdDialogContainer extends BasePortalHost implements OnDestroy {
/** The portal host inside of this container into which the dialog content will be loaded. */
@ViewChild(PortalHostDirective) _portalHost: PortalHostDirective;
+ /** The directive that traps and manages focus within the dialog. */
+ @ViewChild(FocusTrap) _focusTrap: FocusTrap;
+
+ /** Element that was focused before the dialog was opened. Save this to restore upon close. */
+ private _elementFocusedBeforeDialogWasOpened: Element = null;
+
/** The dialog configuration. */
dialogConfig: MdDialogConfig;
+ constructor(private _ngZone: NgZone) {
+ super();
+ }
+
/** Attach a portal as content to this dialog container. */
attachComponentPortal(portal: ComponentPortal): ComponentRef {
if (this._portalHost.hasAttached()) {
throw new MdDialogContentAlreadyAttachedError();
}
- return this._portalHost.attachComponentPortal(portal);
+ let attachResult = this._portalHost.attachComponentPortal(portal);
+
+ // If were to attempt to focus immediately, then the content of the dialog would not yet be
+ // ready in instances where change detection has to run first. To deal with this, we simply
+ // wait for the microtask queue to be empty.
+ this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
+ this._elementFocusedBeforeDialogWasOpened = document.activeElement;
+ this._focusTrap.focusFirstTabbableElement();
+ });
+
+ return attachResult;
}
attachTemplatePortal(portal: TemplatePortal): Map {
throw Error('Not yet implemented');
}
+
+ ngOnDestroy() {
+ // When the dialog is destroyed, return focus to the element that originally had it before
+ // the dialog was opened. Wait for the DOM to finish settling before changing the focus so
+ // that it doesn't end up back on the .
+ this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
+ (this._elementFocusedBeforeDialogWasOpened as HTMLElement).focus();
+ });
+ }
}
diff --git a/src/lib/dialog/dialog.spec.ts b/src/lib/dialog/dialog.spec.ts
index a1465652680e..432e21c675e9 100644
--- a/src/lib/dialog/dialog.spec.ts
+++ b/src/lib/dialog/dialog.spec.ts
@@ -1,4 +1,11 @@
-import {inject, async, ComponentFixture, TestBed} from '@angular/core/testing';
+import {
+ inject,
+ async,
+ fakeAsync,
+ flushMicrotasks,
+ ComponentFixture,
+ TestBed,
+} from '@angular/core/testing';
import {NgModule, Component, Directive, ViewChild, ViewContainerRef} from '@angular/core';
import {MdDialog, MdDialogModule} from './dialog';
import {OverlayContainer} from '../core';
@@ -100,6 +107,55 @@ describe('MdDialog', () => {
expect(overlayContainerElement.querySelector('md-dialog-container')).toBeFalsy();
});
+
+ describe('focus management', () => {
+
+ // When testing focus, all of the elements must be in the DOM.
+ beforeEach(() => {
+ document.body.appendChild(overlayContainerElement);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(overlayContainerElement);
+ });
+
+ it('should focus the first tabbable element of the dialog on open', fakeAsync(() => {
+ let config = new MdDialogConfig();
+ config.viewContainerRef = testViewContainerRef;
+
+ dialog.open(PizzaMsg, config);
+ viewContainerFixture.detectChanges();
+ flushMicrotasks();
+
+ expect(document.activeElement.tagName)
+ .toBe('INPUT', 'Expected first tabbable element (input) in the dialog to be focused.');
+ }));
+
+ it('should re-focus trigger element when dialog closes', fakeAsync(() => {
+ // Create a element that has focus before the dialog is opened.
+ let button = document.createElement('button');
+ button.id = 'dialog-trigger';
+ document.body.appendChild(button);
+ button.focus();
+
+ let config = new MdDialogConfig();
+ config.viewContainerRef = testViewContainerRef;
+
+ let dialogRef = dialog.open(PizzaMsg, config);
+ viewContainerFixture.detectChanges();
+ flushMicrotasks();
+
+ expect(document.activeElement.id)
+ .not.toBe('dialog-trigger', 'Expected the focus to change when dialog was opened.');
+
+ dialogRef.close();
+ viewContainerFixture.detectChanges();
+ flushMicrotasks();
+
+ expect(document.activeElement.id)
+ .toBe('dialog-trigger', 'Expected that the trigger was refocused after dialog close');
+ }));
+ });
});
@@ -121,7 +177,7 @@ class ComponentWithChildViewContainer {
}
/** Simple component for testing ComponentPortal. */
-@Component({template: '
Pizza
'})
+@Component({template: '
Pizza
'})
class PizzaMsg {
constructor(public dialogRef: MdDialogRef) { }
}
diff --git a/src/lib/dialog/dialog.ts b/src/lib/dialog/dialog.ts
index cace47f1cf53..8709cd6c51a3 100644
--- a/src/lib/dialog/dialog.ts
+++ b/src/lib/dialog/dialog.ts
@@ -13,18 +13,18 @@ import {MdDialogConfig} from './dialog-config';
import {MdDialogRef} from './dialog-ref';
import {DialogInjector} from './dialog-injector';
import {MdDialogContainer} from './dialog-container';
+import {A11yModule} from '../core/a11y/index';
export {MdDialogConfig} from './dialog-config';
export {MdDialogRef} from './dialog-ref';
-// TODO(jelbourn): add shortcuts for `alert` and `confirm`.
// TODO(jelbourn): add support for opening with a TemplateRef
// TODO(jelbourn): add `closeAll` method
-// TODO(jelbourn): add backdrop
// TODO(jelbourn): default dialog config
-// TODO(jelbourn): focus trapping
-// TODO(jelbourn): potentially change API from accepting component constructor to component factory.
+// TODO(jelbourn): escape key closes dialog
+// TODO(jelbourn): dialog content directives (e.g., md-dialog-header)
+// TODO(jelbourn): animations
@@ -123,7 +123,7 @@ export class MdDialog {
@NgModule({
- imports: [OverlayModule, PortalModule],
+ imports: [OverlayModule, PortalModule, A11yModule],
exports: [MdDialogContainer],
declarations: [MdDialogContainer],
entryComponents: [MdDialogContainer],
@@ -132,7 +132,7 @@ export class MdDialogModule {
static forRoot(): ModuleWithProviders {
return {
ngModule: MdDialogModule,
- providers: [MdDialog, OVERLAY_PROVIDERS],
+ providers: [MdDialog, OVERLAY_PROVIDERS, A11yModule.forRoot().providers],
};
}
}