From 20b47d72b17b53799ef44c99c8d665d6d8590d92 Mon Sep 17 00:00:00 2001 From: Jeremy Elbourn Date: Tue, 7 Nov 2017 09:58:41 -0800 Subject: [PATCH] feat(a11y): add autoCapture option to cdkTrapFocus (#7641) This also renames `FocusTrapDirective` to `CdkTrapFocus` and re-exports it under the old name. --- src/cdk/a11y/a11y-module.ts | 6 ++-- src/cdk/a11y/focus-trap.spec.ts | 50 ++++++++++++++++++++++++++++----- src/cdk/a11y/focus-trap.ts | 31 ++++++++++++++++++-- src/cdk/a11y/public-api.ts | 5 ++++ 4 files changed, 80 insertions(+), 12 deletions(-) diff --git a/src/cdk/a11y/a11y-module.ts b/src/cdk/a11y/a11y-module.ts index 1cb501da20d..27b754af6a4 100644 --- a/src/cdk/a11y/a11y-module.ts +++ b/src/cdk/a11y/a11y-module.ts @@ -7,7 +7,7 @@ */ import {NgModule} from '@angular/core'; -import {FocusTrapDeprecatedDirective, FocusTrapDirective, FocusTrapFactory} from './focus-trap'; +import {FocusTrapDeprecatedDirective, CdkTrapFocus, FocusTrapFactory} from './focus-trap'; import {LIVE_ANNOUNCER_PROVIDER} from './live-announcer'; import {InteractivityChecker} from './interactivity-checker'; import {CommonModule} from '@angular/common'; @@ -17,8 +17,8 @@ import {CdkMonitorFocus, FOCUS_MONITOR_PROVIDER} from './focus-monitor'; @NgModule({ imports: [CommonModule, PlatformModule], - declarations: [FocusTrapDirective, FocusTrapDeprecatedDirective, CdkMonitorFocus], - exports: [FocusTrapDirective, FocusTrapDeprecatedDirective, CdkMonitorFocus], + declarations: [CdkTrapFocus, FocusTrapDeprecatedDirective, CdkMonitorFocus], + exports: [CdkTrapFocus, FocusTrapDeprecatedDirective, CdkMonitorFocus], providers: [ InteractivityChecker, FocusTrapFactory, diff --git a/src/cdk/a11y/focus-trap.spec.ts b/src/cdk/a11y/focus-trap.spec.ts index bb3657308f1..13cbc9ca1c0 100644 --- a/src/cdk/a11y/focus-trap.spec.ts +++ b/src/cdk/a11y/focus-trap.spec.ts @@ -1,7 +1,7 @@ import {Platform} from '@angular/cdk/platform'; import {Component, ViewChild} from '@angular/core'; import {async, ComponentFixture, TestBed} from '@angular/core/testing'; -import {FocusTrap, FocusTrapDirective, FocusTrapFactory} from './focus-trap'; +import {FocusTrap, CdkTrapFocus, FocusTrapFactory} from './focus-trap'; import {InteractivityChecker} from './interactivity-checker'; @@ -10,12 +10,13 @@ describe('FocusTrap', () => { beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ - FocusTrapDirective, + CdkTrapFocus, FocusTrapWithBindings, SimpleFocusTrap, FocusTrapTargets, FocusTrapWithSvg, FocusTrapWithoutFocusableElements, + FocusTrapWithAutoCapture, ], providers: [InteractivityChecker, Platform, FocusTrapFactory] }); @@ -154,6 +155,27 @@ describe('FocusTrap', () => { expect(() => focusTrapInstance.focusLastTabbableElement()).not.toThrow(); }); }); + + describe('with autoCapture', () => { + it('should automatically capture and return focus on init / destroy', async(() => { + const fixture = TestBed.createComponent(FocusTrapWithAutoCapture); + fixture.detectChanges(); + + const buttonOutsideTrappedRegion = fixture.nativeElement.querySelector('button'); + buttonOutsideTrappedRegion.focus(); + expect(document.activeElement).toBe(buttonOutsideTrappedRegion); + + fixture.componentInstance.showTrappedRegion = true; + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(document.activeElement.id).toBe('auto-capture-target'); + + fixture.destroy(); + expect(document.activeElement).toBe(buttonOutsideTrappedRegion); + }); + })); + }); }); @@ -166,7 +188,21 @@ describe('FocusTrap', () => { ` }) class SimpleFocusTrap { - @ViewChild(FocusTrapDirective) focusTrapDirective: FocusTrapDirective; + @ViewChild(CdkTrapFocus) focusTrapDirective: CdkTrapFocus; +} + +@Component({ + template: ` + +
+ + +
+ ` +}) +class FocusTrapWithAutoCapture { + @ViewChild(CdkTrapFocus) focusTrapDirective: CdkTrapFocus; + showTrappedRegion = false; } @@ -179,7 +215,7 @@ class SimpleFocusTrap { ` }) class FocusTrapWithBindings { - @ViewChild(FocusTrapDirective) focusTrapDirective: FocusTrapDirective; + @ViewChild(CdkTrapFocus) focusTrapDirective: CdkTrapFocus; renderFocusTrap = true; _isFocusTrapEnabled = true; } @@ -199,7 +235,7 @@ class FocusTrapWithBindings { ` }) class FocusTrapTargets { - @ViewChild(FocusTrapDirective) focusTrapDirective: FocusTrapDirective; + @ViewChild(CdkTrapFocus) focusTrapDirective: CdkTrapFocus; } @@ -213,7 +249,7 @@ class FocusTrapTargets { ` }) class FocusTrapWithSvg { - @ViewChild(FocusTrapDirective) focusTrapDirective: FocusTrapDirective; + @ViewChild(CdkTrapFocus) focusTrapDirective: CdkTrapFocus; } @Component({ @@ -224,5 +260,5 @@ class FocusTrapWithSvg { ` }) class FocusTrapWithoutFocusableElements { - @ViewChild(FocusTrapDirective) focusTrapDirective: FocusTrapDirective; + @ViewChild(CdkTrapFocus) focusTrapDirective: CdkTrapFocus; } diff --git a/src/cdk/a11y/focus-trap.ts b/src/cdk/a11y/focus-trap.ts index a89b8cdd0fd..f6e217133c6 100644 --- a/src/cdk/a11y/focus-trap.ts +++ b/src/cdk/a11y/focus-trap.ts @@ -348,24 +348,51 @@ export class FocusTrapDeprecatedDirective implements OnDestroy, AfterContentInit selector: '[cdkTrapFocus]', exportAs: 'cdkTrapFocus', }) -export class FocusTrapDirective implements OnDestroy, AfterContentInit { +export class CdkTrapFocus implements OnDestroy, AfterContentInit { /** Underlying FocusTrap instance. */ focusTrap: FocusTrap; + /** Previously focused element to restore focus to upon destroy when using autoCapture. */ + private _previouslyFocusedElement: HTMLElement | null = null; + /** Whether the focus trap is active. */ @Input('cdkTrapFocus') get enabled(): boolean { return this.focusTrap.enabled; } set enabled(value: boolean) { this.focusTrap.enabled = coerceBooleanProperty(value); } - constructor(private _elementRef: ElementRef, private _focusTrapFactory: FocusTrapFactory) { + /** + * Whether the directive should automatially move focus into the trapped region upon + * initialization and return focus to the previous activeElement upon destruction. + */ + @Input('cdkTrapFocusAutoCapture') + get autoCapture(): boolean { return this._autoCapture; } + set autoCapture(value: boolean) { this._autoCapture = coerceBooleanProperty(value); } + private _autoCapture: boolean; + + constructor( + private _elementRef: ElementRef, + private _focusTrapFactory: FocusTrapFactory, + private _platform: Platform) { this.focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement, true); } ngOnDestroy() { this.focusTrap.destroy(); + + // If we stored a previously focused element when using autoCapture, return focus to that + // element now that the trapped region is being destroyed. + if (this._previouslyFocusedElement) { + this._previouslyFocusedElement.focus(); + this._previouslyFocusedElement = null; + } } ngAfterContentInit() { this.focusTrap.attachAnchors(); + + if (this.autoCapture && this._platform.isBrowser) { + this._previouslyFocusedElement = document.activeElement as HTMLElement; + this.focusTrap.focusInitialElementWhenReady(); + } } } diff --git a/src/cdk/a11y/public-api.ts b/src/cdk/a11y/public-api.ts index c615cd7d5f6..552cd7a5bfc 100644 --- a/src/cdk/a11y/public-api.ts +++ b/src/cdk/a11y/public-api.ts @@ -5,6 +5,8 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ +import {CdkTrapFocus} from './focus-trap'; + export * from './activedescendant-key-manager'; export * from './aria-describer'; @@ -16,3 +18,6 @@ export * from './list-key-manager'; export * from './live-announcer'; export * from './focus-monitor'; export * from './a11y-module'; + +/** @deprecated Renamed to CdkTrapFocus. */ +export {CdkTrapFocus as FocusTrapDirective};