Skip to content

refactor(a11y): reuse global event listeners in focus monitor #14205

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
Nov 27, 2018
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
162 changes: 81 additions & 81 deletions src/cdk/a11y/focus-monitor/focus-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,13 +37,21 @@ export interface FocusOptions {
preventScroll?: boolean;
}


type MonitoredElementInfo = {
unlisten: Function,
checkChildren: boolean,
subject: Subject<FocusOrigin>
};

/**
* 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'})
Expand Down Expand Up @@ -73,12 +80,57 @@ export class FocusMonitor implements OnDestroy {
/** Map of elements being monitored to their info. */
private _elementInfo = new Map<HTMLElement, MonitoredElementInfo>();

/** 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) {}

/**
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}

Expand Down