Skip to content

Commit db685c0

Browse files
committed
feat(cdk/a11y): FocusMonitor now uses InputModalityDetector to resolve origin.
1 parent 27d2972 commit db685c0

File tree

5 files changed

+36
-132
lines changed

5 files changed

+36
-132
lines changed

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,6 @@ describe('FocusMonitor', () => {
149149

150150
it('should detect fake mousedown from a screen reader', fakeAsync(() => {
151151
// Simulate focus via a fake mousedown from a screen reader.
152-
dispatchMouseEvent(buttonElement, 'mousedown');
153152
const event = createMouseEvent('mousedown');
154153
Object.defineProperty(event, 'buttons', {get: () => 0});
155154
dispatchEvent(buttonElement, event);

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

Lines changed: 24 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ import {Observable, of as observableOf, Subject, Subscription} from 'rxjs';
2424
import {coerceElement} from '@angular/cdk/coercion';
2525
import {DOCUMENT} from '@angular/common';
2626
import {
27-
isFakeMousedownFromScreenReader,
28-
isFakeTouchstartFromScreenReader,
29-
} from '../fake-event-detection';
30-
import {TOUCH_BUFFER_MS} from '../input-modality/input-modality-detector';
27+
InputModality,
28+
InputModalityDetector,
29+
TOUCH_BUFFER_MS,
30+
} from '../input-modality/input-modality-detector';
3131

3232

3333
export type FocusOrigin = 'touch' | 'mouse' | 'keyboard' | 'program' | null;
@@ -93,12 +93,6 @@ export class FocusMonitor implements OnDestroy {
9393
/** Whether the window has just been focused. */
9494
private _windowFocused = false;
9595

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-
10296
/** The timeout id of the window focus timeout. */
10397
private _windowFocusTimeoutId: number;
10498

@@ -125,53 +119,6 @@ export class FocusMonitor implements OnDestroy {
125119
*/
126120
private readonly _detectionMode: FocusMonitorDetectionMode;
127121

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-
175122
/**
176123
* Event listener for `focus` events on the window.
177124
* 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 {
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:
195143
FocusMonitorOptions|null) {
196144
this._document = document;
197145
this._detectionMode = options?.detectionMode || FocusMonitorDetectionMode.IMMEDIATE;
146+
147+
this._inputModalityDetector.inputModalityDetected
148+
.subscribe((modality: InputModality) => {
149+
this._setOrigin(modality);
150+
});
198151
}
199152
/**
200153
* Event listener for `focus` and 'blur' events on the document.
@@ -322,7 +275,7 @@ export class FocusMonitor implements OnDestroy {
322275
this._getClosestElementsInfo(nativeElement)
323276
.forEach(([currentElement, info]) => this._originChanged(currentElement, origin, info));
324277
} else {
325-
this._setOriginForCurrentEventQueue(origin);
278+
this._setOrigin(origin);
326279

327280
// `focus` isn't available on the server
328281
if (typeof nativeElement.focus === 'function') {
@@ -354,21 +307,18 @@ export class FocusMonitor implements OnDestroy {
354307
}
355308
}
356309

357-
private _getFocusOrigin(event: FocusEvent): FocusOrigin {
310+
private _getFocusOrigin(): FocusOrigin {
358311
// If we couldn't detect a cause for the focus event, it's due to one of three reasons:
359312
// 1) The window has just regained focus, in which case we want to restore the focused state of
360313
// 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
314+
// 2) The element was programmatically focused, in which case we should mark the origin as
363315
// 'program'.
364316
if (this._origin) {
365317
return this._origin;
366318
}
367319

368320
if (this._windowFocused && this._lastFocusOrigin) {
369321
return this._lastFocusOrigin;
370-
} else if (this._wasCausedByTouch(event)) {
371-
return 'touch';
372322
} else {
373323
return 'program';
374324
}
@@ -388,51 +338,26 @@ export class FocusMonitor implements OnDestroy {
388338
}
389339

390340
/**
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.
341+
* Updates the focus origin. If we're using immediate detection mode, we schedule an async
342+
* function to clear the origin at the end of a timeout. The duration of the timeout depends on
343+
* the origin being set.
393344
* @param origin The origin to set.
394345
*/
395-
private _setOriginForCurrentEventQueue(origin: FocusOrigin): void {
346+
private _setOrigin(origin: FocusOrigin): void {
396347
this._ngZone.runOutsideAngular(() => {
397348
this._origin = origin;
398349

399350
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);
351+
// When a touch origin is received, we need to wait at least `TOUCH_BUFFER_MS` ms until
352+
// clearing the origin. This is because when a touch event is fired, the associated focus
353+
// event isn't yet in the event queue. Otherwise, clear the focus origin at the start of the
354+
// next tick (because Firefox focuses one tick after the interaction event).
355+
const ms = (origin === 'touch') ? TOUCH_BUFFER_MS : 1;
356+
this._originTimeoutId = setTimeout(() => this._origin = null, ms);
404357
}
405358
});
406359
}
407360

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-
436361
/**
437362
* Handles focus events on a registered element.
438363
* @param event The focus event.
@@ -451,7 +376,7 @@ export class FocusMonitor implements OnDestroy {
451376
return;
452377
}
453378

454-
this._originChanged(element, this._getFocusOrigin(event), elementInfo);
379+
this._originChanged(element, this._getFocusOrigin(), elementInfo);
455380
}
456381

457382
/**
@@ -501,15 +426,7 @@ export class FocusMonitor implements OnDestroy {
501426
// Note: we listen to events in the capture phase so we
502427
// can detect them even if the user stops propagation.
503428
this._ngZone.runOutsideAngular(() => {
504-
const document = this._getDocument();
505429
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);
513430
window.addEventListener('focus', this._windowFocusListener);
514431
});
515432
}
@@ -534,20 +451,11 @@ export class FocusMonitor implements OnDestroy {
534451

535452
// Unregister global listeners when last element is unmonitored.
536453
if (!--this._monitoredElementCount) {
537-
const document = this._getDocument();
538454
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);
546455
window.removeEventListener('focus', this._windowFocusListener);
547456

548457
// Clear timeouts for all potentially pending timeouts to prevent the leaks.
549458
clearTimeout(this._windowFocusTimeoutId);
550-
clearTimeout(this._touchTimeoutId);
551459
clearTimeout(this._originTimeoutId);
552460
}
553461
}

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

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,10 @@ describe('InputModalityDetector', () => {
7676
expect(detector.inputModality).toBe('keyboard');
7777
});
7878

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

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

9191
dispatchKeyboardEvent(document, 'keydown');
92-
expect(emitted).toEqual(['keyboard']);
92+
expect(emitted).toEqual(['keyboard', 'keyboard']);
9393

9494
dispatchMouseEvent(document, 'mousedown');
95-
expect(emitted).toEqual(['keyboard', 'mouse']);
96-
97-
dispatchTouchEvent(document, 'touchstart');
98-
expect(emitted).toEqual(['keyboard', 'mouse', 'touch']);
95+
expect(emitted).toEqual(['keyboard', 'keyboard', 'mouse']);
9996

10097
dispatchTouchEvent(document, 'touchstart');
101-
expect(emitted).toEqual(['keyboard', 'mouse', 'touch']);
98+
expect(emitted).toEqual(['keyboard', 'keyboard', 'mouse', 'touch']);
10299

103100
dispatchKeyboardEvent(document, 'keydown');
104-
expect(emitted).toEqual(['keyboard', 'mouse', 'touch', 'keyboard']);
101+
expect(emitted).toEqual(['keyboard', 'keyboard', 'mouse', 'touch', 'keyboard']);
105102
});
106103

107104
it('should ignore fake screen-reader mouse events', () => {

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {Inject, Injectable, InjectionToken, OnDestroy, Optional, NgZone} from '@
1111
import {normalizePassiveListenerOptions, Platform} from '@angular/cdk/platform';
1212
import {DOCUMENT} from '@angular/common';
1313
import {BehaviorSubject, Observable} from 'rxjs';
14-
import {distinctUntilChanged, skip} from 'rxjs/operators';
14+
import {skip} from 'rxjs/operators';
1515
import {
1616
isFakeMousedownFromScreenReader,
1717
isFakeTouchstartFromScreenReader,
@@ -86,8 +86,8 @@ const modalityEventListenerOptions = normalizePassiveListenerOptions({
8686
*/
8787
@Injectable({ providedIn: 'root' })
8888
export class InputModalityDetector implements OnDestroy {
89-
/** Emits when the input modality changes. */
90-
readonly inputModalityChange: Observable<InputModality>;
89+
/** Emits whenever an input modality is detected. */
90+
readonly inputModalityDetected: Observable<InputModality>;
9191

9292
/** Returns the most recently detected input modality. */
9393
get inputModality(): InputModality {
@@ -159,8 +159,8 @@ export class InputModalityDetector implements OnDestroy {
159159
...options,
160160
};
161161

162-
// Only emit if the input modality changes, and skip the first emission as it's null.
163-
this.inputModalityChange = this._inputModality.pipe(distinctUntilChanged(), skip(1));
162+
// Skip the first emission as it's null.
163+
this.inputModalityDetected = this._inputModality.pipe(skip(1));
164164

165165
// If we're not in a browser, this service should do nothing, as there's no relevant input
166166
// modality to detect.

src/dev-app/input-modality/input-modality-detector-demo.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export class InputModalityDetectorDemo implements OnDestroy {
2424
inputModalityDetector: InputModalityDetector,
2525
ngZone: NgZone,
2626
) {
27-
inputModalityDetector.inputModalityChange
27+
inputModalityDetector.inputModalityDetected
2828
.pipe(takeUntil(this._destroyed))
2929
.subscribe(modality => ngZone.run(() => { this._modality = modality; }));
3030
}

0 commit comments

Comments
 (0)