Skip to content

fix(a11y): focus monitor not working inside shadow dom #19135

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 24, 2020
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be safe, we should scan through this in ngOnDestroy and unlisten to any remaining entries

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're looping through and calling stopMonitoring on all the remaining elements which eventually empties this out as well.


/**
* 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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that you didn't change this, but is this right? Won't this cause all previous calls to monitor on this element to behave according to the checkChildren parameter on the current call?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Judging by how it's written, I think that's the point. E.g. you could turn a shallow watch into a deep one by calling monitor again with checkChildren. We could separate it out and have 2 separate streams, but we'd have to make a cache key out of the options.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'm not sure it should work that way. The main way I see it being called twice is if there's e.g. a directive and a component that need to know about focus state on the same element. They might have different requirements about whether to include the children, and we wouldn't want them to affect each other's subscriptions.

So it seems like a bug, but a pre-existing one and I haven't seen anyone complain about it yet, so I think its safe to leave this for another PR.

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