From d4ba648c91c990422a68679a108b2cbbf4adef11 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Thu, 9 Feb 2017 10:35:17 -0800 Subject: [PATCH] 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 --- src/lib/core/style/focus-classes.spec.ts | 75 ++++++++++++++++++++++-- src/lib/core/style/focus-classes.ts | 52 +++++++++++++--- 2 files changed, 114 insertions(+), 13 deletions(-) diff --git a/src/lib/core/style/focus-classes.spec.ts b/src/lib/core/style/focus-classes.spec.ts index bb7a735b6115..2ef8c0e9fd9c 100644 --- a/src/lib/core/style/focus-classes.spec.ts +++ b/src/lib/core/style/focus-classes.spec.ts @@ -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; 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; 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: ``}) -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'); + }; } diff --git a/src/lib/core/style/focus-classes.ts b/src/lib/core/style/focus-classes.ts index efea43939010..e8f0057f0ff7 100644 --- a/src/lib/core/style/focus-classes.ts +++ b/src/lib/core/style/focus-classes.ts @@ -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 { + let subject = new Subject(); + 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) { + // 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) { 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; + constructor(elementRef: ElementRef, focusOriginMonitor: FocusOriginMonitor, renderer: Renderer) { - focusOriginMonitor.registerElementForFocusClasses(elementRef.nativeElement, renderer); + this.changes = + focusOriginMonitor.registerElementForFocusClasses(elementRef.nativeElement, renderer); } }