diff --git a/src/demo-app/style/style-demo.html b/src/demo-app/style/style-demo.html index 66c6ad6641d..81786735f85 100644 --- a/src/demo-app/style/style-demo.html +++ b/src/demo-app/style/style-demo.html @@ -1,4 +1,4 @@ - + @@ -7,3 +7,15 @@
Active classes: {{b.classList}}
+ +
+ +
+

div with element focus monitored

+ +
+ +
+

div with subtree focus monitored

+ +
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 {}