Skip to content

Commit 4e28279

Browse files
committed
Add a detection window option to FocusMonitor to allow users to increase the timeout for attributing previous user event types as focus event origins.
1 parent 9343d08 commit 4e28279

File tree

2 files changed

+114
-17
lines changed

2 files changed

+114
-17
lines changed

src/cdk/a11y/focus-monitor/focus-monitor.ts

Lines changed: 104 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ import {
1212
ElementRef,
1313
EventEmitter,
1414
Injectable,
15+
Inject,
16+
InjectionToken,
1517
NgZone,
1618
OnDestroy,
19+
Optional,
1720
Output,
1821
} from '@angular/core';
1922
import {Observable, of as observableOf, Subject, Subscription} from 'rxjs';
@@ -36,9 +39,38 @@ export interface FocusOptions {
3639
preventScroll?: boolean;
3740
}
3841

42+
/** A set of options to apply to calls of FocusMonitor.monitor(). */
43+
export interface FocusMonitorOptions {
44+
/** Whether to count the element as focused when its children are focused. */
45+
checkChildren: boolean;
46+
47+
/**
48+
* The time window (in milliseconds) during which a mousedown/keydown/touchstart event is
49+
* considered to be "in play" for the pupose of assigning a focus event's origin. Set to
50+
* 'indefinite' to always attribute a focus event's origin to the last corresponding event's
51+
* type, no matter how long ago it occured.
52+
*/
53+
detectionWindow: number|'indefinite';
54+
}
55+
56+
/** Default options used when a dependency injected options object is not provided. */
57+
const FOCUS_MONITOR_DEFAULT_OPTIONS: FocusMonitorOptions = {
58+
checkChildren: false,
59+
// A default of 1ms is used because Firefox seems to focus *one* tick after the interaction
60+
// event fired, causing the focus origin to be misinterpreted. To ensure the focus origin
61+
// is always correct, the focus origin should be determined at least starting from the
62+
// next tick.
63+
detectionWindow: 1,
64+
};
65+
66+
/** InjectionToken that can be used to specify the global FocusMonitorOptions. */
67+
export const FOCUS_MONITOR_GLOBAL_OPTIONS =
68+
new InjectionToken<Partial<FocusMonitorOptions>>('cdk-focus-monitor-global-options');
69+
3970
type MonitoredElementInfo = {
4071
unlisten: Function,
4172
checkChildren: boolean,
73+
detectionWindow: number|'indefinite',
4274
subject: Subject<FocusOrigin>
4375
};
4476

@@ -58,6 +90,9 @@ export class FocusMonitor implements OnDestroy {
5890
/** The focus origin that the next focus event is a result of. */
5991
private _origin: FocusOrigin = null;
6092

93+
/** Timestamp that the current FocusOrigin was set at, in epoch milliseconds. */
94+
private _originTimestamp = 0;
95+
6196
/** The FocusOrigin of the last focus event tracked by the FocusMonitor. */
6297
private _lastFocusOrigin: FocusOrigin;
6398

@@ -73,15 +108,15 @@ export class FocusMonitor implements OnDestroy {
73108
/** The timeout id of the window focus timeout. */
74109
private _windowFocusTimeoutId: number;
75110

76-
/** The timeout id of the origin clearing timeout. */
77-
private _originTimeoutId: number;
78-
79111
/** Map of elements being monitored to their info. */
80112
private _elementInfo = new Map<HTMLElement, MonitoredElementInfo>();
81113

82114
/** The number of elements currently being monitored. */
83115
private _monitoredElementCount = 0;
84116

117+
/** Options to apply to all calls to monitor(). */
118+
private _focusMonitorOptions: FocusMonitorOptions;
119+
85120
/**
86121
* Event listener for `keydown` events on the document.
87122
* 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 {
134169
this._windowFocusTimeoutId = setTimeout(() => this._windowFocused = false);
135170
}
136171

137-
constructor(private _ngZone: NgZone, private _platform: Platform) {}
172+
constructor(
173+
private _ngZone: NgZone, private _platform: Platform,
174+
@Optional() @Inject(FOCUS_MONITOR_GLOBAL_OPTIONS) globalFocusMonitorOptions?:
175+
Partial<FocusMonitorOptions>) {
176+
this._focusMonitorOptions =
177+
coerceFocusMonitorOptions(globalFocusMonitorOptions, FOCUS_MONITOR_DEFAULT_OPTIONS);
178+
}
138179

139180
/**
140181
* Monitors focus on an element and applies appropriate CSS classes.
@@ -154,26 +195,47 @@ export class FocusMonitor implements OnDestroy {
154195
*/
155196
monitor(element: ElementRef<HTMLElement>, checkChildren?: boolean): Observable<FocusOrigin>;
156197

157-
monitor(element: HTMLElement | ElementRef<HTMLElement>,
158-
checkChildren: boolean = false): Observable<FocusOrigin> {
198+
/**
199+
* Monitors focus on an element and applies appropriate CSS classes.
200+
* @param element The element to monitor
201+
* @param options Options to use for focus events on the provided element.
202+
* @returns An observable that emits when the focus state of the element changes.
203+
* When the element is blurred, null will be emitted.
204+
*/
205+
monitor(element: HTMLElement, options?: FocusMonitorOptions): Observable<FocusOrigin>;
206+
207+
/**
208+
* Monitors focus on an element and applies appropriate CSS classes.
209+
* @param element The element to monitor
210+
* @param options Options to use for focus events on the provided element
211+
* @returns An observable that emits when the focus state of the element changes.
212+
* When the element is blurred, null will be emitted.
213+
*/
214+
monitor(element: ElementRef<HTMLElement>, options?: FocusMonitorOptions): Observable<FocusOrigin>;
215+
216+
monitor(element: HTMLElement|ElementRef<HTMLElement>, options?: FocusMonitorOptions|boolean):
217+
Observable<FocusOrigin> {
159218
// Do nothing if we're not on the browser platform.
160219
if (!this._platform.isBrowser) {
161220
return observableOf(null);
162221
}
163222

223+
options = coerceFocusMonitorOptions(options, this._focusMonitorOptions);
224+
164225
const nativeElement = coerceElement(element);
165226

166227
// Check if we're already monitoring this element.
167228
if (this._elementInfo.has(nativeElement)) {
168229
let cachedInfo = this._elementInfo.get(nativeElement);
169-
cachedInfo!.checkChildren = checkChildren;
230+
cachedInfo!.checkChildren = options.checkChildren;
170231
return cachedInfo!.subject.asObservable();
171232
}
172233

173234
// Create monitored element info.
174235
let info: MonitoredElementInfo = {
175236
unlisten: () => {},
176-
checkChildren: checkChildren,
237+
checkChildren: options.checkChildren,
238+
detectionWindow: options.detectionWindow,
177239
subject: new Subject<FocusOrigin>()
178240
};
179241
this._elementInfo.set(nativeElement, info);
@@ -241,7 +303,6 @@ export class FocusMonitor implements OnDestroy {
241303
focusVia(element: HTMLElement | ElementRef<HTMLElement>,
242304
origin: FocusOrigin,
243305
options?: FocusOptions): void {
244-
245306
const nativeElement = coerceElement(element);
246307

247308
this._setOriginForCurrentEventQueue(origin);
@@ -283,16 +344,13 @@ export class FocusMonitor implements OnDestroy {
283344
}
284345

285346
/**
286-
* Sets the origin and schedules an async function to clear it at the end of the event queue.
347+
* Sets the origin and sets origin timestamp to now.
287348
* @param origin The origin to set.
288349
*/
289350
private _setOriginForCurrentEventQueue(origin: FocusOrigin): void {
290351
this._ngZone.runOutsideAngular(() => {
291352
this._origin = origin;
292-
// Sometimes the focus origin won't be valid in Firefox because Firefox seems to focus *one*
293-
// tick after the interaction event fired. To ensure the focus origin is always correct,
294-
// the focus origin will be determined at the beginning of the next tick.
295-
this._originTimeoutId = setTimeout(() => this._origin = null, 1);
353+
this._originTimestamp = new Date().valueOf();
296354
});
297355
}
298356

@@ -349,7 +407,7 @@ export class FocusMonitor implements OnDestroy {
349407
// 3) The element was programmatically focused, in which case we should mark the origin as
350408
// 'program'.
351409
let origin = this._origin;
352-
if (!origin) {
410+
if (!origin || this._isOriginInvalid(elementInfo.detectionWindow)) {
353411
if (this._windowFocused && this._lastFocusOrigin) {
354412
origin = this._lastFocusOrigin;
355413
} else if (this._wasCausedByTouch(event)) {
@@ -418,11 +476,41 @@ export class FocusMonitor implements OnDestroy {
418476
// Clear timeouts for all potentially pending timeouts to prevent the leaks.
419477
clearTimeout(this._windowFocusTimeoutId);
420478
clearTimeout(this._touchTimeoutId);
421-
clearTimeout(this._originTimeoutId);
422479
}
423480
}
481+
482+
/** A focus origin is invalid if it occured before (now - detectionWindow). */
483+
private _isOriginInvalid(detectionWindow: number|'indefinite'): boolean {
484+
if (detectionWindow === 'indefinite') {
485+
return false;
486+
}
487+
488+
const now = new Date().valueOf();
489+
return now - this._originTimestamp > detectionWindow;
490+
}
424491
}
425492

493+
/**
494+
* Takes a partial set of options and a complete default set and merges them. A boolean value for
495+
* options corresponds to the checkChildren parameter.
496+
*/
497+
function coerceFocusMonitorOptions(
498+
options: Partial<FocusMonitorOptions>|boolean|undefined,
499+
defaultOptions: FocusMonitorOptions): FocusMonitorOptions {
500+
if (!options) {
501+
return defaultOptions;
502+
}
503+
if (typeof options === 'boolean') {
504+
options = {checkChildren: options};
505+
}
506+
507+
return {
508+
checkChildren: options.checkChildren !== undefined ? options.checkChildren :
509+
defaultOptions.checkChildren,
510+
detectionWindow: options.detectionWindow ? options.detectionWindow :
511+
defaultOptions.detectionWindow,
512+
};
513+
}
426514

427515
/**
428516
* Directive that determines how a particular element was focused (via keyboard, mouse, touch, or

tools/public_api_guard/cdk/a11y.d.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ export declare class EventListenerFocusTrapInertStrategy implements FocusTrapIne
7979
preventFocus(focusTrap: ConfigurableFocusTrap): void;
8080
}
8181

82+
export declare const FOCUS_MONITOR_GLOBAL_OPTIONS: InjectionToken<Partial<FocusMonitorOptions>>;
83+
8284
export declare const FOCUS_TRAP_INERT_STRATEGY: InjectionToken<FocusTrapInertStrategy>;
8385

8486
export interface FocusableOption extends ListKeyManagerOption {
@@ -92,19 +94,26 @@ export declare class FocusKeyManager<T> extends ListKeyManager<FocusableOption &
9294
}
9395

9496
export declare class FocusMonitor implements OnDestroy {
95-
constructor(_ngZone: NgZone, _platform: Platform);
97+
constructor(_ngZone: NgZone, _platform: Platform, globalFocusMonitorOptions?: Partial<FocusMonitorOptions>);
9698
_onBlur(event: FocusEvent, element: HTMLElement): void;
9799
focusVia(element: HTMLElement, origin: FocusOrigin, options?: FocusOptions): void;
98100
focusVia(element: ElementRef<HTMLElement>, origin: FocusOrigin, options?: FocusOptions): void;
99101
monitor(element: HTMLElement, checkChildren?: boolean): Observable<FocusOrigin>;
100102
monitor(element: ElementRef<HTMLElement>, checkChildren?: boolean): Observable<FocusOrigin>;
103+
monitor(element: HTMLElement, options?: FocusMonitorOptions): Observable<FocusOrigin>;
104+
monitor(element: ElementRef<HTMLElement>, options?: FocusMonitorOptions): Observable<FocusOrigin>;
101105
ngOnDestroy(): void;
102106
stopMonitoring(element: HTMLElement): void;
103107
stopMonitoring(element: ElementRef<HTMLElement>): void;
104108
static ɵfac: i0.ɵɵFactoryDef<FocusMonitor>;
105109
static ɵprov: i0.ɵɵInjectableDef<FocusMonitor>;
106110
}
107111

112+
export interface FocusMonitorOptions {
113+
checkChildren: boolean;
114+
detectionWindow: number | 'indefinite';
115+
}
116+
108117
export interface FocusOptions {
109118
preventScroll?: boolean;
110119
}

0 commit comments

Comments
 (0)