-
Notifications
You must be signed in to change notification settings - Fork 6.8k
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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, | ||
|
@@ -67,7 +67,8 @@ export const FOCUS_MONITOR_DEFAULT_OPTIONS = | |
|
||
type MonitoredElementInfo = { | ||
checkChildren: boolean, | ||
subject: Subject<FocusOrigin> | ||
subject: Subject<FocusOrigin>, | ||
rootNode: HTMLElement|Document | ||
}; | ||
|
||
/** | ||
|
@@ -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. | ||
|
@@ -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); | ||
} | ||
|
||
|
@@ -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); | ||
} | ||
} | ||
|
||
|
@@ -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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
} | ||
|
@@ -264,7 +276,7 @@ export class FocusMonitor implements OnDestroy { | |
|
||
this._setClasses(nativeElement); | ||
this._elementInfo.delete(nativeElement); | ||
this._decrementMonitoredElementCount(); | ||
this._removeGlobalListeners(elementInfo); | ||
} | ||
} | ||
|
||
|
@@ -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)); | ||
} | ||
|
@@ -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; | ||
} | ||
|
||
|
@@ -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, | ||
|
@@ -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, | ||
|
@@ -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 | ||
|
There was a problem hiding this comment.
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 entriesThere was a problem hiding this comment.
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.