Skip to content

Commit

Permalink
feat(sidenav): close via escape key and restore focus to trigger elem…
Browse files Browse the repository at this point in the history
…ent (#1990)

* feat(sidenav): close via escape key and restore focus to trigger element

* Adds the ability to close a sidenav by pressing escape.
* Restores focus to the trigger element after a sidenav is closed.

* fix: test failures in IE and blur element if there's no focusable trigger

* fix: use the keycode instead of (keydown.escape)

* fix: use the renderer for focusing and blurring and fix a typo

* fix a faulty merge

* Fix a linter warning.

* Stop the propagation of the keydown event.

* Pointless commit to resolve git issue.

* Revert pointless commit.

* Fix conflict between the new functionality and the focus trapping.

* Move the focus trapping behavior to the onOpen listener for improved reliability.
  • Loading branch information
crisbeto authored and mmalerba committed Dec 9, 2016
1 parent 58d2aa3 commit a1331ec
Show file tree
Hide file tree
Showing 4 changed files with 100 additions and 6 deletions.
2 changes: 2 additions & 0 deletions src/lib/core/keyboard/keycodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@ export const END = 35;
export const ENTER = 13;
export const SPACE = 32;
export const TAB = 9;

export const ESCAPE = 27;
1 change: 1 addition & 0 deletions src/lib/sidenav/sidenav.scss
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ md-sidenav {
bottom: 0;
z-index: 3;
min-width: 5%;
outline: 0;

// TODO(kara): revisit scrolling behavior for sidenavs
overflow-y: auto;
Expand Down
54 changes: 54 additions & 0 deletions src/lib/sidenav/sidenav.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {By} from '@angular/platform-browser';
import {MdSidenav, MdSidenavModule, MdSidenavToggleResult} from './sidenav';
import {A11yModule} from '../core/a11y/index';
import {PlatformModule} from '../core/platform/platform';
import {ESCAPE} from '../core/keyboard/keycodes';


function endSidenavTransition(fixture: ComponentFixture<any>) {
Expand Down Expand Up @@ -235,6 +236,59 @@ describe('MdSidenav', () => {
expect(testComponent.backdropClickedCount).toBe(1);
}));

it('should close when pressing escape', fakeAsync(() => {
let fixture = TestBed.createComponent(BasicTestApp);
let testComponent: BasicTestApp = fixture.debugElement.componentInstance;
let sidenav: MdSidenav = fixture.debugElement
.query(By.directive(MdSidenav)).componentInstance;

sidenav.open();

fixture.detectChanges();
endSidenavTransition(fixture);
tick();

expect(testComponent.openCount).toBe(1);
expect(testComponent.closeCount).toBe(0);

// Simulate pressing the escape key.
sidenav.handleKeydown({
keyCode: ESCAPE,
stopPropagation: () => {}
} as KeyboardEvent);

fixture.detectChanges();
endSidenavTransition(fixture);
tick();

expect(testComponent.closeCount).toBe(1);
}));

it('should restore focus to the trigger element on close', fakeAsync(() => {
let fixture = TestBed.createComponent(BasicTestApp);
let sidenav: MdSidenav = fixture.debugElement
.query(By.directive(MdSidenav)).componentInstance;
let trigger = document.createElement('button');

document.body.appendChild(trigger);
trigger.focus();
sidenav.open();

fixture.detectChanges();
endSidenavTransition(fixture);
tick();

sidenav.close();

fixture.detectChanges();
endSidenavTransition(fixture);
tick();

expect(document.activeElement)
.toBe(trigger, 'Expected focus to be restored to the trigger on close.');

trigger.parentNode.removeChild(trigger);
}));
});

describe('attributes', () => {
Expand Down
49 changes: 43 additions & 6 deletions src/lib/sidenav/sidenav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,18 @@ import {
ViewChild
} from '@angular/core';
import {CommonModule} from '@angular/common';
import {Dir, coerceBooleanProperty, DefaultStyleCompatibilityModeModule} from '../core';
import {Dir, MdError, coerceBooleanProperty, DefaultStyleCompatibilityModeModule} from '../core';
import {A11yModule, A11Y_PROVIDERS} from '../core/a11y/index';
import {FocusTrap} from '../core/a11y/focus-trap';
import {ESCAPE} from '../core/keyboard/keycodes';


/** 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}"'`);
}
}


/** Sidenav toggle promise result. */
Expand All @@ -40,6 +49,7 @@ export class MdSidenavToggleResult {
template: '<focus-trap [disabled]="isFocusTrapDisabled"><ng-content></ng-content></focus-trap>',
host: {
'(transitionend)': '_onTransitionEnd($event)',
'(keydown)': 'handleKeydown($event)',
// must prevent the browser from aligning text based on value
'[attr.align]': 'null',
'[class.md-sidenav-closed]': '_isClosed',
Expand All @@ -51,6 +61,7 @@ export class MdSidenavToggleResult {
'[class.md-sidenav-push]': '_modePush',
'[class.md-sidenav-side]': '_modeSide',
'[class.md-sidenav-invalid]': '!valid',
'tabIndex': '-1'
},
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
Expand Down Expand Up @@ -128,7 +139,25 @@ export class MdSidenav implements AfterContentInit {
* @param _elementRef The DOM element reference. Used for transition and width calculation.
* If not available we do not hook on transitions.
*/
constructor(private _elementRef: ElementRef) {}
constructor(private _elementRef: ElementRef, private _renderer: Renderer) {
this.onOpen.subscribe(() => {
this._elementFocusedBeforeSidenavWasOpened = document.activeElement as HTMLElement;

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

this.onClose.subscribe(() => {
if (this._elementFocusedBeforeSidenavWasOpened instanceof HTMLElement) {
this._renderer.invokeElementMethod(this._elementFocusedBeforeSidenavWasOpened, 'focus');
} else {
this._renderer.invokeElementMethod(this._elementRef.nativeElement, 'blur');
}

this._elementFocusedBeforeSidenavWasOpened = null;
});
}

ngAfterContentInit() {
// This can happen when the sidenav is set to opened in the template and the transition
Expand Down Expand Up @@ -188,10 +217,6 @@ export class MdSidenav implements AfterContentInit {
this.onCloseStart.emit();
}

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

if (this._toggleAnimationPromise) {
this._resolveToggleAnimationPromise(false);
}
Expand All @@ -202,6 +227,16 @@ export class MdSidenav implements AfterContentInit {
return this._toggleAnimationPromise;
}

/**
* Handles the keyboard events.
*/
handleKeydown(event: KeyboardEvent) {
if (event.keyCode === ESCAPE) {
this.close();
event.stopPropagation();
}
}

/**
* When transition has finished, set the internal state for classes and emit the proper event.
* The event passed is actually of type TransitionEvent, but that type is not available in
Expand Down Expand Up @@ -255,6 +290,8 @@ export class MdSidenav implements AfterContentInit {
}
return 0;
}

private _elementFocusedBeforeSidenavWasOpened: HTMLElement = null;
}

/**
Expand Down

0 comments on commit a1331ec

Please sign in to comment.