diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index d8d1d72a95d..fcd35ee9813 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -28,7 +28,6 @@ export const TOUCH_BUFFER_MS = 650; export type FocusOrigin = 'touch' | 'mouse' | 'keyboard' | 'program' | null; - /** * Corresponds to the options that can be passed to the native `focus` event. * via https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus @@ -38,13 +37,21 @@ export interface FocusOptions { preventScroll?: boolean; } - type MonitoredElementInfo = { unlisten: Function, checkChildren: boolean, subject: Subject }; +/** + * Event listener options that enable capturing and also + * mark the the listener as passive if the browser supports it. + */ +const captureEventListenerOptions = normalizePassiveListenerOptions({ + passive: true, + capture: true +}); + /** Monitors mouse and keyboard events to determine the cause of focus events. */ @Injectable({providedIn: 'root'}) @@ -73,12 +80,57 @@ export class FocusMonitor implements OnDestroy { /** Map of elements being monitored to their info. */ private _elementInfo = new Map(); - /** A map of global objects to lists of current listeners. */ - private _unregisterGlobalListeners = () => {}; - /** The number of elements currently being monitored. */ private _monitoredElementCount = 0; + /** + * Event listener for `keydown` events on the document. + * Needs to be an arrow function in order to preserve the context when it gets bound. + */ + private _documentKeydownListener = () => { + // On keydown record the origin and clear any touch event that may be in progress. + this._lastTouchTarget = null; + this._setOriginForCurrentEventQueue('keyboard'); + } + + /** + * Event listener for `mousedown` events on the document. + * Needs to be an arrow function in order to preserve the context when it gets bound. + */ + private _documentMousedownListener = () => { + // On mousedown record the origin only if there is not touch + // target, since a mousedown can happen as a result of a touch event. + if (!this._lastTouchTarget) { + this._setOriginForCurrentEventQueue('mouse'); + } + } + + /** + * Event listener for `touchstart` events on the document. + * Needs to be an arrow function in order to preserve the context when it gets bound. + */ + private _documentTouchstartListener = (event: TouchEvent) => { + // When the touchstart event fires the focus event is not yet in the event queue. This means + // we can't rely on the trick used above (setting timeout of 1ms). Instead we wait 650ms to + // see if a focus happens. + if (this._touchTimeoutId != null) { + clearTimeout(this._touchTimeoutId); + } + this._lastTouchTarget = event.target; + this._touchTimeoutId = setTimeout(() => this._lastTouchTarget = null, TOUCH_BUFFER_MS); + } + + /** + * Event listener for `focus` events on the window. + * Needs to be an arrow function in order to preserve the context when it gets bound. + */ + private _windowFocusListener = () => { + // Make a note of when the window regains focus, so we can + // restore the origin info for the focused element. + this._windowFocused = true; + this._windowFocusTimeoutId = setTimeout(() => this._windowFocused = false); + } + constructor(private _ngZone: NgZone, private _platform: Platform) {} /** @@ -202,78 +254,6 @@ export class FocusMonitor implements OnDestroy { this._elementInfo.forEach((_info, element) => this.stopMonitoring(element)); } - /** Register necessary event listeners on the document and window. */ - private _registerGlobalListeners() { - // Do nothing if we're not on the browser platform. - if (!this._platform.isBrowser) { - return; - } - - // On keydown record the origin and clear any touch event that may be in progress. - let documentKeydownListener = () => { - this._lastTouchTarget = null; - this._setOriginForCurrentEventQueue('keyboard'); - }; - - // On mousedown record the origin only if there is not touch target, since a mousedown can - // happen as a result of a touch event. - let documentMousedownListener = () => { - if (!this._lastTouchTarget) { - this._setOriginForCurrentEventQueue('mouse'); - } - }; - - // When the touchstart event fires the focus event is not yet in the event queue. This means - // we can't rely on the trick used above (setting timeout of 1ms). Instead we wait 650ms to - // see if a focus happens. - let documentTouchstartListener = (event: TouchEvent) => { - if (this._touchTimeoutId != null) { - clearTimeout(this._touchTimeoutId); - } - this._lastTouchTarget = event.target; - this._touchTimeoutId = setTimeout(() => this._lastTouchTarget = null, TOUCH_BUFFER_MS); - }; - - // Make a note of when the window regains focus, so we can restore the origin info for the - // focused element. - let windowFocusListener = () => { - this._windowFocused = true; - this._windowFocusTimeoutId = setTimeout(() => this._windowFocused = false); - }; - - // Event listener options that enable capturing and also mark the the listener as passive - // if the browser supports it. - const captureEventListenerOptions = normalizePassiveListenerOptions({ - passive: true, - capture: true - }); - - // Note: we listen to events in the capture phase so we can detect them even if the user stops - // propagation. - this._ngZone.runOutsideAngular(() => { - document.addEventListener('keydown', documentKeydownListener, captureEventListenerOptions); - document.addEventListener('mousedown', documentMousedownListener, - captureEventListenerOptions); - document.addEventListener('touchstart', documentTouchstartListener, - captureEventListenerOptions); - window.addEventListener('focus', windowFocusListener); - }); - - this._unregisterGlobalListeners = () => { - document.removeEventListener('keydown', documentKeydownListener, captureEventListenerOptions); - document.removeEventListener('mousedown', documentMousedownListener, - captureEventListenerOptions); - document.removeEventListener('touchstart', documentTouchstartListener, - captureEventListenerOptions); - window.removeEventListener('focus', windowFocusListener); - - // Clear timeouts for all potentially pending timeouts to prevent the leaks. - clearTimeout(this._windowFocusTimeoutId); - clearTimeout(this._touchTimeoutId); - clearTimeout(this._originTimeoutId); - }; - } - private _toggleClass(element: Element, className: string, shouldSet: boolean) { if (shouldSet) { element.classList.add(className); @@ -406,16 +386,36 @@ export class FocusMonitor implements OnDestroy { private _incrementMonitoredElementCount() { // Register global listeners when first element is monitored. - if (++this._monitoredElementCount == 1) { - this._registerGlobalListeners(); + if (++this._monitoredElementCount == 1 && this._platform.isBrowser) { + // Note: we listen to events in the capture phase so we + // can detect them even if the user stops propagation. + this._ngZone.runOutsideAngular(() => { + document.addEventListener('keydown', this._documentKeydownListener, + captureEventListenerOptions); + document.addEventListener('mousedown', this._documentMousedownListener, + captureEventListenerOptions); + document.addEventListener('touchstart', this._documentTouchstartListener, + captureEventListenerOptions); + window.addEventListener('focus', this._windowFocusListener); + }); } } private _decrementMonitoredElementCount() { // Unregister global listeners when last element is unmonitored. if (!--this._monitoredElementCount) { - this._unregisterGlobalListeners(); - this._unregisterGlobalListeners = () => {}; + document.removeEventListener('keydown', this._documentKeydownListener, + captureEventListenerOptions); + document.removeEventListener('mousedown', this._documentMousedownListener, + captureEventListenerOptions); + document.removeEventListener('touchstart', this._documentTouchstartListener, + captureEventListenerOptions); + window.removeEventListener('focus', this._windowFocusListener); + + // Clear timeouts for all potentially pending timeouts to prevent the leaks. + clearTimeout(this._windowFocusTimeoutId); + clearTimeout(this._touchTimeoutId); + clearTimeout(this._originTimeoutId); } }