From 4e2827965ba78bb3f57c1884295b129b211d906c Mon Sep 17 00:00:00 2001 From: Nick Walther Date: Mon, 2 Mar 2020 19:19:34 -0800 Subject: [PATCH 1/9] Add a detection window option to FocusMonitor to allow users to increase the timeout for attributing previous user event types as focus event origins. --- src/cdk/a11y/focus-monitor/focus-monitor.ts | 120 +++++++++++++++++--- tools/public_api_guard/cdk/a11y.d.ts | 11 +- 2 files changed, 114 insertions(+), 17 deletions(-) diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index 68b0daa7bf6d..2c380ef6c5bf 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -12,8 +12,11 @@ import { ElementRef, EventEmitter, Injectable, + Inject, + InjectionToken, NgZone, OnDestroy, + Optional, Output, } from '@angular/core'; import {Observable, of as observableOf, Subject, Subscription} from 'rxjs'; @@ -36,9 +39,38 @@ export interface FocusOptions { preventScroll?: boolean; } +/** A set of options to apply to calls of FocusMonitor.monitor(). */ +export interface FocusMonitorOptions { + /** Whether to count the element as focused when its children are focused. */ + checkChildren: boolean; + + /** + * The time window (in milliseconds) during which a mousedown/keydown/touchstart event is + * considered to be "in play" for the pupose of assigning a focus event's origin. Set to + * 'indefinite' to always attribute a focus event's origin to the last corresponding event's + * type, no matter how long ago it occured. + */ + detectionWindow: number|'indefinite'; +} + +/** Default options used when a dependency injected options object is not provided. */ +const FOCUS_MONITOR_DEFAULT_OPTIONS: FocusMonitorOptions = { + checkChildren: false, + // A default of 1ms is used because Firefox seems to focus *one* tick after the interaction + // event fired, causing the focus origin to be misinterpreted. To ensure the focus origin + // is always correct, the focus origin should be determined at least starting from the + // next tick. + detectionWindow: 1, +}; + +/** InjectionToken that can be used to specify the global FocusMonitorOptions. */ +export const FOCUS_MONITOR_GLOBAL_OPTIONS = + new InjectionToken>('cdk-focus-monitor-global-options'); + type MonitoredElementInfo = { unlisten: Function, checkChildren: boolean, + detectionWindow: number|'indefinite', subject: Subject }; @@ -58,6 +90,9 @@ export class FocusMonitor implements OnDestroy { /** The focus origin that the next focus event is a result of. */ private _origin: FocusOrigin = null; + /** Timestamp that the current FocusOrigin was set at, in epoch milliseconds. */ + private _originTimestamp = 0; + /** The FocusOrigin of the last focus event tracked by the FocusMonitor. */ private _lastFocusOrigin: FocusOrigin; @@ -73,15 +108,15 @@ export class FocusMonitor implements OnDestroy { /** The timeout id of the window focus timeout. */ private _windowFocusTimeoutId: number; - /** The timeout id of the origin clearing timeout. */ - private _originTimeoutId: number; - /** Map of elements being monitored to their info. */ private _elementInfo = new Map(); /** The number of elements currently being monitored. */ private _monitoredElementCount = 0; + /** Options to apply to all calls to monitor(). */ + private _focusMonitorOptions: FocusMonitorOptions; + /** * Event listener for `keydown` events on the document. * Needs to be an arrow function in order to preserve the context when it gets bound. @@ -134,7 +169,13 @@ export class FocusMonitor implements OnDestroy { this._windowFocusTimeoutId = setTimeout(() => this._windowFocused = false); } - constructor(private _ngZone: NgZone, private _platform: Platform) {} + constructor( + private _ngZone: NgZone, private _platform: Platform, + @Optional() @Inject(FOCUS_MONITOR_GLOBAL_OPTIONS) globalFocusMonitorOptions?: + Partial) { + this._focusMonitorOptions = + coerceFocusMonitorOptions(globalFocusMonitorOptions, FOCUS_MONITOR_DEFAULT_OPTIONS); + } /** * Monitors focus on an element and applies appropriate CSS classes. @@ -154,26 +195,47 @@ export class FocusMonitor implements OnDestroy { */ monitor(element: ElementRef, checkChildren?: boolean): Observable; - monitor(element: HTMLElement | ElementRef, - checkChildren: boolean = false): Observable { + /** + * Monitors focus on an element and applies appropriate CSS classes. + * @param element The element to monitor + * @param options Options to use for focus events on the provided element. + * @returns An observable that emits when the focus state of the element changes. + * When the element is blurred, null will be emitted. + */ + monitor(element: HTMLElement, options?: FocusMonitorOptions): Observable; + + /** + * Monitors focus on an element and applies appropriate CSS classes. + * @param element The element to monitor + * @param options Options to use for focus events on the provided element + * @returns An observable that emits when the focus state of the element changes. + * When the element is blurred, null will be emitted. + */ + monitor(element: ElementRef, options?: FocusMonitorOptions): Observable; + + monitor(element: HTMLElement|ElementRef, options?: FocusMonitorOptions|boolean): + Observable { // Do nothing if we're not on the browser platform. if (!this._platform.isBrowser) { return observableOf(null); } + options = coerceFocusMonitorOptions(options, this._focusMonitorOptions); + const nativeElement = coerceElement(element); // Check if we're already monitoring this element. if (this._elementInfo.has(nativeElement)) { let cachedInfo = this._elementInfo.get(nativeElement); - cachedInfo!.checkChildren = checkChildren; + cachedInfo!.checkChildren = options.checkChildren; return cachedInfo!.subject.asObservable(); } // Create monitored element info. let info: MonitoredElementInfo = { unlisten: () => {}, - checkChildren: checkChildren, + checkChildren: options.checkChildren, + detectionWindow: options.detectionWindow, subject: new Subject() }; this._elementInfo.set(nativeElement, info); @@ -241,7 +303,6 @@ export class FocusMonitor implements OnDestroy { focusVia(element: HTMLElement | ElementRef, origin: FocusOrigin, options?: FocusOptions): void { - const nativeElement = coerceElement(element); this._setOriginForCurrentEventQueue(origin); @@ -283,16 +344,13 @@ export class FocusMonitor implements OnDestroy { } /** - * Sets the origin and schedules an async function to clear it at the end of the event queue. + * Sets the origin and sets origin timestamp to now. * @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); + this._originTimestamp = new Date().valueOf(); }); } @@ -349,7 +407,7 @@ export class FocusMonitor implements OnDestroy { // 3) The element was programmatically focused, in which case we should mark the origin as // 'program'. let origin = this._origin; - if (!origin) { + if (!origin || this._isOriginInvalid(elementInfo.detectionWindow)) { if (this._windowFocused && this._lastFocusOrigin) { origin = this._lastFocusOrigin; } else if (this._wasCausedByTouch(event)) { @@ -418,11 +476,41 @@ export class FocusMonitor implements OnDestroy { // Clear timeouts for all potentially pending timeouts to prevent the leaks. clearTimeout(this._windowFocusTimeoutId); clearTimeout(this._touchTimeoutId); - clearTimeout(this._originTimeoutId); } } + + /** A focus origin is invalid if it occured before (now - detectionWindow). */ + private _isOriginInvalid(detectionWindow: number|'indefinite'): boolean { + if (detectionWindow === 'indefinite') { + return false; + } + + const now = new Date().valueOf(); + return now - this._originTimestamp > detectionWindow; + } } +/** + * Takes a partial set of options and a complete default set and merges them. A boolean value for + * options corresponds to the checkChildren parameter. + */ +function coerceFocusMonitorOptions( + options: Partial|boolean|undefined, + defaultOptions: FocusMonitorOptions): FocusMonitorOptions { + if (!options) { + return defaultOptions; + } + if (typeof options === 'boolean') { + options = {checkChildren: options}; + } + + return { + checkChildren: options.checkChildren !== undefined ? options.checkChildren : + defaultOptions.checkChildren, + detectionWindow: options.detectionWindow ? options.detectionWindow : + defaultOptions.detectionWindow, + }; +} /** * Directive that determines how a particular element was focused (via keyboard, mouse, touch, or diff --git a/tools/public_api_guard/cdk/a11y.d.ts b/tools/public_api_guard/cdk/a11y.d.ts index 6985f7e16ef4..922a3f1cfc21 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_GLOBAL_OPTIONS: InjectionToken>; + export declare const FOCUS_TRAP_INERT_STRATEGY: InjectionToken; export interface FocusableOption extends ListKeyManagerOption { @@ -92,12 +94,14 @@ export declare class FocusKeyManager extends ListKeyManager); _onBlur(event: FocusEvent, element: HTMLElement): void; focusVia(element: HTMLElement, origin: FocusOrigin, options?: FocusOptions): void; focusVia(element: ElementRef, origin: FocusOrigin, options?: FocusOptions): void; monitor(element: HTMLElement, checkChildren?: boolean): Observable; monitor(element: ElementRef, checkChildren?: boolean): Observable; + monitor(element: HTMLElement, options?: FocusMonitorOptions): Observable; + monitor(element: ElementRef, options?: FocusMonitorOptions): Observable; ngOnDestroy(): void; stopMonitoring(element: HTMLElement): void; stopMonitoring(element: ElementRef): void; @@ -105,6 +109,11 @@ export declare class FocusMonitor implements OnDestroy { static ɵprov: i0.ɵɵInjectableDef; } +export interface FocusMonitorOptions { + checkChildren: boolean; + detectionWindow: number | 'indefinite'; +} + export interface FocusOptions { preventScroll?: boolean; } From 5eb02c408215298ade9b2166cc9b8207abd38f3e Mon Sep 17 00:00:00 2001 From: Nick Walther Date: Sat, 7 Mar 2020 00:02:42 -0800 Subject: [PATCH 2/9] Switch to a binary detection strategy option. --- src/cdk/a11y/focus-monitor/focus-monitor.ts | 147 +++++++------------- 1 file changed, 51 insertions(+), 96 deletions(-) diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index 2c380ef6c5bf..6aa8cd19e000 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -11,8 +11,8 @@ import { Directive, ElementRef, EventEmitter, - Injectable, Inject, + Injectable, InjectionToken, NgZone, OnDestroy, @@ -39,38 +39,31 @@ export interface FocusOptions { preventScroll?: boolean; } -/** A set of options to apply to calls of FocusMonitor.monitor(). */ -export interface FocusMonitorOptions { - /** Whether to count the element as focused when its children are focused. */ - checkChildren: boolean; - - /** - * The time window (in milliseconds) during which a mousedown/keydown/touchstart event is - * considered to be "in play" for the pupose of assigning a focus event's origin. Set to - * 'indefinite' to always attribute a focus event's origin to the last corresponding event's - * type, no matter how long ago it occured. - */ - detectionWindow: number|'indefinite'; -} +/** + * Detection strategy used for attributing the origin of a focus event. + * A 'convervative' strategy will take any mousedown, keydown, or touchstart event + * that happened in the previous tick or current tick, and use that to assign + * a focus event's origin (either mouse, keyboard, or touch). 'aggressive' will always + * attribute a focus event's origin to the last corresponding user event's type, no + * matter how long ago it occured. 'conservative' is the default option. + */ +export type FocusMonitorDetectionStrategy = 'conservative'|'aggressive'; -/** Default options used when a dependency injected options object is not provided. */ -const FOCUS_MONITOR_DEFAULT_OPTIONS: FocusMonitorOptions = { - checkChildren: false, - // A default of 1ms is used because Firefox seems to focus *one* tick after the interaction - // event fired, causing the focus origin to be misinterpreted. To ensure the focus origin - // is always correct, the focus origin should be determined at least starting from the - // next tick. - detectionWindow: 1, -}; +/** + * Default detection strategy option used when a dependency injected object is + * not provided. + */ +const DEFAULT_FOCUS_MONITOR_DETECTION_STRATEGY: FocusMonitorDetectionStrategy = + 'conservative'; -/** InjectionToken that can be used to specify the global FocusMonitorOptions. */ -export const FOCUS_MONITOR_GLOBAL_OPTIONS = - new InjectionToken>('cdk-focus-monitor-global-options'); +/** InjectionToken that can be used to specify the detection strategy. */ +export const FOCUS_MONITOR_DETECTION_STRATEGY = + new InjectionToken( + 'cdk-focus-monitor-detection-strategy'); type MonitoredElementInfo = { unlisten: Function, checkChildren: boolean, - detectionWindow: number|'indefinite', subject: Subject }; @@ -90,9 +83,6 @@ export class FocusMonitor implements OnDestroy { /** The focus origin that the next focus event is a result of. */ private _origin: FocusOrigin = null; - /** Timestamp that the current FocusOrigin was set at, in epoch milliseconds. */ - private _originTimestamp = 0; - /** The FocusOrigin of the last focus event tracked by the FocusMonitor. */ private _lastFocusOrigin: FocusOrigin; @@ -108,21 +98,32 @@ export class FocusMonitor implements OnDestroy { /** The timeout id of the window focus timeout. */ private _windowFocusTimeoutId: number; + /** The timeout id of the origin clearing timeout. */ + private _originTimeoutId: number; + /** Map of elements being monitored to their info. */ private _elementInfo = new Map(); /** The number of elements currently being monitored. */ private _monitoredElementCount = 0; - /** Options to apply to all calls to monitor(). */ - private _focusMonitorOptions: FocusMonitorOptions; + /** + * Detection strategy used for attributing the origin of a focus event. + * A 'convervative' strategy will take any mousedown, keydown, or touchstart event + * that happened in the previous tick or current tick, and use that to assign + * a focus event's origin (either mouse, keyboard, or touch). 'aggressive' will always + * attribute a focus event's origin to the last corresponding user event's type, no + * matter how long ago it occured. 'conservative' is the default option. + */ + private _detectionStrategy: FocusMonitorDetectionStrategy; /** * Event listener for `keydown` events on the document. * Needs to be an arrow function in order to preserve the context when it gets bound. */ private _documentKeydownListener = () => { - // On keydown record the origin and clear any touch event that may be in progress. + // On keydown record the origin and clear any touch event that may be in + // progress. this._lastTouchTarget = null; this._setOriginForCurrentEventQueue('keyboard'); } @@ -171,10 +172,10 @@ export class FocusMonitor implements OnDestroy { constructor( private _ngZone: NgZone, private _platform: Platform, - @Optional() @Inject(FOCUS_MONITOR_GLOBAL_OPTIONS) globalFocusMonitorOptions?: - Partial) { - this._focusMonitorOptions = - coerceFocusMonitorOptions(globalFocusMonitorOptions, FOCUS_MONITOR_DEFAULT_OPTIONS); + @Optional() @Inject(FOCUS_MONITOR_DETECTION_STRATEGY) detectionStrategy: + FocusMonitorDetectionStrategy|null) { + this._detectionStrategy = + detectionStrategy || DEFAULT_FOCUS_MONITOR_DETECTION_STRATEGY; } /** @@ -195,47 +196,26 @@ export class FocusMonitor implements OnDestroy { */ monitor(element: ElementRef, checkChildren?: boolean): Observable; - /** - * Monitors focus on an element and applies appropriate CSS classes. - * @param element The element to monitor - * @param options Options to use for focus events on the provided element. - * @returns An observable that emits when the focus state of the element changes. - * When the element is blurred, null will be emitted. - */ - monitor(element: HTMLElement, options?: FocusMonitorOptions): Observable; - - /** - * Monitors focus on an element and applies appropriate CSS classes. - * @param element The element to monitor - * @param options Options to use for focus events on the provided element - * @returns An observable that emits when the focus state of the element changes. - * When the element is blurred, null will be emitted. - */ - monitor(element: ElementRef, options?: FocusMonitorOptions): Observable; - - monitor(element: HTMLElement|ElementRef, options?: FocusMonitorOptions|boolean): - Observable { + monitor(element: HTMLElement | ElementRef, + checkChildren: boolean = false): Observable { // Do nothing if we're not on the browser platform. if (!this._platform.isBrowser) { return observableOf(null); } - options = coerceFocusMonitorOptions(options, this._focusMonitorOptions); - const nativeElement = coerceElement(element); // Check if we're already monitoring this element. if (this._elementInfo.has(nativeElement)) { let cachedInfo = this._elementInfo.get(nativeElement); - cachedInfo!.checkChildren = options.checkChildren; + cachedInfo!.checkChildren = checkChildren; return cachedInfo!.subject.asObservable(); } // Create monitored element info. let info: MonitoredElementInfo = { unlisten: () => {}, - checkChildren: options.checkChildren, - detectionWindow: options.detectionWindow, + checkChildren: checkChildren, subject: new Subject() }; this._elementInfo.set(nativeElement, info); @@ -303,6 +283,7 @@ export class FocusMonitor implements OnDestroy { focusVia(element: HTMLElement | ElementRef, origin: FocusOrigin, options?: FocusOptions): void { + const nativeElement = coerceElement(element); this._setOriginForCurrentEventQueue(origin); @@ -344,13 +325,17 @@ export class FocusMonitor implements OnDestroy { } /** - * Sets the origin and sets origin timestamp to now. + * Sets the origin and schedules an async function to clear it at the end of the event queue. + * If the detection strategy is 'aggressive', the origin is never cleared. * @param origin The origin to set. */ private _setOriginForCurrentEventQueue(origin: FocusOrigin): void { this._ngZone.runOutsideAngular(() => { this._origin = origin; - this._originTimestamp = new Date().valueOf(); + + if (this._detectionStrategy === 'conservative') { + this._originTimeoutId = setTimeout(() => this._origin = null, 1); + } }); } @@ -407,7 +392,7 @@ export class FocusMonitor implements OnDestroy { // 3) The element was programmatically focused, in which case we should mark the origin as // 'program'. let origin = this._origin; - if (!origin || this._isOriginInvalid(elementInfo.detectionWindow)) { + if (!origin) { if (this._windowFocused && this._lastFocusOrigin) { origin = this._lastFocusOrigin; } else if (this._wasCausedByTouch(event)) { @@ -476,41 +461,11 @@ export class FocusMonitor implements OnDestroy { // Clear timeouts for all potentially pending timeouts to prevent the leaks. clearTimeout(this._windowFocusTimeoutId); clearTimeout(this._touchTimeoutId); + clearTimeout(this._originTimeoutId); } } - - /** A focus origin is invalid if it occured before (now - detectionWindow). */ - private _isOriginInvalid(detectionWindow: number|'indefinite'): boolean { - if (detectionWindow === 'indefinite') { - return false; - } - - const now = new Date().valueOf(); - return now - this._originTimestamp > detectionWindow; - } } -/** - * Takes a partial set of options and a complete default set and merges them. A boolean value for - * options corresponds to the checkChildren parameter. - */ -function coerceFocusMonitorOptions( - options: Partial|boolean|undefined, - defaultOptions: FocusMonitorOptions): FocusMonitorOptions { - if (!options) { - return defaultOptions; - } - if (typeof options === 'boolean') { - options = {checkChildren: options}; - } - - return { - checkChildren: options.checkChildren !== undefined ? options.checkChildren : - defaultOptions.checkChildren, - detectionWindow: options.detectionWindow ? options.detectionWindow : - defaultOptions.detectionWindow, - }; -} /** * Directive that determines how a particular element was focused (via keyboard, mouse, touch, or From 11a95cffbcc1516efc6465167d4396934c7a1d4a Mon Sep 17 00:00:00 2001 From: Nick Walther Date: Sat, 7 Mar 2020 00:18:26 -0800 Subject: [PATCH 3/9] Accept declarations file change. --- tools/public_api_guard/cdk/a11y.d.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/tools/public_api_guard/cdk/a11y.d.ts b/tools/public_api_guard/cdk/a11y.d.ts index 922a3f1cfc21..131a5d09b7c4 100644 --- a/tools/public_api_guard/cdk/a11y.d.ts +++ b/tools/public_api_guard/cdk/a11y.d.ts @@ -79,7 +79,7 @@ export declare class EventListenerFocusTrapInertStrategy implements FocusTrapIne preventFocus(focusTrap: ConfigurableFocusTrap): void; } -export declare const FOCUS_MONITOR_GLOBAL_OPTIONS: InjectionToken>; +export declare const FOCUS_MONITOR_DETECTION_STRATEGY: InjectionToken; export declare const FOCUS_TRAP_INERT_STRATEGY: InjectionToken; @@ -94,14 +94,12 @@ export declare class FocusKeyManager extends ListKeyManager); + constructor(_ngZone: NgZone, _platform: Platform, detectionStrategy: FocusMonitorDetectionStrategy | null); _onBlur(event: FocusEvent, element: HTMLElement): void; focusVia(element: HTMLElement, origin: FocusOrigin, options?: FocusOptions): void; focusVia(element: ElementRef, origin: FocusOrigin, options?: FocusOptions): void; monitor(element: HTMLElement, checkChildren?: boolean): Observable; monitor(element: ElementRef, checkChildren?: boolean): Observable; - monitor(element: HTMLElement, options?: FocusMonitorOptions): Observable; - monitor(element: ElementRef, options?: FocusMonitorOptions): Observable; ngOnDestroy(): void; stopMonitoring(element: HTMLElement): void; stopMonitoring(element: ElementRef): void; @@ -109,10 +107,7 @@ export declare class FocusMonitor implements OnDestroy { static ɵprov: i0.ɵɵInjectableDef; } -export interface FocusMonitorOptions { - checkChildren: boolean; - detectionWindow: number | 'indefinite'; -} +export declare type FocusMonitorDetectionStrategy = 'conservative' | 'aggressive'; export interface FocusOptions { preventScroll?: boolean; From 360cadfaccad328010423035079c793629e3715b Mon Sep 17 00:00:00 2001 From: Nick Walther Date: Tue, 10 Mar 2020 18:00:31 -0700 Subject: [PATCH 4/9] Rename some variables, types. --- src/cdk/a11y/focus-monitor/focus-monitor.ts | 65 ++++++++++----------- tools/public_api_guard/cdk/a11y.d.ts | 9 ++- 2 files changed, 36 insertions(+), 38 deletions(-) diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index 6aa8cd19e000..374528ee8e9b 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -39,27 +39,25 @@ export interface FocusOptions { preventScroll?: boolean; } -/** - * Detection strategy used for attributing the origin of a focus event. - * A 'convervative' strategy will take any mousedown, keydown, or touchstart event - * that happened in the previous tick or current tick, and use that to assign - * a focus event's origin (either mouse, keyboard, or touch). 'aggressive' will always - * attribute a focus event's origin to the last corresponding user event's type, no - * matter how long ago it occured. 'conservative' is the default option. - */ -export type FocusMonitorDetectionStrategy = 'conservative'|'aggressive'; - -/** - * Default detection strategy option used when a dependency injected object is - * not provided. - */ -const DEFAULT_FOCUS_MONITOR_DETECTION_STRATEGY: FocusMonitorDetectionStrategy = - 'conservative'; +/** 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 +} -/** InjectionToken that can be used to specify the detection strategy. */ -export const FOCUS_MONITOR_DETECTION_STRATEGY = - new InjectionToken( - 'cdk-focus-monitor-detection-strategy'); +/** InjectionToken that can be used to specify the detection mode. */ +export const FOCUS_MONITOR_DETECTION_MODE = + new InjectionToken( + 'cdk-focus-monitor-detection-mode'); type MonitoredElementInfo = { unlisten: Function, @@ -108,22 +106,17 @@ export class FocusMonitor implements OnDestroy { private _monitoredElementCount = 0; /** - * Detection strategy used for attributing the origin of a focus event. - * A 'convervative' strategy will take any mousedown, keydown, or touchstart event - * that happened in the previous tick or current tick, and use that to assign - * a focus event's origin (either mouse, keyboard, or touch). 'aggressive' will always - * attribute a focus event's origin to the last corresponding user event's type, no - * matter how long ago it occured. 'conservative' is the default option. + * The specified detection mode, used for attributing the origin of a focus + * event. */ - private _detectionStrategy: FocusMonitorDetectionStrategy; + private _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. */ private _documentKeydownListener = () => { - // On keydown record the origin and clear any touch event that may be in - // progress. + // On keydown record the origin and clear any touch event that may be in progress. this._lastTouchTarget = null; this._setOriginForCurrentEventQueue('keyboard'); } @@ -172,10 +165,9 @@ export class FocusMonitor implements OnDestroy { constructor( private _ngZone: NgZone, private _platform: Platform, - @Optional() @Inject(FOCUS_MONITOR_DETECTION_STRATEGY) detectionStrategy: - FocusMonitorDetectionStrategy|null) { - this._detectionStrategy = - detectionStrategy || DEFAULT_FOCUS_MONITOR_DETECTION_STRATEGY; + @Optional() @Inject(FOCUS_MONITOR_DETECTION_MODE) detectionMode: + FocusMonitorDetectionMode|null) { + this._detectionMode = detectionMode || FocusMonitorDetectionMode.IMMEDIATE; } /** @@ -326,14 +318,17 @@ 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 strategy is 'aggressive', the origin is never cleared. + * 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; - if (this._detectionStrategy === 'conservative') { + 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 131a5d09b7c4..80bcbdf48921 100644 --- a/tools/public_api_guard/cdk/a11y.d.ts +++ b/tools/public_api_guard/cdk/a11y.d.ts @@ -79,7 +79,7 @@ export declare class EventListenerFocusTrapInertStrategy implements FocusTrapIne preventFocus(focusTrap: ConfigurableFocusTrap): void; } -export declare const FOCUS_MONITOR_DETECTION_STRATEGY: InjectionToken; +export declare const FOCUS_MONITOR_DETECTION_MODE: InjectionToken; export declare const FOCUS_TRAP_INERT_STRATEGY: InjectionToken; @@ -94,7 +94,7 @@ export declare class FocusKeyManager extends ListKeyManager, origin: FocusOrigin, options?: FocusOptions): void; @@ -107,7 +107,10 @@ export declare class FocusMonitor implements OnDestroy { static ɵprov: i0.ɵɵInjectableDef; } -export declare type FocusMonitorDetectionStrategy = 'conservative' | 'aggressive'; +export declare const enum FocusMonitorDetectionMode { + IMMEDIATE = 0, + EVENTUAL = 1 +} export interface FocusOptions { preventScroll?: boolean; From a75993f1529ad81c532a1f3087d8e982431a1e63 Mon Sep 17 00:00:00 2001 From: Nick Walther Date: Tue, 10 Mar 2020 23:17:57 -0700 Subject: [PATCH 5/9] Add unit tests --- .../a11y/focus-monitor/focus-monitor.spec.ts | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts b/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts index 24a0087790ff..2dccee0cd5f8 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_DETECTION_MODE, + TOUCH_BUFFER_MS, +} from './focus-monitor'; describe('FocusMonitor', () => { @@ -239,6 +245,16 @@ 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'); + })); }); @@ -450,6 +466,49 @@ describe('cdkMonitorFocus', () => { }); }); +describe('FocusMonitor', () => { + 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_DETECTION_MODE, + useValue: 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('FocusMonitor observable stream', () => { let fixture: ComponentFixture; let buttonElement: HTMLElement; From 431fe442136f869e4ab7ba7107710cc25a8c8642 Mon Sep 17 00:00:00 2001 From: Nick Walther Date: Tue, 10 Mar 2020 23:36:04 -0700 Subject: [PATCH 6/9] Minor renames --- .../a11y/focus-monitor/focus-monitor.spec.ts | 86 +++++++++---------- src/cdk/a11y/focus-monitor/focus-monitor.ts | 2 +- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts b/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts index 2dccee0cd5f8..dc9f50f7513e 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts @@ -257,6 +257,49 @@ describe('FocusMonitor', () => { })); }); +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_DETECTION_MODE, + useValue: 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(() => { @@ -466,49 +509,6 @@ describe('cdkMonitorFocus', () => { }); }); -describe('FocusMonitor', () => { - 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_DETECTION_MODE, - useValue: 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('FocusMonitor observable stream', () => { let fixture: ComponentFixture; let buttonElement: HTMLElement; diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index 374528ee8e9b..95eb00546cc3 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -109,7 +109,7 @@ export class FocusMonitor implements OnDestroy { * The specified detection mode, used for attributing the origin of a focus * event. */ - private _detectionMode: FocusMonitorDetectionMode; + private readonly _detectionMode: FocusMonitorDetectionMode; /** * Event listener for `keydown` events on the document. From ff4c7ea5c874bc7c5e3cfc44ec44aa54327dfa5e Mon Sep 17 00:00:00 2001 From: Nick Walther Date: Wed, 11 Mar 2020 10:11:13 -0700 Subject: [PATCH 7/9] Add a wrapping config object --- .../a11y/focus-monitor/focus-monitor.spec.ts | 8 +++++--- src/cdk/a11y/focus-monitor/focus-monitor.ts | 18 +++++++++++------- tools/public_api_guard/cdk/a11y.d.ts | 8 ++++++-- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts b/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts index dc9f50f7513e..35892f15b6f9 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts @@ -13,7 +13,7 @@ import { FocusMonitor, FocusMonitorDetectionMode, FocusOrigin, - FOCUS_MONITOR_DETECTION_MODE, + FOCUS_MONITOR_DEFAULT_OPTIONS, TOUCH_BUFFER_MS, } from './focus-monitor'; @@ -271,8 +271,10 @@ describe('FocusMonitor with "eventual" detection', () => { ], providers: [ { - provide: FOCUS_MONITOR_DETECTION_MODE, - useValue: FocusMonitorDetectionMode.EVENTUAL, + provide: FOCUS_MONITOR_DEFAULT_OPTIONS, + useValue: { + detectionMode: FocusMonitorDetectionMode.EVENTUAL, + }, }, ], }).compileComponents(); diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index 95eb00546cc3..838ed745e5e2 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -54,10 +54,14 @@ export const enum FocusMonitorDetectionMode { EVENTUAL } -/** InjectionToken that can be used to specify the detection mode. */ -export const FOCUS_MONITOR_DETECTION_MODE = - new InjectionToken( - 'cdk-focus-monitor-detection-mode'); +/** 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, @@ -165,9 +169,9 @@ export class FocusMonitor implements OnDestroy { constructor( private _ngZone: NgZone, private _platform: Platform, - @Optional() @Inject(FOCUS_MONITOR_DETECTION_MODE) detectionMode: - FocusMonitorDetectionMode|null) { - this._detectionMode = detectionMode || FocusMonitorDetectionMode.IMMEDIATE; + @Optional() @Inject(FOCUS_MONITOR_DEFAULT_OPTIONS) options: + FocusMonitorOptions|null) { + this._detectionMode = options?.detectionMode || FocusMonitorDetectionMode.IMMEDIATE; } /** diff --git a/tools/public_api_guard/cdk/a11y.d.ts b/tools/public_api_guard/cdk/a11y.d.ts index 80bcbdf48921..a1962ed5c0ca 100644 --- a/tools/public_api_guard/cdk/a11y.d.ts +++ b/tools/public_api_guard/cdk/a11y.d.ts @@ -79,7 +79,7 @@ export declare class EventListenerFocusTrapInertStrategy implements FocusTrapIne preventFocus(focusTrap: ConfigurableFocusTrap): void; } -export declare const FOCUS_MONITOR_DETECTION_MODE: InjectionToken; +export declare const FOCUS_MONITOR_DEFAULT_OPTIONS: InjectionToken; export declare const FOCUS_TRAP_INERT_STRATEGY: InjectionToken; @@ -94,7 +94,7 @@ export declare class FocusKeyManager extends ListKeyManager, origin: FocusOrigin, options?: FocusOptions): void; @@ -112,6 +112,10 @@ export declare const enum FocusMonitorDetectionMode { EVENTUAL = 1 } +export interface FocusMonitorOptions { + detectionMode?: FocusMonitorDetectionMode; +} + export interface FocusOptions { preventScroll?: boolean; } From 29526a7547036e9ab11605776830cc4d736a4353 Mon Sep 17 00:00:00 2001 From: Nick Walther Date: Mon, 16 Mar 2020 17:45:11 -0700 Subject: [PATCH 8/9] Fix build from merge. --- src/cdk/a11y/focus-monitor/focus-monitor.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index 2e8c30204754..41a8c9dc56f2 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -18,8 +18,6 @@ import { OnDestroy, Optional, Output, - Optional, - Inject, } from '@angular/core'; import {Observable, of as observableOf, Subject, Subscription} from 'rxjs'; import {coerceElement} from '@angular/cdk/coercion'; @@ -177,7 +175,7 @@ export class FocusMonitor implements OnDestroy { private _ngZone: NgZone, private _platform: Platform, /** @breaking-change 11.0.0 make document required */ - @Optional() @Inject(DOCUMENT) document?: any, + @Optional() @Inject(DOCUMENT) document: any|null, @Optional() @Inject(FOCUS_MONITOR_DEFAULT_OPTIONS) options: FocusMonitorOptions|null) { this._document = document; From 06b876bb8f2543fcf32c315d36e203d87e061e57 Mon Sep 17 00:00:00 2001 From: Nick Walther Date: Mon, 16 Mar 2020 17:49:30 -0700 Subject: [PATCH 9/9] Accept declarations file change. --- tools/public_api_guard/cdk/a11y.d.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/public_api_guard/cdk/a11y.d.ts b/tools/public_api_guard/cdk/a11y.d.ts index 880861a3926c..96f934a81bbc 100644 --- a/tools/public_api_guard/cdk/a11y.d.ts +++ b/tools/public_api_guard/cdk/a11y.d.ts @@ -95,7 +95,8 @@ export declare class FocusKeyManager extends ListKeyManager, origin: FocusOrigin, options?: FocusOptions): void;