Commit
…e origin.
- Loading branch information
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -24,10 +24,10 @@ import {Observable, of as observableOf, Subject, Subscription} from 'rxjs'; | |
import {coerceElement} from '@angular/cdk/coercion'; | ||
import {DOCUMENT} from '@angular/common'; | ||
import { | ||
isFakeMousedownFromScreenReader, | ||
isFakeTouchstartFromScreenReader, | ||
} from '../fake-event-detection'; | ||
import {TOUCH_BUFFER_MS} from '../input-modality/input-modality-detector'; | ||
InputModality, | ||
InputModalityDetector, | ||
TOUCH_BUFFER_MS, | ||
} from '../input-modality/input-modality-detector'; | ||
|
||
|
||
export type FocusOrigin = 'touch' | 'mouse' | 'keyboard' | 'program' | null; | ||
|
@@ -93,12 +93,6 @@ export class FocusMonitor implements OnDestroy { | |
/** Whether the window has just been focused. */ | ||
private _windowFocused = false; | ||
|
||
/** The target of the last touch event. */ | ||
private _lastTouchTarget: EventTarget | null; | ||
|
||
/** The timeout id of the touch timeout, used to cancel timeout later. */ | ||
private _touchTimeoutId: number; | ||
|
||
/** The timeout id of the window focus timeout. */ | ||
private _windowFocusTimeoutId: number; | ||
|
||
|
@@ -125,53 +119,6 @@ export class FocusMonitor implements OnDestroy { | |
*/ | ||
private readonly _detectionMode: FocusMonitorDetectionMode; | ||
|
||
/** | ||
* 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 = (event: MouseEvent) => { | ||
// 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) { | ||
// In some cases screen readers fire fake `mousedown` events instead of `keydown`. | ||
// Resolve the focus source to `keyboard` if we detect one of them. | ||
const source = isFakeMousedownFromScreenReader(event) ? 'keyboard' : 'mouse'; | ||
This comment has been minimized.
Sorry, something went wrong. |
||
this._setOriginForCurrentEventQueue(source); | ||
} | ||
} | ||
|
||
/** | ||
* 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) => { | ||
// Some screen readers will fire a fake `touchstart` event if an element is activated using | ||
// the keyboard while on a device with a touchsreen. Consider such events as keyboard focus. | ||
if (!isFakeTouchstartFromScreenReader(event)) { | ||
// 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 = getTarget(event); | ||
this._touchTimeoutId = setTimeout(() => this._lastTouchTarget = null, TOUCH_BUFFER_MS); | ||
} else if (!this._lastTouchTarget) { | ||
this._setOriginForCurrentEventQueue('keyboard'); | ||
} | ||
} | ||
|
||
/** | ||
* Event listener for `focus` events on the window. | ||
* Needs to be an arrow function in order to preserve the context when it gets bound. | ||
|
@@ -189,12 +136,18 @@ export class FocusMonitor implements OnDestroy { | |
constructor( | ||
private _ngZone: NgZone, | ||
private _platform: Platform, | ||
private readonly _inputModalityDetector: InputModalityDetector, | ||
/** @breaking-change 11.0.0 make document required */ | ||
@Optional() @Inject(DOCUMENT) document: any|null, | ||
@Optional() @Inject(FOCUS_MONITOR_DEFAULT_OPTIONS) options: | ||
FocusMonitorOptions|null) { | ||
this._document = document; | ||
this._detectionMode = options?.detectionMode || FocusMonitorDetectionMode.IMMEDIATE; | ||
|
||
this._inputModalityDetector.inputModalityDetected | ||
This comment has been minimized.
Sorry, something went wrong.
zelliott
Author
Collaborator
|
||
.subscribe((modality: InputModality) => { | ||
this._setOrigin(modality); | ||
}); | ||
} | ||
/** | ||
* Event listener for `focus` and 'blur' events on the document. | ||
|
@@ -322,7 +275,7 @@ export class FocusMonitor implements OnDestroy { | |
this._getClosestElementsInfo(nativeElement) | ||
.forEach(([currentElement, info]) => this._originChanged(currentElement, origin, info)); | ||
} else { | ||
this._setOriginForCurrentEventQueue(origin); | ||
this._setOrigin(origin); | ||
|
||
// `focus` isn't available on the server | ||
if (typeof nativeElement.focus === 'function') { | ||
|
@@ -354,21 +307,18 @@ export class FocusMonitor implements OnDestroy { | |
} | ||
} | ||
|
||
private _getFocusOrigin(event: FocusEvent): FocusOrigin { | ||
private _getFocusOrigin(): FocusOrigin { | ||
// If we couldn't detect a cause for the focus event, it's due to one of three reasons: | ||
// 1) The window has just regained focus, in which case we want to restore the focused state of | ||
// the element from before the window blurred. | ||
// 2) It was caused by a touch event, in which case we mark the origin as 'touch'. | ||
// 3) The element was programmatically focused, in which case we should mark the origin as | ||
// 2) The element was programmatically focused, in which case we should mark the origin as | ||
// 'program'. | ||
if (this._origin) { | ||
return this._origin; | ||
} | ||
|
||
if (this._windowFocused && this._lastFocusOrigin) { | ||
return this._lastFocusOrigin; | ||
} else if (this._wasCausedByTouch(event)) { | ||
return 'touch'; | ||
} else { | ||
return 'program'; | ||
} | ||
|
@@ -388,51 +338,26 @@ export class FocusMonitor implements OnDestroy { | |
} | ||
|
||
/** | ||
* Sets the origin and schedules an async function to clear it at the end of the event queue. | ||
* If the detection mode is 'eventual', the origin is never cleared. | ||
* Updates the focus origin. If we're using immediate detection mode, we schedule an async | ||
* function to clear the origin at the end of a timeout. The duration of the timeout depends on | ||
* the origin being set. | ||
* @param origin The origin to set. | ||
*/ | ||
private _setOriginForCurrentEventQueue(origin: FocusOrigin): void { | ||
private _setOrigin(origin: FocusOrigin): void { | ||
This comment has been minimized.
Sorry, something went wrong.
zelliott
Author
Collaborator
|
||
this._ngZone.runOutsideAngular(() => { | ||
this._origin = origin; | ||
|
||
if (this._detectionMode === FocusMonitorDetectionMode.IMMEDIATE) { | ||
// Sometimes the focus origin won't be valid in Firefox because Firefox seems to focus *one* | ||
// tick after the interaction event fired. To ensure the focus origin is always correct, | ||
// the focus origin will be determined at the beginning of the next tick. | ||
this._originTimeoutId = setTimeout(() => this._origin = null, 1); | ||
// When a touch origin is received, we need to wait at least `TOUCH_BUFFER_MS` ms until | ||
// clearing the origin. This is because when a touch event is fired, the associated focus | ||
// event isn't yet in the event queue. Otherwise, clear the focus origin at the start of the | ||
// next tick (because Firefox focuses one tick after the interaction event). | ||
const ms = (origin === 'touch') ? TOUCH_BUFFER_MS : 1; | ||
this._originTimeoutId = setTimeout(() => this._origin = null, ms); | ||
} | ||
}); | ||
} | ||
|
||
/** | ||
* Checks whether the given focus event was caused by a touchstart event. | ||
* @param event The focus event to check. | ||
* @returns Whether the event was caused by a touch. | ||
*/ | ||
private _wasCausedByTouch(event: FocusEvent): boolean { | ||
This comment has been minimized.
Sorry, something went wrong.
zelliott
Author
Collaborator
|
||
// Note(mmalerba): This implementation is not quite perfect, there is a small edge case. | ||
// Consider the following dom structure: | ||
// | ||
// <div #parent tabindex="0" cdkFocusClasses> | ||
// <div #child (click)="#parent.focus()"></div> | ||
// </div> | ||
// | ||
// If the user touches the #child element and the #parent is programmatically focused as a | ||
// result, this code will still consider it to have been caused by the touch event and will | ||
// apply the cdk-touch-focused class rather than the cdk-program-focused class. This is a | ||
// relatively small edge-case that can be worked around by using | ||
// focusVia(parentEl, 'program') to focus the parent element. | ||
// | ||
// If we decide that we absolutely must handle this case correctly, we can do so by listening | ||
// 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. | ||
const focusTarget = getTarget(event); | ||
return this._lastTouchTarget instanceof Node && focusTarget instanceof Node && | ||
(focusTarget === this._lastTouchTarget || focusTarget.contains(this._lastTouchTarget)); | ||
} | ||
|
||
/** | ||
* Handles focus events on a registered element. | ||
* @param event The focus event. | ||
|
@@ -451,7 +376,7 @@ export class FocusMonitor implements OnDestroy { | |
return; | ||
} | ||
|
||
this._originChanged(element, this._getFocusOrigin(event), elementInfo); | ||
this._originChanged(element, this._getFocusOrigin(), elementInfo); | ||
} | ||
|
||
/** | ||
|
@@ -501,15 +426,7 @@ export class FocusMonitor implements OnDestroy { | |
// 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('keydown', this._documentKeydownListener, | ||
captureEventListenerOptions); | ||
document.addEventListener('mousedown', this._documentMousedownListener, | ||
captureEventListenerOptions); | ||
document.addEventListener('touchstart', this._documentTouchstartListener, | ||
captureEventListenerOptions); | ||
window.addEventListener('focus', this._windowFocusListener); | ||
}); | ||
} | ||
|
@@ -534,20 +451,11 @@ export class FocusMonitor implements OnDestroy { | |
|
||
// Unregister global listeners when last element is unmonitored. | ||
if (!--this._monitoredElementCount) { | ||
const document = this._getDocument(); | ||
const window = this._getWindow(); | ||
|
||
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); | ||
} | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,7 +11,7 @@ import {Inject, Injectable, InjectionToken, OnDestroy, Optional, NgZone} from '@ | |
import {normalizePassiveListenerOptions, Platform} from '@angular/cdk/platform'; | ||
import {DOCUMENT} from '@angular/common'; | ||
import {BehaviorSubject, Observable} from 'rxjs'; | ||
import {distinctUntilChanged, skip} from 'rxjs/operators'; | ||
import {skip} from 'rxjs/operators'; | ||
import { | ||
isFakeMousedownFromScreenReader, | ||
isFakeTouchstartFromScreenReader, | ||
|
@@ -86,8 +86,8 @@ const modalityEventListenerOptions = normalizePassiveListenerOptions({ | |
*/ | ||
@Injectable({ providedIn: 'root' }) | ||
export class InputModalityDetector implements OnDestroy { | ||
/** Emits when the input modality changes. */ | ||
readonly inputModalityChange: Observable<InputModality>; | ||
/** Emits whenever an input modality is detected. */ | ||
readonly inputModalityDetected: Observable<InputModality>; | ||
This comment has been minimized.
Sorry, something went wrong.
zelliott
Author
Collaborator
|
||
|
||
/** Returns the most recently detected input modality. */ | ||
get inputModality(): InputModality { | ||
|
@@ -159,8 +159,8 @@ export class InputModalityDetector implements OnDestroy { | |
...options, | ||
}; | ||
|
||
// Only emit if the input modality changes, and skip the first emission as it's null. | ||
this.inputModalityChange = this._inputModality.pipe(distinctUntilChanged(), skip(1)); | ||
// Skip the first emission as it's null. | ||
this.inputModalityDetected = this._inputModality.pipe(skip(1)); | ||
|
||
// If we're not in a browser, this service should do nothing, as there's no relevant input | ||
// modality to detect. | ||
|
Previously, when
FocusMonitor
detected focus changes as a result of fake mouse/touch events from a screen reader, it resolved their origin as keyboard. WhenInputModalityDetector
receives fake mouse/touch events, it ignores them and doesn't detect any input modality. Thus, after integrating,FocusMonitor
now resolves the origin as program instead of keyboard.Ultimately,
FocusMonitor
attempts to identify screen reader usage as keyboard, whereasInputModalityDetector
attempts to ignore screen reader usage altogether. The rationale for whyInputModalityDetector
attempts to ignore SR usage is documented in the top-level class comment here (https://github.com/angular/components/pull/22371/files#diff-84f7a919f0a9034767f08d4fd13ce6e0d52a8bc1ad9bc0c52c62c3ae0eb5e3cf).