diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index de68db312731..20aeb4a1e85f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts b/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts index b6202af33e15..09a3f71ed27e 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts @@ -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'; diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index 2fe97c6cf1f1..30509770c577 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -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; diff --git a/src/cdk/a11y/input-modality/input-modality-detector.spec.ts b/src/cdk/a11y/input-modality/input-modality-detector.spec.ts new file mode 100644 index 000000000000..c3a3166b9456 --- /dev/null +++ b/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'); + })); +}); diff --git a/src/cdk/a11y/input-modality/input-modality-detector.ts b/src/cdk/a11y/input-modality/input-modality-detector.ts new file mode 100644 index 000000000000..f841b22e9279 --- /dev/null +++ b/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('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; + + /** 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(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); + } +} diff --git a/src/cdk/a11y/public-api.ts b/src/cdk/a11y/public-api.ts index 266884cccb63..3d805b97d6dd 100644 --- a/src/cdk/a11y/public-api.ts +++ b/src/cdk/a11y/public-api.ts @@ -16,6 +16,13 @@ export * from './focus-trap/event-listener-inert-strategy'; export * from './focus-trap/focus-trap'; export * from './focus-trap/focus-trap-inert-strategy'; export * from './interactivity-checker/interactivity-checker'; +export { + InputModality, + InputModalityDetector, + InputModalityDetectorOptions, + INPUT_MODALITY_DETECTOR_DEFAULT_OPTIONS, + INPUT_MODALITY_DETECTOR_OPTIONS, +} from './input-modality/input-modality-detector'; export * from './live-announcer/live-announcer'; export * from './live-announcer/live-announcer-tokens'; export * from './focus-monitor/focus-monitor'; diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index 705c56244a76..1ca69d3d33a1 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -46,6 +46,7 @@ ng_module( "//src/dev-app/icon", "//src/dev-app/input", "//src/dev-app/layout", + "//src/dev-app/input-modality", "//src/dev-app/list", "//src/dev-app/live-announcer", "//src/dev-app/mdc-autocomplete", diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index 1df1239e7bc3..333440b54e3f 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -55,6 +55,7 @@ export class DevAppLayout { {name: 'Grid List', route: '/grid-list'}, {name: 'Icon', route: '/icon'}, {name: 'Input', route: '/input'}, + {name: 'Input Modality', route: '/input-modality'}, {name: 'List', route: '/list'}, {name: 'Layout', route: '/layout'}, {name: 'Live Announcer', route: '/live-announcer'}, diff --git a/src/dev-app/dev-app/routes.ts b/src/dev-app/dev-app/routes.ts index d49836ad4bb1..79a8581d9609 100644 --- a/src/dev-app/dev-app/routes.ts +++ b/src/dev-app/dev-app/routes.ts @@ -62,6 +62,11 @@ export const DEV_APP_ROUTES: Routes = [ {path: 'icon', loadChildren: 'icon/icon-demo-module#IconDemoModule'}, {path: 'input', loadChildren: 'input/input-demo-module#InputDemoModule'}, {path: 'layout', loadChildren: 'layout/layout-demo-module#LayoutDemoModule'}, + { + path: 'input-modality', + loadChildren: + 'input-modality/input-modality-detector-demo-module#InputModalityDetectorDemoModule', + }, {path: 'list', loadChildren: 'list/list-demo-module#ListDemoModule'}, { path: 'live-announcer', diff --git a/src/dev-app/input-modality/BUILD.bazel b/src/dev-app/input-modality/BUILD.bazel new file mode 100644 index 000000000000..4c21642634ce --- /dev/null +++ b/src/dev-app/input-modality/BUILD.bazel @@ -0,0 +1,18 @@ +load("//tools:defaults.bzl", "ng_module") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "input-modality", + srcs = glob(["**/*.ts"]), + assets = ["input-modality-detector-demo.html"], + deps = [ + "//src/cdk/a11y", + "//src/material/button", + "//src/material/form-field", + "//src/material/input", + "//src/material/radio", + "//src/material/select", + "@npm//@angular/router", + ], +) diff --git a/src/dev-app/input-modality/input-modality-detector-demo-module.ts b/src/dev-app/input-modality/input-modality-detector-demo-module.ts new file mode 100644 index 000000000000..6923aeaf41b0 --- /dev/null +++ b/src/dev-app/input-modality/input-modality-detector-demo-module.ts @@ -0,0 +1,33 @@ +/** + * @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 {A11yModule} from '@angular/cdk/a11y'; +import {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatInputModule} from '@angular/material/input'; +import {MatRadioModule} from '@angular/material/radio'; +import {MatSelectModule} from '@angular/material/select'; +import {RouterModule} from '@angular/router'; +import {InputModalityDetectorDemo} from './input-modality-detector-demo'; + +@NgModule({ + imports: [ + A11yModule, + CommonModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatRadioModule, + MatSelectModule, + RouterModule.forChild([{path: '', component: InputModalityDetectorDemo}]), + ], + declarations: [InputModalityDetectorDemo], +}) +export class InputModalityDetectorDemoModule {} diff --git a/src/dev-app/input-modality/input-modality-detector-demo.html b/src/dev-app/input-modality/input-modality-detector-demo.html new file mode 100644 index 000000000000..6191e3a0be80 --- /dev/null +++ b/src/dev-app/input-modality/input-modality-detector-demo.html @@ -0,0 +1,40 @@ +
+

Input Modality

+ +
+

+ Interact with the controls below with keyboard, mouse, or touch to see the detected + input modality change. +

+ +

+ Detected input modality: + {{_modality || '(unknown)'}} +

+ + +

+ + + Name + + +

+ + + Food + + Apple + Banana + Carrot + + +

+ + + Red + Orange + Yellow + +
+
diff --git a/src/dev-app/input-modality/input-modality-detector-demo.ts b/src/dev-app/input-modality/input-modality-detector-demo.ts new file mode 100644 index 000000000000..ecceaab3701f --- /dev/null +++ b/src/dev-app/input-modality/input-modality-detector-demo.ts @@ -0,0 +1,36 @@ +/** + * @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 {Component, OnDestroy, NgZone} from '@angular/core'; +import {InputModality, InputModalityDetector} from '@angular/cdk/a11y'; + +import {Subject} from 'rxjs'; +import {takeUntil} from 'rxjs/operators'; + +@Component({ + selector: 'input-modality-detector-demo', + templateUrl: 'input-modality-detector-demo.html', +}) +export class InputModalityDetectorDemo implements OnDestroy { + _modality: InputModality = null; + _destroyed = new Subject(); + + constructor( + inputModalityDetector: InputModalityDetector, + ngZone: NgZone, + ) { + inputModalityDetector.modalityChanges + .pipe(takeUntil(this._destroyed)) + .subscribe(modality => ngZone.run(() => { this._modality = modality; })); + } + + ngOnDestroy() { + this._destroyed.next(); + this._destroyed.complete(); + } +} diff --git a/tools/public_api_guard/cdk/a11y.d.ts b/tools/public_api_guard/cdk/a11y.d.ts index 55b1da2eb1a2..dc1001f7d1cc 100644 --- a/tools/public_api_guard/cdk/a11y.d.ts +++ b/tools/public_api_guard/cdk/a11y.d.ts @@ -187,6 +187,25 @@ export interface Highlightable extends ListKeyManagerOption { setInactiveStyles(): void; } +export declare const INPUT_MODALITY_DETECTOR_DEFAULT_OPTIONS: InputModalityDetectorOptions; + +export declare const INPUT_MODALITY_DETECTOR_OPTIONS: InjectionToken; + +export declare type InputModality = 'keyboard' | 'mouse' | 'touch' | null; + +export declare class InputModalityDetector implements OnDestroy { + readonly modalityChanges: Observable; + get mostRecentModality(): InputModality; + constructor(_platform: Platform, ngZone: NgZone, document: Document, options?: InputModalityDetectorOptions); + ngOnDestroy(): void; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵprov: i0.ɵɵInjectableDef; +} + +export interface InputModalityDetectorOptions { + ignoreKeys?: number[]; +} + export declare class InteractivityChecker { constructor(_platform: Platform); isDisabled(element: HTMLElement): boolean; @@ -266,5 +285,3 @@ export interface RegisteredMessage { messageElement: Element; referenceCount: number; } - -export declare const TOUCH_BUFFER_MS = 650;