Skip to content

Commit

Permalink
feat(dialog): add focus management (#1321)
Browse files Browse the repository at this point in the history
* feat(dialog): add focus management

* fix typos
  • Loading branch information
jelbourn authored and kara committed Sep 24, 2016
1 parent 332a4a2 commit 74dc5b2
Show file tree
Hide file tree
Showing 8 changed files with 143 additions and 29 deletions.
4 changes: 2 additions & 2 deletions src/lib/core/a11y/focus-trap.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand Down
12 changes: 6 additions & 6 deletions src/lib/core/a11y/focus-trap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,26 @@ import {InteractivityChecker} from './interactivity-checker';
selector: 'focus-trap',
// TODO(jelbourn): move this to a separate file.
template: `
<div tabindex="0" (focus)="reverseWrapFocus()"></div>
<div tabindex="0" (focus)="focusLastTabbableElement()"></div>
<div #trappedContent><ng-content></ng-content></div>
<div tabindex="0" (focus)="wrapFocus()"></div>`,
<div tabindex="0" (focus)="focusFirstTabbableElement()"></div>`,
encapsulation: ViewEncapsulation.None,
})
export class FocusTrap {
@ViewChild('trappedContent') trappedContent: ElementRef;

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();
Expand Down
22 changes: 22 additions & 0 deletions src/lib/core/a11y/index.ts
Original file line number Diff line number Diff line change
@@ -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],
};
}
}
9 changes: 5 additions & 4 deletions src/lib/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
};
}
}
4 changes: 3 additions & 1 deletion src/lib/dialog/dialog-container.html
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
<template portalHost></template>
<focus-trap>
<template portalHost></template>
</focus-trap>
49 changes: 41 additions & 8 deletions src/lib/dialog/dialog-container.ts
Original file line number Diff line number Diff line change
@@ -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';


/**
Expand All @@ -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<T>(portal: ComponentPortal<T>): ComponentRef<T> {
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<string, any> {
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 <body>.
this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
(this._elementFocusedBeforeDialogWasOpened as HTMLElement).focus();
});
}
}
60 changes: 58 additions & 2 deletions src/lib/dialog/dialog.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
}));
});
});


Expand All @@ -121,7 +177,7 @@ class ComponentWithChildViewContainer {
}

/** Simple component for testing ComponentPortal. */
@Component({template: '<p>Pizza</p>'})
@Component({template: '<p>Pizza</p> <input> <button>Close</button>'})
class PizzaMsg {
constructor(public dialogRef: MdDialogRef<PizzaMsg>) { }
}
Expand Down
12 changes: 6 additions & 6 deletions src/lib/dialog/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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



Expand Down Expand Up @@ -123,7 +123,7 @@ export class MdDialog {


@NgModule({
imports: [OverlayModule, PortalModule],
imports: [OverlayModule, PortalModule, A11yModule],
exports: [MdDialogContainer],
declarations: [MdDialogContainer],
entryComponents: [MdDialogContainer],
Expand All @@ -132,7 +132,7 @@ export class MdDialogModule {
static forRoot(): ModuleWithProviders {
return {
ngModule: MdDialogModule,
providers: [MdDialog, OVERLAY_PROVIDERS],
providers: [MdDialog, OVERLAY_PROVIDERS, A11yModule.forRoot().providers],
};
}
}

0 comments on commit 74dc5b2

Please sign in to comment.