Skip to content

Commit 9131d00

Browse files
committed
Focus capturing for sidenav.
Captures focus when sidenav is open in "over" or "push" mode, but not when opened in "side" mode.
1 parent f525db1 commit 9131d00

File tree

6 files changed

+116
-21
lines changed

6 files changed

+116
-21
lines changed

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

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import {Component, ViewEncapsulation, ViewChild, ElementRef} from '@angular/core';
1+
import {
2+
Component, ViewEncapsulation, ViewChild, ElementRef, Input, AfterContentInit, NgZone
3+
} from '@angular/core';
24
import {InteractivityChecker} from './interactivity-checker';
35

46

@@ -15,15 +17,44 @@ import {InteractivityChecker} from './interactivity-checker';
1517
selector: 'focus-trap',
1618
// TODO(jelbourn): move this to a separate file.
1719
template: `
18-
<div tabindex="0" (focus)="focusLastTabbableElement()"></div>
20+
<div *ngIf="active" tabindex="0" (focus)="focusLastTabbableElement()"></div>
1921
<div #trappedContent><ng-content></ng-content></div>
20-
<div tabindex="0" (focus)="focusFirstTabbableElement()"></div>`,
22+
<div *ngIf="active" tabindex="0" (focus)="focusFirstTabbableElement()"></div>`,
2123
encapsulation: ViewEncapsulation.None,
2224
})
23-
export class FocusTrap {
25+
export class FocusTrap implements AfterContentInit {
2426
@ViewChild('trappedContent') trappedContent: ElementRef;
2527

26-
constructor(private _checker: InteractivityChecker) { }
28+
@Input()
29+
get active(): boolean {
30+
return this._active;
31+
}
32+
set active(val : boolean) {
33+
this._active = val;
34+
if (val && this._contentReady) {
35+
this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
36+
this.focusFirstTabbableElement();
37+
});
38+
}
39+
}
40+
41+
/** Whether the DOM content is ready. */
42+
private _contentReady : boolean = false;
43+
44+
/** Whether the focus trap is active. */
45+
private _active : boolean = true;
46+
47+
constructor(private _checker: InteractivityChecker, private _ngZone: NgZone) { }
48+
49+
ngAfterContentInit() {
50+
this._contentReady = true;
51+
// Trigger setter behavior.
52+
if (this.active) {
53+
this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
54+
this.focusFirstTabbableElement();
55+
});
56+
}
57+
}
2758

2859
/** Focuses the first tabbable element within the focus trap region. */
2960
focusFirstTabbableElement() {

src/lib/core/a11y/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import {NgModule, ModuleWithProviders} from '@angular/core';
22
import {FocusTrap} from './focus-trap';
33
import {MdLiveAnnouncer} from './live-announcer';
44
import {InteractivityChecker} from './interactivity-checker';
5+
import {CommonModule} from "@angular/common";
56

67
export const A11Y_PROVIDERS = [MdLiveAnnouncer, InteractivityChecker];
78

89
@NgModule({
910
declarations: [FocusTrap],
11+
imports: [CommonModule],
1012
exports: [FocusTrap],
1113
})
1214
export class A11yModule {
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
<focus-trap>
1+
<focus-trap [active]="focusTrapActive">
22
<template portalHost></template>
33
</focus-trap>

src/lib/dialog/dialog-container.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {BasePortalHost, ComponentPortal, PortalHostDirective, TemplatePortal} fr
1010
import {MdDialogConfig} from './dialog-config';
1111
import {MdDialogRef} from './dialog-ref';
1212
import {MdDialogContentAlreadyAttachedError} from './dialog-errors';
13-
import {FocusTrap} from '../core/a11y/focus-trap';
1413
import 'rxjs/add/operator/first';
1514

1615

@@ -33,9 +32,6 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy {
3332
/** The portal host inside of this container into which the dialog content will be loaded. */
3433
@ViewChild(PortalHostDirective) _portalHost: PortalHostDirective;
3534

36-
/** The directive that traps and manages focus within the dialog. */
37-
@ViewChild(FocusTrap) _focusTrap: FocusTrap;
38-
3935
/** Element that was focused before the dialog was opened. Save this to restore upon close. */
4036
private _elementFocusedBeforeDialogWasOpened: Element = null;
4137

@@ -45,6 +41,9 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy {
4541
/** Reference to the open dialog. */
4642
dialogRef: MdDialogRef<any>;
4743

44+
/** Whether the focus trap is active. */
45+
focusTrapActive: boolean = false;
46+
4847
constructor(private _ngZone: NgZone) {
4948
super();
5049
}
@@ -57,13 +56,8 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy {
5756

5857
let attachResult = this._portalHost.attachComponentPortal(portal);
5958

60-
// If were to attempt to focus immediately, then the content of the dialog would not yet be
61-
// ready in instances where change detection has to run first. To deal with this, we simply
62-
// wait for the microtask queue to be empty.
63-
this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
64-
this._elementFocusedBeforeDialogWasOpened = document.activeElement;
65-
this._focusTrap.focusFirstTabbableElement();
66-
});
59+
this._elementFocusedBeforeDialogWasOpened = document.activeElement;
60+
this.focusTrapActive = true;
6761

6862
return attachResult;
6963
}

src/lib/sidenav/sidenav.spec.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {fakeAsync, async, tick, ComponentFixture, TestBed} from '@angular/core/t
22
import {Component} from '@angular/core';
33
import {By} from '@angular/platform-browser';
44
import {MdSidenav, MdSidenavModule} from './sidenav';
5+
import {A11yModule} from "../core/a11y/index";
56

67

78
function endSidenavTransition(fixture: ComponentFixture<any>) {
@@ -18,14 +19,15 @@ describe('MdSidenav', () => {
1819

1920
beforeEach(async(() => {
2021
TestBed.configureTestingModule({
21-
imports: [MdSidenavModule.forRoot()],
22+
imports: [MdSidenavModule.forRoot(), A11yModule.forRoot()],
2223
declarations: [
2324
BasicTestApp,
2425
SidenavLayoutTwoSidenavTestApp,
2526
SidenavLayoutNoSidenavTestApp,
2627
SidenavSetToOpenedFalse,
2728
SidenavSetToOpenedTrue,
2829
SidenavDynamicAlign,
30+
SidenavWitFocusableElements,
2931
],
3032
});
3133

@@ -197,7 +199,6 @@ describe('MdSidenav', () => {
197199
});
198200

199201
describe('attributes', () => {
200-
201202
it('should correctly parse opened="false"', () => {
202203
let fixture = TestBed.createComponent(SidenavSetToOpenedFalse);
203204
fixture.detectChanges();
@@ -251,6 +252,55 @@ describe('MdSidenav', () => {
251252
});
252253
});
253254

255+
describe('focus trapping behavior', () => {
256+
let fixture: ComponentFixture<SidenavWitFocusableElements>;
257+
let testComponent: SidenavWitFocusableElements;
258+
let sidenav: MdSidenav;
259+
let link1Element: HTMLElement;
260+
let link2Element: HTMLElement;
261+
262+
beforeEach(() => {
263+
fixture = TestBed.createComponent(SidenavWitFocusableElements);
264+
testComponent = fixture.debugElement.componentInstance;
265+
sidenav = fixture.debugElement.query(By.directive(MdSidenav)).componentInstance;
266+
link1Element = fixture.debugElement.query(By.css('.link1')).nativeElement;
267+
link2Element = fixture.debugElement.query(By.css('.link1')).nativeElement;
268+
link2Element.focus();
269+
});
270+
271+
it('should trp focus when opened in "over" mode', fakeAsync(() => {
272+
testComponent.mode = 'over';
273+
link2Element.focus();
274+
275+
sidenav.open();
276+
endSidenavTransition(fixture);
277+
tick();
278+
279+
expect(document.activeElement).toBe(link1Element);
280+
}));
281+
282+
it('should trap tabs when opened in "push" mode', fakeAsync(() => {
283+
testComponent.mode = 'push';
284+
link2Element.focus();
285+
286+
sidenav.open();
287+
endSidenavTransition(fixture);
288+
tick();
289+
290+
expect(document.activeElement).toBe(link1Element);
291+
}));
292+
293+
it('should not trap tabs when opened in "side" mode', fakeAsync(() => {
294+
testComponent.mode = 'side';
295+
link2Element.focus();
296+
297+
sidenav.open();
298+
endSidenavTransition(fixture);
299+
tick();
300+
301+
expect(document.activeElement).toBe(link2Element);
302+
}));
303+
});
254304
});
255305

256306

@@ -337,3 +387,16 @@ class SidenavDynamicAlign {
337387
sidenav1Align = 'start';
338388
sidenav2Align = 'end';
339389
}
390+
391+
@Component({
392+
template: `
393+
<md-sidenav-layout>
394+
<md-sidenav align="start" [mode]="mode">
395+
<a class="link1" href="#">link1</a>
396+
</md-sidenav>
397+
<a class="link2" href="#">link2</a>
398+
</md-sidenav-layout>`,
399+
})
400+
class SidenavWitFocusableElements {
401+
mode: string = 'over';
402+
}

src/lib/sidenav/sidenav.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from '@angular/core';
1717
import {CommonModule} from '@angular/common';
1818
import {Dir, MdError, coerceBooleanProperty} from '../core';
19+
import {A11yModule} from "../core/a11y/index";
1920

2021

2122
/** Exception thrown when two MdSidenav are matching the same side. */
@@ -35,7 +36,7 @@ export class MdDuplicatedSidenavError extends MdError {
3536
@Component({
3637
moduleId: module.id,
3738
selector: 'md-sidenav',
38-
template: '<ng-content></ng-content>',
39+
template: '<focus-trap [active]="focusTrapActive"><ng-content></ng-content></focus-trap>',
3940
host: {
4041
'(transitionend)': '_onTransitionEnd($event)',
4142
// must prevent the browser from aligning text based on value
@@ -106,6 +107,10 @@ export class MdSidenav implements AfterContentInit {
106107
/** Event emitted when the sidenav alignment changes. */
107108
@Output('align-changed') onAlignChanged = new EventEmitter<void>();
108109

110+
get focusTrapActive() {
111+
return this.opened && this.mode != 'side';
112+
}
113+
109114
/**
110115
* @param _elementRef The DOM element reference. Used for transition and width calculation.
111116
* If not available we do not hook on transitions.
@@ -465,7 +470,7 @@ export class MdSidenavLayout implements AfterContentInit {
465470

466471

467472
@NgModule({
468-
imports: [CommonModule],
473+
imports: [CommonModule, A11yModule],
469474
exports: [MdSidenavLayout, MdSidenav],
470475
declarations: [MdSidenavLayout, MdSidenav],
471476
})

0 commit comments

Comments
 (0)