diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index 30509770c577..b71190344a8f 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 { - isFakeMousedownFromScreenReader, - isFakeTouchstartFromScreenReader, -} from '../fake-event-detection'; -import {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; @@ -51,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 } @@ -93,12 +90,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 +116,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. @@ -186,9 +130,13 @@ export class FocusMonitor implements OnDestroy { /** Used to reference correct document/window */ protected _document?: Document; + /** Subject for stopping our InputModalityDetector subscription. */ + private readonly _stopInputModalityDetector = new Subject(); + 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: @@ -322,7 +270,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,24 +302,21 @@ export class FocusMonitor implements OnDestroy { } } - private _getFocusOrigin(event: FocusEvent): 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 - // 'program'. + private _getFocusOrigin(): FocusOrigin { if (this._origin) { return this._origin; } - if (this._windowFocused && this._lastFocusOrigin) { - return this._lastFocusOrigin; - } else if (this._wasCausedByTouch(event)) { - return 'touch'; - } 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'; } /** @@ -388,51 +333,29 @@ 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. + * @param isFromInteractionEvent Whether we are setting the origin from an interaction event. */ - private _setOriginForCurrentEventQueue(origin: FocusOrigin): void { + private _setOrigin(origin: FocusOrigin, isFromInteractionEvent = false): void { 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. Before doing so, clear any pending timeouts. 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); + clearTimeout(this._originTimeoutId); + const ms = ((origin === 'touch') && isFromInteractionEvent) ? 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 +374,7 @@ export class FocusMonitor implements OnDestroy { return; } - this._originChanged(element, this._getFocusOrigin(event), elementInfo); + this._originChanged(element, this._getFocusOrigin(), elementInfo); } /** @@ -501,17 +424,14 @@ 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); }); + + // The InputModalityDetector is also just a collection of global listeners. + this._inputModalityDetector.modalityDetected + .pipe(takeUntil(this._stopInputModalityDetector)) + .subscribe(modality => { this._setOrigin(modality, true /* isFromInteractionEvent */); }); } } @@ -534,20 +454,14 @@ 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); + // Equivalently, stop our InputModalityDetector subscription. + this._stopInputModalityDetector.next(); + // 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..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'; @@ -76,10 +76,35 @@ describe('InputModalityDetector', () => { expect(detector.mostRecentModality).toBe('keyboard'); }); + it('should emit when input modalities are detected', () => { + detector = new InputModalityDetector(platform, ngZone, document); + const emitted: InputModality[] = []; + detector.modalityDetected.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', 'keyboard']); + + dispatchMouseEvent(document, 'mousedown'); + expect(emitted).toEqual(['keyboard', 'keyboard', 'mouse']); + + dispatchTouchEvent(document, 'touchstart'); + expect(emitted).toEqual(['keyboard', 'keyboard', 'mouse', 'touch']); + + dispatchKeyboardEvent(document, 'keydown'); + 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.modalityChanges.subscribe((modality: InputModality) => { + detector.modalityChanged.subscribe((modality: InputModality) => { emitted.push(modality); }); @@ -104,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. @@ -112,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. @@ -123,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', () => { @@ -131,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 f841b22e9279..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], }; /** @@ -79,15 +83,17 @@ 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, but in general this service's behavior is largely + * undefined. */ @Injectable({ providedIn: 'root' }) export class InputModalityDetector implements OnDestroy { + /** Emits whenever an input modality is detected. */ + readonly modalityDetected: Observable; + /** Emits when the input modality changes. */ - readonly modalityChanges: Observable; + readonly modalityChanged: Observable; /** The most recently detected input modality. */ get mostRecentModality(): InputModality { @@ -107,8 +113,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 @@ -119,18 +125,18 @@ 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; } - this._modality.next('mouse'); + // 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'); } /** @@ -138,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. @@ -159,8 +170,9 @@ 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)); + 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 ecceaab3701f..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.modalityChanges + inputModalityDetector.modalityChanged .pipe(takeUntil(this._destroyed)) .subscribe(modality => ngZone.run(() => { this._modality = modality; })); } 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;