Skip to content

Commit d4ba648

Browse files
mmalerbatinayuangao
authored andcommitted
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
1 parent b91964a commit d4ba648

File tree

2 files changed

+114
-13
lines changed

2 files changed

+114
-13
lines changed

src/lib/core/style/focus-classes.spec.ts

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import {async, ComponentFixture, TestBed, inject} from '@angular/core/testing';
2-
import {Component, Renderer} from '@angular/core';
2+
import {Component, Renderer, ViewChild} from '@angular/core';
33
import {StyleModule} from './index';
44
import {By} from '@angular/platform-browser';
55
import {TAB} from '../keyboard/keycodes';
6-
import {FocusOriginMonitor} from './focus-classes';
6+
import {FocusOriginMonitor, FocusOrigin, CdkFocusClasses} from './focus-classes';
77

88
describe('FocusOriginMonitor', () => {
99
let fixture: ComponentFixture<PlainButton>;
1010
let buttonElement: HTMLElement;
1111
let buttonRenderer: Renderer;
1212
let focusOriginMonitor: FocusOriginMonitor;
13+
let changeHandler: (origin: FocusOrigin) => void;
1314

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

33-
focusOriginMonitor.registerElementForFocusClasses(buttonElement, buttonRenderer);
34+
changeHandler = jasmine.createSpy('focus origin change handler');
35+
focusOriginMonitor.registerElementForFocusClasses(buttonElement, buttonRenderer)
36+
.subscribe(changeHandler);
3437

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

4649
expect(buttonElement.classList.contains('cdk-focused'))
4750
.toBe(true, 'button should have cdk-focused class');
51+
expect(changeHandler).toHaveBeenCalledTimes(1);
4852
}, 0);
4953
}));
5054

@@ -63,6 +67,7 @@ describe('FocusOriginMonitor', () => {
6367
.toBe(true, 'button should have cdk-focused class');
6468
expect(buttonElement.classList.contains('cdk-keyboard-focused'))
6569
.toBe(true, 'button should have cdk-keyboard-focused class');
70+
expect(changeHandler).toHaveBeenCalledWith('keyboard');
6671
}, 0);
6772
}));
6873

@@ -81,6 +86,7 @@ describe('FocusOriginMonitor', () => {
8186
.toBe(true, 'button should have cdk-focused class');
8287
expect(buttonElement.classList.contains('cdk-mouse-focused'))
8388
.toBe(true, 'button should have cdk-mouse-focused class');
89+
expect(changeHandler).toHaveBeenCalledWith('mouse');
8490
}, 0);
8591
}));
8692

@@ -98,6 +104,7 @@ describe('FocusOriginMonitor', () => {
98104
.toBe(true, 'button should have cdk-focused class');
99105
expect(buttonElement.classList.contains('cdk-program-focused'))
100106
.toBe(true, 'button should have cdk-program-focused class');
107+
expect(changeHandler).toHaveBeenCalledWith('program');
101108
}, 0);
102109
}));
103110

@@ -114,6 +121,7 @@ describe('FocusOriginMonitor', () => {
114121
.toBe(true, 'button should have cdk-focused class');
115122
expect(buttonElement.classList.contains('cdk-keyboard-focused'))
116123
.toBe(true, 'button should have cdk-keyboard-focused class');
124+
expect(changeHandler).toHaveBeenCalledWith('keyboard');
117125
}, 0);
118126
}));
119127

@@ -130,6 +138,7 @@ describe('FocusOriginMonitor', () => {
130138
.toBe(true, 'button should have cdk-focused class');
131139
expect(buttonElement.classList.contains('cdk-mouse-focused'))
132140
.toBe(true, 'button should have cdk-mouse-focused class');
141+
expect(changeHandler).toHaveBeenCalledWith('mouse');
133142
}, 0);
134143
}));
135144

@@ -146,6 +155,27 @@ describe('FocusOriginMonitor', () => {
146155
.toBe(true, 'button should have cdk-focused class');
147156
expect(buttonElement.classList.contains('cdk-program-focused'))
148157
.toBe(true, 'button should have cdk-program-focused class');
158+
expect(changeHandler).toHaveBeenCalledWith('program');
159+
}, 0);
160+
}));
161+
162+
it('should remove focus classes on blur', async(() => {
163+
buttonElement.focus();
164+
fixture.detectChanges();
165+
166+
setTimeout(() => {
167+
fixture.detectChanges();
168+
169+
expect(buttonElement.classList.length)
170+
.toBe(2, 'button should have exactly 2 focus classes');
171+
expect(changeHandler).toHaveBeenCalledWith('program');
172+
173+
buttonElement.blur();
174+
fixture.detectChanges();
175+
176+
expect(buttonElement.classList.length)
177+
.toBe(0, 'button should not have any focus classes');
178+
expect(changeHandler).toHaveBeenCalledWith(null);
149179
}, 0);
150180
}));
151181
});
@@ -154,6 +184,7 @@ describe('FocusOriginMonitor', () => {
154184
describe('cdkFocusClasses', () => {
155185
let fixture: ComponentFixture<ButtonWithFocusClasses>;
156186
let buttonElement: HTMLElement;
187+
let changeHandler: (origin: FocusOrigin) => void;
157188

158189
beforeEach(async(() => {
159190
TestBed.configureTestingModule({
@@ -170,6 +201,8 @@ describe('cdkFocusClasses', () => {
170201
fixture = TestBed.createComponent(ButtonWithFocusClasses);
171202
fixture.detectChanges();
172203

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

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

@@ -213,6 +247,7 @@ describe('cdkFocusClasses', () => {
213247
.toBe(true, 'button should have cdk-focused class');
214248
expect(buttonElement.classList.contains('cdk-mouse-focused'))
215249
.toBe(true, 'button should have cdk-mouse-focused class');
250+
expect(changeHandler).toHaveBeenCalledWith('mouse');
216251
}, 0);
217252
}));
218253

@@ -230,6 +265,27 @@ describe('cdkFocusClasses', () => {
230265
.toBe(true, 'button should have cdk-focused class');
231266
expect(buttonElement.classList.contains('cdk-program-focused'))
232267
.toBe(true, 'button should have cdk-program-focused class');
268+
expect(changeHandler).toHaveBeenCalledWith('program');
269+
}, 0);
270+
}));
271+
272+
it('should remove focus classes on blur', async(() => {
273+
buttonElement.focus();
274+
fixture.detectChanges();
275+
276+
setTimeout(() => {
277+
fixture.detectChanges();
278+
279+
expect(buttonElement.classList.length)
280+
.toBe(2, 'button should have exactly 2 focus classes');
281+
expect(changeHandler).toHaveBeenCalledWith('program');
282+
283+
buttonElement.blur();
284+
fixture.detectChanges();
285+
286+
expect(buttonElement.classList.length)
287+
.toBe(0, 'button should not have any focus classes');
288+
expect(changeHandler).toHaveBeenCalledWith(null);
233289
}, 0);
234290
}));
235291
});
@@ -242,7 +298,9 @@ class PlainButton {
242298

243299

244300
@Component({template: `<button cdkFocusClasses>focus me!</button>`})
245-
class ButtonWithFocusClasses {}
301+
class ButtonWithFocusClasses {
302+
@ViewChild(CdkFocusClasses) cdkFocusClasses: CdkFocusClasses;
303+
}
246304

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

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

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

283345
element.focus = () => {
284346
document.hasFocus() ? _nativeButtonFocus() : dispatchFocusEvent(element);
285347
};
348+
element.blur = () => {
349+
document.hasFocus() ? _nativeButtonBlur() : dispatchFocusEvent(element, 'blur');
350+
};
286351
}

src/lib/core/style/focus-classes.ts

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import {Directive, Injectable, Optional, SkipSelf, Renderer, ElementRef} from '@angular/core';
2+
import {Observable} from 'rxjs/Observable';
3+
import {Subject} from 'rxjs/Subject';
24

35

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

15+
/** The FocusOrigin of the last focus event tracked by the FocusOriginMonitor. */
16+
private _lastFocusOrigin: FocusOrigin;
17+
18+
/** Whether the window has just been focused. */
19+
private _windowFocused = false;
20+
1321
constructor() {
1422
// Listen to keydown and mousedown in the capture phase so we can detect them even if the user
1523
// stops propagation.
@@ -18,12 +26,21 @@ export class FocusOriginMonitor {
1826
'keydown', () => this._setOriginForCurrentEventQueue('keyboard'), true);
1927
document.addEventListener(
2028
'mousedown', () => this._setOriginForCurrentEventQueue('mouse'), true);
29+
30+
// Make a note of when the window regains focus, so we can restore the origin info for the
31+
// focused element.
32+
window.addEventListener('focus', () => {
33+
this._windowFocused = true;
34+
setTimeout(() => this._windowFocused = false, 0);
35+
});
2136
}
2237

2338
/** Register an element to receive focus classes. */
24-
registerElementForFocusClasses(element: Element, renderer: Renderer) {
25-
renderer.listen(element, 'focus', () => this._onFocus(element, renderer));
26-
renderer.listen(element, 'blur', () => this._onBlur(element, renderer));
39+
registerElementForFocusClasses(element: Element, renderer: Renderer): Observable<FocusOrigin> {
40+
let subject = new Subject<FocusOrigin>();
41+
renderer.listen(element, 'focus', () => this._onFocus(element, renderer, subject));
42+
renderer.listen(element, 'blur', () => this._onBlur(element, renderer, subject));
43+
return subject.asObservable();
2744
}
2845

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

4158
/** Handles focus events on a registered element. */
42-
private _onFocus(element: Element, renderer: Renderer) {
59+
private _onFocus(element: Element, renderer: Renderer, subject: Subject<FocusOrigin>) {
60+
// If we couldn't detect a cause for the focus event, it's due to one of two reasons:
61+
// 1) The window has just regained focus, in which case we want to restore the focused state of
62+
// the element from before the window blurred.
63+
// 2) The element was programmatically focused, in which case we should mark the origin as
64+
// 'program'.
65+
if (!this._origin) {
66+
if (this._windowFocused && this._lastFocusOrigin) {
67+
this._origin = this._lastFocusOrigin;
68+
} else {
69+
this._origin = 'program';
70+
}
71+
}
72+
4373
renderer.setElementClass(element, 'cdk-focused', true);
4474
renderer.setElementClass(element, 'cdk-keyboard-focused', this._origin == 'keyboard');
4575
renderer.setElementClass(element, 'cdk-mouse-focused', this._origin == 'mouse');
46-
renderer.setElementClass(element, 'cdk-program-focused',
47-
!this._origin || this._origin == 'program');
76+
renderer.setElementClass(element, 'cdk-program-focused', this._origin == 'program');
77+
78+
subject.next(this._origin);
79+
this._lastFocusOrigin = this._origin;
4880
this._origin = null;
4981
}
5082

5183
/** Handles blur events on a registered element. */
52-
private _onBlur(element: Element, renderer: Renderer) {
84+
private _onBlur(element: Element, renderer: Renderer, subject: Subject<FocusOrigin>) {
5385
renderer.setElementClass(element, 'cdk-focused', false);
5486
renderer.setElementClass(element, 'cdk-keyboard-focused', false);
5587
renderer.setElementClass(element, 'cdk-mouse-focused', false);
5688
renderer.setElementClass(element, 'cdk-program-focused', false);
89+
subject.next(null);
5790
}
5891
}
5992

@@ -66,8 +99,11 @@ export class FocusOriginMonitor {
6699
selector: '[cdkFocusClasses]',
67100
})
68101
export class CdkFocusClasses {
102+
changes: Observable<FocusOrigin>;
103+
69104
constructor(elementRef: ElementRef, focusOriginMonitor: FocusOriginMonitor, renderer: Renderer) {
70-
focusOriginMonitor.registerElementForFocusClasses(elementRef.nativeElement, renderer);
105+
this.changes =
106+
focusOriginMonitor.registerElementForFocusClasses(elementRef.nativeElement, renderer);
71107
}
72108
}
73109

0 commit comments

Comments
 (0)