mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
@@ -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');
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user