Skip to content

Commit bad81f0

Browse files
crisbetommalerba
authored andcommitted
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. (cherry picked from commit 706fc48)
1 parent 9600939 commit bad81f0

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,21 +134,21 @@ 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 prioritize the first focus target', () => {
133141
// Because we can't mimic a real tab press focus change in a unit test, just call the
134142
// focus event handler directly.
135143
focusTrapInstance.focusFirstTabbableElement();
136-
expect(document.activeElement!.id).toBe('first');
144+
expect(getActiveElement().id).toBe('first');
137145
});
138146

139147
it('should be able to prioritize the last focus target', () => {
140148
// Because we can't mimic a real tab press focus change in a unit test, just call the
141149
// focus event handler directly.
142150
focusTrapInstance.focusLastTabbableElement();
143-
expect(document.activeElement!.id).toBe('last');
151+
expect(getActiveElement().id).toBe('last');
144152
});
145153

146154
it('should warn if the initial focus target is not focusable', () => {
@@ -176,16 +184,16 @@ describe('FocusTrap', () => {
176184

177185
const buttonOutsideTrappedRegion = fixture.nativeElement.querySelector('button');
178186
buttonOutsideTrappedRegion.focus();
179-
expect(document.activeElement).toBe(buttonOutsideTrappedRegion);
187+
expect(getActiveElement()).toBe(buttonOutsideTrappedRegion);
180188

181189
fixture.componentInstance.showTrappedRegion = true;
182190
fixture.detectChanges();
183191

184192
fixture.whenStable().then(() => {
185-
expect(document.activeElement!.id).toBe('auto-capture-target');
193+
expect(getActiveElement().id).toBe('auto-capture-target');
186194

187195
fixture.destroy();
188-
expect(document.activeElement).toBe(buttonOutsideTrappedRegion);
196+
expect(getActiveElement()).toBe(buttonOutsideTrappedRegion);
189197
});
190198
}));
191199

@@ -197,19 +205,71 @@ describe('FocusTrap', () => {
197205

198206
const buttonOutsideTrappedRegion = fixture.nativeElement.querySelector('button');
199207
buttonOutsideTrappedRegion.focus();
200-
expect(document.activeElement).toBe(buttonOutsideTrappedRegion);
208+
expect(getActiveElement()).toBe(buttonOutsideTrappedRegion);
201209

202210
fixture.componentInstance.autoCaptureEnabled = true;
203211
fixture.detectChanges();
204212

205213
fixture.whenStable().then(() => {
206-
expect(document.activeElement!.id).toBe('auto-capture-target');
214+
expect(getActiveElement().id).toBe('auto-capture-target');
207215

208216
fixture.destroy();
209-
expect(document.activeElement).toBe(buttonOutsideTrappedRegion);
217+
expect(getActiveElement()).toBe(buttonOutsideTrappedRegion);
210218
});
211219
}));
212220

221+
it('should automatically capture and return focus on init / destroy inside the shadow DOM',
222+
waitForAsync(() => {
223+
if (!_supportsShadowDom()) {
224+
return;
225+
}
226+
227+
const fixture = TestBed.createComponent(FocusTrapWithAutoCaptureInShadowDom);
228+
fixture.detectChanges();
229+
230+
const buttonOutsideTrappedRegion =
231+
fixture.debugElement.query(By.css('button')).nativeElement;
232+
buttonOutsideTrappedRegion.focus();
233+
expect(getActiveElement()).toBe(buttonOutsideTrappedRegion);
234+
235+
fixture.componentInstance.showTrappedRegion = true;
236+
fixture.detectChanges();
237+
238+
fixture.whenStable().then(() => {
239+
expect(getActiveElement().id).toBe('auto-capture-target');
240+
241+
fixture.destroy();
242+
expect(getActiveElement()).toBe(buttonOutsideTrappedRegion);
243+
});
244+
}));
245+
246+
it('should capture focus if auto capture is enabled later on inside the shadow DOM',
247+
waitForAsync(() => {
248+
if (!_supportsShadowDom()) {
249+
return;
250+
}
251+
252+
const fixture = TestBed.createComponent(FocusTrapWithAutoCaptureInShadowDom);
253+
fixture.componentInstance.autoCaptureEnabled = false;
254+
fixture.componentInstance.showTrappedRegion = true;
255+
fixture.detectChanges();
256+
257+
const buttonOutsideTrappedRegion =
258+
fixture.debugElement.query(By.css('button')).nativeElement;
259+
buttonOutsideTrappedRegion.focus();
260+
expect(getActiveElement()).toBe(buttonOutsideTrappedRegion);
261+
262+
fixture.componentInstance.autoCaptureEnabled = true;
263+
fixture.detectChanges();
264+
265+
fixture.whenStable().then(() => {
266+
expect(getActiveElement().id).toBe('auto-capture-target');
267+
268+
fixture.destroy();
269+
expect(getActiveElement()).toBe(buttonOutsideTrappedRegion);
270+
});
271+
}));
272+
213273
});
214274

215275
it('should put anchors inside the outlet when set at the root of a template portal', () => {
@@ -234,6 +294,11 @@ describe('FocusTrap', () => {
234294
});
235295
});
236296

297+
/** Gets the currently-focused element while accounting for the shadow DOM. */
298+
function getActiveElement() {
299+
const activeElement = document.activeElement as HTMLElement|null;
300+
return activeElement?.shadowRoot?.activeElement as HTMLElement || activeElement;
301+
}
237302

238303
@Component({
239304
template: `
@@ -247,21 +312,27 @@ class SimpleFocusTrap {
247312
@ViewChild(CdkTrapFocus) focusTrapDirective: CdkTrapFocus;
248313
}
249314

250-
@Component({
251-
template: `
252-
<button type="button">Toggle</button>
253-
<div *ngIf="showTrappedRegion" cdkTrapFocus [cdkTrapFocusAutoCapture]="autoCaptureEnabled">
254-
<input id="auto-capture-target">
255-
<button>SAVE</button>
256-
</div>
257-
`
258-
})
315+
const AUTO_FOCUS_TEMPLATE = `
316+
<button type="button">Toggle</button>
317+
<div *ngIf="showTrappedRegion" cdkTrapFocus [cdkTrapFocusAutoCapture]="autoCaptureEnabled">
318+
<input id="auto-capture-target">
319+
<button>SAVE</button>
320+
</div>
321+
`;
322+
323+
@Component({template: AUTO_FOCUS_TEMPLATE})
259324
class FocusTrapWithAutoCapture {
260325
@ViewChild(CdkTrapFocus) focusTrapDirective: CdkTrapFocus;
261326
showTrappedRegion = false;
262327
autoCaptureEnabled = true;
263328
}
264329

330+
@Component({
331+
template: AUTO_FOCUS_TEMPLATE,
332+
encapsulation: ViewEncapsulation.ShadowDom
333+
})
334+
class FocusTrapWithAutoCaptureInShadowDom extends FocusTrapWithAutoCapture {
335+
}
265336

266337
@Component({
267338
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)