Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(focus-monitor): Add eventual detection mode option to focus monitor #18684

Merged
merged 10 commits into from
Mar 17, 2020
63 changes: 62 additions & 1 deletion src/cdk/a11y/focus-monitor/focus-monitor.spec.ts
Original file line number Diff line number Diff line change
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
52 changes: 47 additions & 5 deletions src/cdk/a11y/focus-monitor/focus-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import {
Directive,
ElementRef,
EventEmitter,
Inject,
Injectable,
InjectionToken,
NgZone,
OnDestroy,
Optional,
Output,
} from '@angular/core';
import {Observable, of as observableOf, Subject, Subscription} from 'rxjs';
Expand All @@ -36,6 +39,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 @@ -82,6 +109,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 @@ -134,7 +167,12 @@ 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_DEFAULT_OPTIONS) options:
FocusMonitorOptions|null) {
this._detectionMode = options?.detectionMode || FocusMonitorDetectionMode.IMMEDIATE;
}

/**
* Monitors focus on an element and applies appropriate CSS classes.
Expand Down Expand Up @@ -284,15 +322,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
Original file line number Diff line number Diff line change
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 @@ -92,7 +94,7 @@ export declare class FocusKeyManager<T> extends ListKeyManager<FocusableOption &
}

export declare class FocusMonitor implements OnDestroy {
constructor(_ngZone: NgZone, _platform: Platform);
constructor(_ngZone: NgZone, _platform: Platform, 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 @@ -105,6 +107,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