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
This commit is contained in:
mmalerba
2016-12-05 18:57:51 -08:00
committed by GitHub
parent 54bf6ceb1a
commit b9c3304b65
5 changed files with 116 additions and 19 deletions
+2 -2
View File
@@ -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>
+29 -2
View File
@@ -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() {
+2 -1
View File
@@ -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],
})
+66 -3
View File
@@ -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>) {
@@ -15,10 +17,9 @@ function endSidenavTransition(fixture: ComponentFixture<any>) {
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<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);
}));
});
});
@@ -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';
}
+17 -11
View File
@@ -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: '<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
@@ -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],
})