feat(focus-classes): expose focus origin changes through observable (#2974)

* return observable from registerElementForFocusClasses

* added tests

* restore focus origin on window focus

* addressed comments

* rebased
This commit is contained in:
mmalerba
2017-02-09 10:35:17 -08:00
committed by tinayuangao
parent b91964a00a
commit d4ba648c91
2 changed files with 114 additions and 13 deletions
+70 -5
View File
@@ -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({
@@ -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);
@@ -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);
}));
@@ -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);
}));
@@ -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);
}));
@@ -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);
}));
@@ -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);
}));
@@ -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);
}));
@@ -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);
}));
});
@@ -154,6 +184,7 @@ describe('FocusOriginMonitor', () => {
describe('cdkFocusClasses', () => {
let fixture: ComponentFixture<ButtonWithFocusClasses>;
let buttonElement: HTMLElement;
let changeHandler: (origin: FocusOrigin) => void;
beforeEach(async(() => {
TestBed.configureTestingModule({
@@ -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.
@@ -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);
}));
@@ -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);
}));
@@ -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);
}));
});
@@ -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
@@ -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');
};
}
+44 -8
View File
@@ -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';
@@ -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.
@@ -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. */
@@ -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);
}
}
@@ -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);
}
}