Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Focus capturing for sidenav. #1695

Merged
merged 8 commits into from
Dec 6, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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