From a1331ec574ab97b8335e32f97ea5c49372554777 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 9 Dec 2016 23:06:03 +0100 Subject: [PATCH] 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. --- src/lib/core/keyboard/keycodes.ts | 2 ++ src/lib/sidenav/sidenav.scss | 1 + src/lib/sidenav/sidenav.spec.ts | 54 +++++++++++++++++++++++++++++++ src/lib/sidenav/sidenav.ts | 49 ++++++++++++++++++++++++---- 4 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/lib/core/keyboard/keycodes.ts b/src/lib/core/keyboard/keycodes.ts index 6204987a0e8a..af4c4b702f9a 100644 --- a/src/lib/core/keyboard/keycodes.ts +++ b/src/lib/core/keyboard/keycodes.ts @@ -18,3 +18,5 @@ export const END = 35; export const ENTER = 13; export const SPACE = 32; export const TAB = 9; + +export const ESCAPE = 27; diff --git a/src/lib/sidenav/sidenav.scss b/src/lib/sidenav/sidenav.scss index 1349cfee58df..bb0533cd473c 100644 --- a/src/lib/sidenav/sidenav.scss +++ b/src/lib/sidenav/sidenav.scss @@ -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; diff --git a/src/lib/sidenav/sidenav.spec.ts b/src/lib/sidenav/sidenav.spec.ts index ad24acffd9ee..5678797639b1 100644 --- a/src/lib/sidenav/sidenav.spec.ts +++ b/src/lib/sidenav/sidenav.spec.ts @@ -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) { @@ -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', () => { diff --git a/src/lib/sidenav/sidenav.ts b/src/lib/sidenav/sidenav.ts index e6cc23d6e638..a993045b6602 100644 --- a/src/lib/sidenav/sidenav.ts +++ b/src/lib/sidenav/sidenav.ts @@ -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. */ @@ -40,6 +49,7 @@ export class MdSidenavToggleResult { template: '', 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', @@ -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, @@ -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 @@ -188,10 +217,6 @@ export class MdSidenav implements AfterContentInit { this.onCloseStart.emit(); } - if (!this.isFocusTrapDisabled) { - this._focusTrap.focusFirstTabbableElementWhenReady(); - } - if (this._toggleAnimationPromise) { this._resolveToggleAnimationPromise(false); } @@ -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 @@ -255,6 +290,8 @@ export class MdSidenav implements AfterContentInit { } return 0; } + + private _elementFocusedBeforeSidenavWasOpened: HTMLElement = null; } /**