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-classes): expose focus origin changes through observable #2974

Merged
merged 5 commits into from
Feb 9, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really want to emit null on blur? I didn't see any test for it yet.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do think we want this, because I want to later expand this to apply the classes if the element or one of its children is focused, and it would be nice to have an easy way for developers to know when focus leaves the subtree. I'll add tests though

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. Just was wondering because of the tests.

}
}

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