From a99a4d298adf9824a3d57756b9fb35b7a1729f36 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 25 Feb 2021 15:27:33 +0100 Subject: [PATCH] fix(cdk/a11y): detect fake touchstart events from screen readers (#21987) We currently have handling for the case where a `mousedown` event is thrown off by a fake `mousedown` listener that may be dispatched by a screen reader when an element is activated. It turns out that if the device has touch support, screen readers may dispatch a fake `touchstart` event instead of a fake `mousedown`. These changes add another utility function that allows us to distinguish the fake events and fix some issues where keyboard focus wasn't being shown because of the fake `touchstart` events. Fixes #21947. (cherry picked from commit c7edf03a023220a7a230902aa5194e65435e89ae) --- src/cdk/a11y/fake-event-detection.ts | 29 +++++++++++++++++++++ src/cdk/a11y/fake-mousedown.ts | 18 ------------- src/cdk/a11y/focus-monitor/focus-monitor.ts | 27 ++++++++++++------- src/cdk/a11y/public-api.ts | 2 +- src/material/core/ripple/ripple-renderer.ts | 4 +-- src/material/menu/menu-trigger.ts | 13 +++++++-- tools/public_api_guard/cdk/a11y.d.ts | 2 ++ 7 files changed, 63 insertions(+), 32 deletions(-) create mode 100644 src/cdk/a11y/fake-event-detection.ts delete mode 100644 src/cdk/a11y/fake-mousedown.ts diff --git a/src/cdk/a11y/fake-event-detection.ts b/src/cdk/a11y/fake-event-detection.ts new file mode 100644 index 000000000000..6835b6885c49 --- /dev/null +++ b/src/cdk/a11y/fake-event-detection.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** Gets whether an event could be a faked `mousedown` event dispatched by a screen reader. */ +export function isFakeMousedownFromScreenReader(event: MouseEvent): boolean { + // We can typically distinguish between these faked mousedown events and real mousedown events + // using the "buttons" property. While real mousedowns will indicate the mouse button that was + // pressed (e.g. "1" for the left mouse button), faked mousedowns will usually set the property + // value to 0. + return event.buttons === 0; +} + +/** Gets whether an event could be a faked `touchstart` event dispatched by a screen reader. */ +export function isFakeTouchstartFromScreenReader(event: TouchEvent): boolean { + const touch: Touch | undefined = (event.touches && event.touches[0]) || + (event.changedTouches && event.changedTouches[0]); + + // A fake `touchstart` can be distinguished from a real one by looking at the `identifier` + // which is typically >= 0 on a real device versus -1 from a screen reader. Just to be safe, + // we can also look at `radiusX` and `radiusY`. This behavior was observed against a Windows 10 + // device with a touch screen running NVDA v2020.4 and Firefox 85 or Chrome 88. + return !!touch && touch.identifier === -1 && (touch.radiusX == null || touch.radiusX === 1) && + (touch.radiusY == null || touch.radiusY === 1); +} diff --git a/src/cdk/a11y/fake-mousedown.ts b/src/cdk/a11y/fake-mousedown.ts deleted file mode 100644 index 1376faa3ca1b..000000000000 --- a/src/cdk/a11y/fake-mousedown.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -/** - * Screenreaders will often fire fake mousedown events when a focusable element - * is activated using the keyboard. We can typically distinguish between these faked - * mousedown events and real mousedown events using the "buttons" property. While - * real mousedowns will indicate the mouse button that was pressed (e.g. "1" for - * the left mouse button), faked mousedowns will usually set the property value to 0. - */ -export function isFakeMousedownFromScreenReader(event: MouseEvent): boolean { - return event.buttons === 0; -} diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index ba8cfb3beb82..96c118f8eb12 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -23,7 +23,10 @@ import { import {Observable, of as observableOf, Subject, Subscription} from 'rxjs'; import {coerceElement} from '@angular/cdk/coercion'; import {DOCUMENT} from '@angular/common'; -import {isFakeMousedownFromScreenReader} from '../fake-mousedown'; +import { + isFakeMousedownFromScreenReader, + isFakeTouchstartFromScreenReader, +} from '../fake-event-detection'; // This is the value used by AngularJS Material. Through trial and error (on iPhone 6S) they found @@ -156,15 +159,21 @@ export class FocusMonitor implements OnDestroy { * Needs to be an arrow function in order to preserve the context when it gets bound. */ private _documentTouchstartListener = (event: TouchEvent) => { - // 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); - } + // 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); + this._lastTouchTarget = getTarget(event); + this._touchTimeoutId = setTimeout(() => this._lastTouchTarget = null, TOUCH_BUFFER_MS); + } else if (!this._lastTouchTarget) { + this._setOriginForCurrentEventQueue('keyboard'); + } } /** diff --git a/src/cdk/a11y/public-api.ts b/src/cdk/a11y/public-api.ts index cb70ff27f286..e4c350d92ae6 100644 --- a/src/cdk/a11y/public-api.ts +++ b/src/cdk/a11y/public-api.ts @@ -18,7 +18,7 @@ export * from './interactivity-checker/interactivity-checker'; export * from './live-announcer/live-announcer'; export * from './live-announcer/live-announcer-tokens'; export * from './focus-monitor/focus-monitor'; -export * from './fake-mousedown'; +export * from './fake-event-detection'; export * from './a11y-module'; export { HighContrastModeDetector, diff --git a/src/material/core/ripple/ripple-renderer.ts b/src/material/core/ripple/ripple-renderer.ts index 57ca082a6c3d..bb17ec284f2a 100644 --- a/src/material/core/ripple/ripple-renderer.ts +++ b/src/material/core/ripple/ripple-renderer.ts @@ -7,7 +7,7 @@ */ import {ElementRef, NgZone} from '@angular/core'; import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform'; -import {isFakeMousedownFromScreenReader} from '@angular/cdk/a11y'; +import {isFakeMousedownFromScreenReader, isFakeTouchstartFromScreenReader} from '@angular/cdk/a11y'; import {coerceElement} from '@angular/cdk/coercion'; import {RippleRef, RippleState, RippleConfig} from './ripple-ref'; @@ -259,7 +259,7 @@ export class RippleRenderer implements EventListenerObject { /** Function being called whenever the trigger is being pressed using touch. */ private _onTouchStart(event: TouchEvent) { - if (!this._target.rippleDisabled) { + if (!this._target.rippleDisabled && !isFakeTouchstartFromScreenReader(event)) { // Some browsers fire mouse events after a `touchstart` event. Those synthetic mouse // events will launch a second ripple if we don't ignore mouse events for a specific // time after a touchstart event. diff --git a/src/material/menu/menu-trigger.ts b/src/material/menu/menu-trigger.ts index 22e6cc279ea1..f17c10f4c373 100644 --- a/src/material/menu/menu-trigger.ts +++ b/src/material/menu/menu-trigger.ts @@ -6,7 +6,12 @@ * found in the LICENSE file at https://angular.io/license */ -import {FocusMonitor, FocusOrigin, isFakeMousedownFromScreenReader} from '@angular/cdk/a11y'; +import { + FocusMonitor, + FocusOrigin, + isFakeMousedownFromScreenReader, + isFakeTouchstartFromScreenReader, +} from '@angular/cdk/a11y'; import {Direction, Directionality} from '@angular/cdk/bidi'; import {ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE} from '@angular/cdk/keycodes'; import { @@ -99,7 +104,11 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy { * Handles touch start events on the trigger. * Needs to be an arrow function so we can easily use addEventListener and removeEventListener. */ - private _handleTouchStart = () => this._openedBy = 'touch'; + private _handleTouchStart = (event: TouchEvent) => { + if (!isFakeTouchstartFromScreenReader(event)) { + this._openedBy = 'touch'; + } + } // Tracking input type is necessary so it's possible to only auto-focus // the first item of the list when the menu is opened via the keyboard diff --git a/tools/public_api_guard/cdk/a11y.d.ts b/tools/public_api_guard/cdk/a11y.d.ts index 69246c1732aa..8b213a71da46 100644 --- a/tools/public_api_guard/cdk/a11y.d.ts +++ b/tools/public_api_guard/cdk/a11y.d.ts @@ -193,6 +193,8 @@ export declare class InteractivityChecker { export declare function isFakeMousedownFromScreenReader(event: MouseEvent): boolean; +export declare function isFakeTouchstartFromScreenReader(event: TouchEvent): boolean; + export declare class IsFocusableConfig { ignoreVisibility: boolean; }