avances en plantillas
This commit is contained in:
parent
0f84beacf1
commit
da0530d79b
2062 changed files with 598814 additions and 22 deletions
591
storage/public/dist/libs/countup.js/src/countUp.spec.ts
vendored
Normal file
591
storage/public/dist/libs/countup.js/src/countUp.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,591 @@
|
|||
import { CountUp, CountUpPlugin } from './countUp';
|
||||
|
||||
type IntersectionCallback = (entries: Partial<IntersectionObserverEntry>[]) => void;
|
||||
|
||||
class MockIntersectionObserver {
|
||||
callback: IntersectionCallback;
|
||||
elements: Element[] = [];
|
||||
static instances: MockIntersectionObserver[] = [];
|
||||
|
||||
constructor(callback: IntersectionCallback) {
|
||||
this.callback = callback;
|
||||
MockIntersectionObserver.instances.push(this);
|
||||
}
|
||||
observe(el: Element) { this.elements.push(el); }
|
||||
unobserve(el: Element) { this.elements = this.elements.filter(e => e !== el); }
|
||||
disconnect() { this.elements = []; }
|
||||
|
||||
trigger(isIntersecting: boolean) {
|
||||
this.callback(this.elements.map(target => ({ isIntersecting, target } as Partial<IntersectionObserverEntry>)));
|
||||
}
|
||||
}
|
||||
|
||||
describe('CountUp', () => {
|
||||
|
||||
let countUp;
|
||||
let time;
|
||||
|
||||
const getTargetHtml = () => document.getElementById('target')?.innerHTML;
|
||||
const resetRAF = () => {
|
||||
time = 0;
|
||||
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => {
|
||||
time += 100;
|
||||
if (time < 2500) {
|
||||
return cb(time) as any;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML =
|
||||
'<div>' +
|
||||
' <h1 id="target"></h1>' +
|
||||
'</div>';
|
||||
|
||||
(window as any).IntersectionObserver = MockIntersectionObserver;
|
||||
MockIntersectionObserver.instances = [];
|
||||
|
||||
countUp = new CountUp('target', 100);
|
||||
resetRAF();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
|
||||
it('should create for a valid target, and print startVal', () => {
|
||||
expect(countUp).toBeTruthy();
|
||||
expect(countUp.error.length).toBe(0);
|
||||
expect(getTargetHtml()).toEqual('0');
|
||||
});
|
||||
|
||||
it('should set an error for a bad target', () => {
|
||||
countUp = new CountUp('notThere', 100);
|
||||
|
||||
expect(countUp.error.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should set an error for a bad endVal', () => {
|
||||
const endVal = '%' as any;
|
||||
countUp = new CountUp('target', endVal);
|
||||
|
||||
expect(countUp.error.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should set an error for a bad startVal', () => {
|
||||
const startVal = 'oops' as any;
|
||||
countUp = new CountUp('target', 100, { startVal });
|
||||
|
||||
expect(countUp.error.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return a value for version', () => {
|
||||
expect(countUp.version).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should support getting endVal from the target element', () => {
|
||||
document.body.innerHTML =
|
||||
'<div>' +
|
||||
' <h1 id="target">1,500</h1>' +
|
||||
'</div>';
|
||||
|
||||
countUp = new CountUp('target');
|
||||
expect(countUp.endVal).toBe(1500);
|
||||
});
|
||||
|
||||
it('should set an error when endVal is omitted and not in target element', () => {
|
||||
document.body.innerHTML =
|
||||
'<div>' +
|
||||
' <h1 id="target"></h1>' +
|
||||
'</div>';
|
||||
countUp = new CountUp('target');
|
||||
expect(countUp.error.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not call parse when an endVal is passed to the constructor', () => {
|
||||
const parseSpy = jest.spyOn(CountUp.prototype, 'parse');
|
||||
|
||||
countUp = new CountUp('target', 0, { startVal: 100 });
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
parseSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('class methods', () => {
|
||||
describe('# start', () => {
|
||||
it('should count when start method is called', () => {
|
||||
countUp.start();
|
||||
|
||||
expect(getTargetHtml()).toEqual('100');
|
||||
});
|
||||
|
||||
it('should use a callback provided to start', () => {
|
||||
const cb = jest.fn();
|
||||
countUp.start(cb);
|
||||
|
||||
expect(getTargetHtml()).toEqual('100');
|
||||
expect(cb).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('# pauseResume', () => {
|
||||
it('should pause when pauseResume is called', () => {
|
||||
countUp.start();
|
||||
countUp.pauseResume();
|
||||
|
||||
expect(countUp.paused).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('# reset', () => {
|
||||
it('should reset when reset is called', () => {
|
||||
countUp.start();
|
||||
countUp.reset();
|
||||
|
||||
expect(getTargetHtml()).toEqual('0');
|
||||
expect(countUp.paused).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('# update', () => {
|
||||
it('should update when update is called', () => {
|
||||
countUp.start();
|
||||
expect(getTargetHtml()).toEqual('100');
|
||||
|
||||
resetRAF();
|
||||
countUp.update(200);
|
||||
expect(getTargetHtml()).toEqual('200');
|
||||
});
|
||||
});
|
||||
|
||||
describe('# onDestroy', () => {
|
||||
it('should cancel a running animation', () => {
|
||||
const cancelSpy = jest.spyOn(window, 'cancelAnimationFrame');
|
||||
countUp.start();
|
||||
countUp.onDestroy();
|
||||
|
||||
expect(cancelSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set paused to true', () => {
|
||||
countUp.start();
|
||||
expect(countUp.paused).toBe(false);
|
||||
|
||||
countUp.onDestroy();
|
||||
expect(countUp.paused).toBe(true);
|
||||
});
|
||||
|
||||
it('should disconnect the observer', () => {
|
||||
countUp = new CountUp('target', 100, { autoAnimate: true });
|
||||
const observer = MockIntersectionObserver.instances[MockIntersectionObserver.instances.length - 1];
|
||||
const disconnectSpy = jest.spyOn(observer, 'disconnect');
|
||||
|
||||
countUp.onDestroy();
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear onCompleteCallback', () => {
|
||||
const cb = jest.fn();
|
||||
countUp = new CountUp('target', 100, { onCompleteCallback: cb });
|
||||
|
||||
countUp.onDestroy();
|
||||
expect(countUp.options.onCompleteCallback).toBeNull();
|
||||
});
|
||||
|
||||
it('should clear onStartCallback', () => {
|
||||
const cb = jest.fn();
|
||||
countUp = new CountUp('target', 100, { onStartCallback: cb });
|
||||
|
||||
countUp.onDestroy();
|
||||
expect(countUp.options.onStartCallback).toBeNull();
|
||||
});
|
||||
|
||||
it('should prevent onCompleteCallback from firing after destroy', () => {
|
||||
const cb = jest.fn();
|
||||
countUp = new CountUp('target', 100, { onCompleteCallback: cb });
|
||||
countUp.onDestroy();
|
||||
|
||||
resetRAF();
|
||||
countUp.start();
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should be safe to call on a fresh instance', () => {
|
||||
countUp = new CountUp('target', 100);
|
||||
expect(() => countUp.onDestroy()).not.toThrow();
|
||||
expect(countUp.paused).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('# parse', () => {
|
||||
it('should properly parse numbers', () => {
|
||||
countUp = new CountUp('target', 0);
|
||||
const result0 = countUp.parse('14,921.00123');
|
||||
|
||||
countUp = new CountUp('target', 0, { separator: '.', decimal: ',' });
|
||||
const result1 = countUp.parse('1.500,0');
|
||||
|
||||
countUp = new CountUp('target', 0, { separator: ' ' });
|
||||
const result2 = countUp.parse('2 800');
|
||||
|
||||
expect(result0).toEqual(14921.00123);
|
||||
expect(result1).toEqual(1500);
|
||||
expect(result2).toEqual(2800);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('various use-cases', () => {
|
||||
it('should handle large numbers', () => {
|
||||
countUp = new CountUp('target', 6000);
|
||||
const spy = jest.spyOn(countUp, 'determineDirectionAndSmartEasing');
|
||||
countUp.start();
|
||||
|
||||
expect(getTargetHtml()).toEqual('6,000');
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not use easing when specified with a large number (auto-smooth)', () => {
|
||||
countUp = new CountUp('target', 6000, { useEasing: false });
|
||||
const spy = jest.spyOn(countUp, 'easingFn');
|
||||
countUp.start();
|
||||
|
||||
expect(getTargetHtml()).toEqual('6,000');
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should count down when endVal is less than startVal', () => {
|
||||
countUp = new CountUp('target', 10, { startVal: 500 });
|
||||
expect(getTargetHtml()).toEqual('500');
|
||||
countUp.start();
|
||||
|
||||
expect(getTargetHtml()).toEqual('10');
|
||||
});
|
||||
|
||||
it('should handle negative numbers', () => {
|
||||
countUp = new CountUp('target', -500);
|
||||
countUp.start();
|
||||
|
||||
expect(getTargetHtml()).toEqual('-500');
|
||||
});
|
||||
|
||||
it('should properly handle a zero duration', () => {
|
||||
countUp = new CountUp('target', 2000, { duration: 0 });
|
||||
countUp.start();
|
||||
|
||||
expect(getTargetHtml()).toEqual('2,000');
|
||||
});
|
||||
|
||||
it('should call the callback when finished if there is one', () => {
|
||||
const cb = jest.fn();
|
||||
countUp.start(cb);
|
||||
|
||||
expect(getTargetHtml()).toEqual('100');
|
||||
expect(cb).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('options', () => {
|
||||
it('should respect the decimalPlaces option', () => {
|
||||
countUp = new CountUp('target', 100, { decimalPlaces: 2 });
|
||||
countUp.start();
|
||||
|
||||
expect(getTargetHtml()).toEqual('100.00');
|
||||
});
|
||||
|
||||
it('should respect the duration option', () => {
|
||||
countUp = new CountUp('target', 100, { duration: 1 });
|
||||
countUp.start();
|
||||
|
||||
expect(getTargetHtml()).toEqual('100');
|
||||
});
|
||||
|
||||
it('should respect the useEasing option', () => {
|
||||
countUp = new CountUp('target', 100, { useEasing: false });
|
||||
countUp.start();
|
||||
|
||||
expect(getTargetHtml()).toEqual('100');
|
||||
});
|
||||
|
||||
it('should respect the useGrouping option', () => {
|
||||
countUp = new CountUp('target', 100000, { useGrouping: false });
|
||||
countUp.start();
|
||||
|
||||
expect(getTargetHtml()).toEqual('100000');
|
||||
resetRAF();
|
||||
|
||||
countUp = new CountUp('target', 1000000, { useGrouping: true });
|
||||
countUp.start();
|
||||
|
||||
expect(getTargetHtml()).toEqual('1,000,000');
|
||||
});
|
||||
|
||||
it('should respect the useIndianSeparators option', () => {
|
||||
countUp = new CountUp('target', 100000, { useIndianSeparators: true });
|
||||
countUp.start();
|
||||
|
||||
expect(getTargetHtml()).toEqual('1,00,000');
|
||||
resetRAF();
|
||||
|
||||
countUp = new CountUp('target', 10000000, { useIndianSeparators: true });
|
||||
countUp.start();
|
||||
|
||||
expect(getTargetHtml()).toEqual('1,00,00,000');
|
||||
});
|
||||
|
||||
it('should respect the separator option', () => {
|
||||
countUp = new CountUp('target', 10000, { separator: ':' });
|
||||
countUp.start();
|
||||
|
||||
expect(getTargetHtml()).toEqual('10:000');
|
||||
});
|
||||
|
||||
it('should respect the decimal option', () => {
|
||||
countUp = new CountUp('target', 100, { decimal: ',', decimalPlaces: 1 });
|
||||
countUp.start();
|
||||
|
||||
expect(getTargetHtml()).toEqual('100,0');
|
||||
});
|
||||
|
||||
it('should respect the easingFn option', () => {
|
||||
const easeOutQuintic = jest.fn().mockReturnValue(100);
|
||||
countUp = new CountUp('target', 100, { easingFn: easeOutQuintic });
|
||||
countUp.start();
|
||||
|
||||
expect(easeOutQuintic).toHaveBeenCalled();
|
||||
expect(getTargetHtml()).toEqual('100');
|
||||
});
|
||||
|
||||
it('should respect the formattingFn option', () => {
|
||||
const formatter = jest.fn().mockReturnValue('~100~');
|
||||
countUp = new CountUp('target', 100, { formattingFn: formatter });
|
||||
countUp.start();
|
||||
|
||||
expect(formatter).toHaveBeenCalled();
|
||||
expect(getTargetHtml()).toEqual('~100~');
|
||||
});
|
||||
|
||||
it('should respect the prefix option', () => {
|
||||
countUp = new CountUp('target', 100, { prefix: '$' });
|
||||
countUp.start();
|
||||
|
||||
expect(getTargetHtml()).toEqual('$100');
|
||||
});
|
||||
|
||||
it('should respect the suffix option', () => {
|
||||
countUp = new CountUp('target', 100, { suffix: '!' });
|
||||
countUp.start();
|
||||
|
||||
expect(getTargetHtml()).toEqual('100!');
|
||||
});
|
||||
|
||||
it('should respect the numerals option', () => {
|
||||
const numerals = [')', '!', '@', '#', '$', '%', '^', '&', '*', '('];
|
||||
countUp = new CountUp('target', 100, { numerals });
|
||||
countUp.start();
|
||||
|
||||
expect(getTargetHtml()).toEqual('!))');
|
||||
});
|
||||
|
||||
it('should respect the onCompleteCallback option', () => {
|
||||
const options = { onCompleteCallback: jest.fn() };
|
||||
const callbackSpy = jest.spyOn(options, 'onCompleteCallback');
|
||||
countUp = new CountUp('target', 100, options);
|
||||
countUp.start();
|
||||
|
||||
expect(getTargetHtml()).toEqual('100');
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should respect the onStartCallback option', () => {
|
||||
const options = { onStartCallback: jest.fn() };
|
||||
const callbackSpy = jest.spyOn(options, 'onStartCallback');
|
||||
countUp = new CountUp('target', 100, options);
|
||||
countUp.start();
|
||||
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
expect(getTargetHtml()).toEqual('100');
|
||||
});
|
||||
|
||||
it('should respect the plugin option', () => {
|
||||
const plugin: CountUpPlugin = {
|
||||
render: (el, result) => {
|
||||
el.innerHTML = result;
|
||||
}
|
||||
};
|
||||
countUp = new CountUp('target', 1000, {
|
||||
plugin,
|
||||
useGrouping: true
|
||||
});
|
||||
countUp.start();
|
||||
|
||||
expect(getTargetHtml()).toEqual('1,000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoAnimate (IntersectionObserver)', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ doNotFake: ['requestAnimationFrame'] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should create an IntersectionObserver when autoAnimate is true', () => {
|
||||
countUp = new CountUp('target', 100, { autoAnimate: true });
|
||||
|
||||
expect(MockIntersectionObserver.instances.length).toBe(1);
|
||||
expect(MockIntersectionObserver.instances[0].elements).toContain(countUp.el);
|
||||
});
|
||||
|
||||
it('should not create an observer when autoAnimate is false', () => {
|
||||
MockIntersectionObserver.instances = [];
|
||||
countUp = new CountUp('target', 100);
|
||||
|
||||
expect(MockIntersectionObserver.instances.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should start animation when element becomes visible', () => {
|
||||
countUp = new CountUp('target', 100, { autoAnimate: true, autoAnimateDelay: 0 });
|
||||
resetRAF();
|
||||
const observer = MockIntersectionObserver.instances[0];
|
||||
|
||||
observer.trigger(true);
|
||||
jest.advanceTimersByTime(0);
|
||||
|
||||
expect(getTargetHtml()).toEqual('100');
|
||||
});
|
||||
|
||||
it('should respect autoAnimateDelay before starting', () => {
|
||||
countUp = new CountUp('target', 100, { autoAnimate: true, autoAnimateDelay: 500 });
|
||||
resetRAF();
|
||||
const startSpy = jest.spyOn(countUp, 'start');
|
||||
const observer = MockIntersectionObserver.instances[0];
|
||||
|
||||
observer.trigger(true);
|
||||
expect(startSpy).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(500);
|
||||
expect(startSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reset when element goes out of view', () => {
|
||||
countUp = new CountUp('target', 100, { autoAnimate: true, autoAnimateDelay: 0 });
|
||||
resetRAF();
|
||||
const observer = MockIntersectionObserver.instances[0];
|
||||
|
||||
observer.trigger(true);
|
||||
jest.advanceTimersByTime(0);
|
||||
expect(getTargetHtml()).toEqual('100');
|
||||
|
||||
observer.trigger(false);
|
||||
expect(countUp.paused).toBe(true);
|
||||
expect(getTargetHtml()).toEqual('0');
|
||||
});
|
||||
|
||||
it('should disconnect observer when autoAnimateOnce is true', () => {
|
||||
countUp = new CountUp('target', 100, { autoAnimate: true, autoAnimateOnce: true, autoAnimateDelay: 0 });
|
||||
const observer = MockIntersectionObserver.instances[0];
|
||||
const disconnectSpy = jest.spyOn(observer, 'disconnect');
|
||||
|
||||
observer.trigger(true);
|
||||
jest.advanceTimersByTime(0);
|
||||
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
expect(countUp.once).toBe(true);
|
||||
});
|
||||
|
||||
it('should not disconnect observer when autoAnimateOnce is false', () => {
|
||||
countUp = new CountUp('target', 100, { autoAnimate: true, autoAnimateOnce: false, autoAnimateDelay: 0 });
|
||||
const observer = MockIntersectionObserver.instances[0];
|
||||
const disconnectSpy = jest.spyOn(observer, 'disconnect');
|
||||
|
||||
observer.trigger(true);
|
||||
jest.advanceTimersByTime(0);
|
||||
|
||||
expect(disconnectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not re-animate after first run when autoAnimateOnce is true', () => {
|
||||
countUp = new CountUp('target', 100, { autoAnimate: true, autoAnimateOnce: true, autoAnimateDelay: 0 });
|
||||
resetRAF();
|
||||
const observer = MockIntersectionObserver.instances[MockIntersectionObserver.instances.length - 1];
|
||||
|
||||
observer.trigger(true);
|
||||
jest.advanceTimersByTime(0);
|
||||
expect(getTargetHtml()).toEqual('100');
|
||||
|
||||
// observer was disconnected so subsequent triggers process no entries
|
||||
observer.trigger(false);
|
||||
expect(getTargetHtml()).toEqual('100');
|
||||
|
||||
observer.trigger(true);
|
||||
jest.advanceTimersByTime(0);
|
||||
expect(getTargetHtml()).toEqual('100');
|
||||
});
|
||||
|
||||
it('should allow re-animation after manual reset when autoAnimateOnce is true', () => {
|
||||
countUp = new CountUp('target', 100, { autoAnimate: true, autoAnimateOnce: true, autoAnimateDelay: 0 });
|
||||
resetRAF();
|
||||
const observer = MockIntersectionObserver.instances[MockIntersectionObserver.instances.length - 1];
|
||||
|
||||
observer.trigger(true);
|
||||
jest.advanceTimersByTime(0);
|
||||
expect(getTargetHtml()).toEqual('100');
|
||||
expect(countUp.once).toBe(true);
|
||||
|
||||
// manual reset clears the once flag
|
||||
countUp.reset();
|
||||
expect(getTargetHtml()).toEqual('0');
|
||||
expect(countUp.once).toBe(false);
|
||||
|
||||
// re-observe and trigger — animation should play again
|
||||
observer.observe(countUp.el);
|
||||
resetRAF();
|
||||
observer.trigger(true);
|
||||
jest.advanceTimersByTime(0);
|
||||
|
||||
expect(getTargetHtml()).toEqual('100');
|
||||
});
|
||||
|
||||
it('should support multiple independent instances', () => {
|
||||
document.body.innerHTML =
|
||||
'<h1 id="target1"></h1>' +
|
||||
'<h1 id="target2"></h1>';
|
||||
MockIntersectionObserver.instances = [];
|
||||
|
||||
const cu1 = new CountUp('target1', 50, { autoAnimate: true, autoAnimateDelay: 0 });
|
||||
const cu2 = new CountUp('target2', 200, { autoAnimate: true, autoAnimateDelay: 0 });
|
||||
|
||||
expect(MockIntersectionObserver.instances.length).toBe(2);
|
||||
|
||||
const obs1 = MockIntersectionObserver.instances[0];
|
||||
const obs2 = MockIntersectionObserver.instances[1];
|
||||
|
||||
expect(obs1.elements).toContain(cu1.el);
|
||||
expect(obs2.elements).toContain(cu2.el);
|
||||
expect(obs1).not.toBe(obs2);
|
||||
|
||||
resetRAF();
|
||||
obs1.trigger(true);
|
||||
jest.advanceTimersByTime(0);
|
||||
expect(document.getElementById('target1')!.innerHTML).toEqual('50');
|
||||
expect(cu2.paused).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow cleanup via unobserve()', () => {
|
||||
countUp = new CountUp('target', 100, { autoAnimate: true });
|
||||
const observer = MockIntersectionObserver.instances[0];
|
||||
const disconnectSpy = jest.spyOn(observer, 'disconnect');
|
||||
|
||||
countUp.unobserve();
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should map deprecated enableScrollSpy to autoAnimate', () => {
|
||||
countUp = new CountUp('target', 100, { enableScrollSpy: true });
|
||||
expect(countUp.options.autoAnimate).toBe(true);
|
||||
expect(MockIntersectionObserver.instances.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
425
storage/public/dist/libs/countup.js/src/countUp.ts
vendored
Normal file
425
storage/public/dist/libs/countup.js/src/countUp.ts
vendored
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
export interface CountUpOptions {
|
||||
/** Number to start at @default 0 */
|
||||
startVal?: number;
|
||||
/** Number of decimal places @default 0 */
|
||||
decimalPlaces?: number;
|
||||
/** Animation duration in seconds @default 2 */
|
||||
duration?: number;
|
||||
/** Example: 1,000 vs 1000 @default true */
|
||||
useGrouping?: boolean;
|
||||
/** Example: 1,00,000 vs 100,000 @default false */
|
||||
useIndianSeparators?: boolean;
|
||||
/** Ease animation @default true */
|
||||
useEasing?: boolean;
|
||||
/** Smooth easing for large numbers above this if useEasing @default 999 */
|
||||
smartEasingThreshold?: number;
|
||||
/** Amount to be eased for numbers above threshold @default 333 */
|
||||
smartEasingAmount?: number;
|
||||
/** Grouping separator @default ',' */
|
||||
separator?: string;
|
||||
/** Decimal character @default '.' */
|
||||
decimal?: string;
|
||||
/** Easing function for animation @default easeOutExpo */
|
||||
easingFn?: (t: number, b: number, c: number, d: number) => number;
|
||||
/** Custom function to format the result */
|
||||
formattingFn?: (n: number) => string;
|
||||
/** Text prepended to result */
|
||||
prefix?: string;
|
||||
/** Text appended to result */
|
||||
suffix?: string;
|
||||
/** Numeral glyph substitution */
|
||||
numerals?: string[];
|
||||
/** Callback called when animation completes */
|
||||
onCompleteCallback?: () => any;
|
||||
/** Callback called when animation starts */
|
||||
onStartCallback?: () => any;
|
||||
/** Plugin for alternate animations */
|
||||
plugin?: CountUpPlugin;
|
||||
/** Trigger animation when target becomes visible @default false */
|
||||
autoAnimate?: boolean;
|
||||
/** Animation delay in ms after auto-animate triggers @default 200 */
|
||||
autoAnimateDelay?: number;
|
||||
/** Run animation only once for auto-animate triggers @default false */
|
||||
autoAnimateOnce?: boolean;
|
||||
|
||||
/** @deprecated Please use autoAnimate instead */
|
||||
enableScrollSpy?: boolean;
|
||||
/** @deprecated Please use autoAnimateDelay instead */
|
||||
scrollSpyDelay?: number;
|
||||
/** @deprecated Please use autoAnimateOnce instead */
|
||||
scrollSpyOnce?: boolean;
|
||||
}
|
||||
|
||||
export declare interface CountUpPlugin {
|
||||
render(elem: HTMLElement, formatted: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animates a number by counting to it.
|
||||
* playground: stackblitz.com/edit/countup-typescript
|
||||
*
|
||||
* @param target - id of html element, input, svg text element, or DOM element reference where counting occurs.
|
||||
* @param endVal - the value you want to arrive at.
|
||||
* @param options - optional configuration object for fine-grain control
|
||||
*/
|
||||
export class CountUp {
|
||||
|
||||
version = '2.10.0';
|
||||
private static observedElements = new WeakMap<HTMLElement, CountUp>();
|
||||
private defaults: CountUpOptions = {
|
||||
startVal: 0,
|
||||
decimalPlaces: 0,
|
||||
duration: 2,
|
||||
useEasing: true,
|
||||
useGrouping: true,
|
||||
useIndianSeparators: false,
|
||||
smartEasingThreshold: 999,
|
||||
smartEasingAmount: 333,
|
||||
separator: ',',
|
||||
decimal: '.',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
autoAnimate: false,
|
||||
autoAnimateDelay: 200,
|
||||
autoAnimateOnce: false,
|
||||
};
|
||||
private rAF: any;
|
||||
private autoAnimateTimeout: any;
|
||||
private startTime: number;
|
||||
private remaining: number;
|
||||
private finalEndVal: number = null; // for smart easing
|
||||
private useEasing = true;
|
||||
private countDown = false;
|
||||
private observer: IntersectionObserver;
|
||||
el: HTMLElement | HTMLInputElement;
|
||||
formattingFn: (num: number) => string;
|
||||
easingFn?: (t: number, b: number, c: number, d: number) => number;
|
||||
error = '';
|
||||
startVal = 0;
|
||||
duration: number;
|
||||
paused = true;
|
||||
frameVal: number;
|
||||
once = false;
|
||||
|
||||
constructor(
|
||||
target: string | HTMLElement | HTMLInputElement,
|
||||
private endVal?: number | null,
|
||||
public options?: CountUpOptions
|
||||
) {
|
||||
this.options = {
|
||||
...this.defaults,
|
||||
...options
|
||||
};
|
||||
if (this.options.enableScrollSpy) {
|
||||
this.options.autoAnimate = true;
|
||||
}
|
||||
if (this.options.scrollSpyDelay !== undefined) {
|
||||
this.options.autoAnimateDelay = this.options.scrollSpyDelay;
|
||||
}
|
||||
if (this.options.scrollSpyOnce) {
|
||||
this.options.autoAnimateOnce = true;
|
||||
}
|
||||
this.formattingFn = (this.options.formattingFn) ?
|
||||
this.options.formattingFn : this.formatNumber;
|
||||
this.easingFn = (this.options.easingFn) ?
|
||||
this.options.easingFn : this.easeOutExpo;
|
||||
|
||||
this.el = (typeof target === 'string') ? document.getElementById(target) : target;
|
||||
endVal = endVal == null ? this.parse(this.el.innerHTML) : endVal;
|
||||
|
||||
this.startVal = this.validateValue(this.options.startVal);
|
||||
this.frameVal = this.startVal;
|
||||
this.endVal = this.validateValue(endVal);
|
||||
this.options.decimalPlaces = Math.max(0 || this.options.decimalPlaces);
|
||||
this.resetDuration();
|
||||
this.options.separator = String(this.options.separator);
|
||||
this.useEasing = this.options.useEasing;
|
||||
if (this.options.separator === '') {
|
||||
this.options.useGrouping = false;
|
||||
}
|
||||
if (this.el) {
|
||||
this.printValue(this.startVal);
|
||||
} else {
|
||||
this.error = '[CountUp] target is null or undefined';
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && this.options.autoAnimate) {
|
||||
if (!this.error && typeof IntersectionObserver !== 'undefined') {
|
||||
this.setupObserver();
|
||||
} else {
|
||||
if (this.error) {
|
||||
console.error(this.error, target);
|
||||
} else {
|
||||
console.error('IntersectionObserver is not supported by this browser');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Set up an IntersectionObserver to auto-animate when the target element appears. */
|
||||
private setupObserver(): void {
|
||||
const existing = CountUp.observedElements.get(this.el as HTMLElement);
|
||||
if (existing) {
|
||||
existing.unobserve();
|
||||
}
|
||||
CountUp.observedElements.set(this.el as HTMLElement, this);
|
||||
this.observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting && this.paused && !this.once) {
|
||||
this.paused = false;
|
||||
this.autoAnimateTimeout = setTimeout(() => this.start(), this.options.autoAnimateDelay);
|
||||
if (this.options.autoAnimateOnce) {
|
||||
this.once = true;
|
||||
this.observer.disconnect();
|
||||
}
|
||||
} else if (!entry.isIntersecting && !this.paused) {
|
||||
clearTimeout(this.autoAnimateTimeout);
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
}, { threshold: 0 });
|
||||
this.observer.observe(this.el);
|
||||
}
|
||||
|
||||
/** Disconnect the IntersectionObserver and stop watching this element. */
|
||||
unobserve(): void {
|
||||
clearTimeout(this.autoAnimateTimeout);
|
||||
this.observer?.disconnect();
|
||||
CountUp.observedElements.delete(this.el as HTMLElement);
|
||||
}
|
||||
|
||||
/** Teardown: cancel animation, disconnect observer, clear callbacks. */
|
||||
onDestroy(): void {
|
||||
clearTimeout(this.autoAnimateTimeout);
|
||||
cancelAnimationFrame(this.rAF);
|
||||
this.paused = true;
|
||||
this.unobserve();
|
||||
this.options.onCompleteCallback = null;
|
||||
this.options.onStartCallback = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart easing works by breaking the animation into 2 parts, the second part being the
|
||||
* smartEasingAmount and first part being the total amount minus the smartEasingAmount. It works
|
||||
* by disabling easing for the first part and enabling it on the second part. It is used if
|
||||
* useEasing is true and the total animation amount exceeds the smartEasingThreshold.
|
||||
*/
|
||||
private determineDirectionAndSmartEasing(): void {
|
||||
const end = (this.finalEndVal) ? this.finalEndVal : this.endVal;
|
||||
this.countDown = (this.startVal > end);
|
||||
const animateAmount = end - this.startVal;
|
||||
if (Math.abs(animateAmount) > this.options.smartEasingThreshold && this.options.useEasing) {
|
||||
this.finalEndVal = end;
|
||||
const up = (this.countDown) ? 1 : -1;
|
||||
this.endVal = end + (up * this.options.smartEasingAmount);
|
||||
this.duration = this.duration / 2;
|
||||
} else {
|
||||
this.endVal = end;
|
||||
this.finalEndVal = null;
|
||||
}
|
||||
if (this.finalEndVal !== null) {
|
||||
// setting finalEndVal indicates smart easing
|
||||
this.useEasing = false;
|
||||
} else {
|
||||
this.useEasing = this.options.useEasing;
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the animation. Optionally pass a callback that fires on completion. */
|
||||
start(callback?: (args?: any) => any): void {
|
||||
if (this.error) {
|
||||
return;
|
||||
}
|
||||
if (this.options.onStartCallback) {
|
||||
this.options.onStartCallback();
|
||||
}
|
||||
if (callback) {
|
||||
this.options.onCompleteCallback = callback;
|
||||
}
|
||||
if (this.duration > 0) {
|
||||
this.determineDirectionAndSmartEasing();
|
||||
this.paused = false;
|
||||
this.rAF = requestAnimationFrame(this.count);
|
||||
} else {
|
||||
this.printValue(this.endVal);
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle pause/resume on the animation. */
|
||||
pauseResume(): void {
|
||||
if (!this.paused) {
|
||||
cancelAnimationFrame(this.rAF);
|
||||
} else {
|
||||
this.startTime = null;
|
||||
this.duration = this.remaining;
|
||||
this.startVal = this.frameVal;
|
||||
this.determineDirectionAndSmartEasing();
|
||||
this.rAF = requestAnimationFrame(this.count);
|
||||
}
|
||||
this.paused = !this.paused;
|
||||
}
|
||||
|
||||
/** Reset to startVal so the animation can be run again. */
|
||||
reset(): void {
|
||||
clearTimeout(this.autoAnimateTimeout);
|
||||
cancelAnimationFrame(this.rAF);
|
||||
this.paused = true;
|
||||
this.once = false;
|
||||
this.resetDuration();
|
||||
this.startVal = this.validateValue(this.options.startVal);
|
||||
this.frameVal = this.startVal;
|
||||
this.printValue(this.startVal);
|
||||
}
|
||||
|
||||
/** Pass a new endVal and start the animation. */
|
||||
update(newEndVal: string | number): void {
|
||||
cancelAnimationFrame(this.rAF);
|
||||
this.startTime = null;
|
||||
this.endVal = this.validateValue(newEndVal);
|
||||
if (this.endVal === this.frameVal) {
|
||||
return;
|
||||
}
|
||||
this.startVal = this.frameVal;
|
||||
if (this.finalEndVal == null) {
|
||||
this.resetDuration();
|
||||
}
|
||||
this.finalEndVal = null;
|
||||
this.determineDirectionAndSmartEasing();
|
||||
this.rAF = requestAnimationFrame(this.count);
|
||||
}
|
||||
|
||||
/** Animation frame callback — advances the value each frame. */
|
||||
count = (timestamp: number): void => {
|
||||
if (!this.startTime) { this.startTime = timestamp; }
|
||||
|
||||
const progress = timestamp - this.startTime;
|
||||
this.remaining = this.duration - progress;
|
||||
|
||||
// to ease or not to ease
|
||||
if (this.useEasing) {
|
||||
if (this.countDown) {
|
||||
this.frameVal = this.startVal - this.easingFn(progress, 0, this.startVal - this.endVal, this.duration);
|
||||
} else {
|
||||
this.frameVal = this.easingFn(progress, this.startVal, this.endVal - this.startVal, this.duration);
|
||||
}
|
||||
} else {
|
||||
this.frameVal = this.startVal + (this.endVal - this.startVal) * (progress / this.duration);
|
||||
}
|
||||
|
||||
// don't go past endVal since progress can exceed duration in the last frame
|
||||
const wentPast = this.countDown ? this.frameVal < this.endVal : this.frameVal > this.endVal;
|
||||
this.frameVal = wentPast ? this.endVal : this.frameVal;
|
||||
|
||||
// decimal
|
||||
this.frameVal = Number(this.frameVal.toFixed(this.options.decimalPlaces));
|
||||
|
||||
// format and print value
|
||||
this.printValue(this.frameVal);
|
||||
|
||||
// whether to continue
|
||||
if (progress < this.duration) {
|
||||
this.rAF = requestAnimationFrame(this.count);
|
||||
} else if (this.finalEndVal !== null) {
|
||||
// smart easing
|
||||
this.update(this.finalEndVal);
|
||||
} else {
|
||||
if (this.options.onCompleteCallback) {
|
||||
this.options.onCompleteCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Format and render the given value to the target element. */
|
||||
printValue(val: number): void {
|
||||
if (!this.el) return;
|
||||
const result = this.formattingFn(val);
|
||||
if (this.options.plugin?.render) {
|
||||
this.options.plugin.render(this.el, result);
|
||||
return;
|
||||
}
|
||||
if (this.el.tagName === 'INPUT') {
|
||||
const input = this.el as HTMLInputElement;
|
||||
input.value = result;
|
||||
} else if (this.el.tagName === 'text' || this.el.tagName === 'tspan') {
|
||||
this.el.textContent = result;
|
||||
} else {
|
||||
this.el.innerHTML = result;
|
||||
}
|
||||
}
|
||||
|
||||
/** Return true if the value is a finite number. */
|
||||
ensureNumber(n: any): boolean {
|
||||
return (typeof n === 'number' && !isNaN(n));
|
||||
}
|
||||
|
||||
/** Validate and convert a value to a number, setting an error if invalid. */
|
||||
validateValue(value: string | number): number {
|
||||
const newValue = Number(value);
|
||||
if (!this.ensureNumber(newValue)) {
|
||||
this.error = `[CountUp] invalid start or end value: ${value}`;
|
||||
return null;
|
||||
} else {
|
||||
return newValue;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset startTime, duration, and remaining to their initial values. */
|
||||
private resetDuration(): void {
|
||||
this.startTime = null;
|
||||
this.duration = Number(this.options.duration) * 1000;
|
||||
this.remaining = this.duration;
|
||||
}
|
||||
|
||||
/** Default number formatter with grouping, decimals, prefix/suffix, and numeral substitution. */
|
||||
formatNumber = (num: number): string => {
|
||||
const neg = (num < 0) ? '-' : '';
|
||||
let result: string, x1: string, x2: string, x3: string;
|
||||
result = Math.abs(num).toFixed(this.options.decimalPlaces);
|
||||
result += '';
|
||||
const x = result.split('.');
|
||||
x1 = x[0];
|
||||
x2 = x.length > 1 ? this.options.decimal + x[1] : '';
|
||||
if (this.options.useGrouping) {
|
||||
x3 = '';
|
||||
let factor = 3, j = 0;
|
||||
for (let i = 0, len = x1.length; i < len; ++i) {
|
||||
if (this.options.useIndianSeparators && i === 4) {
|
||||
factor = 2;
|
||||
j = 1;
|
||||
}
|
||||
if (i !== 0 && (j % factor) === 0) {
|
||||
x3 = this.options.separator + x3;
|
||||
}
|
||||
j++;
|
||||
x3 = x1[len - i - 1] + x3;
|
||||
}
|
||||
x1 = x3;
|
||||
}
|
||||
// optional numeral substitution
|
||||
if (this.options.numerals && this.options.numerals.length) {
|
||||
x1 = x1.replace(/[0-9]/g, (w) => this.options.numerals[+w]);
|
||||
x2 = x2.replace(/[0-9]/g, (w) => this.options.numerals[+w]);
|
||||
}
|
||||
return neg + this.options.prefix + x1 + x2 + this.options.suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default easing function (easeOutExpo).
|
||||
* @param t current time
|
||||
* @param b beginning value
|
||||
* @param c change in value
|
||||
* @param d duration
|
||||
*/
|
||||
easeOutExpo = (t: number, b: number, c: number, d: number): number =>
|
||||
c * (-Math.pow(2, -10 * t / d) + 1) * 1024 / 1023 + b;
|
||||
|
||||
/** Parse a formatted string back to a number using the current separator/decimal options. */
|
||||
parse(number: string): number {
|
||||
// eslint-disable-next-line no-irregular-whitespace
|
||||
const escapeRegExp = (s: string) => s.replace(/([.,' ])/g, '\\$1');
|
||||
const sep = escapeRegExp(this.options.separator);
|
||||
const dec = escapeRegExp(this.options.decimal);
|
||||
const num = number.replace(new RegExp(sep, 'g'), '').replace(new RegExp(dec, 'g'), '.');
|
||||
return parseFloat(num)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue