Skip to content

Commit f11775c

Browse files
zelliottwagnermaciel
authored andcommitted
feat(cdk/a11y): Add a new InputModalityDetector service to detect the user's current input modality. (#22371)
1 parent 5cca533 commit f11775c

14 files changed

+514
-8
lines changed

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@
176176
/src/dev-app/icon/** @jelbourn
177177
/src/dev-app/input/** @mmalerba
178178
/src/dev-app/layout/** @jelbourn
179+
/src/dev-app/input-modality/** @jelbourn @zelliott
179180
/src/dev-app/list/** @jelbourn @crisbeto @devversion
180181
/src/dev-app/live-announcer/** @jelbourn
181182
/src/dev-app/mdc-button/** @andrewseguin

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ import {Component, NgZone} from '@angular/core';
1212
import {ComponentFixture, fakeAsync, flush, inject, TestBed, tick} from '@angular/core/testing';
1313
import {By} from '@angular/platform-browser';
1414
import {A11yModule} from '../index';
15+
import {TOUCH_BUFFER_MS} from '../input-modality/input-modality-detector';
1516
import {
1617
FocusMonitor,
1718
FocusMonitorDetectionMode,
1819
FocusOrigin,
1920
FOCUS_MONITOR_DEFAULT_OPTIONS,
20-
TOUCH_BUFFER_MS,
2121
} from './focus-monitor';
2222

2323

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,7 @@ import {
2727
isFakeMousedownFromScreenReader,
2828
isFakeTouchstartFromScreenReader,
2929
} from '../fake-event-detection';
30-
31-
32-
// This is the value used by AngularJS Material. Through trial and error (on iPhone 6S) they found
33-
// that a value of around 650ms seems appropriate.
34-
export const TOUCH_BUFFER_MS = 650;
30+
import {TOUCH_BUFFER_MS} from '../input-modality/input-modality-detector';
3531

3632

3733
export type FocusOrigin = 'touch' | 'mouse' | 'keyboard' | 'program' | null;
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import {A, ALT, B, C, CONTROL, META, SHIFT} from '@angular/cdk/keycodes';
2+
import {Platform} from '@angular/cdk/platform';
3+
import {NgZone, PLATFORM_ID} from '@angular/core';
4+
5+
import {
6+
createMouseEvent,
7+
dispatchKeyboardEvent,
8+
dispatchMouseEvent,
9+
dispatchTouchEvent,
10+
dispatchEvent,
11+
createTouchEvent,
12+
} from '@angular/cdk/testing/private';
13+
import {fakeAsync, inject, tick} from '@angular/core/testing';
14+
import {InputModality, InputModalityDetector, TOUCH_BUFFER_MS} from './input-modality-detector';
15+
16+
describe('InputModalityDetector', () => {
17+
let platform: Platform;
18+
let ngZone: NgZone;
19+
let detector: InputModalityDetector;
20+
21+
beforeEach(inject([PLATFORM_ID], (platformId: Object) => {
22+
platform = new Platform(platformId);
23+
ngZone = new NgZone({});
24+
}));
25+
26+
afterEach(() => {
27+
detector?.ngOnDestroy();
28+
});
29+
30+
it('should do nothing on non-browser platforms', () => {
31+
platform.isBrowser = false;
32+
detector = new InputModalityDetector(platform, ngZone, document);
33+
expect(detector.mostRecentModality).toBe(null);
34+
35+
dispatchKeyboardEvent(document, 'keydown');
36+
expect(detector.mostRecentModality).toBe(null);
37+
38+
dispatchMouseEvent(document, 'mousedown');
39+
expect(detector.mostRecentModality).toBe(null);
40+
41+
dispatchTouchEvent(document, 'touchstart');
42+
expect(detector.mostRecentModality).toBe(null);
43+
});
44+
45+
it('should detect keyboard input modality', () => {
46+
detector = new InputModalityDetector(platform, ngZone, document);
47+
dispatchKeyboardEvent(document, 'keydown');
48+
expect(detector.mostRecentModality).toBe('keyboard');
49+
});
50+
51+
it('should detect mouse input modality', () => {
52+
detector = new InputModalityDetector(platform, ngZone, document);
53+
dispatchMouseEvent(document, 'mousedown');
54+
expect(detector.mostRecentModality).toBe('mouse');
55+
});
56+
57+
it('should detect touch input modality', () => {
58+
detector = new InputModalityDetector(platform, ngZone, document);
59+
dispatchTouchEvent(document, 'touchstart');
60+
expect(detector.mostRecentModality).toBe('touch');
61+
});
62+
63+
it('should detect changes in input modality', () => {
64+
detector = new InputModalityDetector(platform, ngZone, document);
65+
66+
dispatchKeyboardEvent(document, 'keydown');
67+
expect(detector.mostRecentModality).toBe('keyboard');
68+
69+
dispatchMouseEvent(document, 'mousedown');
70+
expect(detector.mostRecentModality).toBe('mouse');
71+
72+
dispatchTouchEvent(document, 'touchstart');
73+
expect(detector.mostRecentModality).toBe('touch');
74+
75+
dispatchKeyboardEvent(document, 'keydown');
76+
expect(detector.mostRecentModality).toBe('keyboard');
77+
});
78+
79+
it('should emit changes in input modality', () => {
80+
detector = new InputModalityDetector(platform, ngZone, document);
81+
const emitted: InputModality[] = [];
82+
detector.modalityChanges.subscribe((modality: InputModality) => {
83+
emitted.push(modality);
84+
});
85+
86+
expect(emitted.length).toBe(0);
87+
88+
dispatchKeyboardEvent(document, 'keydown');
89+
expect(emitted).toEqual(['keyboard']);
90+
91+
dispatchKeyboardEvent(document, 'keydown');
92+
expect(emitted).toEqual(['keyboard']);
93+
94+
dispatchMouseEvent(document, 'mousedown');
95+
expect(emitted).toEqual(['keyboard', 'mouse']);
96+
97+
dispatchTouchEvent(document, 'touchstart');
98+
expect(emitted).toEqual(['keyboard', 'mouse', 'touch']);
99+
100+
dispatchTouchEvent(document, 'touchstart');
101+
expect(emitted).toEqual(['keyboard', 'mouse', 'touch']);
102+
103+
dispatchKeyboardEvent(document, 'keydown');
104+
expect(emitted).toEqual(['keyboard', 'mouse', 'touch', 'keyboard']);
105+
});
106+
107+
it('should ignore fake screen-reader mouse events', () => {
108+
detector = new InputModalityDetector(platform, ngZone, document);
109+
110+
// Create a fake screen-reader mouse event.
111+
const event = createMouseEvent('mousedown');
112+
Object.defineProperty(event, 'buttons', {get: () => 0});
113+
dispatchEvent(document, event);
114+
115+
expect(detector.mostRecentModality).toBe(null);
116+
});
117+
118+
it('should ignore fake screen-reader touch events', () => {
119+
detector = new InputModalityDetector(platform, ngZone, document);
120+
121+
// Create a fake screen-reader touch event.
122+
const event = createTouchEvent('touchstart');
123+
Object.defineProperty(event, 'touches', {get: () => [{identifier: -1}]});
124+
dispatchEvent(document, event);
125+
126+
expect(detector.mostRecentModality).toBe(null);
127+
});
128+
129+
it('should ignore certain modifier keys by default', () => {
130+
detector = new InputModalityDetector(platform, ngZone, document);
131+
132+
dispatchKeyboardEvent(document, 'keydown', ALT);
133+
dispatchKeyboardEvent(document, 'keydown', CONTROL);
134+
dispatchKeyboardEvent(document, 'keydown', META);
135+
dispatchKeyboardEvent(document, 'keydown', SHIFT);
136+
137+
expect(detector.mostRecentModality).toBe(null);
138+
});
139+
140+
it('should not ignore modifier keys if specified', () => {
141+
detector = new InputModalityDetector(platform, ngZone, document, {ignoreKeys: []});
142+
dispatchKeyboardEvent(document, 'keydown', CONTROL);
143+
expect(detector.mostRecentModality).toBe('keyboard');
144+
});
145+
146+
it('should ignore keys if specified', () => {
147+
detector = new InputModalityDetector(platform, ngZone, document, {ignoreKeys: [A, B, C]});
148+
149+
dispatchKeyboardEvent(document, 'keydown', A);
150+
dispatchKeyboardEvent(document, 'keydown', B);
151+
dispatchKeyboardEvent(document, 'keydown', C);
152+
153+
expect(detector.mostRecentModality).toBe(null);
154+
});
155+
156+
it('should ignore mouse events that occur too closely after a touch event', fakeAsync(() => {
157+
detector = new InputModalityDetector(platform, ngZone, document);
158+
159+
dispatchTouchEvent(document, 'touchstart');
160+
dispatchMouseEvent(document, 'mousedown');
161+
expect(detector.mostRecentModality).toBe('touch');
162+
163+
tick(TOUCH_BUFFER_MS);
164+
dispatchMouseEvent(document, 'mousedown');
165+
expect(detector.mostRecentModality).toBe('mouse');
166+
}));
167+
});
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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+
import {ALT, CONTROL, META, SHIFT} from '@angular/cdk/keycodes';
10+
import {Inject, Injectable, InjectionToken, OnDestroy, Optional, NgZone} from '@angular/core';
11+
import {normalizePassiveListenerOptions, Platform} from '@angular/cdk/platform';
12+
import {DOCUMENT} from '@angular/common';
13+
import {BehaviorSubject, Observable} from 'rxjs';
14+
import {distinctUntilChanged, skip} from 'rxjs/operators';
15+
import {
16+
isFakeMousedownFromScreenReader,
17+
isFakeTouchstartFromScreenReader,
18+
} from '../fake-event-detection';
19+
20+
/**
21+
* The input modalities detected by this service. Null is used if the input modality is unknown.
22+
*/
23+
export type InputModality = 'keyboard' | 'mouse' | 'touch' | null;
24+
25+
/** Options to configure the behavior of the InputModalityDetector. */
26+
export interface InputModalityDetectorOptions {
27+
/** Keys to ignore when detecting keyboard input modality. */
28+
ignoreKeys?: number[];
29+
}
30+
31+
/**
32+
* Injectable options for the InputModalityDetector. These are shallowly merged with the default
33+
* options.
34+
*/
35+
export const INPUT_MODALITY_DETECTOR_OPTIONS =
36+
new InjectionToken<InputModalityDetectorOptions>('cdk-input-modality-detector-options');
37+
38+
/**
39+
* Default options for the InputModalityDetector.
40+
*
41+
* Modifier keys are ignored by default (i.e. when pressed won't cause the service to detect
42+
* keyboard input modality) for two reasons:
43+
*
44+
* 1. Modifier keys are commonly used with mouse to perform actions such as 'right click' or 'open
45+
* in new tab', and are thus less representative of actual keyboard interaction.
46+
* 2. VoiceOver triggers some keyboard events when linearly navigating with Control + Option (but
47+
* confusingly not with Caps Lock). Thus, to have parity with other screen readers, we ignore
48+
* these keys so as to not update the input modality.
49+
*/
50+
export const INPUT_MODALITY_DETECTOR_DEFAULT_OPTIONS: InputModalityDetectorOptions = {
51+
ignoreKeys: [ALT, CONTROL, META, SHIFT],
52+
};
53+
54+
/**
55+
* The amount of time needed to pass after a touchstart event in order for a subsequent mousedown
56+
* event to be attributed as mouse and not touch.
57+
*
58+
* This is the value used by AngularJS Material. Through trial and error (on iPhone 6S) they found
59+
* that a value of around 650ms seems appropriate.
60+
*/
61+
export const TOUCH_BUFFER_MS = 650;
62+
63+
/**
64+
* Event listener options that enable capturing and also mark the listener as passive if the browser
65+
* supports it.
66+
*/
67+
const modalityEventListenerOptions = normalizePassiveListenerOptions({
68+
passive: true,
69+
capture: true,
70+
});
71+
72+
/**
73+
* Service that detects the user's input modality.
74+
*
75+
* This service does not update the input modality when a user navigates with a screen reader
76+
* (e.g. linear navigation with VoiceOver, object navigation / browse mode with NVDA, virtual PC
77+
* cursor mode with JAWS). This is in part due to technical limitations (i.e. keyboard events do not
78+
* fire as expected in these modes) but is also arguably the correct behavior. Navigating with a
79+
* screen reader is akin to visually scanning a page, and should not be interpreted as actual user
80+
* input interaction.
81+
*
82+
* When a user is not navigating but *interacting* with a screen reader, this service's behavior is
83+
* largely undefined and depends on the events fired. For example, in VoiceOver, no keyboard events
84+
* are fired when performing an element's default action via Caps Lock + Space, thus no input
85+
* modality is detected.
86+
*/
87+
@Injectable({ providedIn: 'root' })
88+
export class InputModalityDetector implements OnDestroy {
89+
/** Emits when the input modality changes. */
90+
readonly modalityChanges: Observable<InputModality>;
91+
92+
/** The most recently detected input modality. */
93+
get mostRecentModality(): InputModality {
94+
return this._modality.value;
95+
}
96+
97+
/** The underlying BehaviorSubject that emits whenever an input modality is detected. */
98+
private readonly _modality = new BehaviorSubject<InputModality>(null);
99+
100+
/** Options for this InputModalityDetector. */
101+
private readonly _options: InputModalityDetectorOptions;
102+
103+
/**
104+
* The timestamp of the last touch input modality. Used to determine whether mousedown events
105+
* should be attributed to mouse or touch.
106+
*/
107+
private _lastTouchMs = 0;
108+
109+
/**
110+
* Handles keyboard events. Must be an arrow function in order to preserve the context when it
111+
* gets bound.
112+
*/
113+
private _onKeydown = (event: KeyboardEvent) => {
114+
// If this is one of the keys we should ignore, then ignore it and don't update the input
115+
// modality to keyboard.
116+
if (this._options?.ignoreKeys?.some(keyCode => keyCode === event.keyCode)) { return; }
117+
118+
this._modality.next('keyboard');
119+
}
120+
121+
/**
122+
* Handles mouse events. Must be an arrow function in order to preserve the context when it gets
123+
* bound.
124+
*/
125+
private _onMousedown = (event: MouseEvent) => {
126+
if (isFakeMousedownFromScreenReader(event)) { return; }
127+
128+
// Touches trigger both touch and mouse events, so we need to distinguish between mouse events
129+
// that were triggered via mouse vs touch. To do so, check if the mouse event occurs closely
130+
// after the previous touch event.
131+
if (Date.now() - this._lastTouchMs < TOUCH_BUFFER_MS) { return; }
132+
133+
this._modality.next('mouse');
134+
}
135+
136+
/**
137+
* Handles touchstart events. Must be an arrow function in order to preserve the context when it
138+
* gets bound.
139+
*/
140+
private _onTouchstart = (event: TouchEvent) => {
141+
if (isFakeTouchstartFromScreenReader(event)) { return; }
142+
143+
// Store the timestamp of this touch event, as it's used to distinguish between mouse events
144+
// triggered via mouse vs touch.
145+
this._lastTouchMs = Date.now();
146+
147+
this._modality.next('touch');
148+
}
149+
150+
constructor(
151+
private readonly _platform: Platform,
152+
ngZone: NgZone,
153+
@Inject(DOCUMENT) document: Document,
154+
@Optional() @Inject(INPUT_MODALITY_DETECTOR_OPTIONS)
155+
options?: InputModalityDetectorOptions,
156+
) {
157+
this._options = {
158+
...INPUT_MODALITY_DETECTOR_DEFAULT_OPTIONS,
159+
...options,
160+
};
161+
162+
// Only emit if the input modality changes, and skip the first emission as it's null.
163+
this.modalityChanges = this._modality.pipe(distinctUntilChanged(), skip(1));
164+
165+
// If we're not in a browser, this service should do nothing, as there's no relevant input
166+
// modality to detect.
167+
if (!_platform.isBrowser) { return; }
168+
169+
// Add the event listeners used to detect the user's input modality.
170+
ngZone.runOutsideAngular(() => {
171+
document.addEventListener('keydown', this._onKeydown, modalityEventListenerOptions);
172+
document.addEventListener('mousedown', this._onMousedown, modalityEventListenerOptions);
173+
document.addEventListener('touchstart', this._onTouchstart, modalityEventListenerOptions);
174+
});
175+
}
176+
177+
ngOnDestroy() {
178+
if (!this._platform.isBrowser) { return; }
179+
180+
document.removeEventListener('keydown', this._onKeydown, modalityEventListenerOptions);
181+
document.removeEventListener('mousedown', this._onMousedown, modalityEventListenerOptions);
182+
document.removeEventListener('touchstart', this._onTouchstart, modalityEventListenerOptions);
183+
}
184+
}

0 commit comments

Comments
 (0)