Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cdk/a11y): Add a new InputModalityDetector service to detect the user's current input modality. #22371

Merged
merged 2 commits into from Apr 14, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Expand Up @@ -175,6 +175,7 @@
/src/dev-app/grid-list/** @jelbourn
/src/dev-app/icon/** @jelbourn
/src/dev-app/input/** @mmalerba
/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;
mmalerba marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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 {
zelliott marked this conversation as resolved.
Show resolved Hide resolved
/** 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; }
crisbeto marked this conversation as resolved.
Show resolved Hide resolved

// 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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@Inject(DOCUMENT) document: Document,
@Inject(DOCUMENT) document: Document|Document,

Using Document here will break in SSR because TypeScript preserves type information on decorated params, and Document doesn't existing in a nodejs environment. We can use this weird workaround to trick TS into not emitting the type as-is.

Can go in a follow-up, but you can test this by adding something to make this API run as part of our kitchen-sink.ts component (which is our SSR test component).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the thread at #22371 (comment). The kitchen sink e2e test seems to pass - what should I be looking for?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, maybe something changed in the tooling so that we don't have to worry about this any more ¯\(ツ)

@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);
}
}