feat(elements): implement `utils`
This commit is contained in:
parent
ebfa204af0
commit
24f17f913a
|
@ -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<any>): 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);
|
||||||
|
}
|
|
@ -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<any>)).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 = `
|
||||||
|
<div class="bar" id="barDiv">
|
||||||
|
<span class="baz"></span>
|
||||||
|
<ul class="baz" id="bazUl">
|
||||||
|
<li class="qux" id="quxLi"></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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'); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
Loading…
Reference in New Issue