Skip to content

Commit 4e4c6a6

Browse files
crisbetokara
authored andcommitted
refactor(focus-trap): convert to directive (#3184)
1 parent f40b1b2 commit 4e4c6a6

10 files changed

+259
-98
lines changed

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

Lines changed: 0 additions & 3 deletions
This file was deleted.

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

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import {inject, ComponentFixture, TestBed, async} from '@angular/core/testing';
2-
import {By} from '@angular/platform-browser';
3-
import {Component} from '@angular/core';
4-
import {FocusTrap} from './focus-trap';
1+
import {ComponentFixture, TestBed, async} from '@angular/core/testing';
2+
import {Component, ViewChild} from '@angular/core';
3+
import {FocusTrapFactory, FocusTrapDirective, FocusTrap} from './focus-trap';
54
import {InteractivityChecker} from './interactivity-checker';
65
import {Platform} from '../platform/platform';
76

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

1716
beforeEach(async(() => {
1817
TestBed.configureTestingModule({
19-
declarations: [FocusTrap, FocusTrapTestApp],
20-
providers: [InteractivityChecker, Platform]
18+
declarations: [FocusTrapDirective, FocusTrapTestApp],
19+
providers: [InteractivityChecker, Platform, FocusTrapFactory]
2120
});
2221

2322
TestBed.compileComponents();
24-
}));
2523

26-
beforeEach(inject([InteractivityChecker], (c: InteractivityChecker) => {
2724
fixture = TestBed.createComponent(FocusTrapTestApp);
28-
focusTrapInstance = fixture.debugElement.query(By.directive(FocusTrap)).componentInstance;
25+
fixture.detectChanges();
26+
focusTrapInstance = fixture.componentInstance.focusTrapDirective.focusTrap;
2927
}));
3028

3129
it('wrap focus from end to start', () => {
@@ -48,6 +46,30 @@ describe('FocusTrap', () => {
4846
expect(document.activeElement.nodeName.toLowerCase())
4947
.toBe(lastElement, `Expected ${lastElement} element to be focused`);
5048
});
49+
50+
it('should clean up its anchor sibling elements on destroy', () => {
51+
const rootElement = fixture.debugElement.nativeElement as HTMLElement;
52+
53+
expect(rootElement.querySelectorAll('div.cdk-visually-hidden').length).toBe(2);
54+
55+
fixture.componentInstance.renderFocusTrap = false;
56+
fixture.detectChanges();
57+
58+
expect(rootElement.querySelectorAll('div.cdk-visually-hidden').length).toBe(0);
59+
});
60+
61+
it('should set the appropriate tabindex on the anchors, based on the disabled state', () => {
62+
const anchors = Array.from(
63+
fixture.debugElement.nativeElement.querySelectorAll('div.cdk-visually-hidden')
64+
) as HTMLElement[];
65+
66+
expect(anchors.every(current => current.getAttribute('tabindex') === '0')).toBe(true);
67+
68+
fixture.componentInstance.isFocusTrapEnabled = false;
69+
fixture.detectChanges();
70+
71+
expect(anchors.every(current => current.getAttribute('tabindex') === '-1')).toBe(true);
72+
});
5173
});
5274

5375
describe('with focus targets', () => {
@@ -56,16 +78,15 @@ describe('FocusTrap', () => {
5678

5779
beforeEach(async(() => {
5880
TestBed.configureTestingModule({
59-
declarations: [FocusTrap, FocusTrapTargetTestApp],
60-
providers: [InteractivityChecker, Platform]
81+
declarations: [FocusTrapDirective, FocusTrapTargetTestApp],
82+
providers: [InteractivityChecker, Platform, FocusTrapFactory]
6183
});
6284

6385
TestBed.compileComponents();
64-
}));
6586

66-
beforeEach(inject([InteractivityChecker], (c: InteractivityChecker) => {
6787
fixture = TestBed.createComponent(FocusTrapTargetTestApp);
68-
focusTrapInstance = fixture.debugElement.query(By.directive(FocusTrap)).componentInstance;
88+
fixture.detectChanges();
89+
focusTrapInstance = fixture.componentInstance.focusTrapDirective.focusTrap;
6990
}));
7091

7192
it('should be able to prioritize the first focus target', () => {
@@ -87,23 +108,29 @@ describe('FocusTrap', () => {
87108

88109
@Component({
89110
template: `
90-
<cdk-focus-trap>
111+
<div *ngIf="renderFocusTrap" [cdkTrapFocus]="isFocusTrapEnabled">
91112
<input>
92113
<button>SAVE</button>
93-
</cdk-focus-trap>
114+
</div>
94115
`
95116
})
96-
class FocusTrapTestApp { }
117+
class FocusTrapTestApp {
118+
@ViewChild(FocusTrapDirective) focusTrapDirective: FocusTrapDirective;
119+
renderFocusTrap = true;
120+
isFocusTrapEnabled = true;
121+
}
97122

98123

99124
@Component({
100125
template: `
101-
<cdk-focus-trap>
126+
<div cdkTrapFocus>
102127
<input>
103128
<button id="last" cdk-focus-end></button>
104129
<button id="first" cdk-focus-start>SAVE</button>
105130
<input>
106-
</cdk-focus-trap>
131+
</div>
107132
`
108133
})
109-
class FocusTrapTargetTestApp { }
134+
class FocusTrapTargetTestApp {
135+
@ViewChild(FocusTrapDirective) focusTrapDirective: FocusTrapDirective;
136+
}

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

Lines changed: 157 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,122 @@
1-
import {Component, ViewEncapsulation, ViewChild, ElementRef, Input, NgZone} 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-
@Component({
15-
moduleId: module.id,
16-
selector: 'cdk-focus-trap, focus-trap',
17-
templateUrl: 'focus-trap.html',
18-
encapsulation: ViewEncapsulation.None,
19-
})
2022
export class FocusTrap {
21-
@ViewChild('trappedContent') trappedContent: ElementRef;
23+
private _startAnchor: HTMLElement;
24+
private _endAnchor: HTMLElement;
2225

2326
/** Whether the focus trap is active. */
24-
@Input()
25-
get disabled(): boolean { return this._disabled; }
26-
set disabled(val: boolean) { this._disabled = coerceBooleanProperty(val); }
27-
private _disabled: boolean = false;
27+
get enabled(): boolean { return this._enabled; }
28+
set enabled(val: boolean) {
29+
this._enabled = val;
2830

29-
constructor(private _checker: InteractivityChecker, private _ngZone: NgZone) { }
31+
if (this._startAnchor && this._endAnchor) {
32+
this._startAnchor.tabIndex = this._endAnchor.tabIndex = this._enabled ? 0 : -1;
33+
}
34+
}
35+
private _enabled: boolean = true;
36+
37+
constructor(
38+
private _element: HTMLElement,
39+
private _checker: InteractivityChecker,
40+
private _ngZone: NgZone,
41+
deferAnchors = false) {
42+
43+
if (!deferAnchors) {
44+
this.attachAnchors();
45+
}
46+
}
47+
48+
/** Destroys the focus trap by cleaning up the anchors. */
49+
destroy() {
50+
if (this._startAnchor && this._startAnchor.parentNode) {
51+
this._startAnchor.parentNode.removeChild(this._startAnchor);
52+
}
53+
54+
if (this._endAnchor && this._endAnchor.parentNode) {
55+
this._endAnchor.parentNode.removeChild(this._endAnchor);
56+
}
57+
58+
this._startAnchor = this._endAnchor = null;
59+
}
3060

3161
/**
32-
* Waits for microtask queue to empty, then focuses the first tabbable element within the focus
33-
* trap region.
62+
* Inserts the anchors into the DOM. This is usually done automatically
63+
* in the constructor, but can be deferred for cases like directives with `*ngIf`.
3464
*/
35-
focusFirstTabbableElementWhenReady() {
36-
this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
37-
this.focusFirstTabbableElement();
65+
attachAnchors(): void {
66+
if (!this._startAnchor) {
67+
this._startAnchor = this._createAnchor();
68+
}
69+
70+
if (!this._endAnchor) {
71+
this._endAnchor = this._createAnchor();
72+
}
73+
74+
this._ngZone.runOutsideAngular(() => {
75+
this._element
76+
.insertAdjacentElement('beforebegin', this._startAnchor)
77+
.addEventListener('focus', () => this.focusLastTabbableElement());
78+
79+
this._element
80+
.insertAdjacentElement('afterend', this._endAnchor)
81+
.addEventListener('focus', () => this.focusFirstTabbableElement());
3882
});
3983
}
4084

4185
/**
42-
* Waits for microtask queue to empty, then focuses the last tabbable element within the focus
43-
* trap region.
86+
* Waits for microtask queue to empty, then focuses
87+
* the first tabbable element within the focus trap region.
4488
*/
45-
focusLastTabbableElementWhenReady() {
46-
this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
47-
this.focusLastTabbableElement();
48-
});
89+
focusFirstTabbableElementWhenReady() {
90+
this._ngZone.onMicrotaskEmpty.first().subscribe(() => this.focusFirstTabbableElement());
4991
}
5092

5193
/**
52-
* Focuses the first tabbable element within the focus trap region.
94+
* Waits for microtask queue to empty, then focuses
95+
* the last tabbable element within the focus trap region.
5396
*/
97+
focusLastTabbableElementWhenReady() {
98+
this._ngZone.onMicrotaskEmpty.first().subscribe(() => this.focusLastTabbableElement());
99+
}
100+
101+
/** Focuses the first tabbable element within the focus trap region. */
54102
focusFirstTabbableElement() {
55-
let rootElement = this.trappedContent.nativeElement;
56-
let redirectToElement = rootElement.querySelector('[cdk-focus-start]') as HTMLElement ||
57-
this._getFirstTabbableElement(rootElement);
103+
let redirectToElement = this._element.querySelector('[cdk-focus-start]') as HTMLElement ||
104+
this._getFirstTabbableElement(this._element);
58105

59106
if (redirectToElement) {
60107
redirectToElement.focus();
61108
}
62109
}
63110

64-
/**
65-
* Focuses the last tabbable element within the focus trap region.
66-
*/
111+
/** Focuses the last tabbable element within the focus trap region. */
67112
focusLastTabbableElement() {
68-
let rootElement = this.trappedContent.nativeElement;
69-
let focusTargets = rootElement.querySelectorAll('[cdk-focus-end]');
113+
let focusTargets = this._element.querySelectorAll('[cdk-focus-end]');
70114
let redirectToElement: HTMLElement = null;
71115

72116
if (focusTargets.length) {
73117
redirectToElement = focusTargets[focusTargets.length - 1] as HTMLElement;
74118
} else {
75-
redirectToElement = this._getLastTabbableElement(rootElement);
119+
redirectToElement = this._getLastTabbableElement(this._element);
76120
}
77121

78122
if (redirectToElement) {
@@ -114,4 +158,81 @@ export class FocusTrap {
114158

115159
return null;
116160
}
161+
162+
/** Creates an anchor element. */
163+
private _createAnchor(): HTMLElement {
164+
let anchor = document.createElement('div');
165+
anchor.tabIndex = this._enabled ? 0 : -1;
166+
anchor.classList.add('cdk-visually-hidden');
167+
anchor.classList.add('cdk-focus-trap-anchor');
168+
return anchor;
169+
}
170+
}
171+
172+
173+
/** Factory that allows easy instantiation of focus traps. */
174+
@Injectable()
175+
export class FocusTrapFactory {
176+
constructor(private _checker: InteractivityChecker, private _ngZone: NgZone) { }
177+
178+
create(element: HTMLElement, deferAnchors = false): FocusTrap {
179+
return new FocusTrap(element, this._checker, this._ngZone, deferAnchors);
180+
}
181+
}
182+
183+
184+
/**
185+
* Directive for trapping focus within a region.
186+
* @deprecated
187+
*/
188+
@Directive({
189+
selector: 'cdk-focus-trap',
190+
})
191+
export class FocusTrapDeprecatedDirective implements OnDestroy, AfterContentInit {
192+
focusTrap: FocusTrap;
193+
194+
/** Whether the focus trap is active. */
195+
@Input()
196+
get disabled(): boolean { return !this.focusTrap.enabled; }
197+
set disabled(val: boolean) {
198+
this.focusTrap.enabled = !coerceBooleanProperty(val);
199+
}
200+
201+
constructor(private _elementRef: ElementRef, private _focusTrapFactory: FocusTrapFactory) {
202+
this.focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement, true);
203+
}
204+
205+
ngOnDestroy() {
206+
this.focusTrap.destroy();
207+
}
208+
209+
ngAfterContentInit() {
210+
this.focusTrap.attachAnchors();
211+
}
212+
}
213+
214+
215+
/** Directive for trapping focus within a region. */
216+
@Directive({
217+
selector: '[cdkTrapFocus]'
218+
})
219+
export class FocusTrapDirective implements OnDestroy, AfterContentInit {
220+
focusTrap: FocusTrap;
221+
222+
/** Whether the focus trap is active. */
223+
@Input('cdkTrapFocus')
224+
get enabled(): boolean { return this.focusTrap.enabled; }
225+
set enabled(val: boolean) { this.focusTrap.enabled = val; }
226+
227+
constructor(private _elementRef: ElementRef, private _focusTrapFactory: FocusTrapFactory) {
228+
this.focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement, true);
229+
}
230+
231+
ngOnDestroy() {
232+
this.focusTrap.destroy();
233+
}
234+
235+
ngAfterContentInit() {
236+
this.focusTrap.attachAnchors();
237+
}
117238
}

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, FocusTrapDeprecatedDirective, FocusTrapFactory} 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, FocusTrapDeprecatedDirective],
11+
exports: [FocusTrapDirective, FocusTrapDeprecatedDirective],
12+
providers: [InteractivityChecker, FocusTrapFactory, LIVE_ANNOUNCER_PROVIDER]
1313
})
1414
export class A11yModule {
1515
/** @deprecated */

src/lib/core/core.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export * from './selection/selection';
8080
/** @deprecated */
8181
export {LiveAnnouncer as MdLiveAnnouncer} from './a11y/live-announcer';
8282

83-
export {FocusTrap} from './a11y/focus-trap';
83+
export * from './a11y/focus-trap';
8484
export {InteractivityChecker} from './a11y/interactivity-checker';
8585
export {isFakeMousedownFromScreenReader} from './a11y/fake-mousedown';
8686

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>

0 commit comments

Comments
 (0)