From b3a2c56147b20c337a6acc16a104c138cd27e5b7 Mon Sep 17 00:00:00 2001 From: Nick Walther <61717337+nickwalther@users.noreply.github.com> Date: Tue, 17 Mar 2020 14:24:17 -0700 Subject: [PATCH] =?UTF-8?q?feat(focus-monitor):=20Add=20eventual=20detecti?= =?UTF-8?q?on=20mode=20option=20to=20foc=E2=80=A6=20(#18684)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add a detection window option to FocusMonitor to allow users to increase the timeout for attributing previous user event types as focus event origins. * Switch to a binary detection strategy option. * Accept declarations file change. * Rename some variables, types. * Add unit tests * Minor renames * Add a wrapping config object * Fix build from merge. * Accept declarations file change. --- .../a11y/focus-monitor/focus-monitor.spec.ts | 63 ++++++++++++++++++- src/cdk/a11y/focus-monitor/focus-monitor.ts | 63 +++++++++++++++---- tools/public_api_guard/cdk/a11y.d.ts | 13 +++- 3 files changed, 125 insertions(+), 14 deletions(-) diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts b/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts index 24a0087790ff..35892f15b6f9 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts @@ -9,7 +9,13 @@ 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 {FocusMonitor, FocusOrigin, TOUCH_BUFFER_MS} from './focus-monitor'; +import { + FocusMonitor, + FocusMonitorDetectionMode, + FocusOrigin, + FOCUS_MONITOR_DEFAULT_OPTIONS, + TOUCH_BUFFER_MS, +} from './focus-monitor'; describe('FocusMonitor', () => { @@ -239,8 +245,63 @@ describe('FocusMonitor', () => { flush(); })); + + it('should clear the focus origin after one tick with "immediate" detection', + fakeAsync(() => { + dispatchKeyboardEvent(document, 'keydown', TAB); + tick(2); + buttonElement.focus(); + + // After 2 ticks, the timeout has cleared the origin. Default is 'program'. + expect(changeHandler).toHaveBeenCalledWith('program'); + })); }); +describe('FocusMonitor with "eventual" detection', () => { + let fixture: ComponentFixture; + let buttonElement: HTMLElement; + let focusMonitor: FocusMonitor; + let changeHandler: (origin: FocusOrigin) => void; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [A11yModule], + declarations: [ + PlainButton, + ], + providers: [ + { + provide: FOCUS_MONITOR_DEFAULT_OPTIONS, + useValue: { + detectionMode: FocusMonitorDetectionMode.EVENTUAL, + }, + }, + ], + }).compileComponents(); + }); + + beforeEach(inject([FocusMonitor], (fm: FocusMonitor) => { + fixture = TestBed.createComponent(PlainButton); + fixture.detectChanges(); + + buttonElement = fixture.debugElement.query(By.css('button'))!.nativeElement; + focusMonitor = fm; + + changeHandler = jasmine.createSpy('focus origin change handler'); + focusMonitor.monitor(buttonElement).subscribe(changeHandler); + patchElementFocus(buttonElement); + })); + + + it('should not clear the focus origin, even after a few seconds', fakeAsync(() => { + dispatchKeyboardEvent(document, 'keydown', TAB); + tick(2000); + + buttonElement.focus(); + + expect(changeHandler).toHaveBeenCalledWith('keyboard'); + })); +}); describe('cdkMonitorFocus', () => { beforeEach(() => { diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index 7bfb4950245f..41a8c9dc56f2 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -11,12 +11,13 @@ import { Directive, ElementRef, EventEmitter, + Inject, Injectable, + InjectionToken, NgZone, OnDestroy, - Output, Optional, - Inject, + Output, } from '@angular/core'; import {Observable, of as observableOf, Subject, Subscription} from 'rxjs'; import {coerceElement} from '@angular/cdk/coercion'; @@ -39,6 +40,30 @@ export interface FocusOptions { preventScroll?: boolean; } +/** Detection mode used for attributing the origin of a focus event. */ +export const enum FocusMonitorDetectionMode { + /** + * Any mousedown, keydown, or touchstart event that happened in the previous + * tick or the current tick will be used to assign a focus event's origin (to + * either mouse, keyboard, or touch). This is the default option. + */ + IMMEDIATE, + /** + * A focus event's origin is always attributed to the last corresponding + * mousedown, keydown, or touchstart event, no matter how long ago it occured. + */ + EVENTUAL +} + +/** Injectable service-level options for FocusMonitor. */ +export interface FocusMonitorOptions { + detectionMode?: FocusMonitorDetectionMode; +} + +/** InjectionToken for FocusMonitorOptions. */ +export const FOCUS_MONITOR_DEFAULT_OPTIONS = + new InjectionToken('cdk-focus-monitor-default-options'); + type MonitoredElementInfo = { unlisten: Function, checkChildren: boolean, @@ -85,6 +110,12 @@ export class FocusMonitor implements OnDestroy { /** The number of elements currently being monitored. */ private _monitoredElementCount = 0; + /** + * The specified detection mode, used for attributing the origin of a focus + * event. + */ + private readonly _detectionMode: FocusMonitorDetectionMode; + /** * Event listener for `keydown` events on the document. * Needs to be an arrow function in order to preserve the context when it gets bound. @@ -137,14 +168,18 @@ export class FocusMonitor implements OnDestroy { this._windowFocusTimeoutId = setTimeout(() => this._windowFocused = false); } - /** Used to reference correct document/window */ - protected _document?: Document; + /** Used to reference correct document/window */ + protected _document?: Document; - constructor(private _ngZone: NgZone, - private _platform: Platform, - /** @breaking-change 11.0.0 make document required */ - @Optional() @Inject(DOCUMENT) document?: any) { + constructor( + private _ngZone: NgZone, + private _platform: Platform, + /** @breaking-change 11.0.0 make document required */ + @Optional() @Inject(DOCUMENT) document: any|null, + @Optional() @Inject(FOCUS_MONITOR_DEFAULT_OPTIONS) options: + FocusMonitorOptions|null) { this._document = document; + this._detectionMode = options?.detectionMode || FocusMonitorDetectionMode.IMMEDIATE; } /** @@ -306,15 +341,19 @@ export class FocusMonitor implements OnDestroy { /** * Sets the origin and schedules an async function to clear it at the end of the event queue. + * If the detection mode is 'eventual', the origin is never cleared. * @param origin The origin to set. */ private _setOriginForCurrentEventQueue(origin: FocusOrigin): void { this._ngZone.runOutsideAngular(() => { this._origin = origin; - // Sometimes the focus origin won't be valid in Firefox because Firefox seems to focus *one* - // tick after the interaction event fired. To ensure the focus origin is always correct, - // the focus origin will be determined at the beginning of the next tick. - this._originTimeoutId = setTimeout(() => this._origin = null, 1); + + if (this._detectionMode === FocusMonitorDetectionMode.IMMEDIATE) { + // Sometimes the focus origin won't be valid in Firefox because Firefox seems to focus *one* + // tick after the interaction event fired. To ensure the focus origin is always correct, + // the focus origin will be determined at the beginning of the next tick. + this._originTimeoutId = setTimeout(() => this._origin = null, 1); + } }); } diff --git a/tools/public_api_guard/cdk/a11y.d.ts b/tools/public_api_guard/cdk/a11y.d.ts index 7266f30ff560..96f934a81bbc 100644 --- a/tools/public_api_guard/cdk/a11y.d.ts +++ b/tools/public_api_guard/cdk/a11y.d.ts @@ -79,6 +79,8 @@ export declare class EventListenerFocusTrapInertStrategy implements FocusTrapIne preventFocus(focusTrap: ConfigurableFocusTrap): void; } +export declare const FOCUS_MONITOR_DEFAULT_OPTIONS: InjectionToken; + export declare const FOCUS_TRAP_INERT_STRATEGY: InjectionToken; export interface FocusableOption extends ListKeyManagerOption { @@ -94,7 +96,7 @@ export declare class FocusKeyManager extends ListKeyManager, origin: FocusOrigin, options?: FocusOptions): void; @@ -107,6 +109,15 @@ export declare class FocusMonitor implements OnDestroy { static ɵprov: i0.ɵɵInjectableDef; } +export declare const enum FocusMonitorDetectionMode { + IMMEDIATE = 0, + EVENTUAL = 1 +} + +export interface FocusMonitorOptions { + detectionMode?: FocusMonitorDetectionMode; +} + export interface FocusOptions { preventScroll?: boolean; }