Skip to content

Commit b3a2c56

Browse files
authored
feat(focus-monitor): Add eventual detection mode option to foc… (#18684)
* 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.
1 parent 511f076 commit b3a2c56

File tree

3 files changed

+125
-14
lines changed

3 files changed

+125
-14
lines changed

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

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ import {Component, NgZone} from '@angular/core';
99
import {ComponentFixture, fakeAsync, flush, inject, TestBed, tick} from '@angular/core/testing';
1010
import {By} from '@angular/platform-browser';
1111
import {A11yModule} from '../index';
12-
import {FocusMonitor, FocusOrigin, TOUCH_BUFFER_MS} from './focus-monitor';
12+
import {
13+
FocusMonitor,
14+
FocusMonitorDetectionMode,
15+
FocusOrigin,
16+
FOCUS_MONITOR_DEFAULT_OPTIONS,
17+
TOUCH_BUFFER_MS,
18+
} from './focus-monitor';
1319

1420

1521
describe('FocusMonitor', () => {
@@ -239,8 +245,63 @@ describe('FocusMonitor', () => {
239245

240246
flush();
241247
}));
248+
249+
it('should clear the focus origin after one tick with "immediate" detection',
250+
fakeAsync(() => {
251+
dispatchKeyboardEvent(document, 'keydown', TAB);
252+
tick(2);
253+
buttonElement.focus();
254+
255+
// After 2 ticks, the timeout has cleared the origin. Default is 'program'.
256+
expect(changeHandler).toHaveBeenCalledWith('program');
257+
}));
242258
});
243259

260+
describe('FocusMonitor with "eventual" detection', () => {
261+
let fixture: ComponentFixture<PlainButton>;
262+
let buttonElement: HTMLElement;
263+
let focusMonitor: FocusMonitor;
264+
let changeHandler: (origin: FocusOrigin) => void;
265+
266+
beforeEach(() => {
267+
TestBed.configureTestingModule({
268+
imports: [A11yModule],
269+
declarations: [
270+
PlainButton,
271+
],
272+
providers: [
273+
{
274+
provide: FOCUS_MONITOR_DEFAULT_OPTIONS,
275+
useValue: {
276+
detectionMode: FocusMonitorDetectionMode.EVENTUAL,
277+
},
278+
},
279+
],
280+
}).compileComponents();
281+
});
282+
283+
beforeEach(inject([FocusMonitor], (fm: FocusMonitor) => {
284+
fixture = TestBed.createComponent(PlainButton);
285+
fixture.detectChanges();
286+
287+
buttonElement = fixture.debugElement.query(By.css('button'))!.nativeElement;
288+
focusMonitor = fm;
289+
290+
changeHandler = jasmine.createSpy('focus origin change handler');
291+
focusMonitor.monitor(buttonElement).subscribe(changeHandler);
292+
patchElementFocus(buttonElement);
293+
}));
294+
295+
296+
it('should not clear the focus origin, even after a few seconds', fakeAsync(() => {
297+
dispatchKeyboardEvent(document, 'keydown', TAB);
298+
tick(2000);
299+
300+
buttonElement.focus();
301+
302+
expect(changeHandler).toHaveBeenCalledWith('keyboard');
303+
}));
304+
});
244305

245306
describe('cdkMonitorFocus', () => {
246307
beforeEach(() => {

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

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ import {
1111
Directive,
1212
ElementRef,
1313
EventEmitter,
14+
Inject,
1415
Injectable,
16+
InjectionToken,
1517
NgZone,
1618
OnDestroy,
17-
Output,
1819
Optional,
19-
Inject,
20+
Output,
2021
} from '@angular/core';
2122
import {Observable, of as observableOf, Subject, Subscription} from 'rxjs';
2223
import {coerceElement} from '@angular/cdk/coercion';
@@ -39,6 +40,30 @@ export interface FocusOptions {
3940
preventScroll?: boolean;
4041
}
4142

43+
/** Detection mode used for attributing the origin of a focus event. */
44+
export const enum FocusMonitorDetectionMode {
45+
/**
46+
* Any mousedown, keydown, or touchstart event that happened in the previous
47+
* tick or the current tick will be used to assign a focus event's origin (to
48+
* either mouse, keyboard, or touch). This is the default option.
49+
*/
50+
IMMEDIATE,
51+
/**
52+
* A focus event's origin is always attributed to the last corresponding
53+
* mousedown, keydown, or touchstart event, no matter how long ago it occured.
54+
*/
55+
EVENTUAL
56+
}
57+
58+
/** Injectable service-level options for FocusMonitor. */
59+
export interface FocusMonitorOptions {
60+
detectionMode?: FocusMonitorDetectionMode;
61+
}
62+
63+
/** InjectionToken for FocusMonitorOptions. */
64+
export const FOCUS_MONITOR_DEFAULT_OPTIONS =
65+
new InjectionToken<FocusMonitorOptions>('cdk-focus-monitor-default-options');
66+
4267
type MonitoredElementInfo = {
4368
unlisten: Function,
4469
checkChildren: boolean,
@@ -85,6 +110,12 @@ export class FocusMonitor implements OnDestroy {
85110
/** The number of elements currently being monitored. */
86111
private _monitoredElementCount = 0;
87112

113+
/**
114+
* The specified detection mode, used for attributing the origin of a focus
115+
* event.
116+
*/
117+
private readonly _detectionMode: FocusMonitorDetectionMode;
118+
88119
/**
89120
* Event listener for `keydown` events on the document.
90121
* 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 {
137168
this._windowFocusTimeoutId = setTimeout(() => this._windowFocused = false);
138169
}
139170

140-
/** Used to reference correct document/window */
141-
protected _document?: Document;
171+
/** Used to reference correct document/window */
172+
protected _document?: Document;
142173

143-
constructor(private _ngZone: NgZone,
144-
private _platform: Platform,
145-
/** @breaking-change 11.0.0 make document required */
146-
@Optional() @Inject(DOCUMENT) document?: any) {
174+
constructor(
175+
private _ngZone: NgZone,
176+
private _platform: Platform,
177+
/** @breaking-change 11.0.0 make document required */
178+
@Optional() @Inject(DOCUMENT) document: any|null,
179+
@Optional() @Inject(FOCUS_MONITOR_DEFAULT_OPTIONS) options:
180+
FocusMonitorOptions|null) {
147181
this._document = document;
182+
this._detectionMode = options?.detectionMode || FocusMonitorDetectionMode.IMMEDIATE;
148183
}
149184

150185
/**
@@ -306,15 +341,19 @@ export class FocusMonitor implements OnDestroy {
306341

307342
/**
308343
* Sets the origin and schedules an async function to clear it at the end of the event queue.
344+
* If the detection mode is 'eventual', the origin is never cleared.
309345
* @param origin The origin to set.
310346
*/
311347
private _setOriginForCurrentEventQueue(origin: FocusOrigin): void {
312348
this._ngZone.runOutsideAngular(() => {
313349
this._origin = origin;
314-
// Sometimes the focus origin won't be valid in Firefox because Firefox seems to focus *one*
315-
// tick after the interaction event fired. To ensure the focus origin is always correct,
316-
// the focus origin will be determined at the beginning of the next tick.
317-
this._originTimeoutId = setTimeout(() => this._origin = null, 1);
350+
351+
if (this._detectionMode === FocusMonitorDetectionMode.IMMEDIATE) {
352+
// Sometimes the focus origin won't be valid in Firefox because Firefox seems to focus *one*
353+
// tick after the interaction event fired. To ensure the focus origin is always correct,
354+
// the focus origin will be determined at the beginning of the next tick.
355+
this._originTimeoutId = setTimeout(() => this._origin = null, 1);
356+
}
318357
});
319358
}
320359

tools/public_api_guard/cdk/a11y.d.ts

Lines changed: 12 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_DEFAULT_OPTIONS: InjectionToken<FocusMonitorOptions>;
83+
8284
export declare const FOCUS_TRAP_INERT_STRATEGY: InjectionToken<FocusTrapInertStrategy>;
8385

8486
export interface FocusableOption extends ListKeyManagerOption {
@@ -94,7 +96,7 @@ export declare class FocusKeyManager<T> extends ListKeyManager<FocusableOption &
9496
export declare class FocusMonitor implements OnDestroy {
9597
protected _document?: Document;
9698
constructor(_ngZone: NgZone, _platform: Platform,
97-
document?: any);
99+
document: any | null, options: FocusMonitorOptions | null);
98100
_onBlur(event: FocusEvent, element: HTMLElement): void;
99101
focusVia(element: HTMLElement, origin: FocusOrigin, options?: FocusOptions): void;
100102
focusVia(element: ElementRef<HTMLElement>, origin: FocusOrigin, options?: FocusOptions): void;
@@ -107,6 +109,15 @@ export declare class FocusMonitor implements OnDestroy {
107109
static ɵprov: i0.ɵɵInjectableDef<FocusMonitor>;
108110
}
109111

112+
export declare const enum FocusMonitorDetectionMode {
113+
IMMEDIATE = 0,
114+
EVENTUAL = 1
115+
}
116+
117+
export interface FocusMonitorOptions {
118+
detectionMode?: FocusMonitorDetectionMode;
119+
}
120+
110121
export interface FocusOptions {
111122
preventScroll?: boolean;
112123
}

0 commit comments

Comments
 (0)