UNPKG

countup.js

Version:

Animates a numerical value by counting to it

660 lines (509 loc) 20.7 kB
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('smart easing', () => { let rafQueue: FrameRequestCallback[] = []; const setupControllableRAF = () => { rafQueue = []; time = 0; jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => { rafQueue.push(cb); return rafQueue.length; }); jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined); }; const advanceTime = (ms: number) => { time += ms; const cb = rafQueue.shift(); if (cb) cb(time); }; const runToCompletion = (frameMs = 100, maxFrames = 50) => { let frames = 0; while (rafQueue.length > 0 && frames < maxFrames) { advanceTime(frameMs); frames++; } }; beforeEach(() => { setupControllableRAF(); }); it('should use finalEndVal of 0 when determining direction during smart easing', () => { countUp = new CountUp('target', 333, { startVal: 1500, duration: 2 }); countUp.finalEndVal = 0; countUp.endVal = 333; countUp.determineDirectionAndSmartEasing(); expect(countUp.finalEndVal).toBe(0); expect(countUp.endVal).toBe(333); expect(countUp.countDown).toBe(true); }); it('should preserve full remaining duration when pausing a smart easing animation', () => { countUp = new CountUp('target', 0, { startVal: 2000, duration: 2 }); countUp.start(); advanceTime(100); const phaseDuration = countUp.duration; const phaseRemaining = countUp.remaining; countUp.pauseResume(); expect(countUp.finalEndVal).toBe(0); expect(countUp.remaining).toBe(phaseDuration + phaseRemaining); }); it('should count down to zero after pause and resume during smart easing', () => { countUp = new CountUp('target', 0, { startVal: 2000, duration: 2 }); countUp.start(); advanceTime(100); countUp.pauseResume(); countUp.pauseResume(); runToCompletion(); expect(getTargetHtml()).toEqual('0'); }); }); 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); }); }); });