Skip to content

Commit 74dc5b2

Browse files
jelbournkara
authored andcommitted
feat(dialog): add focus management (#1321)
* feat(dialog): add focus management * fix typos
1 parent 332a4a2 commit 74dc5b2

File tree

8 files changed

+143
-29
lines changed

8 files changed

+143
-29
lines changed

src/lib/core/a11y/focus-trap.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe('FocusTrap', () => {
2626

2727
// Because we can't mimic a real tab press focus change in a unit test, just call the
2828
// focus event handler directly.
29-
focusTrapInstance.wrapFocus();
29+
focusTrapInstance.focusFirstTabbableElement();
3030

3131
expect(document.activeElement.nodeName.toLowerCase())
3232
.toBe('input', 'Expected input element to be focused');
@@ -38,7 +38,7 @@ describe('FocusTrap', () => {
3838

3939
// Because we can't mimic a real tab press focus change in a unit test, just call the
4040
// focus event handler directly.
41-
focusTrapInstance.reverseWrapFocus();
41+
focusTrapInstance.focusLastTabbableElement();
4242

4343
expect(document.activeElement.nodeName.toLowerCase())
4444
.toBe('button', 'Expected button element to be focused');

src/lib/core/a11y/focus-trap.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,26 +15,26 @@ import {InteractivityChecker} from './interactivity-checker';
1515
selector: 'focus-trap',
1616
// TODO(jelbourn): move this to a separate file.
1717
template: `
18-
<div tabindex="0" (focus)="reverseWrapFocus()"></div>
18+
<div tabindex="0" (focus)="focusLastTabbableElement()"></div>
1919
<div #trappedContent><ng-content></ng-content></div>
20-
<div tabindex="0" (focus)="wrapFocus()"></div>`,
20+
<div tabindex="0" (focus)="focusFirstTabbableElement()"></div>`,
2121
encapsulation: ViewEncapsulation.None,
2222
})
2323
export class FocusTrap {
2424
@ViewChild('trappedContent') trappedContent: ElementRef;
2525

2626
constructor(private _checker: InteractivityChecker) { }
2727

28-
/** Wrap focus from the end of the trapped region to the beginning. */
29-
wrapFocus() {
28+
/** Focuses the first tabbable element within the focus trap region. */
29+
focusFirstTabbableElement() {
3030
let redirectToElement = this._getFirstTabbableElement(this.trappedContent.nativeElement);
3131
if (redirectToElement) {
3232
redirectToElement.focus();
3333
}
3434
}
3535

36-
/** Wrap focus from the beginning of the trapped region to the end. */
37-
reverseWrapFocus() {
36+
/** Focuses the last tabbable element within the focus trap region. */
37+
focusLastTabbableElement() {
3838
let redirectToElement = this._getLastTabbableElement(this.trappedContent.nativeElement);
3939
if (redirectToElement) {
4040
redirectToElement.focus();

src/lib/core/a11y/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {NgModule, ModuleWithProviders} from '@angular/core';
2+
import {FocusTrap} from './focus-trap';
3+
import {MdLiveAnnouncer} from './live-announcer';
4+
import {InteractivityChecker} from './interactivity-checker';
5+
6+
export {FocusTrap} from './focus-trap';
7+
export {MdLiveAnnouncer} from './live-announcer';
8+
export {InteractivityChecker} from './interactivity-checker';
9+
10+
11+
@NgModule({
12+
declarations: [FocusTrap],
13+
exports: [FocusTrap],
14+
})
15+
export class A11yModule {
16+
static forRoot(): ModuleWithProviders {
17+
return {
18+
ngModule: A11yModule,
19+
providers: [MdLiveAnnouncer, InteractivityChecker],
20+
};
21+
}
22+
}

src/lib/core/core.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import {RtlModule} from './rtl/dir';
44
import {MdRippleModule} from './ripple/ripple';
55
import {PortalModule} from './portal/portal-directives';
66
import {OverlayModule} from './overlay/overlay-directives';
7-
import {MdLiveAnnouncer} from './a11y/live-announcer';
7+
import {A11yModule} from './a11y/index';
8+
89

910
// RTL
1011
export {Dir, LayoutDirection, RtlModule} from './rtl/dir';
@@ -77,14 +78,14 @@ export * from './keyboard/keycodes';
7778

7879

7980
@NgModule({
80-
imports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule],
81-
exports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule],
81+
imports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule, A11yModule],
82+
exports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule, A11yModule],
8283
})
8384
export class MdCoreModule {
8485
static forRoot(): ModuleWithProviders {
8586
return {
8687
ngModule: MdCoreModule,
87-
providers: [MdLiveAnnouncer]
88+
providers: [A11yModule.forRoot().providers],
8889
};
8990
}
9091
}

src/lib/dialog/dialog-container.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
<template portalHost></template>
1+
<focus-trap>
2+
<template portalHost></template>
3+
</focus-trap>

src/lib/dialog/dialog-container.ts

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
import {Component, ComponentRef, ViewChild, ViewEncapsulation} from '@angular/core';
21
import {
3-
BasePortalHost,
4-
ComponentPortal,
5-
PortalHostDirective,
6-
TemplatePortal
7-
} from '../core';
2+
Component,
3+
ComponentRef,
4+
ViewChild,
5+
ViewEncapsulation,
6+
NgZone,
7+
OnDestroy
8+
} from '@angular/core';
9+
import {BasePortalHost, ComponentPortal, PortalHostDirective, TemplatePortal} from '../core';
810
import {MdDialogConfig} from './dialog-config';
911
import {MdDialogContentAlreadyAttachedError} from './dialog-errors';
12+
import {FocusTrap} from '../core/a11y/focus-trap';
13+
import 'rxjs/add/operator/first';
1014

1115

1216
/**
@@ -23,23 +27,52 @@ import {MdDialogContentAlreadyAttachedError} from './dialog-errors';
2327
},
2428
encapsulation: ViewEncapsulation.None,
2529
})
26-
export class MdDialogContainer extends BasePortalHost {
30+
export class MdDialogContainer extends BasePortalHost implements OnDestroy {
2731
/** The portal host inside of this container into which the dialog content will be loaded. */
2832
@ViewChild(PortalHostDirective) _portalHost: PortalHostDirective;
2933

34+
/** The directive that traps and manages focus within the dialog. */
35+
@ViewChild(FocusTrap) _focusTrap: FocusTrap;
36+
37+
/** Element that was focused before the dialog was opened. Save this to restore upon close. */
38+
private _elementFocusedBeforeDialogWasOpened: Element = null;
39+
3040
/** The dialog configuration. */
3141
dialogConfig: MdDialogConfig;
3242

43+
constructor(private _ngZone: NgZone) {
44+
super();
45+
}
46+
3347
/** Attach a portal as content to this dialog container. */
3448
attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T> {
3549
if (this._portalHost.hasAttached()) {
3650
throw new MdDialogContentAlreadyAttachedError();
3751
}
3852

39-
return this._portalHost.attachComponentPortal(portal);
53+
let attachResult = this._portalHost.attachComponentPortal(portal);
54+
55+
// If were to attempt to focus immediately, then the content of the dialog would not yet be
56+
// ready in instances where change detection has to run first. To deal with this, we simply
57+
// wait for the microtask queue to be empty.
58+
this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
59+
this._elementFocusedBeforeDialogWasOpened = document.activeElement;
60+
this._focusTrap.focusFirstTabbableElement();
61+
});
62+
63+
return attachResult;
4064
}
4165

4266
attachTemplatePortal(portal: TemplatePortal): Map<string, any> {
4367
throw Error('Not yet implemented');
4468
}
69+
70+
ngOnDestroy() {
71+
// When the dialog is destroyed, return focus to the element that originally had it before
72+
// the dialog was opened. Wait for the DOM to finish settling before changing the focus so
73+
// that it doesn't end up back on the <body>.
74+
this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
75+
(this._elementFocusedBeforeDialogWasOpened as HTMLElement).focus();
76+
});
77+
}
4578
}

src/lib/dialog/dialog.spec.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import {inject, async, ComponentFixture, TestBed} from '@angular/core/testing';
1+
import {
2+
inject,
3+
async,
4+
fakeAsync,
5+
flushMicrotasks,
6+
ComponentFixture,
7+
TestBed,
8+
} from '@angular/core/testing';
29
import {NgModule, Component, Directive, ViewChild, ViewContainerRef} from '@angular/core';
310
import {MdDialog, MdDialogModule} from './dialog';
411
import {OverlayContainer} from '../core';
@@ -100,6 +107,55 @@ describe('MdDialog', () => {
100107

101108
expect(overlayContainerElement.querySelector('md-dialog-container')).toBeFalsy();
102109
});
110+
111+
describe('focus management', () => {
112+
113+
// When testing focus, all of the elements must be in the DOM.
114+
beforeEach(() => {
115+
document.body.appendChild(overlayContainerElement);
116+
});
117+
118+
afterEach(() => {
119+
document.body.removeChild(overlayContainerElement);
120+
});
121+
122+
it('should focus the first tabbable element of the dialog on open', fakeAsync(() => {
123+
let config = new MdDialogConfig();
124+
config.viewContainerRef = testViewContainerRef;
125+
126+
dialog.open(PizzaMsg, config);
127+
viewContainerFixture.detectChanges();
128+
flushMicrotasks();
129+
130+
expect(document.activeElement.tagName)
131+
.toBe('INPUT', 'Expected first tabbable element (input) in the dialog to be focused.');
132+
}));
133+
134+
it('should re-focus trigger element when dialog closes', fakeAsync(() => {
135+
// Create a element that has focus before the dialog is opened.
136+
let button = document.createElement('button');
137+
button.id = 'dialog-trigger';
138+
document.body.appendChild(button);
139+
button.focus();
140+
141+
let config = new MdDialogConfig();
142+
config.viewContainerRef = testViewContainerRef;
143+
144+
let dialogRef = dialog.open(PizzaMsg, config);
145+
viewContainerFixture.detectChanges();
146+
flushMicrotasks();
147+
148+
expect(document.activeElement.id)
149+
.not.toBe('dialog-trigger', 'Expected the focus to change when dialog was opened.');
150+
151+
dialogRef.close();
152+
viewContainerFixture.detectChanges();
153+
flushMicrotasks();
154+
155+
expect(document.activeElement.id)
156+
.toBe('dialog-trigger', 'Expected that the trigger was refocused after dialog close');
157+
}));
158+
});
103159
});
104160

105161

@@ -121,7 +177,7 @@ class ComponentWithChildViewContainer {
121177
}
122178

123179
/** Simple component for testing ComponentPortal. */
124-
@Component({template: '<p>Pizza</p>'})
180+
@Component({template: '<p>Pizza</p> <input> <button>Close</button>'})
125181
class PizzaMsg {
126182
constructor(public dialogRef: MdDialogRef<PizzaMsg>) { }
127183
}

src/lib/dialog/dialog.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,18 @@ import {MdDialogConfig} from './dialog-config';
1313
import {MdDialogRef} from './dialog-ref';
1414
import {DialogInjector} from './dialog-injector';
1515
import {MdDialogContainer} from './dialog-container';
16+
import {A11yModule} from '../core/a11y/index';
1617

1718
export {MdDialogConfig} from './dialog-config';
1819
export {MdDialogRef} from './dialog-ref';
1920

2021

21-
// TODO(jelbourn): add shortcuts for `alert` and `confirm`.
2222
// TODO(jelbourn): add support for opening with a TemplateRef
2323
// TODO(jelbourn): add `closeAll` method
24-
// TODO(jelbourn): add backdrop
2524
// TODO(jelbourn): default dialog config
26-
// TODO(jelbourn): focus trapping
27-
// TODO(jelbourn): potentially change API from accepting component constructor to component factory.
25+
// TODO(jelbourn): escape key closes dialog
26+
// TODO(jelbourn): dialog content directives (e.g., md-dialog-header)
27+
// TODO(jelbourn): animations
2828

2929

3030

@@ -123,7 +123,7 @@ export class MdDialog {
123123

124124

125125
@NgModule({
126-
imports: [OverlayModule, PortalModule],
126+
imports: [OverlayModule, PortalModule, A11yModule],
127127
exports: [MdDialogContainer],
128128
declarations: [MdDialogContainer],
129129
entryComponents: [MdDialogContainer],
@@ -132,7 +132,7 @@ export class MdDialogModule {
132132
static forRoot(): ModuleWithProviders {
133133
return {
134134
ngModule: MdDialogModule,
135-
providers: [MdDialog, OVERLAY_PROVIDERS],
135+
providers: [MdDialog, OVERLAY_PROVIDERS, A11yModule.forRoot().providers],
136136
};
137137
}
138138
}

0 commit comments

Comments
 (0)