From 101ba4ef8c1349e9f8c602b0ab867ee5f28b684e Mon Sep 17 00:00:00 2001 From: Jeremy Elbourn Date: Fri, 23 Sep 2016 19:07:02 -0700 Subject: [PATCH 1/2] feat(dialog): add focus management --- src/lib/core/a11y/focus-trap.spec.ts | 4 +- src/lib/core/a11y/focus-trap.ts | 12 +++--- src/lib/core/a11y/index.ts | 22 ++++++++++ src/lib/core/core.ts | 9 +++-- src/lib/dialog/dialog-container.html | 4 +- src/lib/dialog/dialog-container.ts | 49 +++++++++++++++++++---- src/lib/dialog/dialog.spec.ts | 60 +++++++++++++++++++++++++++- src/lib/dialog/dialog.ts | 12 +++--- 8 files changed, 143 insertions(+), 29 deletions(-) create mode 100644 src/lib/core/a11y/index.ts 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..bbeef1490e71 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 stances 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..6ebd37093c5c 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 the first tabbale 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], }; } } From 73b358581be6279dfa326954c2ed0ff645178a17 Mon Sep 17 00:00:00 2001 From: Jeremy Elbourn Date: Fri, 23 Sep 2016 20:12:15 -0700 Subject: [PATCH 2/2] fix typos --- src/lib/dialog/dialog-container.ts | 2 +- src/lib/dialog/dialog.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/dialog/dialog-container.ts b/src/lib/dialog/dialog-container.ts index bbeef1490e71..cc38a229c550 100644 --- a/src/lib/dialog/dialog-container.ts +++ b/src/lib/dialog/dialog-container.ts @@ -53,7 +53,7 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy { 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 stances where change detection has to run first. To deal with this, we simply + // 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; diff --git a/src/lib/dialog/dialog.spec.ts b/src/lib/dialog/dialog.spec.ts index 6ebd37093c5c..432e21c675e9 100644 --- a/src/lib/dialog/dialog.spec.ts +++ b/src/lib/dialog/dialog.spec.ts @@ -128,7 +128,7 @@ describe('MdDialog', () => { flushMicrotasks(); expect(document.activeElement.tagName) - .toBe('INPUT', 'Expected the first tabbale element (input) in the dialog to be focused.'); + .toBe('INPUT', 'Expected first tabbable element (input) in the dialog to be focused.'); })); it('should re-focus trigger element when dialog closes', fakeAsync(() => {