Skip to content

Commit 706fc48

Browse files
authored
fix(cdk/a11y): not restoring focus to elements inside the shadow DOM (#22622)
The focus trap directive has the ability to restore focus to the previously-focused element on destroy automatically, however the logic doesn't account for the fact that `document.activeElement` will point to the shadow root if the element is inside the shadow DOM.
1 parent be872c0 commit 706fc48

File tree

2 files changed

+98
-23
lines changed

2 files changed

+98
-23
lines changed

src/cdk/a11y/focus-trap/focus-trap.spec.ts

Lines changed: 93 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
import {Platform} from '@angular/cdk/platform';
2-
import {Component, ViewChild, TemplateRef, ViewContainerRef} from '@angular/core';
1+
import {Platform, _supportsShadowDom} from '@angular/cdk/platform';
2+
import {
3+
Component,
4+
ViewChild,
5+
TemplateRef,
6+
ViewContainerRef,
7+
ViewEncapsulation,
8+
} from '@angular/core';
39
import {waitForAsync, ComponentFixture, TestBed} from '@angular/core/testing';
410
import {PortalModule, CdkPortalOutlet, TemplatePortal} from '@angular/cdk/portal';
511
import {A11yModule, FocusTrap, CdkTrapFocus} from '../index';
12+
import {By} from '@angular/platform-browser';
613

714

815
describe('FocusTrap', () => {
@@ -19,6 +26,7 @@ describe('FocusTrap', () => {
1926
FocusTrapWithAutoCapture,
2027
FocusTrapUnfocusableTarget,
2128
FocusTrapInsidePortal,
29+
FocusTrapWithAutoCaptureInShadowDom,
2230
],
2331
});
2432

@@ -40,7 +48,7 @@ describe('FocusTrap', () => {
4048
// focus event handler directly.
4149
const result = focusTrapInstance.focusFirstTabbableElement();
4250

43-
expect(document.activeElement!.nodeName.toLowerCase())
51+
expect(getActiveElement().nodeName.toLowerCase())
4452
.toBe('input', 'Expected input element to be focused');
4553
expect(result).toBe(true, 'Expected return value to be true if focus was shifted.');
4654
});
@@ -54,7 +62,7 @@ describe('FocusTrap', () => {
5462
// In iOS button elements are never tabbable, so the last element will be the input.
5563
const lastElement = platform.IOS ? 'input' : 'button';
5664

57-
expect(document.activeElement!.nodeName.toLowerCase())
65+
expect(getActiveElement().nodeName.toLowerCase())
5866
.toBe(lastElement, `Expected ${lastElement} element to be focused`);
5967

6068
expect(result).toBe(true, 'Expected return value to be true if focus was shifted.');
@@ -126,7 +134,7 @@ describe('FocusTrap', () => {
126134
// Because we can't mimic a real tab press focus change in a unit test, just call the
127135
// focus event handler directly.
128136
focusTrapInstance.focusInitialElement();
129-
expect(document.activeElement!.id).toBe('middle');
137+
expect(getActiveElement().id).toBe('middle');
130138
});
131139

132140
it('should be able to pass in focus options to initial focusable element', () => {
@@ -141,7 +149,7 @@ describe('FocusTrap', () => {
141149
// Because we can't mimic a real tab press focus change in a unit test, just call the
142150
// focus event handler directly.
143151
focusTrapInstance.focusFirstTabbableElement();
144-
expect(document.activeElement!.id).toBe('first');
152+
expect(getActiveElement().id).toBe('first');
145153
});
146154

147155
it('should be able to pass in focus options to first focusable element', () => {
@@ -156,7 +164,7 @@ describe('FocusTrap', () => {
156164
// Because we can't mimic a real tab press focus change in a unit test, just call the
157165
// focus event handler directly.
158166
focusTrapInstance.focusLastTabbableElement();
159-
expect(document.activeElement!.id).toBe('last');
167+
expect(getActiveElement().id).toBe('last');
160168
});
161169

162170
it('should be able to pass in focus options to last focusable element', () => {
@@ -200,16 +208,16 @@ describe('FocusTrap', () => {
200208

201209
const buttonOutsideTrappedRegion = fixture.nativeElement.querySelector('button');
202210
buttonOutsideTrappedRegion.focus();
203-
expect(document.activeElement).toBe(buttonOutsideTrappedRegion);
211+
expect(getActiveElement()).toBe(buttonOutsideTrappedRegion);
204212

205213
fixture.componentInstance.showTrappedRegion = true;
206214
fixture.detectChanges();
207215

208216
fixture.whenStable().then(() => {
209-
expect(document.activeElement!.id).toBe('auto-capture-target');
217+
expect(getActiveElement().id).toBe('auto-capture-target');
210218

211219
fixture.destroy();
212-
expect(document.activeElement).toBe(buttonOutsideTrappedRegion);
220+
expect(getActiveElement()).toBe(buttonOutsideTrappedRegion);
213221
});
214222
}));
215223

@@ -221,19 +229,71 @@ describe('FocusTrap', () => {
221229

222230
const buttonOutsideTrappedRegion = fixture.nativeElement.querySelector('button');
223231
buttonOutsideTrappedRegion.focus();
224-
expect(document.activeElement).toBe(buttonOutsideTrappedRegion);
232+
expect(getActiveElement()).toBe(buttonOutsideTrappedRegion);
225233

226234
fixture.componentInstance.autoCaptureEnabled = true;
227235
fixture.detectChanges();
228236

229237
fixture.whenStable().then(() => {
230-
expect(document.activeElement!.id).toBe('auto-capture-target');
238+
expect(getActiveElement().id).toBe('auto-capture-target');
231239

232240
fixture.destroy();
233-
expect(document.activeElement).toBe(buttonOutsideTrappedRegion);
241+
expect(getActiveElement()).toBe(buttonOutsideTrappedRegion);
234242
});
235243
}));
236244

245+
it('should automatically capture and return focus on init / destroy inside the shadow DOM',
246+
waitForAsync(() => {
247+
if (!_supportsShadowDom()) {
248+
return;
249+
}
250+
251+
const fixture = TestBed.createComponent(FocusTrapWithAutoCaptureInShadowDom);
252+
fixture.detectChanges();
253+
254+
const buttonOutsideTrappedRegion =
255+
fixture.debugElement.query(By.css('button')).nativeElement;
256+
buttonOutsideTrappedRegion.focus();
257+
expect(getActiveElement()).toBe(buttonOutsideTrappedRegion);
258+
259+
fixture.componentInstance.showTrappedRegion = true;
260+
fixture.detectChanges();
261+
262+
fixture.whenStable().then(() => {
263+
expect(getActiveElement().id).toBe('auto-capture-target');
264+
265+
fixture.destroy();
266+
expect(getActiveElement()).toBe(buttonOutsideTrappedRegion);
267+
});
268+
}));
269+
270+
it('should capture focus if auto capture is enabled later on inside the shadow DOM',
271+
waitForAsync(() => {
272+
if (!_supportsShadowDom()) {
273+
return;
274+
}
275+
276+
const fixture = TestBed.createComponent(FocusTrapWithAutoCaptureInShadowDom);
277+
fixture.componentInstance.autoCaptureEnabled = false;
278+
fixture.componentInstance.showTrappedRegion = true;
279+
fixture.detectChanges();
280+
281+
const buttonOutsideTrappedRegion =
282+
fixture.debugElement.query(By.css('button')).nativeElement;
283+
buttonOutsideTrappedRegion.focus();
284+
expect(getActiveElement()).toBe(buttonOutsideTrappedRegion);
285+
286+
fixture.componentInstance.autoCaptureEnabled = true;
287+
fixture.detectChanges();
288+
289+
fixture.whenStable().then(() => {
290+
expect(getActiveElement().id).toBe('auto-capture-target');
291+
292+
fixture.destroy();
293+
expect(getActiveElement()).toBe(buttonOutsideTrappedRegion);
294+
});
295+
}));
296+
237297
});
238298

239299
it('should put anchors inside the outlet when set at the root of a template portal', () => {
@@ -258,6 +318,11 @@ describe('FocusTrap', () => {
258318
});
259319
});
260320

321+
/** Gets the currently-focused element while accounting for the shadow DOM. */
322+
function getActiveElement() {
323+
const activeElement = document.activeElement as HTMLElement|null;
324+
return activeElement?.shadowRoot?.activeElement as HTMLElement || activeElement;
325+
}
261326

262327
@Component({
263328
template: `
@@ -271,21 +336,27 @@ class SimpleFocusTrap {
271336
@ViewChild(CdkTrapFocus) focusTrapDirective: CdkTrapFocus;
272337
}
273338

274-
@Component({
275-
template: `
276-
<button type="button">Toggle</button>
277-
<div *ngIf="showTrappedRegion" cdkTrapFocus [cdkTrapFocusAutoCapture]="autoCaptureEnabled">
278-
<input id="auto-capture-target">
279-
<button>SAVE</button>
280-
</div>
281-
`
282-
})
339+
const AUTO_FOCUS_TEMPLATE = `
340+
<button type="button">Toggle</button>
341+
<div *ngIf="showTrappedRegion" cdkTrapFocus [cdkTrapFocusAutoCapture]="autoCaptureEnabled">
342+
<input id="auto-capture-target">
343+
<button>SAVE</button>
344+
</div>
345+
`;
346+
347+
@Component({template: AUTO_FOCUS_TEMPLATE})
283348
class FocusTrapWithAutoCapture {
284349
@ViewChild(CdkTrapFocus) focusTrapDirective: CdkTrapFocus;
285350
showTrappedRegion = false;
286351
autoCaptureEnabled = true;
287352
}
288353

354+
@Component({
355+
template: AUTO_FOCUS_TEMPLATE,
356+
encapsulation: ViewEncapsulation.ShadowDom
357+
})
358+
class FocusTrapWithAutoCaptureInShadowDom extends FocusTrapWithAutoCapture {
359+
}
289360

290361
@Component({
291362
template: `

src/cdk/a11y/focus-trap/focus-trap.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,11 @@ export class CdkTrapFocus implements OnDestroy, AfterContentInit, OnChanges, DoC
454454
}
455455

456456
private _captureFocus() {
457-
this._previouslyFocusedElement = this._document.activeElement as HTMLElement;
457+
// If the `activeElement` is inside a shadow root, `document.activeElement` will
458+
// point to the shadow root so we have to descend into it ourselves.
459+
const activeElement = this._document?.activeElement as HTMLElement|null;
460+
this._previouslyFocusedElement =
461+
activeElement?.shadowRoot?.activeElement as HTMLElement || activeElement;
458462
this.focusTrap.focusInitialElementWhenReady();
459463
}
460464

0 commit comments

Comments
 (0)