Skip to content

Commit a1331ec

Browse files
crisbetommalerba
authored andcommitted
feat(sidenav): close via escape key and restore focus to trigger element (#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.
1 parent 58d2aa3 commit a1331ec

File tree

4 files changed

+100
-6
lines changed

4 files changed

+100
-6
lines changed

src/lib/core/keyboard/keycodes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,5 @@ export const END = 35;
1818
export const ENTER = 13;
1919
export const SPACE = 32;
2020
export const TAB = 9;
21+
22+
export const ESCAPE = 27;

src/lib/sidenav/sidenav.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ md-sidenav {
107107
bottom: 0;
108108
z-index: 3;
109109
min-width: 5%;
110+
outline: 0;
110111

111112
// TODO(kara): revisit scrolling behavior for sidenavs
112113
overflow-y: auto;

src/lib/sidenav/sidenav.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {By} from '@angular/platform-browser';
44
import {MdSidenav, MdSidenavModule, MdSidenavToggleResult} from './sidenav';
55
import {A11yModule} from '../core/a11y/index';
66
import {PlatformModule} from '../core/platform/platform';
7+
import {ESCAPE} from '../core/keyboard/keycodes';
78

89

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

239+
it('should close when pressing escape', fakeAsync(() => {
240+
let fixture = TestBed.createComponent(BasicTestApp);
241+
let testComponent: BasicTestApp = fixture.debugElement.componentInstance;
242+
let sidenav: MdSidenav = fixture.debugElement
243+
.query(By.directive(MdSidenav)).componentInstance;
244+
245+
sidenav.open();
246+
247+
fixture.detectChanges();
248+
endSidenavTransition(fixture);
249+
tick();
250+
251+
expect(testComponent.openCount).toBe(1);
252+
expect(testComponent.closeCount).toBe(0);
253+
254+
// Simulate pressing the escape key.
255+
sidenav.handleKeydown({
256+
keyCode: ESCAPE,
257+
stopPropagation: () => {}
258+
} as KeyboardEvent);
259+
260+
fixture.detectChanges();
261+
endSidenavTransition(fixture);
262+
tick();
263+
264+
expect(testComponent.closeCount).toBe(1);
265+
}));
266+
267+
it('should restore focus to the trigger element on close', fakeAsync(() => {
268+
let fixture = TestBed.createComponent(BasicTestApp);
269+
let sidenav: MdSidenav = fixture.debugElement
270+
.query(By.directive(MdSidenav)).componentInstance;
271+
let trigger = document.createElement('button');
272+
273+
document.body.appendChild(trigger);
274+
trigger.focus();
275+
sidenav.open();
276+
277+
fixture.detectChanges();
278+
endSidenavTransition(fixture);
279+
tick();
280+
281+
sidenav.close();
282+
283+
fixture.detectChanges();
284+
endSidenavTransition(fixture);
285+
tick();
286+
287+
expect(document.activeElement)
288+
.toBe(trigger, 'Expected focus to be restored to the trigger on close.');
289+
290+
trigger.parentNode.removeChild(trigger);
291+
}));
238292
});
239293

240294
describe('attributes', () => {

src/lib/sidenav/sidenav.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,18 @@ import {
1616
ViewChild
1717
} from '@angular/core';
1818
import {CommonModule} from '@angular/common';
19-
import {Dir, coerceBooleanProperty, DefaultStyleCompatibilityModeModule} from '../core';
19+
import {Dir, MdError, coerceBooleanProperty, DefaultStyleCompatibilityModeModule} from '../core';
2020
import {A11yModule, A11Y_PROVIDERS} from '../core/a11y/index';
2121
import {FocusTrap} from '../core/a11y/focus-trap';
22+
import {ESCAPE} from '../core/keyboard/keycodes';
23+
24+
25+
/** Exception thrown when two MdSidenav are matching the same side. */
26+
export class MdDuplicatedSidenavError extends MdError {
27+
constructor(align: string) {
28+
super(`A sidenav was already declared for 'align="${align}"'`);
29+
}
30+
}
2231

2332

2433
/** Sidenav toggle promise result. */
@@ -40,6 +49,7 @@ export class MdSidenavToggleResult {
4049
template: '<focus-trap [disabled]="isFocusTrapDisabled"><ng-content></ng-content></focus-trap>',
4150
host: {
4251
'(transitionend)': '_onTransitionEnd($event)',
52+
'(keydown)': 'handleKeydown($event)',
4353
// must prevent the browser from aligning text based on value
4454
'[attr.align]': 'null',
4555
'[class.md-sidenav-closed]': '_isClosed',
@@ -51,6 +61,7 @@ export class MdSidenavToggleResult {
5161
'[class.md-sidenav-push]': '_modePush',
5262
'[class.md-sidenav-side]': '_modeSide',
5363
'[class.md-sidenav-invalid]': '!valid',
64+
'tabIndex': '-1'
5465
},
5566
changeDetection: ChangeDetectionStrategy.OnPush,
5667
encapsulation: ViewEncapsulation.None,
@@ -128,7 +139,25 @@ export class MdSidenav implements AfterContentInit {
128139
* @param _elementRef The DOM element reference. Used for transition and width calculation.
129140
* If not available we do not hook on transitions.
130141
*/
131-
constructor(private _elementRef: ElementRef) {}
142+
constructor(private _elementRef: ElementRef, private _renderer: Renderer) {
143+
this.onOpen.subscribe(() => {
144+
this._elementFocusedBeforeSidenavWasOpened = document.activeElement as HTMLElement;
145+
146+
if (!this.isFocusTrapDisabled) {
147+
this._focusTrap.focusFirstTabbableElementWhenReady();
148+
}
149+
});
150+
151+
this.onClose.subscribe(() => {
152+
if (this._elementFocusedBeforeSidenavWasOpened instanceof HTMLElement) {
153+
this._renderer.invokeElementMethod(this._elementFocusedBeforeSidenavWasOpened, 'focus');
154+
} else {
155+
this._renderer.invokeElementMethod(this._elementRef.nativeElement, 'blur');
156+
}
157+
158+
this._elementFocusedBeforeSidenavWasOpened = null;
159+
});
160+
}
132161

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

191-
if (!this.isFocusTrapDisabled) {
192-
this._focusTrap.focusFirstTabbableElementWhenReady();
193-
}
194-
195220
if (this._toggleAnimationPromise) {
196221
this._resolveToggleAnimationPromise(false);
197222
}
@@ -202,6 +227,16 @@ export class MdSidenav implements AfterContentInit {
202227
return this._toggleAnimationPromise;
203228
}
204229

230+
/**
231+
* Handles the keyboard events.
232+
*/
233+
handleKeydown(event: KeyboardEvent) {
234+
if (event.keyCode === ESCAPE) {
235+
this.close();
236+
event.stopPropagation();
237+
}
238+
}
239+
205240
/**
206241
* When transition has finished, set the internal state for classes and emit the proper event.
207242
* The event passed is actually of type TransitionEvent, but that type is not available in
@@ -255,6 +290,8 @@ export class MdSidenav implements AfterContentInit {
255290
}
256291
return 0;
257292
}
293+
294+
private _elementFocusedBeforeSidenavWasOpened: HTMLElement = null;
258295
}
259296

260297
/**

0 commit comments

Comments
 (0)