Skip to content

Commit 0a63b61

Browse files
committed
fix(focus-trap): not attaching correctly if element is on in the DOM on init
Currently the focus trap attempts to attach itself once on init (e.g. when it is projected inside of an overlay), however in some cases it might not be in the DOM yet. These changes make it so it re-tries to attach itself until it does so successfully.
1 parent 4d97271 commit 0a63b61

File tree

1 file changed

+52
-35
lines changed

1 file changed

+52
-35
lines changed

src/cdk/a11y/focus-trap.ts

Lines changed: 52 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
Input,
1313
NgZone,
1414
OnDestroy,
15-
AfterContentInit,
15+
DoCheck,
1616
Injectable,
1717
} from '@angular/core';
1818
import {coerceBooleanProperty} from '@angular/cdk/coercion';
@@ -32,6 +32,8 @@ import {InteractivityChecker} from './interactivity-checker';
3232
export class FocusTrap {
3333
private _startAnchor: HTMLElement | null;
3434
private _endAnchor: HTMLElement | null;
35+
private _element: HTMLElement | null;
36+
private _hasAttached = false;
3537

3638
/** Whether the focus trap is active. */
3739
get enabled(): boolean { return this._enabled; }
@@ -45,12 +47,14 @@ export class FocusTrap {
4547
private _enabled: boolean = true;
4648

4749
constructor(
48-
private _element: HTMLElement,
50+
element: HTMLElement,
4951
private _platform: Platform,
5052
private _checker: InteractivityChecker,
5153
private _ngZone: NgZone,
5254
deferAnchors = false) {
5355

56+
this._element = element;
57+
5458
if (!deferAnchors) {
5559
this.attachAnchors();
5660
}
@@ -66,41 +70,41 @@ export class FocusTrap {
6670
this._endAnchor.parentNode.removeChild(this._endAnchor);
6771
}
6872

69-
this._startAnchor = this._endAnchor = null;
73+
this._element = this._startAnchor = this._endAnchor = null;
7074
}
7175

7276
/**
7377
* Inserts the anchors into the DOM. This is usually done automatically
7478
* in the constructor, but can be deferred for cases like directives with `*ngIf`.
79+
* @returns Whether the focus trap managed to attach successfuly. This may not be the case
80+
* if the target element isn't currently in the DOM.
7581
*/
76-
attachAnchors(): void {
82+
attachAnchors(): boolean {
7783
// If we're not on the browser, there can be no focus to trap.
78-
if (!this._platform.isBrowser) {
79-
return;
80-
}
81-
82-
if (!this._startAnchor) {
83-
this._startAnchor = this._createAnchor();
84-
}
85-
86-
if (!this._endAnchor) {
87-
this._endAnchor = this._createAnchor();
84+
if (!this._platform.isBrowser || this._hasAttached) {
85+
this._hasAttached = true;
86+
return true;
8887
}
8988

9089
this._ngZone.runOutsideAngular(() => {
91-
this._startAnchor!.addEventListener('focus', () => {
92-
this.focusLastTabbableElement();
93-
});
94-
95-
this._endAnchor!.addEventListener('focus', () => {
96-
this.focusFirstTabbableElement();
97-
});
90+
if (!this._startAnchor) {
91+
this._startAnchor = this._createAnchor();
92+
this._startAnchor!.addEventListener('focus', () => this.focusLastTabbableElement());
93+
}
9894

99-
if (this._element.parentNode) {
100-
this._element.parentNode.insertBefore(this._startAnchor!, this._element);
101-
this._element.parentNode.insertBefore(this._endAnchor!, this._element.nextSibling);
95+
if (!this._endAnchor) {
96+
this._endAnchor = this._createAnchor();
97+
this._endAnchor!.addEventListener('focus', () => this.focusFirstTabbableElement());
10298
}
10399
});
100+
101+
if (this._element && this._element.parentNode) {
102+
this._element.parentNode.insertBefore(this._startAnchor!, this._element);
103+
this._element.parentNode.insertBefore(this._endAnchor!, this._element.nextSibling);
104+
this._hasAttached = true;
105+
}
106+
107+
return this._hasAttached;
104108
}
105109

106110
/**
@@ -146,7 +150,7 @@ export class FocusTrap {
146150
*/
147151
private _getRegionBoundary(bound: 'start' | 'end'): HTMLElement | null {
148152
// Contains the deprecated version of selector, for temporary backwards comparability.
149-
let markers = this._element.querySelectorAll(`[cdk-focus-region-${bound}], ` +
153+
let markers = this._element!.querySelectorAll(`[cdk-focus-region-${bound}], ` +
150154
`[cdk-focus-${bound}]`) as NodeListOf<HTMLElement>;
151155

152156
for (let i = 0; i < markers.length; i++) {
@@ -157,18 +161,18 @@ export class FocusTrap {
157161
}
158162

159163
if (bound == 'start') {
160-
return markers.length ? markers[0] : this._getFirstTabbableElement(this._element);
164+
return markers.length ? markers[0] : this._getFirstTabbableElement(this._element!);
161165
}
162166
return markers.length ?
163-
markers[markers.length - 1] : this._getLastTabbableElement(this._element);
167+
markers[markers.length - 1] : this._getLastTabbableElement(this._element!);
164168
}
165169

166170
/**
167171
* Focuses the element that should be focused when the focus trap is initialized.
168172
* @returns Returns whether focus was moved successfuly.
169173
*/
170174
focusInitialElement(): boolean {
171-
const redirectToElement = this._element.querySelector('[cdk-focus-initial]') as HTMLElement;
175+
const redirectToElement = this._element!.querySelector('[cdk-focus-initial]') as HTMLElement;
172176

173177
if (redirectToElement) {
174178
redirectToElement.focus();
@@ -206,6 +210,13 @@ export class FocusTrap {
206210
return !!redirectToElement;
207211
}
208212

213+
/**
214+
* Checks whether the focus trap has successfuly been attached.
215+
*/
216+
hasAttached(): boolean {
217+
return this._hasAttached;
218+
}
219+
209220
/** Get the first tabbable element from a DOM subtree (inclusive). */
210221
private _getFirstTabbableElement(root: HTMLElement): HTMLElement | null {
211222
if (this._checker.isFocusable(root) && this._checker.isTabbable(root)) {
@@ -287,12 +298,12 @@ export class FocusTrapFactory {
287298

288299
/**
289300
* Directive for trapping focus within a region.
290-
* @deprecated
301+
* @deprecated Use `cdkTrapFocus` instead.
291302
*/
292303
@Directive({
293304
selector: 'cdk-focus-trap',
294305
})
295-
export class FocusTrapDeprecatedDirective implements OnDestroy, AfterContentInit {
306+
export class FocusTrapDeprecatedDirective implements OnDestroy, DoCheck {
296307
focusTrap: FocusTrap;
297308

298309
/** Whether the focus trap is active. */
@@ -310,8 +321,10 @@ export class FocusTrapDeprecatedDirective implements OnDestroy, AfterContentInit
310321
this.focusTrap.destroy();
311322
}
312323

313-
ngAfterContentInit() {
314-
this.focusTrap.attachAnchors();
324+
ngDoCheck() {
325+
if (!this.focusTrap.hasAttached()) {
326+
this.focusTrap.attachAnchors();
327+
}
315328
}
316329
}
317330

@@ -321,7 +334,7 @@ export class FocusTrapDeprecatedDirective implements OnDestroy, AfterContentInit
321334
selector: '[cdkTrapFocus]',
322335
exportAs: 'cdkTrapFocus',
323336
})
324-
export class FocusTrapDirective implements OnDestroy, AfterContentInit {
337+
export class FocusTrapDirective implements OnDestroy, DoCheck {
325338
focusTrap: FocusTrap;
326339

327340
/** Whether the focus trap is active. */
@@ -337,7 +350,11 @@ export class FocusTrapDirective implements OnDestroy, AfterContentInit {
337350
this.focusTrap.destroy();
338351
}
339352

340-
ngAfterContentInit() {
341-
this.focusTrap.attachAnchors();
353+
ngDoCheck() {
354+
// Since the element may not be attached to the DOM (or another element)
355+
// immediately, keep trying until it is.
356+
if (!this.focusTrap.hasAttached()) {
357+
this.focusTrap.attachAnchors();
358+
}
342359
}
343360
}

0 commit comments

Comments
 (0)