feat(dialog): add focus management (#1321)

* feat(dialog): add focus management

* fix typos
This commit is contained in:
Jeremy Elbourn
2016-09-23 20:13:24 -07:00
committed by Kara
parent 332a4a2273
commit 74dc5b28e0
8 changed files with 143 additions and 29 deletions
+2 -2
View File
@@ -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');
+6 -6
View File
@@ -15,9 +15,9 @@ 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 {
@@ -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();
+22
View File
@@ -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],
};
}
}
+5 -4
View File
@@ -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],
};
}
}
+3 -1
View File
@@ -1 +1,3 @@
<template portalHost></template>
<focus-trap>
<template portalHost></template>
</focus-trap>
+41 -8
View File
@@ -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<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();
});
}
}
+58 -2
View File
@@ -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: '<p>Pizza</p>'})
@Component({template: '<p>Pizza</p> <input> <button>Close</button>'})
class PizzaMsg {
constructor(public dialogRef: MdDialogRef<PizzaMsg>) { }
}
+6 -6
View File
@@ -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],
};
}
}