diff --git a/packages/platform-browser/src/dom/events/hammer_gestures.ts b/packages/platform-browser/src/dom/events/hammer_gestures.ts index c4c6856b95..e51126de6c 100644 --- a/packages/platform-browser/src/dom/events/hammer_gestures.ts +++ b/packages/platform-browser/src/dom/events/hammer_gestures.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Inject, Injectable, InjectionToken, ɵConsole as Console} from '@angular/core'; +import {Inject, Injectable, InjectionToken, Optional, ɵConsole as Console} from '@angular/core'; import {DOCUMENT} from '../dom_tokens'; @@ -58,6 +58,13 @@ const EVENT_NAMES = { */ export const HAMMER_GESTURE_CONFIG = new InjectionToken('HammerGestureConfig'); + +/** Function that loads HammerJS, returning a promise that is resolved once HammerJs is loaded. */ +export type HammerLoader = (() => Promise) | null; + +/** Injection token used to provide a {@link HammerLoader} to Angular. */ +export const HAMMER_LOADER = new InjectionToken('HammerLoader'); + export interface HammerInstance { on(eventName: string, callback?: Function): void; off(eventName: string, callback?: Function): void; @@ -99,8 +106,8 @@ export class HammerGestureConfig { export class HammerGesturesPlugin extends EventManagerPlugin { constructor( @Inject(DOCUMENT) doc: any, - @Inject(HAMMER_GESTURE_CONFIG) private _config: HammerGestureConfig, - private console: Console) { + @Inject(HAMMER_GESTURE_CONFIG) private _config: HammerGestureConfig, private console: Console, + @Optional() @Inject(HAMMER_LOADER) private loader?: HammerLoader) { super(doc); } @@ -109,8 +116,10 @@ export class HammerGesturesPlugin extends EventManagerPlugin { return false; } - if (!(window as any).Hammer) { - this.console.warn(`Hammer.js is not loaded, can not bind '${eventName}' event.`); + if (!(window as any).Hammer && !this.loader) { + this.console.warn( + `The "${eventName}" event cannot be bound because Hammer.JS is not ` + + `loaded and no custom loader has been specified.`); return false; } @@ -121,6 +130,44 @@ export class HammerGesturesPlugin extends EventManagerPlugin { const zone = this.manager.getZone(); eventName = eventName.toLowerCase(); + // If Hammer is not present but a loader is specified, we defer adding the event listener + // until Hammer is loaded. + if (!(window as any).Hammer && this.loader) { + // This `addEventListener` method returns a function to remove the added listener. + // Until Hammer is loaded, the returned function needs to *cancel* the registration rather + // than remove anything. + let cancelRegistration = false; + let deregister: Function = () => { cancelRegistration = true; }; + + this.loader() + .then(() => { + // If Hammer isn't actually loaded when the custom loader resolves, give up. + if (!(window as any).Hammer) { + this.console.warn( + `The custom HAMMER_LOADER completed, but Hammer.JS is not present.`); + deregister = () => {}; + return; + } + + if (!cancelRegistration) { + // Now that Hammer is loaded and the listener is being loaded for real, + // the deregistration function changes from canceling registration to removal. + deregister = this.addEventListener(element, eventName, handler); + } + }) + .catch(() => { + this.console.warn( + `The "${eventName}" event cannot be bound because the custom ` + + `Hammer.JS loader failed.`); + deregister = () => {}; + }); + + // Return a function that *executes* `deregister` (and not `deregister` itself) so that we + // can change the behavior of `deregister` once the listener is added. Using a closure in + // this way allows us to avoid any additional data structures to track listener removal. + return () => { deregister(); }; + } + return zone.runOutsideAngular(() => { // Creating the manager bind events, must be done outside of angular const mc = this._config.buildHammer(element); diff --git a/packages/platform-browser/test/dom/events/hammer_gestures_spec.ts b/packages/platform-browser/test/dom/events/hammer_gestures_spec.ts index d9d87393da..cf82361a9b 100644 --- a/packages/platform-browser/test/dom/events/hammer_gestures_spec.ts +++ b/packages/platform-browser/test/dom/events/hammer_gestures_spec.ts @@ -5,32 +5,150 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ -import {describe, expect, it} from '@angular/core/testing/src/testing_internal'; -import {HammerGestureConfig, HammerGesturesPlugin} from '@angular/platform-browser/src/dom/events/hammer_gestures'; +import {NgZone} from '@angular/core'; +import {fakeAsync, inject, tick} from '@angular/core/testing'; +import {afterEach, beforeEach, describe, expect, it,} from '@angular/core/testing/src/testing_internal'; +import {EventManager} from '@angular/platform-browser'; +import {HammerGestureConfig, HammerGesturesPlugin,} from '@angular/platform-browser/src/dom/events/hammer_gestures'; { describe('HammerGesturesPlugin', () => { let plugin: HammerGesturesPlugin; - let mockConsole: any; + let fakeConsole: any; if (isNode) return; - beforeEach(() => { - mockConsole = {warn: () => {}}; - plugin = new HammerGesturesPlugin(document, new HammerGestureConfig(), mockConsole); + beforeEach(() => { fakeConsole = {warn: jasmine.createSpy('console.warn')}; }); + + describe('with no custom loader', () => { + beforeEach(() => { + plugin = new HammerGesturesPlugin(document, new HammerGestureConfig(), fakeConsole); + }); + + it('should implement addGlobalEventListener', () => { + spyOn(plugin, 'addEventListener').and.callFake(() => {}); + + expect(() => { + plugin.addGlobalEventListener('document', 'swipe', () => {}); + }).not.toThrowError(); + }); + + it('should warn user and do nothing when Hammer.js not loaded', () => { + expect(plugin.supports('swipe')).toBe(false); + expect(fakeConsole.warn) + .toHaveBeenCalledWith( + `The "swipe" event cannot be bound because Hammer.JS is not ` + + `loaded and no custom loader has been specified.`); + }); }); - it('should implement addGlobalEventListener', () => { - spyOn(plugin, 'addEventListener').and.callFake(() => {}); + describe('with a custom loader', () => { + // Use a fake custom loader for tests, with helper functions to resolve or reject. + let loader: () => Promise; + let resolveLoader: () => void; + let failLoader: () => void; - expect(() => plugin.addGlobalEventListener('document', 'swipe', () => {})).not.toThrowError(); - }); + // Arbitrary element and listener for testing. + let someElement: HTMLDivElement; + let someListener: () => void; - it('should warn user and do nothing when Hammer.js not loaeded', () => { - spyOn(mockConsole, 'warn'); + // Keep track of whatever value is in `window.Hammer` before the test so it can be + // restored afterwards so that this test doesn't care whether Hammer is actually loaded. + let originalHammerGlobal: any; - expect(plugin.supports('swipe')).toBe(false); - expect(mockConsole.warn) - .toHaveBeenCalledWith(`Hammer.js is not loaded, can not bind 'swipe' event.`); + // Fake Hammer instance ("mc") used to test the underlying event registration. + let fakeHammerInstance: {on: () => void, off: () => void}; + + // Inject the NgZone so that we can make it available to the plugin through a fake + // EventManager. + let ngZone: NgZone; + beforeEach(inject([NgZone], (z: NgZone) => { ngZone = z; })); + + beforeEach(() => { + originalHammerGlobal = (window as any).Hammer; + (window as any).Hammer = undefined; + + fakeHammerInstance = { + on: jasmine.createSpy('mc.on'), + off: jasmine.createSpy('mc.off'), + }; + + loader = () => new Promise((resolve, reject) => { + resolveLoader = resolve; + failLoader = reject; + }); + + // Make the hammer config return a fake hammer instance + const hammerConfig = new HammerGestureConfig(); + spyOn(hammerConfig, 'buildHammer').and.returnValue(fakeHammerInstance); + + plugin = new HammerGesturesPlugin(document, hammerConfig, fakeConsole, loader); + + // Use a fake EventManager that has access to the NgZone. + plugin.manager = { getZone: () => ngZone } as EventManager; + + someElement = document.createElement('div'); + someListener = () => {}; + }); + + afterEach(() => { (window as any).Hammer = originalHammerGlobal; }); + + it('should not log a warning when HammerJS is not loaded', () => { + plugin.addEventListener(someElement, 'swipe', () => {}); + expect(fakeConsole.warn).not.toHaveBeenCalled(); + }); + + it('should defer registering an event until Hammer is loaded', fakeAsync(() => { + plugin.addEventListener(someElement, 'swipe', someListener); + expect(fakeHammerInstance.on).not.toHaveBeenCalled(); + + (window as any).Hammer = {}; + resolveLoader(); + tick(); + + expect(fakeHammerInstance.on).toHaveBeenCalledWith('swipe', jasmine.any(Function)); + })); + + it('should cancel registration if an event is removed before being added', fakeAsync(() => { + const deregister = plugin.addEventListener(someElement, 'swipe', someListener); + deregister(); + + (window as any).Hammer = {}; + resolveLoader(); + tick(); + + expect(fakeHammerInstance.on).not.toHaveBeenCalled(); + })); + + it('should remove a listener after Hammer is loaded', fakeAsync(() => { + const removeListener = plugin.addEventListener(someElement, 'swipe', someListener); + + (window as any).Hammer = {}; + resolveLoader(); + tick(); + + removeListener(); + expect(fakeHammerInstance.off).toHaveBeenCalledWith('swipe', jasmine.any(Function)); + })); + + it('should log a warning when the loader fails', fakeAsync(() => { + plugin.addEventListener(someElement, 'swipe', () => {}); + failLoader(); + tick(); + + expect(fakeConsole.warn) + .toHaveBeenCalledWith( + `The "swipe" event cannot be bound because the custom Hammer.JS loader failed.`); + })); + + it('should load a warning if the loader resolves and Hammer is not present', fakeAsync(() => { + plugin.addEventListener(someElement, 'swipe', () => {}); + resolveLoader(); + tick(); + + expect(fakeConsole.warn) + .toHaveBeenCalledWith( + `The custom HAMMER_LOADER completed, but Hammer.JS is not present.`); + })); }); }); }