Skip to content

Commit

Permalink
feat(cdk/a11y): Add a new InputModalityDetector service to detect the…
Browse files Browse the repository at this point in the history
… user's current input modality. (#22371)
  • Loading branch information
zelliott authored and wagnermaciel committed Jun 10, 2021
1 parent 5cca533 commit f11775c
Show file tree
Hide file tree
Showing 14 changed files with 514 additions and 8 deletions.
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Expand Up @@ -176,6 +176,7 @@
/src/dev-app/icon/** @jelbourn
/src/dev-app/input/** @mmalerba
/src/dev-app/layout/** @jelbourn
/src/dev-app/input-modality/** @jelbourn @zelliott
/src/dev-app/list/** @jelbourn @crisbeto @devversion
/src/dev-app/live-announcer/** @jelbourn
/src/dev-app/mdc-button/** @andrewseguin
Expand Down
2 changes: 1 addition & 1 deletion src/cdk/a11y/focus-monitor/focus-monitor.spec.ts
Expand Up @@ -12,12 +12,12 @@ import {Component, NgZone} from '@angular/core';
import {ComponentFixture, fakeAsync, flush, inject, TestBed, tick} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {A11yModule} from '../index';
import {TOUCH_BUFFER_MS} from '../input-modality/input-modality-detector';
import {
FocusMonitor,
FocusMonitorDetectionMode,
FocusOrigin,
FOCUS_MONITOR_DEFAULT_OPTIONS,
TOUCH_BUFFER_MS,
} from './focus-monitor';


Expand Down
6 changes: 1 addition & 5 deletions src/cdk/a11y/focus-monitor/focus-monitor.ts
Expand Up @@ -27,11 +27,7 @@ import {
isFakeMousedownFromScreenReader,
isFakeTouchstartFromScreenReader,
} from '../fake-event-detection';


// This is the value used by AngularJS Material. Through trial and error (on iPhone 6S) they found
// that a value of around 650ms seems appropriate.
export const TOUCH_BUFFER_MS = 650;
import {TOUCH_BUFFER_MS} from '../input-modality/input-modality-detector';


export type FocusOrigin = 'touch' | 'mouse' | 'keyboard' | 'program' | null;
Expand Down
167 changes: 167 additions & 0 deletions src/cdk/a11y/input-modality/input-modality-detector.spec.ts
@@ -0,0 +1,167 @@
import {A, ALT, B, C, CONTROL, META, SHIFT} from '@angular/cdk/keycodes';
import {Platform} from '@angular/cdk/platform';
import {NgZone, PLATFORM_ID} from '@angular/core';

import {
createMouseEvent,
dispatchKeyboardEvent,
dispatchMouseEvent,
dispatchTouchEvent,
dispatchEvent,
createTouchEvent,
} from '@angular/cdk/testing/private';
import {fakeAsync, inject, tick} from '@angular/core/testing';
import {InputModality, InputModalityDetector, TOUCH_BUFFER_MS} from './input-modality-detector';

describe('InputModalityDetector', () => {
let platform: Platform;
let ngZone: NgZone;
let detector: InputModalityDetector;

beforeEach(inject([PLATFORM_ID], (platformId: Object) => {
platform = new Platform(platformId);
ngZone = new NgZone({});
}));

afterEach(() => {
detector?.ngOnDestroy();
});

it('should do nothing on non-browser platforms', () => {
platform.isBrowser = false;
detector = new InputModalityDetector(platform, ngZone, document);
expect(detector.mostRecentModality).toBe(null);

dispatchKeyboardEvent(document, 'keydown');
expect(detector.mostRecentModality).toBe(null);

dispatchMouseEvent(document, 'mousedown');
expect(detector.mostRecentModality).toBe(null);

dispatchTouchEvent(document, 'touchstart');
expect(detector.mostRecentModality).toBe(null);
});

it('should detect keyboard input modality', () => {
detector = new InputModalityDetector(platform, ngZone, document);
dispatchKeyboardEvent(document, 'keydown');
expect(detector.mostRecentModality).toBe('keyboard');
});

it('should detect mouse input modality', () => {
detector = new InputModalityDetector(platform, ngZone, document);
dispatchMouseEvent(document, 'mousedown');
expect(detector.mostRecentModality).toBe('mouse');
});

it('should detect touch input modality', () => {
detector = new InputModalityDetector(platform, ngZone, document);
dispatchTouchEvent(document, 'touchstart');
expect(detector.mostRecentModality).toBe('touch');
});

it('should detect changes in input modality', () => {
detector = new InputModalityDetector(platform, ngZone, document);

dispatchKeyboardEvent(document, 'keydown');
expect(detector.mostRecentModality).toBe('keyboard');

dispatchMouseEvent(document, 'mousedown');
expect(detector.mostRecentModality).toBe('mouse');

dispatchTouchEvent(document, 'touchstart');
expect(detector.mostRecentModality).toBe('touch');

dispatchKeyboardEvent(document, 'keydown');
expect(detector.mostRecentModality).toBe('keyboard');
});

it('should emit changes in input modality', () => {
detector = new InputModalityDetector(platform, ngZone, document);
const emitted: InputModality[] = [];
detector.modalityChanges.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);

// Create a fake screen-reader mouse event.
const event = createMouseEvent('mousedown');
Object.defineProperty(event, 'buttons', {get: () => 0});
dispatchEvent(document, event);

expect(detector.mostRecentModality).toBe(null);
});

it('should ignore fake screen-reader touch events', () => {
detector = new InputModalityDetector(platform, ngZone, document);

// Create a fake screen-reader touch event.
const event = createTouchEvent('touchstart');
Object.defineProperty(event, 'touches', {get: () => [{identifier: -1}]});
dispatchEvent(document, event);

expect(detector.mostRecentModality).toBe(null);
});

it('should ignore certain modifier keys by default', () => {
detector = new InputModalityDetector(platform, ngZone, document);

dispatchKeyboardEvent(document, 'keydown', ALT);
dispatchKeyboardEvent(document, 'keydown', CONTROL);
dispatchKeyboardEvent(document, 'keydown', META);
dispatchKeyboardEvent(document, 'keydown', SHIFT);

expect(detector.mostRecentModality).toBe(null);
});

it('should not ignore modifier keys if specified', () => {
detector = new InputModalityDetector(platform, ngZone, document, {ignoreKeys: []});
dispatchKeyboardEvent(document, 'keydown', CONTROL);
expect(detector.mostRecentModality).toBe('keyboard');
});

it('should ignore keys if specified', () => {
detector = new InputModalityDetector(platform, ngZone, document, {ignoreKeys: [A, B, C]});

dispatchKeyboardEvent(document, 'keydown', A);
dispatchKeyboardEvent(document, 'keydown', B);
dispatchKeyboardEvent(document, 'keydown', C);

expect(detector.mostRecentModality).toBe(null);
});

it('should ignore mouse events that occur too closely after a touch event', fakeAsync(() => {
detector = new InputModalityDetector(platform, ngZone, document);

dispatchTouchEvent(document, 'touchstart');
dispatchMouseEvent(document, 'mousedown');
expect(detector.mostRecentModality).toBe('touch');

tick(TOUCH_BUFFER_MS);
dispatchMouseEvent(document, 'mousedown');
expect(detector.mostRecentModality).toBe('mouse');
}));
});
184 changes: 184 additions & 0 deletions src/cdk/a11y/input-modality/input-modality-detector.ts
@@ -0,0 +1,184 @@
/**
* @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
*/

import {ALT, CONTROL, 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';
import {BehaviorSubject, Observable} from 'rxjs';
import {distinctUntilChanged, skip} from 'rxjs/operators';
import {
isFakeMousedownFromScreenReader,
isFakeTouchstartFromScreenReader,
} from '../fake-event-detection';

/**
* The input modalities detected by this service. Null is used if the input modality is unknown.
*/
export type InputModality = 'keyboard' | 'mouse' | 'touch' | null;

/** Options to configure the behavior of the InputModalityDetector. */
export interface InputModalityDetectorOptions {
/** Keys to ignore when detecting keyboard input modality. */
ignoreKeys?: number[];
}

/**
* Injectable options for the InputModalityDetector. These are shallowly merged with the default
* options.
*/
export const INPUT_MODALITY_DETECTOR_OPTIONS =
new InjectionToken<InputModalityDetectorOptions>('cdk-input-modality-detector-options');

/**
* Default options for the InputModalityDetector.
*
* Modifier keys are ignored by default (i.e. when pressed won't cause the service to detect
* keyboard input modality) for two reasons:
*
* 1. Modifier keys are commonly used with mouse to perform actions such as 'right click' or 'open
* in new tab', and are thus less representative of actual keyboard interaction.
* 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.
*/
export const INPUT_MODALITY_DETECTOR_DEFAULT_OPTIONS: InputModalityDetectorOptions = {
ignoreKeys: [ALT, CONTROL, META, SHIFT],
};

/**
* The amount of time needed to pass after a touchstart event in order for a subsequent mousedown
* event to be attributed as mouse and not touch.
*
* This is the value used by AngularJS Material. Through trial and error (on iPhone 6S) they found
* that a value of around 650ms seems appropriate.
*/
export const TOUCH_BUFFER_MS = 650;

/**
* Event listener options that enable capturing and also mark the listener as passive if the browser
* supports it.
*/
const modalityEventListenerOptions = normalizePassiveListenerOptions({
passive: true,
capture: true,
});

/**
* Service that detects the user's input modality.
*
* This service does not update the input modality when a user navigates with a screen reader
* (e.g. linear navigation with VoiceOver, object navigation / browse mode with NVDA, virtual PC
* cursor mode with JAWS). This is in part due to technical limitations (i.e. keyboard events do not
* fire as expected in these modes) but is also arguably the correct behavior. Navigating with a
* 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.
*/
@Injectable({ providedIn: 'root' })
export class InputModalityDetector implements OnDestroy {
/** Emits when the input modality changes. */
readonly modalityChanges: Observable<InputModality>;

/** The most recently detected input modality. */
get mostRecentModality(): InputModality {
return this._modality.value;
}

/** The underlying BehaviorSubject that emits whenever an input modality is detected. */
private readonly _modality = new BehaviorSubject<InputModality>(null);

/** Options for this InputModalityDetector. */
private readonly _options: InputModalityDetectorOptions;

/**
* The timestamp of the last touch input modality. Used to determine whether mousedown events
* should be attributed to mouse or touch.
*/
private _lastTouchMs = 0;

/**
* Handles keyboard 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
// modality to keyboard.
if (this._options?.ignoreKeys?.some(keyCode => keyCode === event.keyCode)) { return; }

this._modality.next('keyboard');
}

/**
* Handles mouse 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');
}

/**
* Handles touchstart events. Must be an arrow function in order to preserve the context when it
* gets bound.
*/
private _onTouchstart = (event: TouchEvent) => {
if (isFakeTouchstartFromScreenReader(event)) { return; }

// Store the timestamp of this touch event, as it's used to distinguish between mouse events
// triggered via mouse vs touch.
this._lastTouchMs = Date.now();

this._modality.next('touch');
}

constructor(
private readonly _platform: Platform,
ngZone: NgZone,
@Inject(DOCUMENT) document: Document,
@Optional() @Inject(INPUT_MODALITY_DETECTOR_OPTIONS)
options?: InputModalityDetectorOptions,
) {
this._options = {
...INPUT_MODALITY_DETECTOR_DEFAULT_OPTIONS,
...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));

// If we're not in a browser, this service should do nothing, as there's no relevant input
// modality to detect.
if (!_platform.isBrowser) { return; }

// Add the event listeners used to detect the user's input modality.
ngZone.runOutsideAngular(() => {
document.addEventListener('keydown', this._onKeydown, modalityEventListenerOptions);
document.addEventListener('mousedown', this._onMousedown, modalityEventListenerOptions);
document.addEventListener('touchstart', this._onTouchstart, modalityEventListenerOptions);
});
}

ngOnDestroy() {
if (!this._platform.isBrowser) { return; }

document.removeEventListener('keydown', this._onKeydown, modalityEventListenerOptions);
document.removeEventListener('mousedown', this._onMousedown, modalityEventListenerOptions);
document.removeEventListener('touchstart', this._onTouchstart, modalityEventListenerOptions);
}
}

0 comments on commit f11775c

Please sign in to comment.