Skip to content

Commit 463a106

Browse files
zelliottwagnermaciel
authored andcommitted
refactor(cdk/a11y): FocusMonitor now uses InputModalityDetector under the hood. (#22489)
1 parent f11775c commit 463a106

File tree

5 files changed

+108
-155
lines changed

5 files changed

+108
-155
lines changed

src/cdk/a11y/focus-monitor/focus-monitor.ts

Lines changed: 41 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,10 @@ import {
2121
AfterViewInit,
2222
} from '@angular/core';
2323
import {Observable, of as observableOf, Subject, Subscription} from 'rxjs';
24+
import {takeUntil} from 'rxjs/operators';
2425
import {coerceElement} from '@angular/cdk/coercion';
2526
import {DOCUMENT} from '@angular/common';
26-
import {
27-
isFakeMousedownFromScreenReader,
28-
isFakeTouchstartFromScreenReader,
29-
} from '../fake-event-detection';
30-
import {TOUCH_BUFFER_MS} from '../input-modality/input-modality-detector';
27+
import {InputModalityDetector, TOUCH_BUFFER_MS} from '../input-modality/input-modality-detector';
3128

3229

3330
export type FocusOrigin = 'touch' | 'mouse' | 'keyboard' | 'program' | null;
@@ -51,7 +48,7 @@ export const enum FocusMonitorDetectionMode {
5148
IMMEDIATE,
5249
/**
5350
* A focus event's origin is always attributed to the last corresponding
54-
* mousedown, keydown, or touchstart event, no matter how long ago it occured.
51+
* mousedown, keydown, or touchstart event, no matter how long ago it occurred.
5552
*/
5653
EVENTUAL
5754
}
@@ -93,12 +90,6 @@ export class FocusMonitor implements OnDestroy {
9390
/** Whether the window has just been focused. */
9491
private _windowFocused = false;
9592

96-
/** The target of the last touch event. */
97-
private _lastTouchTarget: EventTarget | null;
98-
99-
/** The timeout id of the touch timeout, used to cancel timeout later. */
100-
private _touchTimeoutId: number;
101-
10293
/** The timeout id of the window focus timeout. */
10394
private _windowFocusTimeoutId: number;
10495

@@ -125,53 +116,6 @@ export class FocusMonitor implements OnDestroy {
125116
*/
126117
private readonly _detectionMode: FocusMonitorDetectionMode;
127118

128-
/**
129-
* Event listener for `keydown` events on the document.
130-
* Needs to be an arrow function in order to preserve the context when it gets bound.
131-
*/
132-
private _documentKeydownListener = () => {
133-
// On keydown record the origin and clear any touch event that may be in progress.
134-
this._lastTouchTarget = null;
135-
this._setOriginForCurrentEventQueue('keyboard');
136-
}
137-
138-
/**
139-
* Event listener for `mousedown` events on the document.
140-
* Needs to be an arrow function in order to preserve the context when it gets bound.
141-
*/
142-
private _documentMousedownListener = (event: MouseEvent) => {
143-
// On mousedown record the origin only if there is not touch
144-
// target, since a mousedown can happen as a result of a touch event.
145-
if (!this._lastTouchTarget) {
146-
// In some cases screen readers fire fake `mousedown` events instead of `keydown`.
147-
// Resolve the focus source to `keyboard` if we detect one of them.
148-
const source = isFakeMousedownFromScreenReader(event) ? 'keyboard' : 'mouse';
149-
this._setOriginForCurrentEventQueue(source);
150-
}
151-
}
152-
153-
/**
154-
* Event listener for `touchstart` events on the document.
155-
* Needs to be an arrow function in order to preserve the context when it gets bound.
156-
*/
157-
private _documentTouchstartListener = (event: TouchEvent) => {
158-
// Some screen readers will fire a fake `touchstart` event if an element is activated using
159-
// the keyboard while on a device with a touchsreen. Consider such events as keyboard focus.
160-
if (!isFakeTouchstartFromScreenReader(event)) {
161-
// When the touchstart event fires the focus event is not yet in the event queue. This means
162-
// we can't rely on the trick used above (setting timeout of 1ms). Instead we wait 650ms to
163-
// see if a focus happens.
164-
if (this._touchTimeoutId != null) {
165-
clearTimeout(this._touchTimeoutId);
166-
}
167-
168-
this._lastTouchTarget = getTarget(event);
169-
this._touchTimeoutId = setTimeout(() => this._lastTouchTarget = null, TOUCH_BUFFER_MS);
170-
} else if (!this._lastTouchTarget) {
171-
this._setOriginForCurrentEventQueue('keyboard');
172-
}
173-
}
174-
175119
/**
176120
* Event listener for `focus` events on the window.
177121
* Needs to be an arrow function in order to preserve the context when it gets bound.
@@ -186,9 +130,13 @@ export class FocusMonitor implements OnDestroy {
186130
/** Used to reference correct document/window */
187131
protected _document?: Document;
188132

133+
/** Subject for stopping our InputModalityDetector subscription. */
134+
private readonly _stopInputModalityDetector = new Subject<void>();
135+
189136
constructor(
190137
private _ngZone: NgZone,
191138
private _platform: Platform,
139+
private readonly _inputModalityDetector: InputModalityDetector,
192140
/** @breaking-change 11.0.0 make document required */
193141
@Optional() @Inject(DOCUMENT) document: any|null,
194142
@Optional() @Inject(FOCUS_MONITOR_DEFAULT_OPTIONS) options:
@@ -322,7 +270,7 @@ export class FocusMonitor implements OnDestroy {
322270
this._getClosestElementsInfo(nativeElement)
323271
.forEach(([currentElement, info]) => this._originChanged(currentElement, origin, info));
324272
} else {
325-
this._setOriginForCurrentEventQueue(origin);
273+
this._setOrigin(origin);
326274

327275
// `focus` isn't available on the server
328276
if (typeof nativeElement.focus === 'function') {
@@ -354,24 +302,21 @@ export class FocusMonitor implements OnDestroy {
354302
}
355303
}
356304

357-
private _getFocusOrigin(event: FocusEvent): FocusOrigin {
358-
// If we couldn't detect a cause for the focus event, it's due to one of three reasons:
359-
// 1) The window has just regained focus, in which case we want to restore the focused state of
360-
// the element from before the window blurred.
361-
// 2) It was caused by a touch event, in which case we mark the origin as 'touch'.
362-
// 3) The element was programmatically focused, in which case we should mark the origin as
363-
// 'program'.
305+
private _getFocusOrigin(): FocusOrigin {
364306
if (this._origin) {
365307
return this._origin;
366308
}
367309

368-
if (this._windowFocused && this._lastFocusOrigin) {
369-
return this._lastFocusOrigin;
370-
} else if (this._wasCausedByTouch(event)) {
371-
return 'touch';
372-
} else {
373-
return 'program';
374-
}
310+
// If the window has just regained focus, we can restore the most recent origin from before the
311+
// window blurred. Otherwise, we've reached the point where we can't identify the source of the
312+
// focus. This typically means one of two things happened:
313+
//
314+
// 1) The element was programmatically focused, or
315+
// 2) The element was focused via screen reader navigation (which generally doesn't fire
316+
// events).
317+
//
318+
// Because we can't distinguish between these two cases, we default to setting `program`.
319+
return (this._windowFocused && this._lastFocusOrigin) ? this._lastFocusOrigin : 'program';
375320
}
376321

377322
/**
@@ -388,51 +333,29 @@ export class FocusMonitor implements OnDestroy {
388333
}
389334

390335
/**
391-
* Sets the origin and schedules an async function to clear it at the end of the event queue.
392-
* If the detection mode is 'eventual', the origin is never cleared.
336+
* Updates the focus origin. If we're using immediate detection mode, we schedule an async
337+
* function to clear the origin at the end of a timeout. The duration of the timeout depends on
338+
* the origin being set.
393339
* @param origin The origin to set.
340+
* @param isFromInteractionEvent Whether we are setting the origin from an interaction event.
394341
*/
395-
private _setOriginForCurrentEventQueue(origin: FocusOrigin): void {
342+
private _setOrigin(origin: FocusOrigin, isFromInteractionEvent = false): void {
396343
this._ngZone.runOutsideAngular(() => {
397344
this._origin = origin;
398345

346+
// If we're in IMMEDIATE mode, reset the origin at the next tick (or in `TOUCH_BUFFER_MS` ms
347+
// for a touch event). We reset the origin at the next tick because Firefox focuses one tick
348+
// after the interaction event. We wait `TOUCH_BUFFER_MS` ms before resetting the origin for
349+
// a touch event because when a touch event is fired, the associated focus event isn't yet in
350+
// the event queue. Before doing so, clear any pending timeouts.
399351
if (this._detectionMode === FocusMonitorDetectionMode.IMMEDIATE) {
400-
// Sometimes the focus origin won't be valid in Firefox because Firefox seems to focus *one*
401-
// tick after the interaction event fired. To ensure the focus origin is always correct,
402-
// the focus origin will be determined at the beginning of the next tick.
403-
this._originTimeoutId = setTimeout(() => this._origin = null, 1);
352+
clearTimeout(this._originTimeoutId);
353+
const ms = ((origin === 'touch') && isFromInteractionEvent) ? TOUCH_BUFFER_MS : 1;
354+
this._originTimeoutId = setTimeout(() => this._origin = null, ms);
404355
}
405356
});
406357
}
407358

408-
/**
409-
* Checks whether the given focus event was caused by a touchstart event.
410-
* @param event The focus event to check.
411-
* @returns Whether the event was caused by a touch.
412-
*/
413-
private _wasCausedByTouch(event: FocusEvent): boolean {
414-
// Note(mmalerba): This implementation is not quite perfect, there is a small edge case.
415-
// Consider the following dom structure:
416-
//
417-
// <div #parent tabindex="0" cdkFocusClasses>
418-
// <div #child (click)="#parent.focus()"></div>
419-
// </div>
420-
//
421-
// If the user touches the #child element and the #parent is programmatically focused as a
422-
// result, this code will still consider it to have been caused by the touch event and will
423-
// apply the cdk-touch-focused class rather than the cdk-program-focused class. This is a
424-
// relatively small edge-case that can be worked around by using
425-
// focusVia(parentEl, 'program') to focus the parent element.
426-
//
427-
// If we decide that we absolutely must handle this case correctly, we can do so by listening
428-
// for the first focus event after the touchstart, and then the first blur event after that
429-
// focus event. When that blur event fires we know that whatever follows is not a result of the
430-
// touchstart.
431-
const focusTarget = getTarget(event);
432-
return this._lastTouchTarget instanceof Node && focusTarget instanceof Node &&
433-
(focusTarget === this._lastTouchTarget || focusTarget.contains(this._lastTouchTarget));
434-
}
435-
436359
/**
437360
* Handles focus events on a registered element.
438361
* @param event The focus event.
@@ -451,7 +374,7 @@ export class FocusMonitor implements OnDestroy {
451374
return;
452375
}
453376

454-
this._originChanged(element, this._getFocusOrigin(event), elementInfo);
377+
this._originChanged(element, this._getFocusOrigin(), elementInfo);
455378
}
456379

457380
/**
@@ -501,17 +424,14 @@ export class FocusMonitor implements OnDestroy {
501424
// Note: we listen to events in the capture phase so we
502425
// can detect them even if the user stops propagation.
503426
this._ngZone.runOutsideAngular(() => {
504-
const document = this._getDocument();
505427
const window = this._getWindow();
506-
507-
document.addEventListener('keydown', this._documentKeydownListener,
508-
captureEventListenerOptions);
509-
document.addEventListener('mousedown', this._documentMousedownListener,
510-
captureEventListenerOptions);
511-
document.addEventListener('touchstart', this._documentTouchstartListener,
512-
captureEventListenerOptions);
513428
window.addEventListener('focus', this._windowFocusListener);
514429
});
430+
431+
// The InputModalityDetector is also just a collection of global listeners.
432+
this._inputModalityDetector.modalityDetected
433+
.pipe(takeUntil(this._stopInputModalityDetector))
434+
.subscribe(modality => { this._setOrigin(modality, true /* isFromInteractionEvent */); });
515435
}
516436
}
517437

@@ -534,20 +454,14 @@ export class FocusMonitor implements OnDestroy {
534454

535455
// Unregister global listeners when last element is unmonitored.
536456
if (!--this._monitoredElementCount) {
537-
const document = this._getDocument();
538457
const window = this._getWindow();
539-
540-
document.removeEventListener('keydown', this._documentKeydownListener,
541-
captureEventListenerOptions);
542-
document.removeEventListener('mousedown', this._documentMousedownListener,
543-
captureEventListenerOptions);
544-
document.removeEventListener('touchstart', this._documentTouchstartListener,
545-
captureEventListenerOptions);
546458
window.removeEventListener('focus', this._windowFocusListener);
547459

460+
// Equivalently, stop our InputModalityDetector subscription.
461+
this._stopInputModalityDetector.next();
462+
548463
// Clear timeouts for all potentially pending timeouts to prevent the leaks.
549464
clearTimeout(this._windowFocusTimeoutId);
550-
clearTimeout(this._touchTimeoutId);
551465
clearTimeout(this._originTimeoutId);
552466
}
553467
}

src/cdk/a11y/input-modality/input-modality-detector.spec.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {A, ALT, B, C, CONTROL, META, SHIFT} from '@angular/cdk/keycodes';
1+
import {A, ALT, B, C, CONTROL, MAC_META, META, SHIFT} from '@angular/cdk/keycodes';
22
import {Platform} from '@angular/cdk/platform';
33
import {NgZone, PLATFORM_ID} from '@angular/core';
44

@@ -76,10 +76,35 @@ describe('InputModalityDetector', () => {
7676
expect(detector.mostRecentModality).toBe('keyboard');
7777
});
7878

79+
it('should emit when input modalities are detected', () => {
80+
detector = new InputModalityDetector(platform, ngZone, document);
81+
const emitted: InputModality[] = [];
82+
detector.modalityDetected.subscribe((modality: InputModality) => {
83+
emitted.push(modality);
84+
});
85+
86+
expect(emitted.length).toBe(0);
87+
88+
dispatchKeyboardEvent(document, 'keydown');
89+
expect(emitted).toEqual(['keyboard']);
90+
91+
dispatchKeyboardEvent(document, 'keydown');
92+
expect(emitted).toEqual(['keyboard', 'keyboard']);
93+
94+
dispatchMouseEvent(document, 'mousedown');
95+
expect(emitted).toEqual(['keyboard', 'keyboard', 'mouse']);
96+
97+
dispatchTouchEvent(document, 'touchstart');
98+
expect(emitted).toEqual(['keyboard', 'keyboard', 'mouse', 'touch']);
99+
100+
dispatchKeyboardEvent(document, 'keydown');
101+
expect(emitted).toEqual(['keyboard', 'keyboard', 'mouse', 'touch', 'keyboard']);
102+
});
103+
79104
it('should emit changes in input modality', () => {
80105
detector = new InputModalityDetector(platform, ngZone, document);
81106
const emitted: InputModality[] = [];
82-
detector.modalityChanges.subscribe((modality: InputModality) => {
107+
detector.modalityChanged.subscribe((modality: InputModality) => {
83108
emitted.push(modality);
84109
});
85110

@@ -104,33 +129,34 @@ describe('InputModalityDetector', () => {
104129
expect(emitted).toEqual(['keyboard', 'mouse', 'touch', 'keyboard']);
105130
});
106131

107-
it('should ignore fake screen-reader mouse events', () => {
132+
it('should detect fake screen reader mouse events as keyboard input modality', () => {
108133
detector = new InputModalityDetector(platform, ngZone, document);
109134

110135
// Create a fake screen-reader mouse event.
111136
const event = createMouseEvent('mousedown');
112137
Object.defineProperty(event, 'buttons', {get: () => 0});
113138
dispatchEvent(document, event);
114139

115-
expect(detector.mostRecentModality).toBe(null);
140+
expect(detector.mostRecentModality).toBe('keyboard');
116141
});
117142

118-
it('should ignore fake screen-reader touch events', () => {
143+
it('should detect fake screen reader touch events as keyboard input modality', () => {
119144
detector = new InputModalityDetector(platform, ngZone, document);
120145

121146
// Create a fake screen-reader touch event.
122147
const event = createTouchEvent('touchstart');
123148
Object.defineProperty(event, 'touches', {get: () => [{identifier: -1}]});
124149
dispatchEvent(document, event);
125150

126-
expect(detector.mostRecentModality).toBe(null);
151+
expect(detector.mostRecentModality).toBe('keyboard');
127152
});
128153

129154
it('should ignore certain modifier keys by default', () => {
130155
detector = new InputModalityDetector(platform, ngZone, document);
131156

132157
dispatchKeyboardEvent(document, 'keydown', ALT);
133158
dispatchKeyboardEvent(document, 'keydown', CONTROL);
159+
dispatchKeyboardEvent(document, 'keydown', MAC_META);
134160
dispatchKeyboardEvent(document, 'keydown', META);
135161
dispatchKeyboardEvent(document, 'keydown', SHIFT);
136162

0 commit comments

Comments
 (0)