diff --git a/packages/elements/src/utils.ts b/packages/elements/src/utils.ts new file mode 100644 index 0000000000..c27ab3fb2e --- /dev/null +++ b/packages/elements/src/utils.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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 {Type} from '@angular/core'; + +const elProto = Element.prototype as any; +const matches = elProto.matches || elProto.matchesSelector || elProto.mozMatchesSelector || + elProto.msMatchesSelector || elProto.oMatchesSelector || elProto.webkitMatchesSelector; + +/** + * Provide methods for scheduling the execution of a callback. + */ +export const scheduler = { + /** + * Schedule a callback to be called after some delay. + */ + schedule(cb: () => void, delay: number): () => + void{const id = window.setTimeout(cb, delay); return () => window.clearTimeout(id);}, + + /** + * Schedule a callback to be called before the next render. + * (If `window.requestAnimationFrame()` is not available, use `scheduler.schedule()` instead.) + */ + scheduleBeforeRender(cb: () => void): () => void{ + // TODO(gkalpak): Implement a better way of accessing `requestAnimationFrame()` + // (e.g. accounting for vendor prefix, SSR-compatibility, etc). + if (typeof window.requestAnimationFrame === 'undefined') { + return scheduler.schedule(cb, 16); + } const id = window.requestAnimationFrame(cb); + return () => window.cancelAnimationFrame(id); + }, +}; + +/** + * Convert a camelCased string to kebab-cased. + */ +export function camelToKebabCase(input: string): string { + return input.replace(/[A-Z]/g, char => `-${char.toLowerCase()}`); +} + +/** + * Create a `CustomEvent` (even on browsers where `CustomEvent` is not a constructor). + */ +export function createCustomEvent(doc: Document, name: string, detail: any): CustomEvent { + const bubbles = false; + const cancelable = false; + + // On IE9-11, `CustomEvent` is not a constructor. + if (typeof CustomEvent !== 'function') { + const event = doc.createEvent('CustomEvent'); + event.initCustomEvent(name, bubbles, cancelable, detail); + return event; + } + + return new CustomEvent(name, {bubbles, cancelable, detail}); +} + +/** + * Return the name of the component or the first line of its stringified version. + */ +export function getComponentName(component: Type): string { + return (component as any).overriddenName || component.name || + component.toString().split('\n', 1)[0]; +} + +/** + * Check whether the input is an `Element`. + */ +export function isElement(node: Node): node is Element { + return node.nodeType === Node.ELEMENT_NODE; +} + +/** + * Check whether the input is a function. + */ +export function isFunction(value: any): value is Function { + return typeof value === 'function'; +} + +/** + * Convert a kebab-cased string to camelCased. + */ +export function kebabToCamelCase(input: string): string { + return input.replace(/-([a-z\d])/g, (_, char) => char.toUpperCase()); +} + +/** + * Check whether an `Element` matches a CSS selector. + */ +export function matchesSelector(element: Element, selector: string): boolean { + return matches.call(element, selector); +} + +/** + * Test two values for strict equality, accounting for the fact that `NaN !== NaN`. + */ +export function strictEquals(value1: any, value2: any): boolean { + return value1 === value2 || (value1 !== value1 && value2 !== value2); +} + +/** + * Throw an error with the specified message. + * (It provides a centralized place where it is easy to apply some change/behavior to all errors.) + */ +export function throwError(message: string): void { + throw Error(message); +} diff --git a/packages/elements/test/utils_spec.ts b/packages/elements/test/utils_spec.ts new file mode 100644 index 0000000000..2a24b453aa --- /dev/null +++ b/packages/elements/test/utils_spec.ts @@ -0,0 +1,259 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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 {Type} from '@angular/core'; +import {camelToKebabCase, createCustomEvent, getComponentName, isElement, isFunction, kebabToCamelCase, matchesSelector, scheduler, strictEquals, throwError} from '../src/utils'; + +export function main() { + describe('utils', () => { + describe('scheduler', () => { + describe('schedule()', () => { + let setTimeoutSpy: jasmine.Spy; + let clearTimeoutSpy: jasmine.Spy; + + beforeEach(() => { + setTimeoutSpy = spyOn(window, 'setTimeout').and.returnValue(42); + clearTimeoutSpy = spyOn(window, 'clearTimeout'); + }); + + it('should delegate to `window.setTimeout()`', () => { + const cb = () => null; + const delay = 1337; + + scheduler.schedule(cb, delay); + + expect(setTimeoutSpy).toHaveBeenCalledWith(cb, delay); + }); + + it('should return a function for cancelling the scheduled job', () => { + const cancelFn = scheduler.schedule(() => null, 0); + expect(clearTimeoutSpy).not.toHaveBeenCalled(); + + cancelFn(); + expect(clearTimeoutSpy).toHaveBeenCalledWith(42); + }); + }); + + describe('scheduleBeforeRender()', () => { + if (typeof window.requestAnimationFrame === 'undefined') { + const mockCancelFn = () => undefined; + let scheduleSpy: jasmine.Spy; + + beforeEach( + () => scheduleSpy = spyOn(scheduler, 'schedule').and.returnValue(mockCancelFn)); + + it('should delegate to `scheduler.schedule()`', () => { + const cb = () => null; + expect(scheduler.scheduleBeforeRender(cb)).toBe(mockCancelFn); + expect(scheduleSpy).toHaveBeenCalledWith(cb, 16); + }); + } else { + let requestAnimationFrameSpy: jasmine.Spy; + let cancelAnimationFrameSpy: jasmine.Spy; + + beforeEach(() => { + requestAnimationFrameSpy = spyOn(window, 'requestAnimationFrame').and.returnValue(42); + cancelAnimationFrameSpy = spyOn(window, 'cancelAnimationFrame'); + }); + + it('should delegate to `window.requestAnimationFrame()`', () => { + const cb = () => null; + scheduler.scheduleBeforeRender(cb); + expect(requestAnimationFrameSpy).toHaveBeenCalledWith(cb); + }); + + it('should return a function for cancelling the scheduled job', () => { + const cancelFn = scheduler.scheduleBeforeRender(() => null); + expect(cancelAnimationFrameSpy).not.toHaveBeenCalled(); + + cancelFn(); + expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(42); + }); + } + }); + }); + + describe('camelToKebabCase()', () => { + it('should convert camel-case to kebab-case', () => { + expect(camelToKebabCase('fooBarBazQux')).toBe('foo-bar-baz-qux'); + expect(camelToKebabCase('foo1Bar2Baz3Qux4')).toBe('foo1-bar2-baz3-qux4'); + }); + + it('should keep existing dashes', + () => { expect(camelToKebabCase('fooBar-baz-Qux')).toBe('foo-bar-baz--qux'); }); + }); + + describe('createCustomEvent()', () => { + it('should create a custom event (with appropriate properties)', () => { + const value = {bar: 'baz'}; + const event = createCustomEvent(document, 'foo', value); + + expect(event).toEqual(jasmine.any(CustomEvent)); + expect(event).toEqual(jasmine.any(Event)); + expect(event.type).toBe('foo'); + expect(event.bubbles).toBe(false); + expect(event.cancelable).toBe(false); + expect(event.detail).toEqual(value); + }); + + }); + + describe('getComponentName()', () => { + it('should return the component\'s name', () => { + class Foo {} + expect(getComponentName(Foo)).toBe('Foo'); + }); + + it('should return the `overriddenName` (if present)', () => { + class Foo { + static overriddenName = 'Bar'; + } + expect(getComponentName(Foo)).toBe('Bar'); + }); + + it('should return the first line of the stringified component if no name', () => { + const Foo = {toString: () => 'Baz\nQux'}; + expect(getComponentName(Foo as Type)).toBe('Baz'); + }); + }); + + describe('isElement()', () => { + it('should return true for Element nodes', () => { + const elems = [ + document.body, + document.createElement('div'), + document.createElement('option'), + document.documentElement, + ]; + + elems.forEach(n => expect(isElement(n)).toBe(true)); + }); + + it('should return false for non-Element nodes', () => { + const nonElems = [ + document, + document.createAttribute('foo'), + document.createDocumentFragment(), + document.createComment('bar'), + document.createTextNode('baz'), + ]; + + nonElems.forEach(n => expect(isElement(n)).toBe(false)); + }); + }); + + describe('isFunction()', () => { + it('should return true for functions', () => { + const obj = {foo: function() {}, bar: () => null, baz() {}}; + const fns = [ + function(){}, + () => null, + obj.foo, + obj.bar, + obj.baz, + Function, + Date, + ]; + + fns.forEach(v => expect(isFunction(v)).toBe(true)); + }); + + it('should return false for non-functions', () => { + const nonFns = [ + undefined, + null, + true, + 42, + {}, + ]; + + nonFns.forEach(v => expect(isFunction(v)).toBe(false)); + }); + }); + + describe('kebabToCamelCase()', () => { + it('should convert camel-case to kebab-case', () => { + expect(kebabToCamelCase('foo-bar-baz-qux')).toBe('fooBarBazQux'); + expect(kebabToCamelCase('foo1-bar2-baz3-qux4')).toBe('foo1Bar2Baz3Qux4'); + expect(kebabToCamelCase('foo-1-bar-2-baz-3-qux-4')).toBe('foo1Bar2Baz3Qux4'); + }); + + it('should keep uppercase letters', () => { + expect(kebabToCamelCase('foo-barBaz-Qux')).toBe('fooBarBaz-Qux'); + expect(kebabToCamelCase('foo-barBaz--qux')).toBe('fooBarBaz-Qux'); + }); + }); + + describe('matchesSelector()', () => { + let li: HTMLLIElement; + + beforeEach(() => { + const div = document.createElement('div'); + div.innerHTML = ` +
+ +
    +
  • +
+
+ `; + li = div.querySelector('li') !; + }); + + it('should return whether the element matches the selector', () => { + expect(matchesSelector(li, 'li')).toBe(true); + expect(matchesSelector(li, '.qux')).toBe(true); + expect(matchesSelector(li, '#quxLi')).toBe(true); + expect(matchesSelector(li, '.qux#quxLi:not(.quux)')).toBe(true); + expect(matchesSelector(li, '.bar > #bazUl > li')).toBe(true); + expect(matchesSelector(li, '.bar .baz ~ .baz li')).toBe(true); + + expect(matchesSelector(li, 'ol')).toBe(false); + expect(matchesSelector(li, '.quux')).toBe(false); + expect(matchesSelector(li, '#quuxOl')).toBe(false); + expect(matchesSelector(li, '.qux#quxLi:not(li)')).toBe(false); + expect(matchesSelector(li, '.bar > #bazUl > .quxLi')).toBe(false); + expect(matchesSelector(li, 'div span ul li')).toBe(false); + }); + }); + + describe('strictEquals()', () => { + it('should perform strict equality check', () => { + const values = [ + undefined, + null, + true, + false, + 42, + '42', + () => undefined, + () => undefined, + {}, + {}, + ]; + + values.forEach((v1, i) => { + values.forEach((v2, j) => { expect(strictEquals(v1, v2)).toBe(i === j); }); + }); + }); + + it('should consider two `NaN` values equals', () => { + expect(strictEquals(NaN, NaN)).toBe(true); + expect(strictEquals(NaN, 'foo')).toBe(false); + expect(strictEquals(NaN, 42)).toBe(false); + expect(strictEquals(NaN, null)).toBe(false); + expect(strictEquals(NaN, undefined)).toBe(false); + }); + }); + + describe('throwError()', () => { + it('should throw an error based on the specified message', + () => { expect(() => throwError('Test')).toThrowError('Test'); }); + }); + }); +} diff --git a/packages/elements/testing/index.ts b/packages/elements/testing/index.ts new file mode 100644 index 0000000000..845ab36f6b --- /dev/null +++ b/packages/elements/testing/index.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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 {scheduler} from '../src/utils'; + +export interface MockScheduler { + schedule: (typeof scheduler)['schedule']; + scheduleBeforeRender: (typeof scheduler)['scheduleBeforeRender']; +} + +export class AsyncMockScheduler implements MockScheduler { + private uid = 0; + private pendingBeforeRenderCallbacks: ({id: number, cb: () => void})[] = []; + private pendingDelayedCallbacks: ({id: number, cb: () => void, delay: number})[] = []; + + flushBeforeRender(): void { + while (this.pendingBeforeRenderCallbacks.length) { + const cb = this.pendingBeforeRenderCallbacks.shift() !.cb; + cb(); + } + } + + reset(): void { + this.pendingBeforeRenderCallbacks.length = 0; + this.pendingDelayedCallbacks.length = 0; + } + + schedule(cb: () => void, delay: number): () => void { + const id = ++this.uid; + let idx = this.pendingDelayedCallbacks.length; + + for (let i = this.pendingDelayedCallbacks.length - 1; i >= 0; --i) { + if (this.pendingDelayedCallbacks[i].delay <= delay) { + idx = i + 1; + break; + } + } + this.pendingDelayedCallbacks.splice(idx, 0, {id, cb, delay}); + + return () => this.remove(id, this.pendingDelayedCallbacks); + } + + scheduleBeforeRender(cb: () => void): () => void { + const id = ++this.uid; + this.pendingBeforeRenderCallbacks.push({id, cb}); + return () => this.remove(id, this.pendingBeforeRenderCallbacks); + } + + tick(ms: number): void { + this.flushBeforeRender(); + + this.pendingDelayedCallbacks.forEach(item => item.delay -= ms); + this.pendingDelayedCallbacks = this.pendingDelayedCallbacks.filter(item => { + if (item.delay <= 0) { + const cb = item.cb; + cb(); + return false; + } + return true; + }); + } + + private remove(id: number, items: {id: number}[]): void { + for (let i = 0, ii = items.length; i < ii; ++i) { + if (items[i].id === id) { + items.splice(i, 1); + break; + } + } + } +} + +export class SyncMockScheduler implements MockScheduler { + schedule(cb: () => void, delay: number): () => void { + cb(); + return () => undefined; + } + + scheduleBeforeRender(cb: () => void): () => void { + cb(); + return () => undefined; + } +} + +export function installMockScheduler(isSync?: false): AsyncMockScheduler; +export function installMockScheduler(isSync: true): SyncMockScheduler; +export function installMockScheduler(isSync?: boolean): AsyncMockScheduler|SyncMockScheduler { + const mockScheduler = isSync ? new SyncMockScheduler() : new AsyncMockScheduler(); + + Object.keys(scheduler).forEach((method: keyof typeof scheduler) => { + spyOn(scheduler, method).and.callFake(mockScheduler[method].bind(mockScheduler)); + }); + + return mockScheduler; +}