Skip to content

Commit

Permalink
feat(cdk/a11y): FocusMonitor now uses InputModalityDetector to resolv…
Browse files Browse the repository at this point in the history
…e origin.
  • Loading branch information
zelliott committed Apr 6, 2021
1 parent 27d2972 commit db685c0
Show file tree
Hide file tree
Showing 5 changed files with 36 additions and 132 deletions.
1 change: 0 additions & 1 deletion src/cdk/a11y/focus-monitor/focus-monitor.spec.ts
Expand Up @@ -149,7 +149,6 @@ describe('FocusMonitor', () => {

it('should detect fake mousedown from a screen reader', fakeAsync(() => {
// Simulate focus via a fake mousedown from a screen reader.
dispatchMouseEvent(buttonElement, 'mousedown');
const event = createMouseEvent('mousedown');
Object.defineProperty(event, 'buttons', {get: () => 0});
dispatchEvent(buttonElement, event);
Expand Down
140 changes: 24 additions & 116 deletions src/cdk/a11y/focus-monitor/focus-monitor.ts
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand All @@ -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.

Copy link
@zelliott

zelliott Apr 6, 2021

Author Collaborator

Previously, when FocusMonitor detected focus changes as a result of fake mouse/touch events from a screen reader, it resolved their origin as keyboard. When InputModalityDetector 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, whereas InputModalityDetector attempts to ignore screen reader usage altogether. The rationale for why InputModalityDetector attempts to ignore SR usage is documented in the top-level class comment here (https://github.com/angular/components/pull/22371/files#diff-84f7a919f0a9034767f08d4fd13ce6e0d52a8bc1ad9bc0c52c62c3ae0eb5e3cf).

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.
Expand All @@ -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.

Copy link
@zelliott

zelliott Apr 6, 2021

Author Collaborator

Note that the detector is subscribed to in the constructor now, which represents a regression in behavior. Previously, document-level listeners were only added once monitor was called.

.subscribe((modality: InputModality) => {
this._setOrigin(modality);
});
}
/**
* Event listener for `focus` and 'blur' events on the document.
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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';
}
Expand All @@ -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.

Copy link
@zelliott

zelliott Apr 6, 2021

Author Collaborator

This method was renamed because it no longer sets the origin for just the current event queue, it potentially sets it for TOUCH_BUFFER_MS if the origin is touch. Note that the current name was inaccurate anyway, as it only sets the origin for the current event queue in IMMEDIATE mode.

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.

Copy link
@zelliott

zelliott Apr 6, 2021

Author Collaborator

The removal of this method represents a slight regression in behavior. Previously, when a touch event was followed by a focus event, we'd use this method to determine if the touch event should be associated with the focus event. This method essentially associates the events if (1) the focus event occurred < 650ms after the touch event and (2) the touch event target is contained within the focus event target (or the targets are the same).

Now, we essentially just associate the events if 1 is true. The reason we can't do 2 is because we no longer have the touch event itself. In practice, I think this is a super small regression in behavior (and the previous behavior had some gotchas anyways, per the huge comment below).

// 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.
Expand All @@ -451,7 +376,7 @@ export class FocusMonitor implements OnDestroy {
return;
}

this._originChanged(element, this._getFocusOrigin(event), elementInfo);
this._originChanged(element, this._getFocusOrigin(), elementInfo);
}

/**
Expand Down Expand Up @@ -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);
});
}
Expand All @@ -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);
}
}
Expand Down
15 changes: 6 additions & 9 deletions src/cdk/a11y/input-modality/input-modality-detector.spec.ts
Expand Up @@ -76,10 +76,10 @@ describe('InputModalityDetector', () => {
expect(detector.inputModality).toBe('keyboard');
});

it('should emit changes in input modality', () => {
it('should emit when input modalities are detected', () => {
detector = new InputModalityDetector(platform, ngZone, document);
const emitted: InputModality[] = [];
detector.inputModalityChange.subscribe((inputModality: InputModality) => {
detector.inputModalityDetected.subscribe((inputModality: InputModality) => {
emitted.push(inputModality);
});

Expand All @@ -89,19 +89,16 @@ describe('InputModalityDetector', () => {
expect(emitted).toEqual(['keyboard']);

dispatchKeyboardEvent(document, 'keydown');
expect(emitted).toEqual(['keyboard']);
expect(emitted).toEqual(['keyboard', 'keyboard']);

dispatchMouseEvent(document, 'mousedown');
expect(emitted).toEqual(['keyboard', 'mouse']);

dispatchTouchEvent(document, 'touchstart');
expect(emitted).toEqual(['keyboard', 'mouse', 'touch']);
expect(emitted).toEqual(['keyboard', 'keyboard', 'mouse']);

dispatchTouchEvent(document, 'touchstart');
expect(emitted).toEqual(['keyboard', 'mouse', 'touch']);
expect(emitted).toEqual(['keyboard', 'keyboard', 'mouse', 'touch']);

dispatchKeyboardEvent(document, 'keydown');
expect(emitted).toEqual(['keyboard', 'mouse', 'touch', 'keyboard']);
expect(emitted).toEqual(['keyboard', 'keyboard', 'mouse', 'touch', 'keyboard']);
});

it('should ignore fake screen-reader mouse events', () => {
Expand Down
10 changes: 5 additions & 5 deletions src/cdk/a11y/input-modality/input-modality-detector.ts
Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Copy link
@zelliott

zelliott Apr 6, 2021

Author Collaborator

When FocusMonitor is in IMMEDIATE mode, it attempts to associate the focus event with the interaction event in the current/previous tick (note that this isn't entirely true for touch interactions). This means we need to update InputModalityDetector.inputModalityChange to emit all modality detections, as opposed to just when the modality changes (i.e. essentially remove the distinctUntilChanged() and rename it to inputModalityDetected). Otherwise, the modality that FocusMonitor receives from the InputModalityDetector could be stale.


/** Returns the most recently detected input modality. */
get inputModality(): InputModality {
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/dev-app/input-modality/input-modality-detector-demo.ts
Expand Up @@ -24,7 +24,7 @@ export class InputModalityDetectorDemo implements OnDestroy {
inputModalityDetector: InputModalityDetector,
ngZone: NgZone,
) {
inputModalityDetector.inputModalityChange
inputModalityDetector.inputModalityDetected
.pipe(takeUntil(this._destroyed))
.subscribe(modality => ngZone.run(() => { this._modality = modality; }));
}
Expand Down

0 comments on commit db685c0

Please sign in to comment.