Skip to content

Commit d944dde

Browse files
committed
refactor: add a FocusTrapService
1 parent 28a4c0f commit d944dde

File tree

6 files changed

+124
-49
lines changed

6 files changed

+124
-49
lines changed

src/lib/core/a11y/focus-trap.spec.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {ComponentFixture, TestBed, async} from '@angular/core/testing';
22
import {Component, ViewChild} from '@angular/core';
3-
import {FocusTrap} from './focus-trap';
3+
import {FocusTrapService, FocusTrapDirective, FocusTrap} from './focus-trap';
44
import {InteractivityChecker} from './interactivity-checker';
55
import {Platform} from '../platform/platform';
66

@@ -15,15 +15,15 @@ describe('FocusTrap', () => {
1515

1616
beforeEach(async(() => {
1717
TestBed.configureTestingModule({
18-
declarations: [FocusTrap, FocusTrapTestApp],
19-
providers: [InteractivityChecker, Platform]
18+
declarations: [FocusTrapDirective, FocusTrapTestApp],
19+
providers: [InteractivityChecker, Platform, FocusTrapService]
2020
});
2121

2222
TestBed.compileComponents();
2323

2424
fixture = TestBed.createComponent(FocusTrapTestApp);
2525
fixture.detectChanges();
26-
focusTrapInstance = fixture.componentInstance.focusTrap;
26+
focusTrapInstance = fixture.componentInstance.focusTrapDirective.focusTrap;
2727
}));
2828

2929
it('wrap focus from end to start', () => {
@@ -78,15 +78,15 @@ describe('FocusTrap', () => {
7878

7979
beforeEach(async(() => {
8080
TestBed.configureTestingModule({
81-
declarations: [FocusTrap, FocusTrapTargetTestApp],
82-
providers: [InteractivityChecker, Platform]
81+
declarations: [FocusTrapDirective, FocusTrapTargetTestApp],
82+
providers: [InteractivityChecker, Platform, FocusTrapService]
8383
});
8484

8585
TestBed.compileComponents();
8686

8787
fixture = TestBed.createComponent(FocusTrapTargetTestApp);
8888
fixture.detectChanges();
89-
focusTrapInstance = fixture.componentInstance.focusTrap;
89+
focusTrapInstance = fixture.componentInstance.focusTrapDirective.focusTrap;
9090
}));
9191

9292
it('should be able to prioritize the first focus target', () => {
@@ -115,7 +115,7 @@ describe('FocusTrap', () => {
115115
`
116116
})
117117
class FocusTrapTestApp {
118-
@ViewChild(FocusTrap) focusTrap: FocusTrap;
118+
@ViewChild(FocusTrapDirective) focusTrapDirective: FocusTrapDirective;
119119
renderFocusTrap = true;
120120
isFocusTrapDisabled = false;
121121
}
@@ -132,5 +132,5 @@ class FocusTrapTestApp {
132132
`
133133
})
134134
class FocusTrapTargetTestApp {
135-
@ViewChild(FocusTrap) focusTrap: FocusTrap;
135+
@ViewChild(FocusTrapDirective) focusTrapDirective: FocusTrapDirective;
136136
}

src/lib/core/a11y/focus-trap.ts

Lines changed: 91 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,56 @@
1-
import {Directive, ElementRef, Input, NgZone, AfterViewInit, OnDestroy} from '@angular/core';
1+
import {
2+
Directive,
3+
ElementRef,
4+
Input,
5+
NgZone,
6+
OnDestroy,
7+
AfterContentInit,
8+
Injectable,
9+
} from '@angular/core';
210
import {InteractivityChecker} from './interactivity-checker';
311
import {coerceBooleanProperty} from '../coercion/boolean-property';
412

513

614
/**
7-
* Directive for trapping focus within a region.
15+
* Class that allows for trapping focus within a DOM element.
816
*
9-
* NOTE: This directive currently uses a very simple (naive) approach to focus trapping.
17+
* NOTE: This class currently uses a very simple (naive) approach to focus trapping.
1018
* It assumes that the tab order is the same as DOM order, which is not necessarily true.
1119
* Things like tabIndex > 0, flex `order`, and shadow roots can cause to two to misalign.
1220
* This will be replaced with a more intelligent solution before the library is considered stable.
1321
*/
14-
@Directive({
15-
selector: 'cdk-focus-trap, focus-trap, [cdk-focus-trap], [focus-trap]',
16-
})
17-
export class FocusTrap implements AfterViewInit, OnDestroy {
22+
export class FocusTrap {
1823
private _startAnchor: HTMLElement = this._createAnchor();
1924
private _endAnchor: HTMLElement = this._createAnchor();
2025

2126
/** Whether the focus trap is active. */
22-
@Input()
2327
get disabled(): boolean { return this._disabled; }
2428
set disabled(val: boolean) {
25-
this._disabled = coerceBooleanProperty(val);
29+
this._disabled = val;
2630
this._startAnchor.tabIndex = this._endAnchor.tabIndex = this._disabled ? -1 : 0;
2731
}
2832
private _disabled: boolean = false;
2933

34+
/** Element to which the focus trap is attached. */
35+
get element(): HTMLElement {
36+
return this._element;
37+
}
38+
3039
constructor(
40+
private _element: HTMLElement,
3141
private _checker: InteractivityChecker,
3242
private _ngZone: NgZone,
33-
private _elementRef: ElementRef) { }
34-
35-
ngAfterViewInit() {
36-
this._ngZone.runOutsideAngular(() => {
37-
this._elementRef.nativeElement
38-
.insertAdjacentElement('beforebegin', this._startAnchor)
39-
.addEventListener('focus', () => this.focusLastTabbableElement());
43+
deferAnchors = false) {
4044

41-
this._elementRef.nativeElement
42-
.insertAdjacentElement('afterend', this._endAnchor)
43-
.addEventListener('focus', () => this.focusFirstTabbableElement());
44-
});
45+
if (!deferAnchors) {
46+
this.attachAnchors();
47+
}
4548
}
4649

47-
ngOnDestroy() {
50+
/**
51+
* Destroys the focus trap by cleaning up the anchors.
52+
*/
53+
destroy() {
4854
if (this._startAnchor.parentNode) {
4955
this._startAnchor.parentNode.removeChild(this._startAnchor);
5056
}
@@ -56,6 +62,22 @@ export class FocusTrap implements AfterViewInit, OnDestroy {
5662
this._startAnchor = this._endAnchor = null;
5763
}
5864

65+
/**
66+
* Inserts the anchors into the DOM. This is usually done automatically
67+
* in the constructor, but can be deferred for cases like directives with `*ngIf`.
68+
*/
69+
attachAnchors(): void {
70+
this._ngZone.runOutsideAngular(() => {
71+
this._element
72+
.insertAdjacentElement('beforebegin', this._startAnchor)
73+
.addEventListener('focus', () => this.focusLastTabbableElement());
74+
75+
this._element
76+
.insertAdjacentElement('afterend', this._endAnchor)
77+
.addEventListener('focus', () => this.focusFirstTabbableElement());
78+
});
79+
}
80+
5981
/**
6082
* Waits for microtask queue to empty, then focuses the first tabbable element within the focus
6183
* trap region.
@@ -76,9 +98,8 @@ export class FocusTrap implements AfterViewInit, OnDestroy {
7698
* Focuses the first tabbable element within the focus trap region.
7799
*/
78100
focusFirstTabbableElement() {
79-
let rootElement = this._elementRef.nativeElement;
80-
let redirectToElement = rootElement.querySelector('[cdk-focus-start]') as HTMLElement ||
81-
this._getFirstTabbableElement(rootElement);
101+
let redirectToElement = this._element.querySelector('[cdk-focus-start]') as HTMLElement ||
102+
this._getFirstTabbableElement(this._element);
82103

83104
if (redirectToElement) {
84105
redirectToElement.focus();
@@ -89,13 +110,13 @@ export class FocusTrap implements AfterViewInit, OnDestroy {
89110
* Focuses the last tabbable element within the focus trap region.
90111
*/
91112
focusLastTabbableElement() {
92-
let focusTargets = this._elementRef.nativeElement.querySelectorAll('[cdk-focus-end]');
113+
let focusTargets = this._element.querySelectorAll('[cdk-focus-end]');
93114
let redirectToElement: HTMLElement = null;
94115

95116
if (focusTargets.length) {
96117
redirectToElement = focusTargets[focusTargets.length - 1] as HTMLElement;
97118
} else {
98-
redirectToElement = this._getLastTabbableElement(this._elementRef.nativeElement);
119+
redirectToElement = this._getLastTabbableElement(this._element);
99120
}
100121

101122
if (redirectToElement) {
@@ -142,6 +163,50 @@ export class FocusTrap implements AfterViewInit, OnDestroy {
142163
let anchor = document.createElement('div');
143164
anchor.tabIndex = 0;
144165
anchor.classList.add('cdk-visually-hidden');
166+
anchor.classList.add('cdk-focus-trap-anchor');
145167
return anchor;
146168
}
147169
}
170+
171+
172+
/**
173+
* Service that allows easy instantiation of focus traps.
174+
*/
175+
@Injectable()
176+
export class FocusTrapService {
177+
constructor(private _checker: InteractivityChecker, private _ngZone: NgZone) { }
178+
179+
attach(element: HTMLElement, deferAnchors = false): FocusTrap {
180+
return new FocusTrap(element, this._checker, this._ngZone, deferAnchors);
181+
}
182+
}
183+
184+
185+
/**
186+
* Directive for trapping focus within a region.
187+
*/
188+
@Directive({
189+
selector: 'cdk-focus-trap, [cdkFocusTrap]',
190+
})
191+
export class FocusTrapDirective implements OnDestroy, AfterContentInit {
192+
focusTrap: FocusTrap;
193+
194+
/** Whether the focus trap is active. */
195+
@Input()
196+
get disabled(): boolean { return this.focusTrap.disabled; }
197+
set disabled(val: boolean) {
198+
this.focusTrap.disabled = coerceBooleanProperty(val);
199+
}
200+
201+
constructor(private _elementRef: ElementRef, private _focusTrapService: FocusTrapService) {
202+
this.focusTrap = this._focusTrapService.attach(this._elementRef.nativeElement, true);
203+
}
204+
205+
ngOnDestroy() {
206+
this.focusTrap.destroy();
207+
}
208+
209+
ngAfterContentInit() {
210+
this.focusTrap.attachAnchors();
211+
}
212+
}

src/lib/core/a11y/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import {NgModule, ModuleWithProviders} from '@angular/core';
2-
import {FocusTrap} from './focus-trap';
2+
import {FocusTrapDirective, FocusTrapService} from './focus-trap';
33
import {LIVE_ANNOUNCER_PROVIDER} from './live-announcer';
44
import {InteractivityChecker} from './interactivity-checker';
55
import {CommonModule} from '@angular/common';
66
import {PlatformModule} from '../platform/index';
77

88
@NgModule({
99
imports: [CommonModule, PlatformModule],
10-
declarations: [FocusTrap],
11-
exports: [FocusTrap],
12-
providers: [InteractivityChecker, LIVE_ANNOUNCER_PROVIDER]
10+
declarations: [FocusTrapDirective],
11+
exports: [FocusTrapDirective],
12+
providers: [InteractivityChecker, FocusTrapService, LIVE_ANNOUNCER_PROVIDER]
1313
})
1414
export class A11yModule {
1515
/** @deprecated */

src/lib/dialog/dialog-container.html

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1 @@
1-
<cdk-focus-trap>
2-
<template cdkPortalHost></template>
3-
</cdk-focus-trap>
1+
<template cdkPortalHost></template>

src/lib/dialog/dialog-container.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import {
66
NgZone,
77
OnDestroy,
88
Renderer,
9+
ElementRef,
910
} from '@angular/core';
1011
import {BasePortalHost, ComponentPortal, PortalHostDirective, TemplatePortal} from '../core';
1112
import {MdDialogConfig} from './dialog-config';
1213
import {MdDialogRef} from './dialog-ref';
1314
import {MdDialogContentAlreadyAttachedError} from './dialog-errors';
14-
import {FocusTrap} from '../core/a11y/focus-trap';
15+
import {FocusTrapService, FocusTrap} from '../core/a11y/focus-trap';
1516
import 'rxjs/add/operator/first';
1617

1718

@@ -34,8 +35,8 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy {
3435
/** The portal host inside of this container into which the dialog content will be loaded. */
3536
@ViewChild(PortalHostDirective) _portalHost: PortalHostDirective;
3637

37-
/** The directive that traps and manages focus within the dialog. */
38-
@ViewChild(FocusTrap) _focusTrap: FocusTrap;
38+
/** The class that traps and manages focus within the dialog. */
39+
private _focusTrap: FocusTrap;
3940

4041
/** Element that was focused before the dialog was opened. Save this to restore upon close. */
4142
private _elementFocusedBeforeDialogWasOpened: Element = null;
@@ -46,7 +47,12 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy {
4647
/** Reference to the open dialog. */
4748
dialogRef: MdDialogRef<any>;
4849

49-
constructor(private _ngZone: NgZone, private _renderer: Renderer) {
50+
constructor(
51+
private _ngZone: NgZone,
52+
private _renderer: Renderer,
53+
private _elementRef: ElementRef,
54+
private _focusTrapService: FocusTrapService) {
55+
5056
super();
5157
}
5258

@@ -83,6 +89,10 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy {
8389
* @private
8490
*/
8591
private _trapFocus() {
92+
if (!this._focusTrap) {
93+
this._focusTrap = this._focusTrapService.attach(this._elementRef.nativeElement);
94+
}
95+
8696
// If were to attempt to focus immediately, then the content of the dialog would not yet be
8797
// ready in instances where change detection has to run first. To deal with this, we simply
8898
// wait for the microtask queue to be empty.
@@ -102,5 +112,7 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy {
102112
this._renderer.invokeElementMethod(this._elementFocusedBeforeDialogWasOpened, 'focus');
103113
});
104114
}
115+
116+
this._focusTrap.destroy();
105117
}
106118
}

src/lib/sidenav/sidenav.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
import {CommonModule} from '@angular/common';
2020
import {Dir, MdError, coerceBooleanProperty, CompatibilityModule} from '../core';
2121
import {A11yModule} from '../core/a11y/index';
22-
import {FocusTrap} from '../core/a11y/focus-trap';
22+
import {FocusTrapDirective} from '../core/a11y/focus-trap';
2323
import {ESCAPE} from '../core/keyboard/keycodes';
2424
import {OverlayModule} from '../core/overlay/overlay-directives';
2525
import 'rxjs/add/operator/first';
@@ -72,7 +72,7 @@ export class MdSidenavToggleResult {
7272
encapsulation: ViewEncapsulation.None,
7373
})
7474
export class MdSidenav implements AfterContentInit {
75-
@ViewChild(FocusTrap) _focusTrap: FocusTrap;
75+
@ViewChild(FocusTrapDirective) _focusTrapDirective: FocusTrapDirective;
7676

7777
/** Alignment of the sidenav (direction neutral); whether 'start' or 'end'. */
7878
private _align: 'start' | 'end' = 'start';
@@ -152,7 +152,7 @@ export class MdSidenav implements AfterContentInit {
152152
this._elementFocusedBeforeSidenavWasOpened = document.activeElement as HTMLElement;
153153

154154
if (!this.isFocusTrapDisabled) {
155-
this._focusTrap.focusFirstTabbableElementWhenReady();
155+
this._focusTrapDirective.focusTrap.focusFirstTabbableElementWhenReady();
156156
}
157157
});
158158

0 commit comments

Comments
 (0)