Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dialog): add focus management #1321

Merged
merged 2 commits into from
Sep 24, 2016
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 stances where change detection has to run first. To deal with this, we simply
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in stances -> in instances

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

// 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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you prefer as HTMLElement to (<HTMLElement>this.whatever).focus() ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't know the as syntax existed until recently; I do this it is a bit cleaner and more in keeping with TypeScript's normally putting the type at the end. We can chat about it when we're all back in the office.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't feel strongly about it; was just curious

});
}
}
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 the first tabbale element (input) in the dialog to be focused.');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tabbale -> tabbable

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

}));

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],
};
}
}