mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 21:27:06 +04:00
417 lines
13 KiB
TypeScript
417 lines
13 KiB
TypeScript
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
|
|
import { clearFixture, getFixture } from '../helpers/fixture'
|
|
import CountUp from '../../src/countup'
|
|
|
|
const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
|
describe('CountUp', () => {
|
|
let fixtureEl: HTMLElement
|
|
|
|
beforeAll(() => {
|
|
fixtureEl = getFixture()
|
|
})
|
|
|
|
beforeEach(() => {
|
|
fixtureEl.innerHTML = '<h1 data-countup>30000</h1>'
|
|
})
|
|
|
|
afterEach(() => {
|
|
clearFixture()
|
|
})
|
|
|
|
const element = (): HTMLElement => fixtureEl.querySelector('h1')!
|
|
// The fixture sits off screen, so an auto-animated countup never starts on its own.
|
|
const quick = (config: Record<string, unknown> = {}): CountUp => new CountUp(element(), { autoAnimate: false, duration: 0.1, ...config })
|
|
|
|
describe('NAME', () => {
|
|
it('should return plugin name', () => {
|
|
expect(CountUp.NAME).toBe('countup')
|
|
})
|
|
})
|
|
|
|
describe('constructor', () => {
|
|
it('should print the start value and wait for the viewport with autoAnimate', async () => {
|
|
new CountUp(element())
|
|
await wait(50)
|
|
|
|
expect(element().textContent).toBe('0')
|
|
})
|
|
|
|
it('should count to the parsed number when autoAnimate is off', async () => {
|
|
const spy = vi.fn()
|
|
element().addEventListener('complete.bs.countup', spy)
|
|
quick()
|
|
await wait(300)
|
|
|
|
expect(element().textContent).toBe('30,000')
|
|
expect(spy).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should read options from the data-countup JSON', () => {
|
|
fixtureEl.innerHTML = '<h1 data-countup=\'{"duration":4,"suffix":"%"}\'>300</h1>'
|
|
|
|
const instance = new CountUp(element())
|
|
|
|
expect(instance._config.duration).toBe(4)
|
|
expect(element().textContent).toBe('0%')
|
|
})
|
|
|
|
it('should let the config object win over the attribute', () => {
|
|
fixtureEl.innerHTML = '<h1 data-countup=\'{"duration":4}\'>300</h1>'
|
|
|
|
const instance = new CountUp(element(), { duration: 6 })
|
|
|
|
expect(instance._config.duration).toBe(6)
|
|
})
|
|
|
|
it('should ignore invalid JSON', () => {
|
|
fixtureEl.innerHTML = '<h1 data-countup="{oops">300</h1>'
|
|
|
|
expect(() => new CountUp(element())).not.toThrow()
|
|
expect(element().textContent).toBe('0')
|
|
})
|
|
|
|
it('should parse formatted numbers', async () => {
|
|
fixtureEl.innerHTML = '<h1 data-countup>$1,234.5</h1>'
|
|
|
|
quick({ decimalPlaces: 1, prefix: '$' })
|
|
await wait(300)
|
|
|
|
expect(element().textContent).toBe('$1,234.5')
|
|
})
|
|
|
|
it('should leave text alone when it is not a number', () => {
|
|
fixtureEl.innerHTML = '<h1 data-countup>soon</h1>'
|
|
|
|
new CountUp(element())
|
|
|
|
expect(element().textContent).toBe('soon')
|
|
})
|
|
})
|
|
|
|
describe('autoAnimate', () => {
|
|
it('should start on its own once the element is in view', async () => {
|
|
const visible = document.createElement('h1')
|
|
visible.dataset.countup = '{"duration":0.1}'
|
|
visible.textContent = '500'
|
|
document.body.append(visible)
|
|
|
|
try {
|
|
new CountUp(visible)
|
|
await wait(400)
|
|
|
|
expect(visible.textContent).toBe('500')
|
|
} finally {
|
|
visible.remove()
|
|
}
|
|
})
|
|
|
|
it('should animate update() at once, even before the element is in view', async () => {
|
|
const instance = new CountUp(element(), { duration: 0.1 })
|
|
instance.update(77)
|
|
await wait(300)
|
|
|
|
expect(element().textContent).toBe('77')
|
|
})
|
|
|
|
it('should not fire complete.bs.countup a second time when the element scrolls into view after update()', () => {
|
|
const spy = vi.fn()
|
|
element().addEventListener('complete.bs.countup', spy)
|
|
|
|
const instance = new CountUp(element(), { duration: 0 })
|
|
instance.update(77)
|
|
expect(spy).toHaveBeenCalledTimes(1)
|
|
|
|
instance._observerCallback([{ isIntersecting: true } as unknown as IntersectionObserverEntry])
|
|
|
|
expect(spy).toHaveBeenCalledTimes(1)
|
|
expect(element().textContent).toBe('77')
|
|
})
|
|
|
|
it('should not fire complete.bs.countup twice when update() ran off screen and it then really scrolls into view', async () => {
|
|
const spy = vi.fn()
|
|
const off = document.createElement('h1')
|
|
off.dataset.countup = '{"duration":0.1}'
|
|
off.textContent = '500'
|
|
off.style.position = 'absolute'
|
|
off.style.top = '-10000px'
|
|
off.style.left = '-10000px'
|
|
document.body.append(off)
|
|
off.addEventListener('complete.bs.countup', spy)
|
|
|
|
try {
|
|
const instance = new CountUp(off)
|
|
instance.update(77)
|
|
await wait(300)
|
|
expect(spy).toHaveBeenCalledTimes(1)
|
|
|
|
off.style.top = '0px'
|
|
off.style.left = '0px'
|
|
await wait(500)
|
|
|
|
expect(spy).toHaveBeenCalledTimes(1)
|
|
expect(off.textContent).toBe('77')
|
|
} finally {
|
|
off.remove()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('data-tblr-countup', () => {
|
|
it('should read options from the aliased attribute', () => {
|
|
fixtureEl.innerHTML = '<h1 data-tblr-countup=\'{"suffix":"%"}\'>300</h1>'
|
|
|
|
new CountUp(element())
|
|
|
|
expect(element().textContent).toBe('0%')
|
|
})
|
|
})
|
|
|
|
describe('events', () => {
|
|
it('should fire start.bs.countup once per animation, not on resume', async () => {
|
|
const spy = vi.fn()
|
|
element().addEventListener('start.bs.countup', spy)
|
|
const instance = quick({ duration: 0.3 })
|
|
await wait(50)
|
|
instance.pauseResume()
|
|
instance.pauseResume()
|
|
await wait(400)
|
|
instance.update(5)
|
|
|
|
expect(spy).toHaveBeenCalledTimes(2)
|
|
})
|
|
})
|
|
|
|
describe('reduced motion', () => {
|
|
it('should show the final value at once', () => {
|
|
const spy = vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: true } as MediaQueryList)
|
|
|
|
try {
|
|
quick({ duration: 5 })
|
|
expect(element().textContent).toBe('30,000')
|
|
} finally {
|
|
spy.mockRestore()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('formatting', () => {
|
|
it('should round to the decimal places', async () => {
|
|
fixtureEl.innerHTML = '<h1 data-countup>3.6</h1>'
|
|
|
|
quick()
|
|
await wait(300)
|
|
|
|
expect(element().textContent).toBe('4')
|
|
})
|
|
|
|
it('should count at a steady pace without easing', async () => {
|
|
fixtureEl.innerHTML = '<h1 data-countup>1000</h1>'
|
|
|
|
quick({ useEasing: false, useGrouping: false, duration: 0.4 })
|
|
await wait(200)
|
|
const midway = Number(element().textContent)
|
|
|
|
expect(midway).toBeGreaterThan(350)
|
|
expect(midway).toBeLessThan(650)
|
|
})
|
|
|
|
it('should apply grouping, decimals, separators, prefix and suffix', async () => {
|
|
fixtureEl.innerHTML = '<h1 data-countup>-1234567.891</h1>'
|
|
|
|
quick({ decimalPlaces: 2, separator: ' ', decimal: ',', prefix: '€', suffix: ' net' })
|
|
await wait(300)
|
|
|
|
expect(element().textContent).toBe('-€1 234 567,89 net')
|
|
})
|
|
|
|
it('should skip grouping when asked', async () => {
|
|
quick({ useGrouping: false })
|
|
await wait(300)
|
|
|
|
expect(element().textContent).toBe('30000')
|
|
})
|
|
|
|
it('should count down from a higher start value', () => {
|
|
const instance = quick({ startVal: 50000, duration: 10 })
|
|
|
|
expect(element().textContent).toBe('50,000')
|
|
instance.reset()
|
|
expect(element().textContent).toBe('50,000')
|
|
})
|
|
|
|
it('should not print a negative zero when a value rounds to zero', async () => {
|
|
fixtureEl.innerHTML = '<h1 data-countup>-0.3</h1>'
|
|
|
|
quick({ decimalPlaces: 0 })
|
|
await wait(300)
|
|
|
|
expect(element().textContent).toBe('0')
|
|
})
|
|
})
|
|
|
|
describe('format', () => {
|
|
it('should count a time in minutes and print it as h:mm', async () => {
|
|
fixtureEl.innerHTML = '<h1 data-countup=\'{"format":"time","suffix":" hrs"}\'>3:28</h1>'
|
|
|
|
const instance = new CountUp(element(), { autoAnimate: false, duration: 0.1 })
|
|
expect(instance._endVal).toBe(208)
|
|
expect(element().textContent).toBe('0:00 hrs')
|
|
|
|
await wait(300)
|
|
expect(element().textContent).toBe('3:28 hrs')
|
|
})
|
|
|
|
it('should read a time behind a prefix and reject one without minutes', () => {
|
|
fixtureEl.innerHTML = '<h1 data-countup=\'{"format":"time"}\'>~12:05</h1>'
|
|
expect(new CountUp(element(), { autoAnimate: false, duration: 0 })._endVal).toBe(725)
|
|
|
|
fixtureEl.innerHTML = '<h1 data-countup=\'{"format":"time"}\'>9999999999</h1>'
|
|
new CountUp(element(), { autoAnimate: false })
|
|
expect(element().textContent).toBe('9999999999')
|
|
})
|
|
|
|
it('should not print a negative zero when a time rounds to zero minutes', async () => {
|
|
fixtureEl.innerHTML = '<h1 data-countup=\'{"format":"time"}\'>0:00</h1>'
|
|
|
|
const instance = new CountUp(element(), { autoAnimate: false, duration: 0.1, format: 'time' })
|
|
await wait(300)
|
|
instance.update(-0.4)
|
|
await wait(300)
|
|
|
|
expect(element().textContent).toBe('0:00')
|
|
})
|
|
|
|
it('should round negative half minutes like positive ones in the time format', async () => {
|
|
fixtureEl.innerHTML = '<h1>0:00</h1>'
|
|
|
|
const instance = new CountUp(element(), { autoAnimate: false, duration: 0.1, format: 'time' })
|
|
await wait(300)
|
|
instance.update(90.5)
|
|
await wait(300)
|
|
expect(element().textContent).toBe('1:31')
|
|
|
|
instance.update(-90.5)
|
|
await wait(300)
|
|
expect(element().textContent).toBe('-1:31')
|
|
})
|
|
|
|
it('should use a formatter function from the config', async () => {
|
|
quick({ formatter: (value: number) => `${Math.round(value / 1000)}k` })
|
|
await wait(300)
|
|
|
|
expect(element().textContent).toBe('30k')
|
|
})
|
|
})
|
|
|
|
describe('public API', () => {
|
|
it('should animate to a new value with update()', async () => {
|
|
const instance = quick()
|
|
await wait(300)
|
|
instance.update('42')
|
|
await wait(300)
|
|
|
|
expect(element().textContent).toBe('42')
|
|
})
|
|
|
|
it('should pause and resume', async () => {
|
|
const instance = quick({ duration: 0.3 })
|
|
await wait(100)
|
|
instance.pauseResume()
|
|
const paused = element().textContent
|
|
await wait(100)
|
|
|
|
expect(element().textContent).toBe(paused)
|
|
expect(paused).not.toBe('30,000')
|
|
|
|
instance.pauseResume()
|
|
await wait(400)
|
|
expect(element().textContent).toBe('30,000')
|
|
})
|
|
|
|
it('should reset to the start value', async () => {
|
|
const instance = quick()
|
|
await wait(300)
|
|
instance.reset()
|
|
|
|
expect(element().textContent).toBe('0')
|
|
})
|
|
|
|
it('should stop a running animation on reset()', async () => {
|
|
const instance = quick({ duration: 0.3 })
|
|
await wait(100)
|
|
instance.reset()
|
|
await wait(300)
|
|
|
|
expect(element().textContent).toBe('0')
|
|
})
|
|
|
|
it('should ignore an update() that is not a number', async () => {
|
|
const instance = quick()
|
|
await wait(300)
|
|
instance.update('soon')
|
|
await wait(100)
|
|
|
|
expect(element().textContent).toBe('30,000')
|
|
})
|
|
|
|
it('should stop and remove the instance on dispose', async () => {
|
|
const instance = quick({ duration: 1 })
|
|
await wait(50)
|
|
instance.dispose()
|
|
const frozen = element().textContent
|
|
await wait(100)
|
|
|
|
expect(element().textContent).toBe(frozen)
|
|
expect(CountUp.getInstance(element())).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('getOrCreateInstance', () => {
|
|
it('should reuse the instance', () => {
|
|
const first = CountUp.getOrCreateInstance(element())
|
|
const second = CountUp.getOrCreateInstance(element())
|
|
|
|
expect(second).toBe(first)
|
|
})
|
|
})
|
|
describe('loose data-countup types', () => {
|
|
it('should read numbers and booleans written as strings, as countUp.js did', () => {
|
|
fixtureEl.innerHTML = '<h1 data-countup=\'{"duration":"3","decimalPlaces":"2","useGrouping":"false","separator":1}\'>30000</h1>'
|
|
|
|
const instance = new CountUp(element())
|
|
|
|
expect(instance._config.duration).toBe(3)
|
|
expect(instance._config.decimalPlaces).toBe(2)
|
|
expect(instance._config.useGrouping).toBe(false)
|
|
expect(instance._config.separator).toBe('1')
|
|
})
|
|
|
|
it('should still reject a value that is not a number', () => {
|
|
fixtureEl.innerHTML = '<h1 data-countup=\'{"duration":"fast"}\'>30000</h1>'
|
|
|
|
expect(() => new CountUp(element())).toThrow(TypeError)
|
|
})
|
|
})
|
|
|
|
describe('robustness', () => {
|
|
it('ignores a data-countup value that is not an object', () => {
|
|
fixtureEl.innerHTML = '<h1 data-countup="null">30</h1>'
|
|
expect(() => new CountUp(element())).not.toThrow()
|
|
|
|
fixtureEl.innerHTML = '<h1 data-countup="[1,2]">30</h1>'
|
|
expect(() => new CountUp(element())).not.toThrow()
|
|
})
|
|
|
|
it('lets the animation start again after reset', () => {
|
|
const instance = quick()
|
|
instance.update(40)
|
|
expect(instance._started).toBe(true)
|
|
|
|
instance.reset()
|
|
|
|
expect(instance._started).toBe(false)
|
|
})
|
|
})
|
|
})
|