diff --git a/src/lib/core/a11y/focus-trap.html b/src/lib/core/a11y/focus-trap.html
index 577b68bfa8d1..077fc134b42a 100644
--- a/src/lib/core/a11y/focus-trap.html
+++ b/src/lib/core/a11y/focus-trap.html
@@ -1,3 +1,3 @@
-
+
-
+
diff --git a/src/lib/core/a11y/focus-trap.ts b/src/lib/core/a11y/focus-trap.ts
index 56368bb41e8d..bda63179dcbe 100644
--- a/src/lib/core/a11y/focus-trap.ts
+++ b/src/lib/core/a11y/focus-trap.ts
@@ -1,5 +1,6 @@
-import {Component, ViewEncapsulation, ViewChild, ElementRef} from '@angular/core';
+import {Component, ViewEncapsulation, ViewChild, ElementRef, Input, NgZone} from '@angular/core';
import {InteractivityChecker} from './interactivity-checker';
+import {coerceBooleanProperty} from '../coersion/boolean-property';
/**
@@ -19,7 +20,33 @@ import {InteractivityChecker} from './interactivity-checker';
export class FocusTrap {
@ViewChild('trappedContent') trappedContent: ElementRef;
- constructor(private _checker: InteractivityChecker) { }
+ /** Whether the focus trap is active. */
+ @Input()
+ get disabled(): boolean { return this._disabled; }
+ set disabled(val: boolean) { this._disabled = coerceBooleanProperty(val); }
+ private _disabled: boolean = false;
+
+ constructor(private _checker: InteractivityChecker, private _ngZone: NgZone) { }
+
+ /**
+ * Waits for microtask queue to empty, then focuses the first tabbable element within the focus
+ * trap region.
+ */
+ focusFirstTabbableElementWhenReady() {
+ this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
+ this.focusFirstTabbableElement();
+ });
+ }
+
+ /**
+ * Waits for microtask queue to empty, then focuses the last tabbable element within the focus
+ * trap region.
+ */
+ focusLastTabbableElementWhenReady() {
+ this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
+ this.focusLastTabbableElement();
+ });
+ }
/** Focuses the first tabbable element within the focus trap region. */
focusFirstTabbableElement() {
diff --git a/src/lib/core/a11y/index.ts b/src/lib/core/a11y/index.ts
index 0e285f77d60c..0be8e05abeb6 100644
--- a/src/lib/core/a11y/index.ts
+++ b/src/lib/core/a11y/index.ts
@@ -2,12 +2,13 @@ import {NgModule, ModuleWithProviders} from '@angular/core';
import {FocusTrap} from './focus-trap';
import {MdLiveAnnouncer} from './live-announcer';
import {InteractivityChecker} from './interactivity-checker';
+import {CommonModule} from '@angular/common';
import {PlatformModule} from '../platform/platform';
export const A11Y_PROVIDERS = [MdLiveAnnouncer, InteractivityChecker];
@NgModule({
- imports: [PlatformModule],
+ imports: [CommonModule, PlatformModule],
declarations: [FocusTrap],
exports: [FocusTrap],
})
diff --git a/src/lib/sidenav/sidenav.spec.ts b/src/lib/sidenav/sidenav.spec.ts
index 206519434115..ad24acffd9ee 100644
--- a/src/lib/sidenav/sidenav.spec.ts
+++ b/src/lib/sidenav/sidenav.spec.ts
@@ -2,6 +2,8 @@ import {fakeAsync, async, tick, ComponentFixture, TestBed} from '@angular/core/t
import {Component} from '@angular/core';
import {By} from '@angular/platform-browser';
import {MdSidenav, MdSidenavModule, MdSidenavToggleResult} from './sidenav';
+import {A11yModule} from '../core/a11y/index';
+import {PlatformModule} from '../core/platform/platform';
function endSidenavTransition(fixture: ComponentFixture) {
@@ -15,10 +17,9 @@ function endSidenavTransition(fixture: ComponentFixture) {
describe('MdSidenav', () => {
-
beforeEach(async(() => {
TestBed.configureTestingModule({
- imports: [MdSidenavModule.forRoot()],
+ imports: [MdSidenavModule.forRoot(), A11yModule.forRoot(), PlatformModule.forRoot()],
declarations: [
BasicTestApp,
SidenavLayoutTwoSidenavTestApp,
@@ -26,6 +27,7 @@ describe('MdSidenav', () => {
SidenavSetToOpenedFalse,
SidenavSetToOpenedTrue,
SidenavDynamicAlign,
+ SidenavWitFocusableElements,
],
});
@@ -236,7 +238,6 @@ describe('MdSidenav', () => {
});
describe('attributes', () => {
-
it('should correctly parse opened="false"', () => {
let fixture = TestBed.createComponent(SidenavSetToOpenedFalse);
fixture.detectChanges();
@@ -290,6 +291,55 @@ describe('MdSidenav', () => {
});
});
+ describe('focus trapping behavior', () => {
+ let fixture: ComponentFixture;
+ let testComponent: SidenavWitFocusableElements;
+ let sidenav: MdSidenav;
+ let firstFocusableElement: HTMLElement;
+ let lastFocusableElement: HTMLElement;
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(SidenavWitFocusableElements);
+ testComponent = fixture.debugElement.componentInstance;
+ sidenav = fixture.debugElement.query(By.directive(MdSidenav)).componentInstance;
+ firstFocusableElement = fixture.debugElement.query(By.css('.link1')).nativeElement;
+ lastFocusableElement = fixture.debugElement.query(By.css('.link1')).nativeElement;
+ lastFocusableElement.focus();
+ });
+
+ it('should trap focus when opened in "over" mode', fakeAsync(() => {
+ testComponent.mode = 'over';
+ lastFocusableElement.focus();
+
+ sidenav.open();
+ endSidenavTransition(fixture);
+ tick();
+
+ expect(document.activeElement).toBe(firstFocusableElement);
+ }));
+
+ it('should trap focus when opened in "push" mode', fakeAsync(() => {
+ testComponent.mode = 'push';
+ lastFocusableElement.focus();
+
+ sidenav.open();
+ endSidenavTransition(fixture);
+ tick();
+
+ expect(document.activeElement).toBe(firstFocusableElement);
+ }));
+
+ it('should not trap focus when opened in "side" mode', fakeAsync(() => {
+ testComponent.mode = 'side';
+ lastFocusableElement.focus();
+
+ sidenav.open();
+ endSidenavTransition(fixture);
+ tick();
+
+ expect(document.activeElement).toBe(lastFocusableElement);
+ }));
+ });
});
@@ -381,3 +431,16 @@ class SidenavDynamicAlign {
sidenav1Align = 'start';
sidenav2Align = 'end';
}
+
+@Component({
+ template: `
+
+
+ link1
+
+ link2
+ `,
+})
+class SidenavWitFocusableElements {
+ mode: string = 'over';
+}
diff --git a/src/lib/sidenav/sidenav.ts b/src/lib/sidenav/sidenav.ts
index 8398f6892320..a3083de1bacf 100644
--- a/src/lib/sidenav/sidenav.ts
+++ b/src/lib/sidenav/sidenav.ts
@@ -13,17 +13,12 @@ import {
EventEmitter,
Renderer,
ViewEncapsulation,
+ ViewChild
} from '@angular/core';
import {CommonModule} from '@angular/common';
-import {Dir, MdError, coerceBooleanProperty, DefaultStyleCompatibilityModeModule} from '../core';
-
-
-/** Exception thrown when two MdSidenav are matching the same side. */
-export class MdDuplicatedSidenavError extends MdError {
- constructor(align: string) {
- super(`A sidenav was already declared for 'align="${align}"'`);
- }
-}
+import {Dir, coerceBooleanProperty, DefaultStyleCompatibilityModeModule} from '../core';
+import {A11yModule} from '../core/a11y/index';
+import {FocusTrap} from '../core/a11y/focus-trap';
/** Sidenav toggle promise result. */
@@ -42,7 +37,7 @@ export class MdSidenavToggleResult {
@Component({
moduleId: module.id,
selector: 'md-sidenav, mat-sidenav',
- template: '',
+ template: '',
host: {
'(transitionend)': '_onTransitionEnd($event)',
// must prevent the browser from aligning text based on value
@@ -61,6 +56,8 @@ export class MdSidenavToggleResult {
encapsulation: ViewEncapsulation.None,
})
export class MdSidenav implements AfterContentInit {
+ @ViewChild(FocusTrap) _focusTrap: FocusTrap;
+
/** Alignment of the sidenav (direction neutral); whether 'start' or 'end'. */
private _align: 'start' | 'end' = 'start';
@@ -122,6 +119,11 @@ export class MdSidenav implements AfterContentInit {
*/
private _resolveToggleAnimationPromise: (animationFinished: boolean) => void = null;
+ get isFocusTrapDisabled() {
+ // The focus trap is only enabled when the sidenav is open in any mode other than side.
+ return !this.opened || this.mode == 'side';
+ }
+
/**
* @param _elementRef The DOM element reference. Used for transition and width calculation.
* If not available we do not hook on transitions.
@@ -186,6 +188,10 @@ export class MdSidenav implements AfterContentInit {
this.onCloseStart.emit();
}
+ if (!this.isFocusTrapDisabled) {
+ this._focusTrap.focusFirstTabbableElementWhenReady();
+ }
+
if (this._toggleAnimationPromise) {
this._resolveToggleAnimationPromise(false);
}
@@ -456,7 +462,7 @@ export class MdSidenavLayout implements AfterContentInit {
@NgModule({
- imports: [CommonModule, DefaultStyleCompatibilityModeModule],
+ imports: [CommonModule, DefaultStyleCompatibilityModeModule, A11yModule],
exports: [MdSidenavLayout, MdSidenav, DefaultStyleCompatibilityModeModule],
declarations: [MdSidenavLayout, MdSidenav],
})