Skip to content

Commit e7bfb47

Browse files
crisbetojelbourn
authored andcommitted
fix(a11y): focus monitor not working inside shadow dom (#19135)
In #18667 event delegation was implemented for the `FocusMonitor` which is based around binding a single event on the `document`. The problem is that the browser won't invoke the `focus` and `blur` handlers on the `document`, if focus is moved within the same shadow root. These changes switch to delegating the events either through the closest shadow root or the `document`. Fixes #19133.
1 parent f5f3662 commit e7bfb47

File tree

1 file changed

+74
-29
lines changed

1 file changed

+74
-29
lines changed

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

Lines changed: 74 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform';
9+
import {Platform, normalizePassiveListenerOptions, _getShadowRoot} from '@angular/cdk/platform';
1010
import {
1111
Directive,
1212
ElementRef,
@@ -67,7 +67,8 @@ export const FOCUS_MONITOR_DEFAULT_OPTIONS =
6767

6868
type MonitoredElementInfo = {
6969
checkChildren: boolean,
70-
subject: Subject<FocusOrigin>
70+
subject: Subject<FocusOrigin>,
71+
rootNode: HTMLElement|Document
7172
};
7273

7374
/**
@@ -110,6 +111,14 @@ export class FocusMonitor implements OnDestroy {
110111
/** The number of elements currently being monitored. */
111112
private _monitoredElementCount = 0;
112113

114+
/**
115+
* Keeps track of the root nodes to which we've currently bound a focus/blur handler,
116+
* as well as the number of monitored elements that they contain. We have to treat focus/blur
117+
* handlers differently from the rest of the events, because the browser won't emit events
118+
* to the document when focus moves inside of a shadow root.
119+
*/
120+
private _rootNodeFocusListenerCount = new Map<HTMLElement|Document, number>();
121+
113122
/**
114123
* The specified detection mode, used for attributing the origin of a focus
115124
* event.
@@ -153,10 +162,7 @@ export class FocusMonitor implements OnDestroy {
153162
clearTimeout(this._touchTimeoutId);
154163
}
155164

156-
// Since this listener is bound on the `document` level, any events coming from the shadow DOM
157-
// will have their `target` set to the shadow root. If available, use `composedPath` to
158-
// figure out the event target.
159-
this._lastTouchTarget = event.composedPath ? event.composedPath()[0] : event.target;
165+
this._lastTouchTarget = getTarget(event);
160166
this._touchTimeoutId = setTimeout(() => this._lastTouchTarget = null, TOUCH_BUFFER_MS);
161167
}
162168

@@ -188,13 +194,13 @@ export class FocusMonitor implements OnDestroy {
188194
* Event listener for `focus` and 'blur' events on the document.
189195
* Needs to be an arrow function in order to preserve the context when it gets bound.
190196
*/
191-
private _documentFocusAndBlurListener = (event: FocusEvent) => {
192-
const target = event.target as HTMLElement|null;
197+
private _rootNodeFocusAndBlurListener = (event: Event) => {
198+
const target = getTarget(event);
193199
const handler = event.type === 'focus' ? this._onFocus : this._onBlur;
194200

195201
// We need to walk up the ancestor chain in order to support `checkChildren`.
196-
for (let el = target; el; el = el.parentElement) {
197-
handler.call(this, event, el);
202+
for (let element = target; element; element = element.parentElement) {
203+
handler.call(this, event as FocusEvent, element);
198204
}
199205
}
200206

@@ -225,20 +231,26 @@ export class FocusMonitor implements OnDestroy {
225231

226232
const nativeElement = coerceElement(element);
227233

234+
// If the element is inside the shadow DOM, we need to bind our focus/blur listeners to
235+
// the shadow root, rather than the `document`, because the browser won't emit focus events
236+
// to the `document`, if focus is moving within the same shadow root.
237+
const rootNode = (_getShadowRoot(nativeElement) as HTMLElement|null) || this._getDocument();
238+
228239
// Check if we're already monitoring this element.
229240
if (this._elementInfo.has(nativeElement)) {
230-
const cachedInfo = this._elementInfo.get(nativeElement);
231-
cachedInfo!.checkChildren = checkChildren;
232-
return cachedInfo!.subject.asObservable();
241+
const cachedInfo = this._elementInfo.get(nativeElement)!;
242+
cachedInfo.checkChildren = checkChildren;
243+
return cachedInfo.subject.asObservable();
233244
}
234245

235246
// Create monitored element info.
236247
const info: MonitoredElementInfo = {
237248
checkChildren: checkChildren,
238-
subject: new Subject<FocusOrigin>()
249+
subject: new Subject<FocusOrigin>(),
250+
rootNode
239251
};
240252
this._elementInfo.set(nativeElement, info);
241-
this._incrementMonitoredElementCount();
253+
this._registerGlobalListeners(info);
242254

243255
return info.subject.asObservable();
244256
}
@@ -264,7 +276,7 @@ export class FocusMonitor implements OnDestroy {
264276

265277
this._setClasses(nativeElement);
266278
this._elementInfo.delete(nativeElement);
267-
this._decrementMonitoredElementCount();
279+
this._removeGlobalListeners(elementInfo);
268280
}
269281
}
270282

@@ -396,7 +408,7 @@ export class FocusMonitor implements OnDestroy {
396408
// for the first focus event after the touchstart, and then the first blur event after that
397409
// focus event. When that blur event fires we know that whatever follows is not a result of the
398410
// touchstart.
399-
let focusTarget = event.target;
411+
const focusTarget = getTarget(event);
400412
return this._lastTouchTarget instanceof Node && focusTarget instanceof Node &&
401413
(focusTarget === this._lastTouchTarget || focusTarget.contains(this._lastTouchTarget));
402414
}
@@ -415,7 +427,7 @@ export class FocusMonitor implements OnDestroy {
415427
// If we are not counting child-element-focus as focused, make sure that the event target is the
416428
// monitored element itself.
417429
const elementInfo = this._elementInfo.get(element);
418-
if (!elementInfo || (!elementInfo.checkChildren && element !== event.target)) {
430+
if (!elementInfo || (!elementInfo.checkChildren && element !== getTarget(event))) {
419431
return;
420432
}
421433

@@ -448,19 +460,33 @@ export class FocusMonitor implements OnDestroy {
448460
this._ngZone.run(() => subject.next(origin));
449461
}
450462

451-
private _incrementMonitoredElementCount() {
463+
private _registerGlobalListeners(elementInfo: MonitoredElementInfo) {
464+
if (!this._platform.isBrowser) {
465+
return;
466+
}
467+
468+
const rootNode = elementInfo.rootNode;
469+
const rootNodeFocusListeners = this._rootNodeFocusListenerCount.get(rootNode) || 0;
470+
471+
if (!rootNodeFocusListeners) {
472+
this._ngZone.runOutsideAngular(() => {
473+
rootNode.addEventListener('focus', this._rootNodeFocusAndBlurListener,
474+
captureEventListenerOptions);
475+
rootNode.addEventListener('blur', this._rootNodeFocusAndBlurListener,
476+
captureEventListenerOptions);
477+
});
478+
}
479+
480+
this._rootNodeFocusListenerCount.set(rootNode, rootNodeFocusListeners + 1);
481+
452482
// Register global listeners when first element is monitored.
453-
if (++this._monitoredElementCount == 1 && this._platform.isBrowser) {
483+
if (++this._monitoredElementCount === 1) {
454484
// Note: we listen to events in the capture phase so we
455485
// can detect them even if the user stops propagation.
456486
this._ngZone.runOutsideAngular(() => {
457487
const document = this._getDocument();
458488
const window = this._getWindow();
459489

460-
document.addEventListener('focus', this._documentFocusAndBlurListener,
461-
captureEventListenerOptions);
462-
document.addEventListener('blur', this._documentFocusAndBlurListener,
463-
captureEventListenerOptions);
464490
document.addEventListener('keydown', this._documentKeydownListener,
465491
captureEventListenerOptions);
466492
document.addEventListener('mousedown', this._documentMousedownListener,
@@ -472,16 +498,28 @@ export class FocusMonitor implements OnDestroy {
472498
}
473499
}
474500

475-
private _decrementMonitoredElementCount() {
501+
private _removeGlobalListeners(elementInfo: MonitoredElementInfo) {
502+
const rootNode = elementInfo.rootNode;
503+
504+
if (this._rootNodeFocusListenerCount.has(rootNode)) {
505+
const rootNodeFocusListeners = this._rootNodeFocusListenerCount.get(rootNode)!;
506+
507+
if (rootNodeFocusListeners > 1) {
508+
this._rootNodeFocusListenerCount.set(rootNode, rootNodeFocusListeners - 1);
509+
} else {
510+
rootNode.removeEventListener('focus', this._rootNodeFocusAndBlurListener,
511+
captureEventListenerOptions);
512+
rootNode.removeEventListener('blur', this._rootNodeFocusAndBlurListener,
513+
captureEventListenerOptions);
514+
this._rootNodeFocusListenerCount.delete(rootNode);
515+
}
516+
}
517+
476518
// Unregister global listeners when last element is unmonitored.
477519
if (!--this._monitoredElementCount) {
478520
const document = this._getDocument();
479521
const window = this._getWindow();
480522

481-
document.removeEventListener('focus', this._documentFocusAndBlurListener,
482-
captureEventListenerOptions);
483-
document.removeEventListener('blur', this._documentFocusAndBlurListener,
484-
captureEventListenerOptions);
485523
document.removeEventListener('keydown', this._documentKeydownListener,
486524
captureEventListenerOptions);
487525
document.removeEventListener('mousedown', this._documentMousedownListener,
@@ -498,6 +536,13 @@ export class FocusMonitor implements OnDestroy {
498536
}
499537
}
500538

539+
/** Gets the target of an event, accounting for Shadow DOM. */
540+
function getTarget(event: Event): HTMLElement|null {
541+
// If an event is bound outside the Shadow DOM, the `event.target` will
542+
// point to the shadow root so we have to use `composedPath` instead.
543+
return (event.composedPath ? event.composedPath()[0] : event.target) as HTMLElement | null;
544+
}
545+
501546

502547
/**
503548
* Directive that determines how a particular element was focused (via keyboard, mouse, touch, or

0 commit comments

Comments
 (0)