Skip to content

Commit

Permalink
fix(a11y): focus monitor not working inside shadow dom (#19135)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
crisbeto authored and jelbourn committed Apr 24, 2020
1 parent f5f3662 commit e7bfb47
Showing 1 changed file with 74 additions and 29 deletions.
103 changes: 74 additions & 29 deletions src/cdk/a11y/focus-monitor/focus-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform';
import {Platform, normalizePassiveListenerOptions, _getShadowRoot} from '@angular/cdk/platform';
import {
Directive,
ElementRef,
Expand Down Expand Up @@ -67,7 +67,8 @@ export const FOCUS_MONITOR_DEFAULT_OPTIONS =

type MonitoredElementInfo = {
checkChildren: boolean,
subject: Subject<FocusOrigin>
subject: Subject<FocusOrigin>,
rootNode: HTMLElement|Document
};

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

/**
* Keeps track of the root nodes to which we've currently bound a focus/blur handler,
* as well as the number of monitored elements that they contain. We have to treat focus/blur
* handlers differently from the rest of the events, because the browser won't emit events
* to the document when focus moves inside of a shadow root.
*/
private _rootNodeFocusListenerCount = new Map<HTMLElement|Document, number>();

/**
* The specified detection mode, used for attributing the origin of a focus
* event.
Expand Down Expand Up @@ -153,10 +162,7 @@ export class FocusMonitor implements OnDestroy {
clearTimeout(this._touchTimeoutId);
}

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

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

// We need to walk up the ancestor chain in order to support `checkChildren`.
for (let el = target; el; el = el.parentElement) {
handler.call(this, event, el);
for (let element = target; element; element = element.parentElement) {
handler.call(this, event as FocusEvent, element);
}
}

Expand Down Expand Up @@ -225,20 +231,26 @@ export class FocusMonitor implements OnDestroy {

const nativeElement = coerceElement(element);

// If the element is inside the shadow DOM, we need to bind our focus/blur listeners to
// the shadow root, rather than the `document`, because the browser won't emit focus events
// to the `document`, if focus is moving within the same shadow root.
const rootNode = (_getShadowRoot(nativeElement) as HTMLElement|null) || this._getDocument();

// Check if we're already monitoring this element.
if (this._elementInfo.has(nativeElement)) {
const cachedInfo = this._elementInfo.get(nativeElement);
cachedInfo!.checkChildren = checkChildren;
return cachedInfo!.subject.asObservable();
const cachedInfo = this._elementInfo.get(nativeElement)!;
cachedInfo.checkChildren = checkChildren;
return cachedInfo.subject.asObservable();
}

// Create monitored element info.
const info: MonitoredElementInfo = {
checkChildren: checkChildren,
subject: new Subject<FocusOrigin>()
subject: new Subject<FocusOrigin>(),
rootNode
};
this._elementInfo.set(nativeElement, info);
this._incrementMonitoredElementCount();
this._registerGlobalListeners(info);

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

this._setClasses(nativeElement);
this._elementInfo.delete(nativeElement);
this._decrementMonitoredElementCount();
this._removeGlobalListeners(elementInfo);
}
}

Expand Down Expand Up @@ -396,7 +408,7 @@ export class FocusMonitor implements OnDestroy {
// for the first focus event after the touchstart, and then the first blur event after that
// focus event. When that blur event fires we know that whatever follows is not a result of the
// touchstart.
let focusTarget = event.target;
const focusTarget = getTarget(event);
return this._lastTouchTarget instanceof Node && focusTarget instanceof Node &&
(focusTarget === this._lastTouchTarget || focusTarget.contains(this._lastTouchTarget));
}
Expand All @@ -415,7 +427,7 @@ export class FocusMonitor implements OnDestroy {
// If we are not counting child-element-focus as focused, make sure that the event target is the
// monitored element itself.
const elementInfo = this._elementInfo.get(element);
if (!elementInfo || (!elementInfo.checkChildren && element !== event.target)) {
if (!elementInfo || (!elementInfo.checkChildren && element !== getTarget(event))) {
return;
}

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

private _incrementMonitoredElementCount() {
private _registerGlobalListeners(elementInfo: MonitoredElementInfo) {
if (!this._platform.isBrowser) {
return;
}

const rootNode = elementInfo.rootNode;
const rootNodeFocusListeners = this._rootNodeFocusListenerCount.get(rootNode) || 0;

if (!rootNodeFocusListeners) {
this._ngZone.runOutsideAngular(() => {
rootNode.addEventListener('focus', this._rootNodeFocusAndBlurListener,
captureEventListenerOptions);
rootNode.addEventListener('blur', this._rootNodeFocusAndBlurListener,
captureEventListenerOptions);
});
}

this._rootNodeFocusListenerCount.set(rootNode, rootNodeFocusListeners + 1);

// Register global listeners when first element is monitored.
if (++this._monitoredElementCount == 1 && this._platform.isBrowser) {
if (++this._monitoredElementCount === 1) {
// Note: we listen to events in the capture phase so we
// can detect them even if the user stops propagation.
this._ngZone.runOutsideAngular(() => {
const document = this._getDocument();
const window = this._getWindow();

document.addEventListener('focus', this._documentFocusAndBlurListener,
captureEventListenerOptions);
document.addEventListener('blur', this._documentFocusAndBlurListener,
captureEventListenerOptions);
document.addEventListener('keydown', this._documentKeydownListener,
captureEventListenerOptions);
document.addEventListener('mousedown', this._documentMousedownListener,
Expand All @@ -472,16 +498,28 @@ export class FocusMonitor implements OnDestroy {
}
}

private _decrementMonitoredElementCount() {
private _removeGlobalListeners(elementInfo: MonitoredElementInfo) {
const rootNode = elementInfo.rootNode;

if (this._rootNodeFocusListenerCount.has(rootNode)) {
const rootNodeFocusListeners = this._rootNodeFocusListenerCount.get(rootNode)!;

if (rootNodeFocusListeners > 1) {
this._rootNodeFocusListenerCount.set(rootNode, rootNodeFocusListeners - 1);
} else {
rootNode.removeEventListener('focus', this._rootNodeFocusAndBlurListener,
captureEventListenerOptions);
rootNode.removeEventListener('blur', this._rootNodeFocusAndBlurListener,
captureEventListenerOptions);
this._rootNodeFocusListenerCount.delete(rootNode);
}
}

// Unregister global listeners when last element is unmonitored.
if (!--this._monitoredElementCount) {
const document = this._getDocument();
const window = this._getWindow();

document.removeEventListener('focus', this._documentFocusAndBlurListener,
captureEventListenerOptions);
document.removeEventListener('blur', this._documentFocusAndBlurListener,
captureEventListenerOptions);
document.removeEventListener('keydown', this._documentKeydownListener,
captureEventListenerOptions);
document.removeEventListener('mousedown', this._documentMousedownListener,
Expand All @@ -498,6 +536,13 @@ export class FocusMonitor implements OnDestroy {
}
}

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


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

0 comments on commit e7bfb47

Please sign in to comment.