Skip to content

Commit b9c3304

Browse files
authored
feat(sidenav): focus capturing (#1695)
* Focus capturing for sidenav. Captures focus when sidenav is open in "over" or "push" mode, but not when opened in "side" mode. * lint fixes. * address comments * addressed comments * s/active/!disabled * fix tests * fix lint * fix visibility issue
1 parent 54bf6ce commit b9c3304

File tree

5 files changed

+116
-19
lines changed

5 files changed

+116
-19
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
<div tabindex="0" (focus)="focusLastTabbableElement()"></div>
1+
<div *ngIf="!disabled" tabindex="0" (focus)="focusLastTabbableElement()"></div>
22
<div #trappedContent><ng-content></ng-content></div>
3-
<div tabindex="0" (focus)="focusFirstTabbableElement()"></div>
3+
<div *ngIf="!disabled" tabindex="0" (focus)="focusFirstTabbableElement()"></div>

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

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import {Component, ViewEncapsulation, ViewChild, ElementRef} from '@angular/core';
1+
import {Component, ViewEncapsulation, ViewChild, ElementRef, Input, NgZone} from '@angular/core';
22
import {InteractivityChecker} from './interactivity-checker';
3+
import {coerceBooleanProperty} from '../coersion/boolean-property';
34

45

56
/**
@@ -19,7 +20,33 @@ import {InteractivityChecker} from './interactivity-checker';
1920
export class FocusTrap {
2021
@ViewChild('trappedContent') trappedContent: ElementRef;
2122

22-
constructor(private _checker: InteractivityChecker) { }
23+
/** Whether the focus trap is active. */
24+
@Input()
25+
get disabled(): boolean { return this._disabled; }
26+
set disabled(val: boolean) { this._disabled = coerceBooleanProperty(val); }
27+
private _disabled: boolean = false;
28+
29+
constructor(private _checker: InteractivityChecker, private _ngZone: NgZone) { }
30+
31+
/**
32+
* Waits for microtask queue to empty, then focuses the first tabbable element within the focus
33+
* trap region.
34+
*/
35+
focusFirstTabbableElementWhenReady() {
36+
this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
37+
this.focusFirstTabbableElement();
38+
});
39+
}
40+
41+
/**
42+
* Waits for microtask queue to empty, then focuses the last tabbable element within the focus
43+
* trap region.
44+
*/
45+
focusLastTabbableElementWhenReady() {
46+
this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
47+
this.focusLastTabbableElement();
48+
});
49+
}
2350

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

src/lib/core/a11y/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +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
import {PlatformModule} from '../platform/platform';
67

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

910
@NgModule({
10-
imports: [PlatformModule],
11+
imports: [CommonModule, PlatformModule],
1112
declarations: [FocusTrap],
1213
exports: [FocusTrap],
1314
})

src/lib/sidenav/sidenav.spec.ts

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ 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, MdSidenavToggleResult} from './sidenav';
5+
import {A11yModule} from '../core/a11y/index';
6+
import {PlatformModule} from '../core/platform/platform';
57

68

79
function endSidenavTransition(fixture: ComponentFixture<any>) {
@@ -15,17 +17,17 @@ function endSidenavTransition(fixture: ComponentFixture<any>) {
1517

1618

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

@@ -236,7 +238,6 @@ describe('MdSidenav', () => {
236238
});
237239

238240
describe('attributes', () => {
239-
240241
it('should correctly parse opened="false"', () => {
241242
let fixture = TestBed.createComponent(SidenavSetToOpenedFalse);
242243
fixture.detectChanges();
@@ -290,6 +291,55 @@ describe('MdSidenav', () => {
290291
});
291292
});
292293

294+
describe('focus trapping behavior', () => {
295+
let fixture: ComponentFixture<SidenavWitFocusableElements>;
296+
let testComponent: SidenavWitFocusableElements;
297+
let sidenav: MdSidenav;
298+
let firstFocusableElement: HTMLElement;
299+
let lastFocusableElement: HTMLElement;
300+
301+
beforeEach(() => {
302+
fixture = TestBed.createComponent(SidenavWitFocusableElements);
303+
testComponent = fixture.debugElement.componentInstance;
304+
sidenav = fixture.debugElement.query(By.directive(MdSidenav)).componentInstance;
305+
firstFocusableElement = fixture.debugElement.query(By.css('.link1')).nativeElement;
306+
lastFocusableElement = fixture.debugElement.query(By.css('.link1')).nativeElement;
307+
lastFocusableElement.focus();
308+
});
309+
310+
it('should trap focus when opened in "over" mode', fakeAsync(() => {
311+
testComponent.mode = 'over';
312+
lastFocusableElement.focus();
313+
314+
sidenav.open();
315+
endSidenavTransition(fixture);
316+
tick();
317+
318+
expect(document.activeElement).toBe(firstFocusableElement);
319+
}));
320+
321+
it('should trap focus when opened in "push" mode', fakeAsync(() => {
322+
testComponent.mode = 'push';
323+
lastFocusableElement.focus();
324+
325+
sidenav.open();
326+
endSidenavTransition(fixture);
327+
tick();
328+
329+
expect(document.activeElement).toBe(firstFocusableElement);
330+
}));
331+
332+
it('should not trap focus when opened in "side" mode', fakeAsync(() => {
333+
testComponent.mode = 'side';
334+
lastFocusableElement.focus();
335+
336+
sidenav.open();
337+
endSidenavTransition(fixture);
338+
tick();
339+
340+
expect(document.activeElement).toBe(lastFocusableElement);
341+
}));
342+
});
293343
});
294344

295345

@@ -381,3 +431,16 @@ class SidenavDynamicAlign {
381431
sidenav1Align = 'start';
382432
sidenav2Align = 'end';
383433
}
434+
435+
@Component({
436+
template: `
437+
<md-sidenav-layout>
438+
<md-sidenav align="start" [mode]="mode">
439+
<a class="link1" href="#">link1</a>
440+
</md-sidenav>
441+
<a class="link2" href="#">link2</a>
442+
</md-sidenav-layout>`,
443+
})
444+
class SidenavWitFocusableElements {
445+
mode: string = 'over';
446+
}

src/lib/sidenav/sidenav.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,12 @@ import {
1313
EventEmitter,
1414
Renderer,
1515
ViewEncapsulation,
16+
ViewChild
1617
} from '@angular/core';
1718
import {CommonModule} from '@angular/common';
18-
import {Dir, MdError, coerceBooleanProperty, DefaultStyleCompatibilityModeModule} from '../core';
19-
20-
21-
/** Exception thrown when two MdSidenav are matching the same side. */
22-
export class MdDuplicatedSidenavError extends MdError {
23-
constructor(align: string) {
24-
super(`A sidenav was already declared for 'align="${align}"'`);
25-
}
26-
}
19+
import {Dir, coerceBooleanProperty, DefaultStyleCompatibilityModeModule} from '../core';
20+
import {A11yModule} from '../core/a11y/index';
21+
import {FocusTrap} from '../core/a11y/focus-trap';
2722

2823

2924
/** Sidenav toggle promise result. */
@@ -42,7 +37,7 @@ export class MdSidenavToggleResult {
4237
@Component({
4338
moduleId: module.id,
4439
selector: 'md-sidenav, mat-sidenav',
45-
template: '<ng-content></ng-content>',
40+
template: '<focus-trap [disabled]="isFocusTrapDisabled"><ng-content></ng-content></focus-trap>',
4641
host: {
4742
'(transitionend)': '_onTransitionEnd($event)',
4843
// must prevent the browser from aligning text based on value
@@ -61,6 +56,8 @@ export class MdSidenavToggleResult {
6156
encapsulation: ViewEncapsulation.None,
6257
})
6358
export class MdSidenav implements AfterContentInit {
59+
@ViewChild(FocusTrap) _focusTrap: FocusTrap;
60+
6461
/** Alignment of the sidenav (direction neutral); whether 'start' or 'end'. */
6562
private _align: 'start' | 'end' = 'start';
6663

@@ -122,6 +119,11 @@ export class MdSidenav implements AfterContentInit {
122119
*/
123120
private _resolveToggleAnimationPromise: (animationFinished: boolean) => void = null;
124121

122+
get isFocusTrapDisabled() {
123+
// The focus trap is only enabled when the sidenav is open in any mode other than side.
124+
return !this.opened || this.mode == 'side';
125+
}
126+
125127
/**
126128
* @param _elementRef The DOM element reference. Used for transition and width calculation.
127129
* If not available we do not hook on transitions.
@@ -186,6 +188,10 @@ export class MdSidenav implements AfterContentInit {
186188
this.onCloseStart.emit();
187189
}
188190

191+
if (!this.isFocusTrapDisabled) {
192+
this._focusTrap.focusFirstTabbableElementWhenReady();
193+
}
194+
189195
if (this._toggleAnimationPromise) {
190196
this._resolveToggleAnimationPromise(false);
191197
}
@@ -456,7 +462,7 @@ export class MdSidenavLayout implements AfterContentInit {
456462

457463

458464
@NgModule({
459-
imports: [CommonModule, DefaultStyleCompatibilityModeModule],
465+
imports: [CommonModule, DefaultStyleCompatibilityModeModule, A11yModule],
460466
exports: [MdSidenavLayout, MdSidenav, DefaultStyleCompatibilityModeModule],
461467
declarations: [MdSidenavLayout, MdSidenav],
462468
})

0 commit comments

Comments
 (0)