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], })