diff --git a/src/demo-app/style/style-demo.scss b/src/demo-app/style/style-demo.scss
index 95b98232e98..ddfd64de6bb 100644
--- a/src/demo-app/style/style-demo.scss
+++ b/src/demo-app/style/style-demo.scss
@@ -1,19 +1,19 @@
-.demo-button.cdk-focused {
+.demo-focusable.cdk-focused {
border: 2px solid red;
}
-.demo-button.cdk-mouse-focused {
+.demo-focusable.cdk-mouse-focused {
background: green;
}
-.demo-button.cdk-keyboard-focused {
+.demo-focusable.cdk-keyboard-focused {
background: yellow;
}
-.demo-button.cdk-program-focused {
+.demo-focusable.cdk-program-focused {
background: blue;
}
-.demo-button.cdk-touch-focused {
+.demo-focusable.cdk-touch-focused {
background: purple;
}
diff --git a/src/lib/core/style/focus-classes.ts b/src/lib/core/style/focus-classes.ts
deleted file mode 100644
index 9eefdf85dbc..00000000000
--- a/src/lib/core/style/focus-classes.ts
+++ /dev/null
@@ -1,183 +0,0 @@
-import {Directive, Injectable, Optional, SkipSelf, Renderer, ElementRef} from '@angular/core';
-import {Observable} from 'rxjs/Observable';
-import {Subject} from 'rxjs/Subject';
-
-
-// This is the value used by AngularJS Material. Through trial and error (on iPhone 6S) they found
-// that a value of around 650ms seems appropriate.
-export const TOUCH_BUFFER_MS = 650;
-
-
-export type FocusOrigin = 'touch' | 'mouse' | 'keyboard' | 'program';
-
-
-/** Monitors mouse and keyboard events to determine the cause of focus events. */
-@Injectable()
-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;
-
- /** The target of the last touch event. */
- private _lastTouchTarget: EventTarget;
-
- /** The timeout id of the touch timeout, used to cancel timeout later. */
- private _touchTimeout: number;
-
- constructor() {
- // Note: we listen to events in the capture phase so we can detect them even if the user stops
- // propagation.
-
- // On keydown record the origin and clear any touch event that may be in progress.
- document.addEventListener('keydown', () => {
- this._lastTouchTarget = null;
- this._setOriginForCurrentEventQueue('keyboard');
- }, true);
-
- // On mousedown record the origin only if there is not touch target, since a mousedown can
- // happen as a result of a touch event.
- document.addEventListener('mousedown', () => {
- if (!this._lastTouchTarget) {
- this._setOriginForCurrentEventQueue('mouse');
- }
- }, true);
-
- // When the touchstart event fires the focus event is not yet in the event queue. This means we
- // can't rely on the trick used above (setting timeout of 0ms). Instead we wait 650ms to see if
- // a focus happens.
- document.addEventListener('touchstart', (event: Event) => {
- if (this._touchTimeout != null) {
- clearTimeout(this._touchTimeout);
- }
- this._lastTouchTarget = event.target;
- this._touchTimeout = setTimeout(() => this._lastTouchTarget = null, TOUCH_BUFFER_MS);
- }, 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): Observable {
- let subject = new Subject();
- renderer.listen(element, 'focus',
- (event: Event) => this._onFocus(event, element, renderer, subject));
- renderer.listen(element, 'blur', () => this._onBlur(element, renderer, subject));
- return subject.asObservable();
- }
-
- /** Focuses the element via the specified focus origin. */
- focusVia(element: Node, renderer: Renderer, origin: FocusOrigin) {
- this._setOriginForCurrentEventQueue(origin);
- renderer.invokeElementMethod(element, 'focus');
- }
-
- /** Sets the origin and schedules an async function to clear it at the end of the event queue. */
- private _setOriginForCurrentEventQueue(origin: FocusOrigin) {
- this._origin = origin;
- setTimeout(() => this._origin = null, 0);
- }
-
- /** Checks whether the given focus event was caused by a touchstart event. */
- private _wasCausedByTouch(event: Event): boolean {
- // Note(mmalerba): This implementation is not quite perfect, there is a small edge case.
- // Consider the following dom structure:
- //
- //
- //
- //
- //
- // If the user touches the #child element and the #parent is programmatically focused as a
- // result, this code will still consider it to have been caused by the touch event and will
- // apply the cdk-touch-focused class rather than the cdk-program-focused class. This is a
- // relatively small edge-case that can be worked around by using
- // focusVia(parentEl, renderer, 'program') to focus the parent element.
- //
- // If we decide that we absolutely must handle this case correctly, we can do so by listening
- // for the first focus event after the touchstart, and then the first blur event after that
- // focus event. When that blur event fires we know that whatever follows is not a result of the
- // touchstart.
- let focusTarget = event.target;
- return this._lastTouchTarget instanceof Node && focusTarget instanceof Node &&
- (focusTarget == this._lastTouchTarget || focusTarget.contains(this._lastTouchTarget));
- }
-
- /** Handles focus events on a registered element. */
- private _onFocus(event: Event, 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) It was caused by a touch event, in which case we mark the origin as 'touch'.
- // 3) 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 if (this._wasCausedByTouch(event)) {
- this._origin = 'touch';
- } else {
- this._origin = 'program';
- }
- }
-
- renderer.setElementClass(element, 'cdk-focused', true);
- renderer.setElementClass(element, 'cdk-touch-focused', this._origin == 'touch');
- 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 == '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, subject: Subject) {
- renderer.setElementClass(element, 'cdk-focused', false);
- renderer.setElementClass(element, 'cdk-touch-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);
- }
-}
-
-
-/**
- * Directive that determines how a particular element was focused (via keyboard, mouse, or
- * programmatically) and adds corresponding classes to the element.
- */
-@Directive({
- selector: '[cdkFocusClasses]',
-})
-export class CdkFocusClasses {
- changes: Observable;
-
- constructor(elementRef: ElementRef, focusOriginMonitor: FocusOriginMonitor, renderer: Renderer) {
- this.changes =
- focusOriginMonitor.registerElementForFocusClasses(elementRef.nativeElement, renderer);
- }
-}
-
-
-export function FOCUS_ORIGIN_MONITOR_PROVIDER_FACTORY(parentDispatcher: FocusOriginMonitor) {
- return parentDispatcher || new FocusOriginMonitor();
-}
-
-
-export const FOCUS_ORIGIN_MONITOR_PROVIDER = {
- // If there is already a FocusOriginMonitor available, use that. Otherwise, provide a new one.
- provide: FocusOriginMonitor,
- deps: [[new Optional(), new SkipSelf(), FocusOriginMonitor]],
- useFactory: FOCUS_ORIGIN_MONITOR_PROVIDER_FACTORY
-};
diff --git a/src/lib/core/style/focus-classes.spec.ts b/src/lib/core/style/focus-origin-monitor.spec.ts
similarity index 56%
rename from src/lib/core/style/focus-classes.spec.ts
rename to src/lib/core/style/focus-origin-monitor.spec.ts
index 02cd142a750..bc20ecf413f 100644
--- a/src/lib/core/style/focus-classes.spec.ts
+++ b/src/lib/core/style/focus-origin-monitor.spec.ts
@@ -1,9 +1,9 @@
-import {async, ComponentFixture, TestBed, inject} from '@angular/core/testing';
-import {Component, Renderer, ViewChild} from '@angular/core';
+import {async, ComponentFixture, inject, TestBed} from '@angular/core/testing';
+import {Component, Renderer} from '@angular/core';
import {StyleModule} from './index';
import {By} from '@angular/platform-browser';
import {TAB} from '../keyboard/keycodes';
-import {FocusOriginMonitor, FocusOrigin, CdkFocusClasses, TOUCH_BUFFER_MS} from './focus-classes';
+import {FocusOrigin, FocusOriginMonitor, TOUCH_BUFFER_MS} from './focus-origin-monitor';
describe('FocusOriginMonitor', () => {
let fixture: ComponentFixture;
@@ -32,8 +32,7 @@ describe('FocusOriginMonitor', () => {
focusOriginMonitor = fom;
changeHandler = jasmine.createSpy('focus origin change handler');
- focusOriginMonitor.registerElementForFocusClasses(buttonElement, buttonRenderer)
- .subscribe(changeHandler);
+ focusOriginMonitor.monitor(buttonElement, buttonRenderer, false).subscribe(changeHandler);
// Patch the element focus to properly emit focus events when the browser is blurred.
patchElementFocus(buttonElement);
@@ -214,149 +213,269 @@ describe('FocusOriginMonitor', () => {
expect(changeHandler).toHaveBeenCalledWith(null);
}, 0);
}));
+
+ it('should remove classes on unmonitor', async(() => {
+ buttonElement.focus();
+ fixture.detectChanges();
+
+ setTimeout(() => {
+ fixture.detectChanges();
+
+ expect(buttonElement.classList.length)
+ .toBe(2, 'button should have exactly 2 focus classes');
+
+ focusOriginMonitor.unmonitor(buttonElement);
+ fixture.detectChanges();
+
+ expect(buttonElement.classList.length).toBe(0, 'button should not have any focus classes');
+ }, 0);
+ }));
});
-describe('cdkFocusClasses', () => {
- let fixture: ComponentFixture;
- let buttonElement: HTMLElement;
- let changeHandler: (origin: FocusOrigin) => void;
-
+describe('cdkMonitorFocus', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [StyleModule],
declarations: [
ButtonWithFocusClasses,
+ ComplexComponentWithMonitorElementFocus,
+ ComplexComponentWithMonitorSubtreeFocus,
],
});
TestBed.compileComponents();
}));
- beforeEach(() => {
- fixture = TestBed.createComponent(ButtonWithFocusClasses);
- fixture.detectChanges();
+ describe('button with cdkMonitorElementFocus', () => {
+ let fixture: ComponentFixture;
+ let buttonElement: HTMLElement;
- changeHandler = jasmine.createSpy('focus origin change handler');
- fixture.componentInstance.cdkFocusClasses.changes.subscribe(changeHandler);
- buttonElement = fixture.debugElement.query(By.css('button')).nativeElement;
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ButtonWithFocusClasses);
+ fixture.detectChanges();
- // Patch the element focus to properly emit focus events when the browser is blurred.
- patchElementFocus(buttonElement);
+ spyOn(fixture.componentInstance, 'focusChanged');
+ buttonElement = fixture.debugElement.query(By.css('button')).nativeElement;
+
+ // Patch the element focus to properly emit focus events when the browser is blurred.
+ patchElementFocus(buttonElement);
+ });
+
+ it('should initially not be focused', () => {
+ expect(buttonElement.classList.length).toBe(0, 'button should not have focus classes');
+ });
+
+ it('should detect focus via keyboard', async(() => {
+ // Simulate focus via keyboard.
+ dispatchKeydownEvent(document, TAB);
+ buttonElement.focus();
+ fixture.detectChanges();
+
+ setTimeout(() => {
+ fixture.detectChanges();
+
+ expect(buttonElement.classList.length)
+ .toBe(2, 'button should have exactly 2 focus classes');
+ expect(buttonElement.classList.contains('cdk-focused'))
+ .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(fixture.componentInstance.focusChanged).toHaveBeenCalledWith('keyboard');
+ }, 0);
+ }));
+
+ it('should detect focus via mouse', async(() => {
+ // Simulate focus via mouse.
+ dispatchMousedownEvent(buttonElement);
+ buttonElement.focus();
+ fixture.detectChanges();
+
+ setTimeout(() => {
+ fixture.detectChanges();
+
+ expect(buttonElement.classList.length)
+ .toBe(2, 'button should have exactly 2 focus classes');
+ expect(buttonElement.classList.contains('cdk-focused'))
+ .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(fixture.componentInstance.focusChanged).toHaveBeenCalledWith('mouse');
+ }, 0);
+ }));
+
+ it('should detect focus via touch', async(() => {
+ // Simulate focus via touch.
+ dispatchTouchstartEvent(buttonElement);
+ buttonElement.focus();
+ fixture.detectChanges();
+
+ setTimeout(() => {
+ fixture.detectChanges();
+
+ expect(buttonElement.classList.length)
+ .toBe(2, 'button should have exactly 2 focus classes');
+ expect(buttonElement.classList.contains('cdk-focused'))
+ .toBe(true, 'button should have cdk-focused class');
+ expect(buttonElement.classList.contains('cdk-touch-focused'))
+ .toBe(true, 'button should have cdk-touch-focused class');
+ expect(fixture.componentInstance.focusChanged).toHaveBeenCalledWith('touch');
+ }, TOUCH_BUFFER_MS);
+ }));
+
+ it('should detect programmatic focus', async(() => {
+ // Programmatically focus.
+ buttonElement.focus();
+ fixture.detectChanges();
+
+ setTimeout(() => {
+ fixture.detectChanges();
+
+ expect(buttonElement.classList.length)
+ .toBe(2, 'button should have exactly 2 focus classes');
+ expect(buttonElement.classList.contains('cdk-focused'))
+ .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(fixture.componentInstance.focusChanged).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(fixture.componentInstance.focusChanged).toHaveBeenCalledWith('program');
+
+ buttonElement.blur();
+ fixture.detectChanges();
+
+ expect(buttonElement.classList.length)
+ .toBe(0, 'button should not have any focus classes');
+ expect(fixture.componentInstance.focusChanged).toHaveBeenCalledWith(null);
+ }, 0);
+ }));
});
- it('should initially not be focused', () => {
- expect(buttonElement.classList.length).toBe(0, 'button should not have focus classes');
+ describe('complex component with cdkMonitorElementFocus', () => {
+ let fixture: ComponentFixture;
+ let parentElement: HTMLElement;
+ let childElement: HTMLElement;
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ComplexComponentWithMonitorElementFocus);
+ fixture.detectChanges();
+
+ parentElement = fixture.debugElement.query(By.css('div')).nativeElement;
+ childElement = fixture.debugElement.query(By.css('button')).nativeElement;
+
+ // Patch the element focus to properly emit focus events when the browser is blurred.
+ patchElementFocus(parentElement);
+ patchElementFocus(childElement);
+ });
+
+ it('should add focus classes on parent focus', async(() => {
+ parentElement.focus();
+ fixture.detectChanges();
+
+ setTimeout(() => {
+ fixture.detectChanges();
+
+ expect(parentElement.classList.length)
+ .toBe(2, 'button should have exactly 2 focus classes');
+ }, 0);
+ }));
+
+ it('should not add focus classes on child focus', async(() => {
+ childElement.focus();
+ fixture.detectChanges();
+
+ setTimeout(() => {
+ fixture.detectChanges();
+
+ expect(parentElement.classList.length)
+ .toBe(0, 'button should not have any focus classes');
+ }, 0);
+ }));
});
- it('should detect focus via keyboard', async(() => {
- // Simulate focus via keyboard.
- dispatchKeydownEvent(document, TAB);
- buttonElement.focus();
- fixture.detectChanges();
+ describe('complex component with cdkMonitorSubtreeFocus', () => {
+ let fixture: ComponentFixture;
+ let parentElement: HTMLElement;
+ let childElement: HTMLElement;
- setTimeout(() => {
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ComplexComponentWithMonitorSubtreeFocus);
fixture.detectChanges();
- expect(buttonElement.classList.length)
- .toBe(2, 'button should have exactly 2 focus classes');
- expect(buttonElement.classList.contains('cdk-focused'))
- .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);
- }));
+ parentElement = fixture.debugElement.query(By.css('div')).nativeElement;
+ childElement = fixture.debugElement.query(By.css('button')).nativeElement;
- it('should detect focus via mouse', async(() => {
- // Simulate focus via mouse.
- dispatchMousedownEvent(buttonElement);
- buttonElement.focus();
- fixture.detectChanges();
+ // Patch the element focus to properly emit focus events when the browser is blurred.
+ patchElementFocus(parentElement);
+ patchElementFocus(childElement);
+ });
- setTimeout(() => {
+ it('should add focus classes on parent focus', async(() => {
+ parentElement.focus();
fixture.detectChanges();
- expect(buttonElement.classList.length)
- .toBe(2, 'button should have exactly 2 focus classes');
- expect(buttonElement.classList.contains('cdk-focused'))
- .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);
- }));
+ setTimeout(() => {
+ fixture.detectChanges();
- it('should detect focus via touch', async(() => {
- // Simulate focus via touch.
- dispatchTouchstartEvent(buttonElement);
- buttonElement.focus();
- fixture.detectChanges();
+ expect(parentElement.classList.length)
+ .toBe(2, 'button should have exactly 2 focus classes');
+ }, 0);
+ }));
- setTimeout(() => {
+ it('should add focus classes on child focus', async(() => {
+ childElement.focus();
fixture.detectChanges();
- expect(buttonElement.classList.length)
- .toBe(2, 'button should have exactly 2 focus classes');
- expect(buttonElement.classList.contains('cdk-focused'))
- .toBe(true, 'button should have cdk-focused class');
- expect(buttonElement.classList.contains('cdk-touch-focused'))
- .toBe(true, 'button should have cdk-touch-focused class');
- expect(changeHandler).toHaveBeenCalledWith('touch');
- }, TOUCH_BUFFER_MS);
- }));
+ setTimeout(() => {
+ fixture.detectChanges();
- it('should detect programmatic focus', async(() => {
- // Programmatically focus.
- buttonElement.focus();
- fixture.detectChanges();
-
- setTimeout(() => {
- fixture.detectChanges();
-
- expect(buttonElement.classList.length)
- .toBe(2, 'button should have exactly 2 focus classes');
- expect(buttonElement.classList.contains('cdk-focused'))
- .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);
- }));
+ expect(parentElement.classList.length)
+ .toBe(2, 'button should have exactly 2 focus classes');
+ }, 0);
+ }));
+ });
});
-@Component({template: ``})
+@Component({
+ template: ``
+})
class PlainButton {
constructor(public renderer: Renderer) {}
}
-@Component({template: ``})
+@Component({
+ template: ``
+})
class ButtonWithFocusClasses {
- @ViewChild(CdkFocusClasses) cdkFocusClasses: CdkFocusClasses;
+ focusChanged(origin: FocusOrigin) {};
}
+
+@Component({
+ template: ``
+})
+class ComplexComponentWithMonitorElementFocus {}
+
+
+@Component({
+ template: ``
+})
+class ComplexComponentWithMonitorSubtreeFocus {}
+
+
// TODO(devversion): move helper functions into a global utility file. See #2902
/** Dispatches a mousedown event on the specified element. */
diff --git a/src/lib/core/style/focus-origin-monitor.ts b/src/lib/core/style/focus-origin-monitor.ts
new file mode 100644
index 00000000000..bbb61f28470
--- /dev/null
+++ b/src/lib/core/style/focus-origin-monitor.ts
@@ -0,0 +1,312 @@
+import {
+ Directive,
+ ElementRef,
+ EventEmitter,
+ Injectable,
+ NgZone,
+ OnDestroy,
+ Optional,
+ Output,
+ Renderer,
+ SkipSelf
+} from '@angular/core';
+import {Observable} from 'rxjs/Observable';
+import {Subject} from 'rxjs/Subject';
+
+
+// This is the value used by AngularJS Material. Through trial and error (on iPhone 6S) they found
+// that a value of around 650ms seems appropriate.
+export const TOUCH_BUFFER_MS = 650;
+
+
+export type FocusOrigin = 'touch' | 'mouse' | 'keyboard' | 'program';
+
+
+type MonitoredElementInfo = {
+ unlisten: Function,
+ checkChildren: boolean,
+ renderer: Renderer,
+ subject: Subject
+};
+
+
+/** Monitors mouse and keyboard events to determine the cause of focus events. */
+@Injectable()
+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;
+
+ /** The target of the last touch event. */
+ private _lastTouchTarget: EventTarget;
+
+ /** The timeout id of the touch timeout, used to cancel timeout later. */
+ private _touchTimeout: number;
+
+ /** Weak map of elements being monitored to their info. */
+ private _elementInfo = new WeakMap();
+
+ constructor(private _ngZone: NgZone) {
+ this._ngZone.runOutsideAngular(() => this._registerDocumentEvents());
+ }
+
+ /**
+ * Monitors focus on an element and applies appropriate CSS classes.
+ * @param element The element to monitor
+ * @param renderer The renderer to use to apply CSS classes to the element.
+ * @param checkChildren Whether to count the element as focused when its children are focused.
+ * @returns An observable that emits when the focus state of the element changes.
+ * When the element is blurred, null will be emitted.
+ */
+ monitor(element: Element, renderer: Renderer, checkChildren: boolean): Observable {
+ // Check if we're already monitoring this element.
+ if (this._elementInfo.has(element)) {
+ let info = this._elementInfo.get(element);
+ info.checkChildren = checkChildren;
+ return info.subject.asObservable();
+ }
+
+ // Create monitored element info.
+ let info: MonitoredElementInfo = {
+ unlisten: null,
+ checkChildren: checkChildren,
+ renderer: renderer,
+ subject: new Subject()
+ };
+ this._elementInfo.set(element, info);
+
+ // Start listening. We need to listen in capture phase since focus events don't bubble.
+ let focusListener = (event: FocusEvent) => this._onFocus(event, element);
+ let blurListener = (event: FocusEvent) => this._onBlur(event, element);
+ this._ngZone.runOutsideAngular(() => {
+ element.addEventListener('focus', focusListener, true);
+ element.addEventListener('blur', blurListener, true);
+ });
+
+ // Create an unlisten function for later.
+ info.unlisten = () => {
+ element.removeEventListener('focus', focusListener, true);
+ element.removeEventListener('blur', blurListener, true);
+ };
+
+ return info.subject.asObservable();
+ }
+
+ /**
+ * Stops monitoring an element and removes all focus classes.
+ * @param element The element to stop monitoring.
+ */
+ unmonitor(element: Element): void {
+ if (!this._elementInfo.has(element)) {
+ return;
+ }
+ this._elementInfo.get(element).unlisten();
+ this._setClasses(element, null);
+ this._elementInfo.delete(element);
+ }
+
+ /**
+ * Focuses the element via the specified focus origin.
+ * @param element The element to focus.
+ * @param renderer The renderer to use to invoke the focus method on the element.
+ * @param origin The focus origin.
+ */
+ focusVia(element: Node, renderer: Renderer, origin: FocusOrigin): void {
+ this._setOriginForCurrentEventQueue(origin);
+ renderer.invokeElementMethod(element, 'focus');
+ }
+
+ /** Register necessary event listeners on the document and window. */
+ private _registerDocumentEvents() {
+ // Note: we listen to events in the capture phase so we can detect them even if the user stops
+ // propagation.
+
+ // On keydown record the origin and clear any touch event that may be in progress.
+ document.addEventListener('keydown', () => {
+ this._lastTouchTarget = null;
+ this._setOriginForCurrentEventQueue('keyboard');
+ }, true);
+
+ // On mousedown record the origin only if there is not touch target, since a mousedown can
+ // happen as a result of a touch event.
+ document.addEventListener('mousedown', () => {
+ if (!this._lastTouchTarget) {
+ this._setOriginForCurrentEventQueue('mouse');
+ }
+ }, true);
+
+ // When the touchstart event fires the focus event is not yet in the event queue. This means
+ // we can't rely on the trick used above (setting timeout of 0ms). Instead we wait 650ms to
+ // see if a focus happens.
+ document.addEventListener('touchstart', (event: Event) => {
+ if (this._touchTimeout != null) {
+ clearTimeout(this._touchTimeout);
+ }
+ this._lastTouchTarget = event.target;
+ this._touchTimeout = setTimeout(() => this._lastTouchTarget = null, TOUCH_BUFFER_MS);
+ }, 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);
+ });
+ }
+
+ /**
+ * Sets the focus classes on the element based on the given focus origin.
+ * @param element The element to update the classes on.
+ * @param origin The focus origin.
+ */
+ private _setClasses(element: Element, origin: FocusOrigin): void {
+ let renderer = this._elementInfo.get(element).renderer;
+ renderer.setElementClass(element, 'cdk-focused', !!origin);
+ renderer.setElementClass(element, 'cdk-touch-focused', origin === 'touch');
+ renderer.setElementClass(element, 'cdk-keyboard-focused', origin === 'keyboard');
+ renderer.setElementClass(element, 'cdk-mouse-focused', origin === 'mouse');
+ renderer.setElementClass(element, 'cdk-program-focused', origin === 'program');
+ }
+
+ /**
+ * Sets the origin and schedules an async function to clear it at the end of the event queue.
+ * @param origin The origin to set.
+ */
+ private _setOriginForCurrentEventQueue(origin: FocusOrigin): void {
+ this._origin = origin;
+ setTimeout(() => this._origin = null, 0);
+ }
+
+ /**
+ * Checks whether the given focus event was caused by a touchstart event.
+ * @param event The focus event to check.
+ * @returns Whether the event was caused by a touch.
+ */
+ private _wasCausedByTouch(event: FocusEvent): boolean {
+ // Note(mmalerba): This implementation is not quite perfect, there is a small edge case.
+ // Consider the following dom structure:
+ //
+ //
+ //
+ //
+ //
+ // If the user touches the #child element and the #parent is programmatically focused as a
+ // result, this code will still consider it to have been caused by the touch event and will
+ // apply the cdk-touch-focused class rather than the cdk-program-focused class. This is a
+ // relatively small edge-case that can be worked around by using
+ // focusVia(parentEl, renderer, 'program') to focus the parent element.
+ //
+ // If we decide that we absolutely must handle this case correctly, we can do so by listening
+ // for the first focus event after the touchstart, and then the first blur event after that
+ // focus event. When that blur event fires we know that whatever follows is not a result of the
+ // touchstart.
+ let focusTarget = event.target;
+ return this._lastTouchTarget instanceof Node && focusTarget instanceof Node &&
+ (focusTarget === this._lastTouchTarget || focusTarget.contains(this._lastTouchTarget));
+ }
+
+ /**
+ * Handles focus events on a registered element.
+ * @param event The focus event.
+ * @param element The monitored element.
+ */
+ private _onFocus(event: FocusEvent, element: Element) {
+ // NOTE(mmalerba): We currently set the classes based on the focus origin of the most recent
+ // focus event affecting the monitored element. If we want to use the origin of the first event
+ // instead we should check for the cdk-focused class here and return if the element already has
+ // it. (This only matters for elements that have includesChildren = true).
+
+ // If we are not counting child-element-focus as focused, make sure that the event target is the
+ // monitored element itself.
+ if (!this._elementInfo.get(element).checkChildren && element !== event.target) {
+ return;
+ }
+
+ // If we couldn't detect a cause for the focus event, it's due to one of three 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) It was caused by a touch event, in which case we mark the origin as 'touch'.
+ // 3) 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 if (this._wasCausedByTouch(event)) {
+ this._origin = 'touch';
+ } else {
+ this._origin = 'program';
+ }
+ }
+
+ this._setClasses(element, this._origin);
+ this._elementInfo.get(element).subject.next(this._origin);
+ this._lastFocusOrigin = this._origin;
+ this._origin = null;
+ }
+
+ /**
+ * Handles blur events on a registered element.
+ * @param event The blur event.
+ * @param element The monitored element.
+ */
+ private _onBlur(event: FocusEvent, element: Element) {
+ // If we are counting child-element-focus as focused, make sure that we aren't just blurring in
+ // order to focus another child of the monitored element.
+ if (this._elementInfo.get(element).checkChildren && event.relatedTarget instanceof Node &&
+ element.contains(event.relatedTarget)) {
+ return;
+ }
+
+ this._setClasses(element, null);
+ this._elementInfo.get(element).subject.next(null);
+ }
+}
+
+
+/**
+ * Directive that determines how a particular element was focused (via keyboard, mouse, touch, or
+ * programmatically) and adds corresponding classes to the element.
+ *
+ * There are two variants of this directive:
+ * 1) cdkMonitorElementFocus: does not consider an element to be focused if one of its children is
+ * focused.
+ * 2) cdkMonitorSubtreeFocus: considers an element focused if it or any of its children are focused.
+ */
+@Directive({
+ selector: '[cdkMonitorElementFocus], [cdkMonitorSubtreeFocus]',
+})
+export class CdkMonitorFocus implements OnDestroy {
+ @Output()
+ cdkFocusChange = new EventEmitter();
+
+ constructor(private _elementRef: ElementRef, private _focusOriginMonitor: FocusOriginMonitor,
+ renderer: Renderer) {
+ this._focusOriginMonitor.monitor(
+ this._elementRef.nativeElement, renderer,
+ this._elementRef.nativeElement.hasAttribute('cdkMonitorSubtreeFocus'))
+ .subscribe(origin => this.cdkFocusChange.emit(origin));
+ }
+
+ ngOnDestroy() {
+ this._focusOriginMonitor.unmonitor(this._elementRef.nativeElement);
+ }
+}
+
+
+export function FOCUS_ORIGIN_MONITOR_PROVIDER_FACTORY(parentDispatcher: FocusOriginMonitor,
+ ngZone: NgZone) {
+ return parentDispatcher || new FocusOriginMonitor(ngZone);
+}
+
+
+export const FOCUS_ORIGIN_MONITOR_PROVIDER = {
+ // If there is already a FocusOriginMonitor available, use that. Otherwise, provide a new one.
+ provide: FocusOriginMonitor,
+ deps: [[new Optional(), new SkipSelf(), FocusOriginMonitor], NgZone],
+ useFactory: FOCUS_ORIGIN_MONITOR_PROVIDER_FACTORY
+};
diff --git a/src/lib/core/style/index.ts b/src/lib/core/style/index.ts
index 13c58e4ea29..b5f487f5422 100644
--- a/src/lib/core/style/index.ts
+++ b/src/lib/core/style/index.ts
@@ -1,13 +1,13 @@
import {NgModule} from '@angular/core';
-import {CdkFocusClasses, FOCUS_ORIGIN_MONITOR_PROVIDER} from './focus-classes';
+import {CdkMonitorFocus, FOCUS_ORIGIN_MONITOR_PROVIDER} from './focus-origin-monitor';
-export * from './focus-classes';
+export * from './focus-origin-monitor';
export * from './apply-transform';
@NgModule({
- declarations: [CdkFocusClasses],
- exports: [CdkFocusClasses],
+ declarations: [CdkMonitorFocus],
+ exports: [CdkMonitorFocus],
providers: [FOCUS_ORIGIN_MONITOR_PROVIDER],
})
export class StyleModule {}