Skip to content

Commit

Permalink
feat(focus-monitor): Add eventual detection mode option to foc… (#18684)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
nickwalther committed Mar 17, 2020
1 parent 511f076 commit b3a2c56
Show file tree
Hide file tree
Showing 3 changed files with 125 additions and 14 deletions.
63 changes: 62 additions & 1 deletion src/cdk/a11y/focus-monitor/focus-monitor.spec.ts
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<PlainButton>;
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(() => {
Expand Down
63 changes: 51 additions & 12 deletions src/cdk/a11y/focus-monitor/focus-monitor.ts
Expand Up @@ -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';
Expand All @@ -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<FocusMonitorOptions>('cdk-focus-monitor-default-options');

type MonitoredElementInfo = {
unlisten: Function,
checkChildren: boolean,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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);
}
});
}

Expand Down
13 changes: 12 additions & 1 deletion tools/public_api_guard/cdk/a11y.d.ts
Expand Up @@ -79,6 +79,8 @@ export declare class EventListenerFocusTrapInertStrategy implements FocusTrapIne
preventFocus(focusTrap: ConfigurableFocusTrap): void;
}

export declare const FOCUS_MONITOR_DEFAULT_OPTIONS: InjectionToken<FocusMonitorOptions>;

export declare const FOCUS_TRAP_INERT_STRATEGY: InjectionToken<FocusTrapInertStrategy>;

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

export declare const enum FocusMonitorDetectionMode {
IMMEDIATE = 0,
EVENTUAL = 1
}

export interface FocusMonitorOptions {
detectionMode?: FocusMonitorDetectionMode;
}

export interface FocusOptions {
preventScroll?: boolean;
}
Expand Down

0 comments on commit b3a2c56

Please sign in to comment.