Skip to content

Commit

Permalink
feat(sidenav): focus capturing (#1695)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
mmalerba authored Dec 6, 2016
1 parent 54bf6ce commit b9c3304
Show file tree
Hide file tree
Showing 5 changed files with 116 additions and 19 deletions.
4 changes: 2 additions & 2 deletions src/lib/core/a11y/focus-trap.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<div tabindex="0" (focus)="focusLastTabbableElement()"></div>
<div *ngIf="!disabled" tabindex="0" (focus)="focusLastTabbableElement()"></div>
<div #trappedContent><ng-content></ng-content></div>
<div tabindex="0" (focus)="focusFirstTabbableElement()"></div>
<div *ngIf="!disabled" tabindex="0" (focus)="focusFirstTabbableElement()"></div>
31 changes: 29 additions & 2 deletions src/lib/core/a11y/focus-trap.ts
Original file line number Diff line number Diff line change
@@ -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';


/**
Expand All @@ -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() {
Expand Down
3 changes: 2 additions & 1 deletion src/lib/core/a11y/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
})
Expand Down
69 changes: 66 additions & 3 deletions src/lib/sidenav/sidenav.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>) {
Expand All @@ -15,17 +17,17 @@ function endSidenavTransition(fixture: ComponentFixture<any>) {


describe('MdSidenav', () => {

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [MdSidenavModule.forRoot()],
imports: [MdSidenavModule.forRoot(), A11yModule.forRoot(), PlatformModule.forRoot()],
declarations: [
BasicTestApp,
SidenavLayoutTwoSidenavTestApp,
SidenavLayoutNoSidenavTestApp,
SidenavSetToOpenedFalse,
SidenavSetToOpenedTrue,
SidenavDynamicAlign,
SidenavWitFocusableElements,
],
});

Expand Down Expand Up @@ -236,7 +238,6 @@ describe('MdSidenav', () => {
});

describe('attributes', () => {

it('should correctly parse opened="false"', () => {
let fixture = TestBed.createComponent(SidenavSetToOpenedFalse);
fixture.detectChanges();
Expand Down Expand Up @@ -290,6 +291,55 @@ describe('MdSidenav', () => {
});
});

describe('focus trapping behavior', () => {
let fixture: ComponentFixture<SidenavWitFocusableElements>;
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);
}));
});
});


Expand Down Expand Up @@ -381,3 +431,16 @@ class SidenavDynamicAlign {
sidenav1Align = 'start';
sidenav2Align = 'end';
}

@Component({
template: `
<md-sidenav-layout>
<md-sidenav align="start" [mode]="mode">
<a class="link1" href="#">link1</a>
</md-sidenav>
<a class="link2" href="#">link2</a>
</md-sidenav-layout>`,
})
class SidenavWitFocusableElements {
mode: string = 'over';
}
28 changes: 17 additions & 11 deletions src/lib/sidenav/sidenav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -42,7 +37,7 @@ export class MdSidenavToggleResult {
@Component({
moduleId: module.id,
selector: 'md-sidenav, mat-sidenav',
template: '<ng-content></ng-content>',
template: '<focus-trap [disabled]="isFocusTrapDisabled"><ng-content></ng-content></focus-trap>',
host: {
'(transitionend)': '_onTransitionEnd($event)',
// must prevent the browser from aligning text based on value
Expand All @@ -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';

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -186,6 +188,10 @@ export class MdSidenav implements AfterContentInit {
this.onCloseStart.emit();
}

if (!this.isFocusTrapDisabled) {
this._focusTrap.focusFirstTabbableElementWhenReady();
}

if (this._toggleAnimationPromise) {
this._resolveToggleAnimationPromise(false);
}
Expand Down Expand Up @@ -456,7 +462,7 @@ export class MdSidenavLayout implements AfterContentInit {


@NgModule({
imports: [CommonModule, DefaultStyleCompatibilityModeModule],
imports: [CommonModule, DefaultStyleCompatibilityModeModule, A11yModule],
exports: [MdSidenavLayout, MdSidenav, DefaultStyleCompatibilityModeModule],
declarations: [MdSidenavLayout, MdSidenav],
})
Expand Down

0 comments on commit b9c3304

Please sign in to comment.