Skip to content

Commit

Permalink
feat(focus-classes): expose focus origin changes through observable (#…
Browse files Browse the repository at this point in the history
…2974)

* return observable from registerElementForFocusClasses

* added tests

* restore focus origin on window focus

* addressed comments

* rebased
  • Loading branch information
mmalerba authored and tinayuangao committed Feb 9, 2017
1 parent b91964a commit d4ba648
Show file tree
Hide file tree
Showing 2 changed files with 114 additions and 13 deletions.
75 changes: 70 additions & 5 deletions src/lib/core/style/focus-classes.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import {async, ComponentFixture, TestBed, inject} from '@angular/core/testing';
import {Component, Renderer} from '@angular/core';
import {Component, Renderer, ViewChild} from '@angular/core';
import {StyleModule} from './index';
import {By} from '@angular/platform-browser';
import {TAB} from '../keyboard/keycodes';
import {FocusOriginMonitor} from './focus-classes';
import {FocusOriginMonitor, FocusOrigin, CdkFocusClasses} from './focus-classes';

describe('FocusOriginMonitor', () => {
let fixture: ComponentFixture<PlainButton>;
let buttonElement: HTMLElement;
let buttonRenderer: Renderer;
let focusOriginMonitor: FocusOriginMonitor;
let changeHandler: (origin: FocusOrigin) => void;

beforeEach(async(() => {
TestBed.configureTestingModule({
Expand All @@ -30,7 +31,9 @@ describe('FocusOriginMonitor', () => {
buttonRenderer = fixture.componentInstance.renderer;
focusOriginMonitor = fom;

focusOriginMonitor.registerElementForFocusClasses(buttonElement, buttonRenderer);
changeHandler = jasmine.createSpy('focus origin change handler');
focusOriginMonitor.registerElementForFocusClasses(buttonElement, buttonRenderer)
.subscribe(changeHandler);

// Patch the element focus to properly emit focus events when the browser is blurred.
patchElementFocus(buttonElement);
Expand All @@ -45,6 +48,7 @@ describe('FocusOriginMonitor', () => {

expect(buttonElement.classList.contains('cdk-focused'))
.toBe(true, 'button should have cdk-focused class');
expect(changeHandler).toHaveBeenCalledTimes(1);
}, 0);
}));

Expand All @@ -63,6 +67,7 @@ describe('FocusOriginMonitor', () => {
.toBe(true, 'button should have cdk-focused class');
expect(buttonElement.classList.contains('cdk-keyboard-focused'))
.toBe(true, 'button should have cdk-keyboard-focused class');
expect(changeHandler).toHaveBeenCalledWith('keyboard');
}, 0);
}));

Expand All @@ -81,6 +86,7 @@ describe('FocusOriginMonitor', () => {
.toBe(true, 'button should have cdk-focused class');
expect(buttonElement.classList.contains('cdk-mouse-focused'))
.toBe(true, 'button should have cdk-mouse-focused class');
expect(changeHandler).toHaveBeenCalledWith('mouse');
}, 0);
}));

Expand All @@ -98,6 +104,7 @@ describe('FocusOriginMonitor', () => {
.toBe(true, 'button should have cdk-focused class');
expect(buttonElement.classList.contains('cdk-program-focused'))
.toBe(true, 'button should have cdk-program-focused class');
expect(changeHandler).toHaveBeenCalledWith('program');
}, 0);
}));

Expand All @@ -114,6 +121,7 @@ describe('FocusOriginMonitor', () => {
.toBe(true, 'button should have cdk-focused class');
expect(buttonElement.classList.contains('cdk-keyboard-focused'))
.toBe(true, 'button should have cdk-keyboard-focused class');
expect(changeHandler).toHaveBeenCalledWith('keyboard');
}, 0);
}));

Expand All @@ -130,6 +138,7 @@ describe('FocusOriginMonitor', () => {
.toBe(true, 'button should have cdk-focused class');
expect(buttonElement.classList.contains('cdk-mouse-focused'))
.toBe(true, 'button should have cdk-mouse-focused class');
expect(changeHandler).toHaveBeenCalledWith('mouse');
}, 0);
}));

Expand All @@ -146,6 +155,27 @@ describe('FocusOriginMonitor', () => {
.toBe(true, 'button should have cdk-focused class');
expect(buttonElement.classList.contains('cdk-program-focused'))
.toBe(true, 'button should have cdk-program-focused class');
expect(changeHandler).toHaveBeenCalledWith('program');
}, 0);
}));

it('should remove focus classes on blur', async(() => {
buttonElement.focus();
fixture.detectChanges();

setTimeout(() => {
fixture.detectChanges();

expect(buttonElement.classList.length)
.toBe(2, 'button should have exactly 2 focus classes');
expect(changeHandler).toHaveBeenCalledWith('program');

buttonElement.blur();
fixture.detectChanges();

expect(buttonElement.classList.length)
.toBe(0, 'button should not have any focus classes');
expect(changeHandler).toHaveBeenCalledWith(null);
}, 0);
}));
});
Expand All @@ -154,6 +184,7 @@ describe('FocusOriginMonitor', () => {
describe('cdkFocusClasses', () => {
let fixture: ComponentFixture<ButtonWithFocusClasses>;
let buttonElement: HTMLElement;
let changeHandler: (origin: FocusOrigin) => void;

beforeEach(async(() => {
TestBed.configureTestingModule({
Expand All @@ -170,6 +201,8 @@ describe('cdkFocusClasses', () => {
fixture = TestBed.createComponent(ButtonWithFocusClasses);
fixture.detectChanges();

changeHandler = jasmine.createSpy('focus origin change handler');
fixture.componentInstance.cdkFocusClasses.changes.subscribe(changeHandler);
buttonElement = fixture.debugElement.query(By.css('button')).nativeElement;

// Patch the element focus to properly emit focus events when the browser is blurred.
Expand All @@ -195,6 +228,7 @@ describe('cdkFocusClasses', () => {
.toBe(true, 'button should have cdk-focused class');
expect(buttonElement.classList.contains('cdk-keyboard-focused'))
.toBe(true, 'button should have cdk-keyboard-focused class');
expect(changeHandler).toHaveBeenCalledWith('keyboard');
}, 0);
}));

Expand All @@ -213,6 +247,7 @@ describe('cdkFocusClasses', () => {
.toBe(true, 'button should have cdk-focused class');
expect(buttonElement.classList.contains('cdk-mouse-focused'))
.toBe(true, 'button should have cdk-mouse-focused class');
expect(changeHandler).toHaveBeenCalledWith('mouse');
}, 0);
}));

Expand All @@ -230,6 +265,27 @@ describe('cdkFocusClasses', () => {
.toBe(true, 'button should have cdk-focused class');
expect(buttonElement.classList.contains('cdk-program-focused'))
.toBe(true, 'button should have cdk-program-focused class');
expect(changeHandler).toHaveBeenCalledWith('program');
}, 0);
}));

it('should remove focus classes on blur', async(() => {
buttonElement.focus();
fixture.detectChanges();

setTimeout(() => {
fixture.detectChanges();

expect(buttonElement.classList.length)
.toBe(2, 'button should have exactly 2 focus classes');
expect(changeHandler).toHaveBeenCalledWith('program');

buttonElement.blur();
fixture.detectChanges();

expect(buttonElement.classList.length)
.toBe(0, 'button should not have any focus classes');
expect(changeHandler).toHaveBeenCalledWith(null);
}, 0);
}));
});
Expand All @@ -242,7 +298,9 @@ class PlainButton {


@Component({template: `<button cdkFocusClasses>focus me!</button>`})
class ButtonWithFocusClasses {}
class ButtonWithFocusClasses {
@ViewChild(CdkFocusClasses) cdkFocusClasses: CdkFocusClasses;
}

// TODO(devversion): move helper functions into a global utility file. See #2902

Expand Down Expand Up @@ -273,14 +331,21 @@ function dispatchFocusEvent(element: Node, type = 'focus') {
element.dispatchEvent(event);
}

/** Patches an elements focus method to properly emit focus events when the browser is blurred. */
/**
* Patches an elements focus and blur methods to properly emit focus events when the browser is
* blurred.
*/
function patchElementFocus(element: HTMLElement) {
// On Saucelabs, browsers will run simultaneously and therefore can't focus all browser windows
// at the same time. This is problematic when testing focus states. Chrome and Firefox
// only fire FocusEvents when the window is focused. This issue also appears locally.
let _nativeButtonFocus = element.focus.bind(element);
let _nativeButtonBlur = element.blur.bind(element);

element.focus = () => {
document.hasFocus() ? _nativeButtonFocus() : dispatchFocusEvent(element);
};
element.blur = () => {
document.hasFocus() ? _nativeButtonBlur() : dispatchFocusEvent(element, 'blur');
};
}
52 changes: 44 additions & 8 deletions src/lib/core/style/focus-classes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {Directive, Injectable, Optional, SkipSelf, Renderer, ElementRef} from '@angular/core';
import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';


export type FocusOrigin = 'mouse' | 'keyboard' | 'program';
Expand All @@ -10,6 +12,12 @@ export class FocusOriginMonitor {
/** The focus origin that the next focus event is a result of. */
private _origin: FocusOrigin = null;

/** The FocusOrigin of the last focus event tracked by the FocusOriginMonitor. */
private _lastFocusOrigin: FocusOrigin;

/** Whether the window has just been focused. */
private _windowFocused = false;

constructor() {
// Listen to keydown and mousedown in the capture phase so we can detect them even if the user
// stops propagation.
Expand All @@ -18,12 +26,21 @@ export class FocusOriginMonitor {
'keydown', () => this._setOriginForCurrentEventQueue('keyboard'), true);
document.addEventListener(
'mousedown', () => this._setOriginForCurrentEventQueue('mouse'), true);

// Make a note of when the window regains focus, so we can restore the origin info for the
// focused element.
window.addEventListener('focus', () => {
this._windowFocused = true;
setTimeout(() => this._windowFocused = false, 0);
});
}

/** Register an element to receive focus classes. */
registerElementForFocusClasses(element: Element, renderer: Renderer) {
renderer.listen(element, 'focus', () => this._onFocus(element, renderer));
renderer.listen(element, 'blur', () => this._onBlur(element, renderer));
registerElementForFocusClasses(element: Element, renderer: Renderer): Observable<FocusOrigin> {
let subject = new Subject<FocusOrigin>();
renderer.listen(element, 'focus', () => this._onFocus(element, renderer, subject));
renderer.listen(element, 'blur', () => this._onBlur(element, renderer, subject));
return subject.asObservable();
}

/** Focuses the element via the specified focus origin. */
Expand All @@ -39,21 +56,37 @@ export class FocusOriginMonitor {
}

/** Handles focus events on a registered element. */
private _onFocus(element: Element, renderer: Renderer) {
private _onFocus(element: Element, renderer: Renderer, subject: Subject<FocusOrigin>) {
// If we couldn't detect a cause for the focus event, it's due to one of two reasons:
// 1) The window has just regained focus, in which case we want to restore the focused state of
// the element from before the window blurred.
// 2) The element was programmatically focused, in which case we should mark the origin as
// 'program'.
if (!this._origin) {
if (this._windowFocused && this._lastFocusOrigin) {
this._origin = this._lastFocusOrigin;
} else {
this._origin = 'program';
}
}

renderer.setElementClass(element, 'cdk-focused', true);
renderer.setElementClass(element, 'cdk-keyboard-focused', this._origin == 'keyboard');
renderer.setElementClass(element, 'cdk-mouse-focused', this._origin == 'mouse');
renderer.setElementClass(element, 'cdk-program-focused',
!this._origin || this._origin == 'program');
renderer.setElementClass(element, 'cdk-program-focused', this._origin == 'program');

subject.next(this._origin);
this._lastFocusOrigin = this._origin;
this._origin = null;
}

/** Handles blur events on a registered element. */
private _onBlur(element: Element, renderer: Renderer) {
private _onBlur(element: Element, renderer: Renderer, subject: Subject<FocusOrigin>) {
renderer.setElementClass(element, 'cdk-focused', false);
renderer.setElementClass(element, 'cdk-keyboard-focused', false);
renderer.setElementClass(element, 'cdk-mouse-focused', false);
renderer.setElementClass(element, 'cdk-program-focused', false);
subject.next(null);
}
}

Expand All @@ -66,8 +99,11 @@ export class FocusOriginMonitor {
selector: '[cdkFocusClasses]',
})
export class CdkFocusClasses {
changes: Observable<FocusOrigin>;

constructor(elementRef: ElementRef, focusOriginMonitor: FocusOriginMonitor, renderer: Renderer) {
focusOriginMonitor.registerElementForFocusClasses(elementRef.nativeElement, renderer);
this.changes =
focusOriginMonitor.registerElementForFocusClasses(elementRef.nativeElement, renderer);
}
}

Expand Down

0 comments on commit d4ba648

Please sign in to comment.