Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(cdk/a11y): Add a new InputModalityDetector service to detect the…
… user's current input modality. (#22371)
- Loading branch information
Showing
14 changed files
with
514 additions
and
8 deletions.
There are no files selected for viewing
Validating CODEOWNERS rules …
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
167 changes: 167 additions & 0 deletions
167
src/cdk/a11y/input-modality/input-modality-detector.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
})); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 { | ||
/** 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); | ||
} | ||
} |
Oops, something went wrong.