-
Notifications
You must be signed in to change notification settings - Fork 6.8k
feat(cdk/a11y): Add a new InputModalityDetector service to detect the user's current input modality. #22371
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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'); | ||
})); | ||
}); |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -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 { | ||||||
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, | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Using Can go in a follow-up, but you can test this by adding something to make this API run as part of our There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||||||
} | ||||||
} |
Uh oh!
There was an error while loading. Please reload this page.