From 55ebc775833321527cdf389a7e109b494c472f48 Mon Sep 17 00:00:00 2001 From: Zack Elliott Date: Tue, 6 Apr 2021 09:32:56 -0700 Subject: [PATCH 1/8] feat(cdk/a11y): FocusMonitor now uses InputModalityDetector to resolve origin. --- .../a11y/focus-monitor/focus-monitor.spec.ts | 1 - src/cdk/a11y/focus-monitor/focus-monitor.ts | 140 +++--------------- .../input-modality-detector.spec.ts | 15 +- .../input-modality/input-modality-detector.ts | 10 +- .../input-modality-detector-demo.ts | 2 +- 5 files changed, 36 insertions(+), 132 deletions(-) diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts b/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts index 09a3f71ed27e..5b193a2851eb 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts @@ -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); diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index 30509770c577..f2fca05f5e0c 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -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._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 + .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,12 +307,11 @@ 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; @@ -367,8 +319,6 @@ export class FocusMonitor implements OnDestroy { 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._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 { - // Note(mmalerba): This implementation is not quite perfect, there is a small edge case. - // Consider the following dom structure: - // - //
- //
- //
- // - // 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); } } diff --git a/src/cdk/a11y/input-modality/input-modality-detector.spec.ts b/src/cdk/a11y/input-modality/input-modality-detector.spec.ts index c3a3166b9456..e795596f2b5c 100644 --- a/src/cdk/a11y/input-modality/input-modality-detector.spec.ts +++ b/src/cdk/a11y/input-modality/input-modality-detector.spec.ts @@ -76,10 +76,10 @@ describe('InputModalityDetector', () => { expect(detector.mostRecentModality).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.modalityChanges.subscribe((modality: InputModality) => { + detector.modalityDetected.subscribe((modality: InputModality) => { emitted.push(modality); }); @@ -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', () => { diff --git a/src/cdk/a11y/input-modality/input-modality-detector.ts b/src/cdk/a11y/input-modality/input-modality-detector.ts index f841b22e9279..a4d456e6ac12 100644 --- a/src/cdk/a11y/input-modality/input-modality-detector.ts +++ b/src/cdk/a11y/input-modality/input-modality-detector.ts @@ -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 modalityChanges: Observable; + /** Emits whenever an input modality is detected. */ + readonly modalityDetected: Observable; /** The most recently detected input modality. */ get mostRecentModality(): 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.modalityChanges = this._modality.pipe(distinctUntilChanged(), skip(1)); + // Skip the first emission as it's null. + this.modalityDetected = this._modality.pipe(skip(1)); // If we're not in a browser, this service should do nothing, as there's no relevant input // modality to detect. diff --git a/src/dev-app/input-modality/input-modality-detector-demo.ts b/src/dev-app/input-modality/input-modality-detector-demo.ts index ecceaab3701f..920b288e670f 100644 --- a/src/dev-app/input-modality/input-modality-detector-demo.ts +++ b/src/dev-app/input-modality/input-modality-detector-demo.ts @@ -24,7 +24,7 @@ export class InputModalityDetectorDemo implements OnDestroy { inputModalityDetector: InputModalityDetector, ngZone: NgZone, ) { - inputModalityDetector.modalityChanges + inputModalityDetector.modalityDetected .pipe(takeUntil(this._destroyed)) .subscribe(modality => ngZone.run(() => { this._modality = modality; })); } From f469ee9ee0c127eea13f9b46a64f16bb96c4cc36 Mon Sep 17 00:00:00 2001 From: Zack Elliott Date: Wed, 14 Apr 2021 17:33:25 -0700 Subject: [PATCH 2/8] refactor(cdk/a11y): Few more changes in advance of opening PR. --- .../a11y/focus-monitor/focus-monitor.spec.ts | 6 ++-- src/cdk/a11y/focus-monitor/focus-monitor.ts | 36 +++++++++++-------- .../input-modality-detector.spec.ts | 28 +++++++++++++++ .../input-modality/input-modality-detector.ts | 6 +++- .../input-modality-detector-demo.ts | 2 +- 5 files changed, 58 insertions(+), 20 deletions(-) diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts b/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts index 5b193a2851eb..b16f815a3f25 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts @@ -161,9 +161,9 @@ describe('FocusMonitor', () => { .toBe(2, 'button should have exactly 2 focus classes'); expect(buttonElement.classList.contains('cdk-focused')) .toBe(true, 'button should have cdk-focused class'); - expect(buttonElement.classList.contains('cdk-keyboard-focused')) - .toBe(true, 'button should have cdk-keyboard-focused class'); - expect(changeHandler).toHaveBeenCalledWith('keyboard'); + expect(buttonElement.classList.contains('cdk-program-focused')) + .toBe(true, 'button should have cdk-program-focused class'); + expect(changeHandler).toHaveBeenCalledWith('program'); })); it('focusVia keyboard should simulate keyboard focus', fakeAsync(() => { diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index f2fca05f5e0c..7050fed86519 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -21,13 +21,10 @@ import { AfterViewInit, } from '@angular/core'; import {Observable, of as observableOf, Subject, Subscription} from 'rxjs'; +import {takeUntil} from 'rxjs/operators'; import {coerceElement} from '@angular/cdk/coercion'; import {DOCUMENT} from '@angular/common'; -import { - InputModality, - InputModalityDetector, - TOUCH_BUFFER_MS, -} from '../input-modality/input-modality-detector'; +import {InputModalityDetector, TOUCH_BUFFER_MS} from '../input-modality/input-modality-detector'; export type FocusOrigin = 'touch' | 'mouse' | 'keyboard' | 'program' | null; @@ -133,6 +130,9 @@ export class FocusMonitor implements OnDestroy { /** Used to reference correct document/window */ protected _document?: Document; + /** Subject for stopping our InputModalityDetector subscription. */ + private _stopInputModalityDetector = new Subject(); + constructor( private _ngZone: NgZone, private _platform: Platform, @@ -143,11 +143,6 @@ export class FocusMonitor implements OnDestroy { FocusMonitorOptions|null) { this._document = document; this._detectionMode = options?.detectionMode || FocusMonitorDetectionMode.IMMEDIATE; - - this._inputModalityDetector.inputModalityDetected - .subscribe((modality: InputModality) => { - this._setOrigin(modality); - }); } /** * Event listener for `focus` and 'blur' events on the document. @@ -308,15 +303,16 @@ export class FocusMonitor implements OnDestroy { } 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) The element was programmatically focused, in which case we should mark the origin as - // 'program'. if (this._origin) { return this._origin; } + // If we couldn't determine a focus origin, it's because either: + // 1) The window has just regained focus, and thus we restore the last origin from before the + // window blurred. + // 2) The element was programmatically focused, and thus we set the origin as 'program'. + // 3) The element was focused via navigating with a screen reader, and thus we set the origin + // as 'program' because keyboard events are rarely fired. if (this._windowFocused && this._lastFocusOrigin) { return this._lastFocusOrigin; } else { @@ -376,6 +372,8 @@ export class FocusMonitor implements OnDestroy { return; } + + this._originChanged(element, this._getFocusOrigin(), elementInfo); } @@ -429,6 +427,11 @@ export class FocusMonitor implements OnDestroy { const window = this._getWindow(); window.addEventListener('focus', this._windowFocusListener); }); + + // The InputModalityDetector is also just a collection of global listeners. + this._inputModalityDetector.modalityDetected + .pipe(takeUntil(this._stopInputModalityDetector)) + .subscribe(modality => { this._setOrigin(modality); }); } } @@ -454,6 +457,9 @@ export class FocusMonitor implements OnDestroy { const window = this._getWindow(); window.removeEventListener('focus', this._windowFocusListener); + // Equivalently, stop our InputModalityDetector subscription. + this._stopInputModalityDetector.next(); + // Clear timeouts for all potentially pending timeouts to prevent the leaks. clearTimeout(this._windowFocusTimeoutId); clearTimeout(this._originTimeoutId); diff --git a/src/cdk/a11y/input-modality/input-modality-detector.spec.ts b/src/cdk/a11y/input-modality/input-modality-detector.spec.ts index e795596f2b5c..60822ca78f8e 100644 --- a/src/cdk/a11y/input-modality/input-modality-detector.spec.ts +++ b/src/cdk/a11y/input-modality/input-modality-detector.spec.ts @@ -101,6 +101,34 @@ describe('InputModalityDetector', () => { expect(emitted).toEqual(['keyboard', 'keyboard', 'mouse', 'touch', 'keyboard']); }); + it('should emit changes in input modality', () => { + detector = new InputModalityDetector(platform, ngZone, document); + const emitted: InputModality[] = []; + detector.modalityChanged.subscribe((modality: InputModality) => { + emitted.push(modality); + }); + + expect(emitted.length).toBe(0); + + dispatchKeyboardEvent(document, 'keydown'); + expect(emitted).toEqual(['keyboard']); + + dispatchKeyboardEvent(document, 'keydown'); + expect(emitted).toEqual(['keyboard']); + + dispatchMouseEvent(document, 'mousedown'); + expect(emitted).toEqual(['keyboard', 'mouse']); + + dispatchTouchEvent(document, 'touchstart'); + expect(emitted).toEqual(['keyboard', 'mouse', 'touch']); + + dispatchTouchEvent(document, 'touchstart'); + expect(emitted).toEqual(['keyboard', 'mouse', 'touch']); + + dispatchKeyboardEvent(document, 'keydown'); + expect(emitted).toEqual(['keyboard', 'mouse', 'touch', 'keyboard']); + }); + it('should ignore fake screen-reader mouse events', () => { detector = new InputModalityDetector(platform, ngZone, document); diff --git a/src/cdk/a11y/input-modality/input-modality-detector.ts b/src/cdk/a11y/input-modality/input-modality-detector.ts index a4d456e6ac12..a5e3f07f2fc0 100644 --- a/src/cdk/a11y/input-modality/input-modality-detector.ts +++ b/src/cdk/a11y/input-modality/input-modality-detector.ts @@ -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 {skip} from 'rxjs/operators'; +import {distinctUntilChanged, skip} from 'rxjs/operators'; import { isFakeMousedownFromScreenReader, isFakeTouchstartFromScreenReader, @@ -89,6 +89,9 @@ export class InputModalityDetector implements OnDestroy { /** Emits whenever an input modality is detected. */ readonly modalityDetected: Observable; + /** Emits when the input modality changes. */ + readonly modalityChanged: Observable; + /** The most recently detected input modality. */ get mostRecentModality(): InputModality { return this._modality.value; @@ -161,6 +164,7 @@ export class InputModalityDetector implements OnDestroy { // Skip the first emission as it's null. this.modalityDetected = this._modality.pipe(skip(1)); + this.modalityChanged = this.modalityDetected.pipe(distinctUntilChanged()); // If we're not in a browser, this service should do nothing, as there's no relevant input // modality to detect. diff --git a/src/dev-app/input-modality/input-modality-detector-demo.ts b/src/dev-app/input-modality/input-modality-detector-demo.ts index 920b288e670f..15d61f258ee3 100644 --- a/src/dev-app/input-modality/input-modality-detector-demo.ts +++ b/src/dev-app/input-modality/input-modality-detector-demo.ts @@ -24,7 +24,7 @@ export class InputModalityDetectorDemo implements OnDestroy { inputModalityDetector: InputModalityDetector, ngZone: NgZone, ) { - inputModalityDetector.modalityDetected + inputModalityDetector.modalityChanged .pipe(takeUntil(this._destroyed)) .subscribe(modality => ngZone.run(() => { this._modality = modality; })); } From bca58cadd156f9e9ff4222a90b76c645a68c870f Mon Sep 17 00:00:00 2001 From: Zack Elliott Date: Thu, 15 Apr 2021 09:24:09 -0700 Subject: [PATCH 3/8] refactor(cdk/a11y): Few additional changes --- .../a11y/focus-monitor/focus-monitor.spec.ts | 7 +++-- src/cdk/a11y/focus-monitor/focus-monitor.ts | 18 +++++------ .../input-modality-detector.spec.ts | 8 ++--- .../input-modality/input-modality-detector.ts | 30 ++++++++++++------- 4 files changed, 35 insertions(+), 28 deletions(-) diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts b/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts index b16f815a3f25..09a3f71ed27e 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts @@ -149,6 +149,7 @@ 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); @@ -161,9 +162,9 @@ describe('FocusMonitor', () => { .toBe(2, 'button should have exactly 2 focus classes'); expect(buttonElement.classList.contains('cdk-focused')) .toBe(true, 'button should have cdk-focused class'); - expect(buttonElement.classList.contains('cdk-program-focused')) - .toBe(true, 'button should have cdk-program-focused class'); - expect(changeHandler).toHaveBeenCalledWith('program'); + expect(buttonElement.classList.contains('cdk-keyboard-focused')) + .toBe(true, 'button should have cdk-keyboard-focused class'); + expect(changeHandler).toHaveBeenCalledWith('keyboard'); })); it('focusVia keyboard should simulate keyboard focus', fakeAsync(() => { diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index 7050fed86519..bb5c7ef80260 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -48,7 +48,7 @@ export const enum FocusMonitorDetectionMode { IMMEDIATE, /** * A focus event's origin is always attributed to the last corresponding - * mousedown, keydown, or touchstart event, no matter how long ago it occured. + * mousedown, keydown, or touchstart event, no matter how long ago it occurred. */ EVENTUAL } @@ -131,7 +131,7 @@ export class FocusMonitor implements OnDestroy { protected _document?: Document; /** Subject for stopping our InputModalityDetector subscription. */ - private _stopInputModalityDetector = new Subject(); + private readonly _stopInputModalityDetector = new Subject(); constructor( private _ngZone: NgZone, @@ -343,11 +343,12 @@ export class FocusMonitor implements OnDestroy { this._ngZone.runOutsideAngular(() => { this._origin = origin; + // If we're in IMMEDIATE mode, reset the origin at the next tick (or in `TOUCH_BUFFER_MS` ms + // for a touch event). We reset the origin at the next tick because Firefox focuses one tick + // after the interaction event. We wait `TOUCH_BUFFER_MS` ms before resetting the origin for + // a touch event because when a touch event is fired, the associated focus event isn't yet in + // the event queue. if (this._detectionMode === FocusMonitorDetectionMode.IMMEDIATE) { - // 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); } @@ -372,8 +373,6 @@ export class FocusMonitor implements OnDestroy { return; } - - this._originChanged(element, this._getFocusOrigin(), elementInfo); } @@ -392,8 +391,7 @@ export class FocusMonitor implements OnDestroy { return; } - this._setClasses(element); - this._emitOrigin(elementInfo.subject, null); + this._originChanged(element, null, elementInfo); } private _emitOrigin(subject: Subject, origin: FocusOrigin) { diff --git a/src/cdk/a11y/input-modality/input-modality-detector.spec.ts b/src/cdk/a11y/input-modality/input-modality-detector.spec.ts index 60822ca78f8e..7e6d932b68da 100644 --- a/src/cdk/a11y/input-modality/input-modality-detector.spec.ts +++ b/src/cdk/a11y/input-modality/input-modality-detector.spec.ts @@ -129,7 +129,7 @@ describe('InputModalityDetector', () => { expect(emitted).toEqual(['keyboard', 'mouse', 'touch', 'keyboard']); }); - it('should ignore fake screen-reader mouse events', () => { + it('should detect fake screen reader mouse events as keyboard input modality', () => { detector = new InputModalityDetector(platform, ngZone, document); // Create a fake screen-reader mouse event. @@ -137,10 +137,10 @@ describe('InputModalityDetector', () => { Object.defineProperty(event, 'buttons', {get: () => 0}); dispatchEvent(document, event); - expect(detector.mostRecentModality).toBe(null); + expect(detector.mostRecentModality).toBe('keyboard'); }); - it('should ignore fake screen-reader touch events', () => { + it('should detect fake screen reader touch events as keyboard input modality', () => { detector = new InputModalityDetector(platform, ngZone, document); // Create a fake screen-reader touch event. @@ -148,7 +148,7 @@ describe('InputModalityDetector', () => { Object.defineProperty(event, 'touches', {get: () => [{identifier: -1}]}); dispatchEvent(document, event); - expect(detector.mostRecentModality).toBe(null); + expect(detector.mostRecentModality).toBe('keyboard'); }); it('should ignore certain modifier keys by default', () => { diff --git a/src/cdk/a11y/input-modality/input-modality-detector.ts b/src/cdk/a11y/input-modality/input-modality-detector.ts index a5e3f07f2fc0..71ae66a829a4 100644 --- a/src/cdk/a11y/input-modality/input-modality-detector.ts +++ b/src/cdk/a11y/input-modality/input-modality-detector.ts @@ -79,10 +79,8 @@ const modalityEventListenerOptions = normalizePassiveListenerOptions({ * screen reader is akin to visually scanning a page, and should not be interpreted as actual user * input interaction. * - * When a user is not navigating but *interacting* with a screen reader, this service's behavior is - * largely undefined and depends on the events fired. For example, in VoiceOver, no keyboard events - * are fired when performing an element's default action via Caps Lock + Space, thus no input - * modality is detected. + * When a user is not navigating but *interacting* with a screen reader, this service attempts to + * update the input modality to keyboard. */ @Injectable({ providedIn: 'root' }) export class InputModalityDetector implements OnDestroy { @@ -110,8 +108,8 @@ export class InputModalityDetector implements OnDestroy { private _lastTouchMs = 0; /** - * Handles keyboard events. Must be an arrow function in order to preserve the context when it - * gets bound. + * Handles keydown events. Must be an arrow function in order to preserve the context when it gets + * bound. */ private _onKeydown = (event: KeyboardEvent) => { // If this is one of the keys we should ignore, then ignore it and don't update the input @@ -122,17 +120,22 @@ export class InputModalityDetector implements OnDestroy { } /** - * Handles mouse events. Must be an arrow function in order to preserve the context when it gets - * bound. + * Handles mousedown events. Must be an arrow function in order to preserve the context when it + * gets bound. */ private _onMousedown = (event: MouseEvent) => { - if (isFakeMousedownFromScreenReader(event)) { return; } - // Touches trigger both touch and mouse events, so we need to distinguish between mouse events // that were triggered via mouse vs touch. To do so, check if the mouse event occurs closely // after the previous touch event. if (Date.now() - this._lastTouchMs < TOUCH_BUFFER_MS) { return; } + // Fake mousedown events are fired by some screen readers when controls are activated by the + // screen reader. Attribute this to keyboard input modality. + if (isFakeMousedownFromScreenReader(event)) { + this._modality.next('keyboard'); + return; + } + this._modality.next('mouse'); } @@ -141,7 +144,12 @@ export class InputModalityDetector implements OnDestroy { * gets bound. */ private _onTouchstart = (event: TouchEvent) => { - if (isFakeTouchstartFromScreenReader(event)) { return; } + // Same scenario as mentioned in _onMousedown, but on touch screen devices, fake touchstart + // events are fired. Again, attribute to keyboard input modality. + if (isFakeTouchstartFromScreenReader(event)) { + this._modality.next('keyboard'); + return; + } // Store the timestamp of this touch event, as it's used to distinguish between mouse events // triggered via mouse vs touch. From 5e127c62925b227809e6e6f99f918005fbe59e7a Mon Sep 17 00:00:00 2001 From: Zack Elliott Date: Thu, 15 Apr 2021 10:07:55 -0700 Subject: [PATCH 4/8] refactor(cdk/a11y): Accept goldens and fix test failures --- src/cdk/a11y/focus-monitor/focus-monitor.ts | 6 ++++-- src/material-experimental/mdc-tooltip/tooltip.spec.ts | 3 +++ src/material/tooltip/tooltip.spec.ts | 3 +++ tools/public_api_guard/cdk/a11y.d.ts | 7 ++++--- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index bb5c7ef80260..6e3166a5f0bb 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -347,8 +347,9 @@ export class FocusMonitor implements OnDestroy { // for a touch event). We reset the origin at the next tick because Firefox focuses one tick // after the interaction event. We wait `TOUCH_BUFFER_MS` ms before resetting the origin for // a touch event because when a touch event is fired, the associated focus event isn't yet in - // the event queue. + // the event queue. Before doing so, clear any pending timeouts. if (this._detectionMode === FocusMonitorDetectionMode.IMMEDIATE) { + clearTimeout(this._originTimeoutId); const ms = (origin === 'touch') ? TOUCH_BUFFER_MS : 1; this._originTimeoutId = setTimeout(() => this._origin = null, ms); } @@ -391,7 +392,8 @@ export class FocusMonitor implements OnDestroy { return; } - this._originChanged(element, null, elementInfo); + this._setClasses(element); + this._emitOrigin(elementInfo.subject, null); } private _emitOrigin(subject: Subject, origin: FocusOrigin) { diff --git a/src/material-experimental/mdc-tooltip/tooltip.spec.ts b/src/material-experimental/mdc-tooltip/tooltip.spec.ts index 38b492ddf6f4..36ec39285e42 100644 --- a/src/material-experimental/mdc-tooltip/tooltip.spec.ts +++ b/src/material-experimental/mdc-tooltip/tooltip.spec.ts @@ -728,6 +728,9 @@ describe('MDC-based MatTooltip', () => { tick(500); expect(overlayContainerElement.querySelector('.mat-mdc-tooltip')).toBeNull(); + + // Flush due to the additional tick that is necessary for the FocusMonitor. + flush(); })); it('should not hide the tooltip when calling `show` twice in a row', fakeAsync(() => { diff --git a/src/material/tooltip/tooltip.spec.ts b/src/material/tooltip/tooltip.spec.ts index a2966aa41672..ed1527583f90 100644 --- a/src/material/tooltip/tooltip.spec.ts +++ b/src/material/tooltip/tooltip.spec.ts @@ -727,6 +727,9 @@ describe('MatTooltip', () => { tick(500); expect(overlayContainerElement.querySelector('.mat-tooltip')).toBeNull(); + + // Flush due to the additional tick that is necessary for the FocusMonitor. + flush(); })); it('should not hide the tooltip when calling `show` twice in a row', fakeAsync(() => { diff --git a/tools/public_api_guard/cdk/a11y.d.ts b/tools/public_api_guard/cdk/a11y.d.ts index 8adaf9d35453..bbc34e61694c 100644 --- a/tools/public_api_guard/cdk/a11y.d.ts +++ b/tools/public_api_guard/cdk/a11y.d.ts @@ -104,7 +104,7 @@ export declare class FocusKeyManager extends ListKeyManager): void; - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; static ɵprov: i0.ɵɵInjectableDef; } @@ -193,7 +193,8 @@ export declare const INPUT_MODALITY_DETECTOR_OPTIONS: InjectionToken; + readonly modalityChanged: Observable; + readonly modalityDetected: Observable; get mostRecentModality(): InputModality; constructor(_platform: Platform, ngZone: NgZone, document: Document, options?: InputModalityDetectorOptions); ngOnDestroy(): void; From b96d9076b8bea7dcc0ec803d888cf362758d8d72 Mon Sep 17 00:00:00 2001 From: Zack Elliott Date: Thu, 15 Apr 2021 14:36:35 -0700 Subject: [PATCH 5/8] refactor(cdk/a11y): Respond to PR comments. --- src/cdk/a11y/input-modality/input-modality-detector.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/cdk/a11y/input-modality/input-modality-detector.ts b/src/cdk/a11y/input-modality/input-modality-detector.ts index 71ae66a829a4..5dbe492a4222 100644 --- a/src/cdk/a11y/input-modality/input-modality-detector.ts +++ b/src/cdk/a11y/input-modality/input-modality-detector.ts @@ -130,13 +130,8 @@ export class InputModalityDetector implements OnDestroy { if (Date.now() - this._lastTouchMs < TOUCH_BUFFER_MS) { return; } // Fake mousedown events are fired by some screen readers when controls are activated by the - // screen reader. Attribute this to keyboard input modality. - if (isFakeMousedownFromScreenReader(event)) { - this._modality.next('keyboard'); - return; - } - - this._modality.next('mouse'); + // screen reader. Attribute them to keyboard input modality. + this._modality.next(isFakeMousedownFromScreenReader(event) ? 'keyboard' : 'mouse'); } /** From 1a8f6d33139d62e4407184aeb75a4daafba4ef01 Mon Sep 17 00:00:00 2001 From: Zack Elliott Date: Thu, 15 Apr 2021 15:14:37 -0700 Subject: [PATCH 6/8] refactor(cdk/a11y): Nit type fix --- src/cdk/a11y/focus-monitor/focus-monitor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index 6e3166a5f0bb..3301a3f5f319 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -131,7 +131,7 @@ export class FocusMonitor implements OnDestroy { protected _document?: Document; /** Subject for stopping our InputModalityDetector subscription. */ - private readonly _stopInputModalityDetector = new Subject(); + private readonly _stopInputModalityDetector = new Subject(); constructor( private _ngZone: NgZone, From f7aa0ea10069ce089fd7f6e4e164ae467bbc29c1 Mon Sep 17 00:00:00 2001 From: Zack Elliott Date: Tue, 20 Apr 2021 16:23:39 -0700 Subject: [PATCH 7/8] refactor(cdk/a11y): Response to PR feedback --- src/cdk/a11y/input-modality/input-modality-detector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cdk/a11y/input-modality/input-modality-detector.ts b/src/cdk/a11y/input-modality/input-modality-detector.ts index 5dbe492a4222..4acf5d604711 100644 --- a/src/cdk/a11y/input-modality/input-modality-detector.ts +++ b/src/cdk/a11y/input-modality/input-modality-detector.ts @@ -131,7 +131,7 @@ export class InputModalityDetector implements OnDestroy { // Fake mousedown events are fired by some screen readers when controls are activated by the // screen reader. Attribute them to keyboard input modality. - this._modality.next(isFakeMousedownFromScreenReader(event) ? 'keyboard' : 'mouse'); + this._modality.next(isFakeMousedownFromScreenReader(event) ? 'keyboard' : 'mouse'); } /** From c33ac0bcf098a15c366edef1e52551f6a4a49998 Mon Sep 17 00:00:00 2001 From: Zack Elliott Date: Sat, 24 Apr 2021 12:51:36 -0700 Subject: [PATCH 8/8] refactor(cdk/a11y): Responded to PR feedback. --- src/cdk/a11y/focus-monitor/focus-monitor.ts | 28 +++++++++---------- .../input-modality-detector.spec.ts | 3 +- .../input-modality/input-modality-detector.ts | 11 ++++++-- .../mdc-tooltip/tooltip.spec.ts | 3 -- src/material/tooltip/tooltip.spec.ts | 3 -- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index 3301a3f5f319..b71190344a8f 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -307,17 +307,16 @@ export class FocusMonitor implements OnDestroy { return this._origin; } - // If we couldn't determine a focus origin, it's because either: - // 1) The window has just regained focus, and thus we restore the last origin from before the - // window blurred. - // 2) The element was programmatically focused, and thus we set the origin as 'program'. - // 3) The element was focused via navigating with a screen reader, and thus we set the origin - // as 'program' because keyboard events are rarely fired. - if (this._windowFocused && this._lastFocusOrigin) { - return this._lastFocusOrigin; - } else { - return 'program'; - } + // If the window has just regained focus, we can restore the most recent origin from before the + // window blurred. Otherwise, we've reached the point where we can't identify the source of the + // focus. This typically means one of two things happened: + // + // 1) The element was programmatically focused, or + // 2) The element was focused via screen reader navigation (which generally doesn't fire + // events). + // + // Because we can't distinguish between these two cases, we default to setting `program`. + return (this._windowFocused && this._lastFocusOrigin) ? this._lastFocusOrigin : 'program'; } /** @@ -338,8 +337,9 @@ export class FocusMonitor implements OnDestroy { * 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. + * @param isFromInteractionEvent Whether we are setting the origin from an interaction event. */ - private _setOrigin(origin: FocusOrigin): void { + private _setOrigin(origin: FocusOrigin, isFromInteractionEvent = false): void { this._ngZone.runOutsideAngular(() => { this._origin = origin; @@ -350,7 +350,7 @@ export class FocusMonitor implements OnDestroy { // the event queue. Before doing so, clear any pending timeouts. if (this._detectionMode === FocusMonitorDetectionMode.IMMEDIATE) { clearTimeout(this._originTimeoutId); - const ms = (origin === 'touch') ? TOUCH_BUFFER_MS : 1; + const ms = ((origin === 'touch') && isFromInteractionEvent) ? TOUCH_BUFFER_MS : 1; this._originTimeoutId = setTimeout(() => this._origin = null, ms); } }); @@ -431,7 +431,7 @@ export class FocusMonitor implements OnDestroy { // The InputModalityDetector is also just a collection of global listeners. this._inputModalityDetector.modalityDetected .pipe(takeUntil(this._stopInputModalityDetector)) - .subscribe(modality => { this._setOrigin(modality); }); + .subscribe(modality => { this._setOrigin(modality, true /* isFromInteractionEvent */); }); } } diff --git a/src/cdk/a11y/input-modality/input-modality-detector.spec.ts b/src/cdk/a11y/input-modality/input-modality-detector.spec.ts index 7e6d932b68da..f379ca8f6b53 100644 --- a/src/cdk/a11y/input-modality/input-modality-detector.spec.ts +++ b/src/cdk/a11y/input-modality/input-modality-detector.spec.ts @@ -1,4 +1,4 @@ -import {A, ALT, B, C, CONTROL, META, SHIFT} from '@angular/cdk/keycodes'; +import {A, ALT, B, C, CONTROL, MAC_META, META, SHIFT} from '@angular/cdk/keycodes'; import {Platform} from '@angular/cdk/platform'; import {NgZone, PLATFORM_ID} from '@angular/core'; @@ -156,6 +156,7 @@ describe('InputModalityDetector', () => { dispatchKeyboardEvent(document, 'keydown', ALT); dispatchKeyboardEvent(document, 'keydown', CONTROL); + dispatchKeyboardEvent(document, 'keydown', MAC_META); dispatchKeyboardEvent(document, 'keydown', META); dispatchKeyboardEvent(document, 'keydown', SHIFT); diff --git a/src/cdk/a11y/input-modality/input-modality-detector.ts b/src/cdk/a11y/input-modality/input-modality-detector.ts index 4acf5d604711..66b41c466faf 100644 --- a/src/cdk/a11y/input-modality/input-modality-detector.ts +++ b/src/cdk/a11y/input-modality/input-modality-detector.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {ALT, CONTROL, META, SHIFT} from '@angular/cdk/keycodes'; +import {ALT, CONTROL, MAC_META, META, SHIFT} from '@angular/cdk/keycodes'; import {Inject, Injectable, InjectionToken, OnDestroy, Optional, NgZone} from '@angular/core'; import {normalizePassiveListenerOptions, Platform} from '@angular/cdk/platform'; import {DOCUMENT} from '@angular/common'; @@ -46,9 +46,13 @@ export const INPUT_MODALITY_DETECTOR_OPTIONS = * 2. VoiceOver triggers some keyboard events when linearly navigating with Control + Option (but * confusingly not with Caps Lock). Thus, to have parity with other screen readers, we ignore * these keys so as to not update the input modality. + * + * Note that we do not by default ignore the right Meta key on Safari because it has the same key + * code as the ContextMenu key on other browsers. When we switch to using event.key, we can + * distinguish between the two. */ export const INPUT_MODALITY_DETECTOR_DEFAULT_OPTIONS: InputModalityDetectorOptions = { - ignoreKeys: [ALT, CONTROL, META, SHIFT], + ignoreKeys: [ALT, CONTROL, MAC_META, META, SHIFT], }; /** @@ -80,7 +84,8 @@ const modalityEventListenerOptions = normalizePassiveListenerOptions({ * input interaction. * * When a user is not navigating but *interacting* with a screen reader, this service attempts to - * update the input modality to keyboard. + * update the input modality to keyboard, but in general this service's behavior is largely + * undefined. */ @Injectable({ providedIn: 'root' }) export class InputModalityDetector implements OnDestroy { diff --git a/src/material-experimental/mdc-tooltip/tooltip.spec.ts b/src/material-experimental/mdc-tooltip/tooltip.spec.ts index 36ec39285e42..38b492ddf6f4 100644 --- a/src/material-experimental/mdc-tooltip/tooltip.spec.ts +++ b/src/material-experimental/mdc-tooltip/tooltip.spec.ts @@ -728,9 +728,6 @@ describe('MDC-based MatTooltip', () => { tick(500); expect(overlayContainerElement.querySelector('.mat-mdc-tooltip')).toBeNull(); - - // Flush due to the additional tick that is necessary for the FocusMonitor. - flush(); })); it('should not hide the tooltip when calling `show` twice in a row', fakeAsync(() => { diff --git a/src/material/tooltip/tooltip.spec.ts b/src/material/tooltip/tooltip.spec.ts index ed1527583f90..a2966aa41672 100644 --- a/src/material/tooltip/tooltip.spec.ts +++ b/src/material/tooltip/tooltip.spec.ts @@ -727,9 +727,6 @@ describe('MatTooltip', () => { tick(500); expect(overlayContainerElement.querySelector('.mat-tooltip')).toBeNull(); - - // Flush due to the additional tick that is necessary for the FocusMonitor. - flush(); })); it('should not hide the tooltip when calling `show` twice in a row', fakeAsync(() => {