Skip to content

Commit a99a4d2

Browse files
crisbetoandrewseguin
authored andcommitted
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 c7edf03)
1 parent 99f10c5 commit a99a4d2

File tree

7 files changed

+63
-32
lines changed

7 files changed

+63
-32
lines changed

src/cdk/a11y/fake-event-detection.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
/** Gets whether an event could be a faked `mousedown` event dispatched by a screen reader. */
10+
export function isFakeMousedownFromScreenReader(event: MouseEvent): boolean {
11+
// We can typically distinguish between these faked mousedown events and real mousedown events
12+
// using the "buttons" property. While real mousedowns will indicate the mouse button that was
13+
// pressed (e.g. "1" for the left mouse button), faked mousedowns will usually set the property
14+
// value to 0.
15+
return event.buttons === 0;
16+
}
17+
18+
/** Gets whether an event could be a faked `touchstart` event dispatched by a screen reader. */
19+
export function isFakeTouchstartFromScreenReader(event: TouchEvent): boolean {
20+
const touch: Touch | undefined = (event.touches && event.touches[0]) ||
21+
(event.changedTouches && event.changedTouches[0]);
22+
23+
// A fake `touchstart` can be distinguished from a real one by looking at the `identifier`
24+
// which is typically >= 0 on a real device versus -1 from a screen reader. Just to be safe,
25+
// we can also look at `radiusX` and `radiusY`. This behavior was observed against a Windows 10
26+
// device with a touch screen running NVDA v2020.4 and Firefox 85 or Chrome 88.
27+
return !!touch && touch.identifier === -1 && (touch.radiusX == null || touch.radiusX === 1) &&
28+
(touch.radiusY == null || touch.radiusY === 1);
29+
}

src/cdk/a11y/fake-mousedown.ts

Lines changed: 0 additions & 18 deletions
This file was deleted.

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

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ import {
2323
import {Observable, of as observableOf, Subject, Subscription} from 'rxjs';
2424
import {coerceElement} from '@angular/cdk/coercion';
2525
import {DOCUMENT} from '@angular/common';
26-
import {isFakeMousedownFromScreenReader} from '../fake-mousedown';
26+
import {
27+
isFakeMousedownFromScreenReader,
28+
isFakeTouchstartFromScreenReader,
29+
} from '../fake-event-detection';
2730

2831

2932
// 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 {
156159
* Needs to be an arrow function in order to preserve the context when it gets bound.
157160
*/
158161
private _documentTouchstartListener = (event: TouchEvent) => {
159-
// When the touchstart event fires the focus event is not yet in the event queue. This means
160-
// we can't rely on the trick used above (setting timeout of 1ms). Instead we wait 650ms to
161-
// see if a focus happens.
162-
if (this._touchTimeoutId != null) {
163-
clearTimeout(this._touchTimeoutId);
164-
}
162+
// Some screen readers will fire a fake `touchstart` event if an element is activated using
163+
// the keyboard while on a device with a touchsreen. Consider such events as keyboard focus.
164+
if (!isFakeTouchstartFromScreenReader(event)) {
165+
// When the touchstart event fires the focus event is not yet in the event queue. This means
166+
// we can't rely on the trick used above (setting timeout of 1ms). Instead we wait 650ms to
167+
// see if a focus happens.
168+
if (this._touchTimeoutId != null) {
169+
clearTimeout(this._touchTimeoutId);
170+
}
165171

166-
this._lastTouchTarget = getTarget(event);
167-
this._touchTimeoutId = setTimeout(() => this._lastTouchTarget = null, TOUCH_BUFFER_MS);
172+
this._lastTouchTarget = getTarget(event);
173+
this._touchTimeoutId = setTimeout(() => this._lastTouchTarget = null, TOUCH_BUFFER_MS);
174+
} else if (!this._lastTouchTarget) {
175+
this._setOriginForCurrentEventQueue('keyboard');
176+
}
168177
}
169178

170179
/**

src/cdk/a11y/public-api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export * from './interactivity-checker/interactivity-checker';
1818
export * from './live-announcer/live-announcer';
1919
export * from './live-announcer/live-announcer-tokens';
2020
export * from './focus-monitor/focus-monitor';
21-
export * from './fake-mousedown';
21+
export * from './fake-event-detection';
2222
export * from './a11y-module';
2323
export {
2424
HighContrastModeDetector,

src/material/core/ripple/ripple-renderer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88
import {ElementRef, NgZone} from '@angular/core';
99
import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform';
10-
import {isFakeMousedownFromScreenReader} from '@angular/cdk/a11y';
10+
import {isFakeMousedownFromScreenReader, isFakeTouchstartFromScreenReader} from '@angular/cdk/a11y';
1111
import {coerceElement} from '@angular/cdk/coercion';
1212
import {RippleRef, RippleState, RippleConfig} from './ripple-ref';
1313

@@ -259,7 +259,7 @@ export class RippleRenderer implements EventListenerObject {
259259

260260
/** Function being called whenever the trigger is being pressed using touch. */
261261
private _onTouchStart(event: TouchEvent) {
262-
if (!this._target.rippleDisabled) {
262+
if (!this._target.rippleDisabled && !isFakeTouchstartFromScreenReader(event)) {
263263
// Some browsers fire mouse events after a `touchstart` event. Those synthetic mouse
264264
// events will launch a second ripple if we don't ignore mouse events for a specific
265265
// time after a touchstart event.

src/material/menu/menu-trigger.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {FocusMonitor, FocusOrigin, isFakeMousedownFromScreenReader} from '@angular/cdk/a11y';
9+
import {
10+
FocusMonitor,
11+
FocusOrigin,
12+
isFakeMousedownFromScreenReader,
13+
isFakeTouchstartFromScreenReader,
14+
} from '@angular/cdk/a11y';
1015
import {Direction, Directionality} from '@angular/cdk/bidi';
1116
import {ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE} from '@angular/cdk/keycodes';
1217
import {
@@ -99,7 +104,11 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
99104
* Handles touch start events on the trigger.
100105
* Needs to be an arrow function so we can easily use addEventListener and removeEventListener.
101106
*/
102-
private _handleTouchStart = () => this._openedBy = 'touch';
107+
private _handleTouchStart = (event: TouchEvent) => {
108+
if (!isFakeTouchstartFromScreenReader(event)) {
109+
this._openedBy = 'touch';
110+
}
111+
}
103112

104113
// Tracking input type is necessary so it's possible to only auto-focus
105114
// the first item of the list when the menu is opened via the keyboard

tools/public_api_guard/cdk/a11y.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ export declare class InteractivityChecker {
193193

194194
export declare function isFakeMousedownFromScreenReader(event: MouseEvent): boolean;
195195

196+
export declare function isFakeTouchstartFromScreenReader(event: TouchEvent): boolean;
197+
196198
export declare class IsFocusableConfig {
197199
ignoreVisibility: boolean;
198200
}

0 commit comments

Comments
 (0)