Skip to content

Commit

Permalink
fix(cdk/a11y): detect fake touchstart events from screen readers (#21987
Browse files Browse the repository at this point in the history
)

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)
  • Loading branch information
crisbeto authored and andrewseguin committed Feb 25, 2021
1 parent 99f10c5 commit a99a4d2
Show file tree
Hide file tree
Showing 7 changed files with 63 additions and 32 deletions.
29 changes: 29 additions & 0 deletions src/cdk/a11y/fake-event-detection.ts
Original file line number Diff line number Diff line change
@@ -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);
}
18 changes: 0 additions & 18 deletions src/cdk/a11y/fake-mousedown.ts

This file was deleted.

27 changes: 18 additions & 9 deletions src/cdk/a11y/focus-monitor/focus-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
}
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/cdk/a11y/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/material/core/ripple/ripple-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
Expand Down
13 changes: 11 additions & 2 deletions src/material/menu/menu-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions tools/public_api_guard/cdk/a11y.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down

0 comments on commit a99a4d2

Please sign in to comment.