mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
Replace the countup.js package with an own implementation (#3053)
This commit is contained in:
1 parent
62408b8c8f
commit
ea10f0e296
12 files changed
+568
-117
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@tabler/core": patch
|
||||
"@tabler/docs": patch
|
||||
---
|
||||
|
||||
Replaced the `countup.js` package with a dependency-free `CountUp` component that respects `prefers-reduced-motion`.
|
||||
+207
-26
@@ -5,15 +5,30 @@
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { CountUp as CountUpPlugin, type CountUpOptions } from 'countup.js'
|
||||
|
||||
import BaseComponent from './bootstrap/base-component'
|
||||
import EventHandler from './bootstrap/dom/event-handler'
|
||||
import SelectorEngine from './bootstrap/dom/selector-engine'
|
||||
import type { ElementSelector } from './bootstrap/types'
|
||||
|
||||
// Mapped copy of the plugin's interface, so it satisfies the `Record` base config
|
||||
type ComponentConfig = { [K in keyof CountUpOptions]: CountUpOptions[K] } & {
|
||||
type CountUpFormat = 'number' | 'time'
|
||||
|
||||
type ComponentConfig = {
|
||||
/** start when the element scrolls into view; off starts at once */
|
||||
autoAnimate: boolean
|
||||
startVal: number
|
||||
/** seconds */
|
||||
duration: number
|
||||
decimalPlaces: number
|
||||
useEasing: boolean
|
||||
useGrouping: boolean
|
||||
separator: string
|
||||
decimal: string
|
||||
prefix: string
|
||||
suffix: string
|
||||
/** `number`, or `time` for a value in minutes shown as h:mm */
|
||||
format: CountUpFormat
|
||||
/** custom rendering of the number, from a JavaScript config only */
|
||||
formatter: ((value: number) => string) | null
|
||||
}
|
||||
|
||||
type ComponentConfigInput = Partial<ComponentConfig> & Record<string, unknown>
|
||||
@@ -23,30 +38,94 @@ type ComponentConfigInput = Partial<ComponentConfig> & Record<string, unknown>
|
||||
*/
|
||||
|
||||
const NAME = 'countup'
|
||||
const DATA_KEY = `bs.${NAME}`
|
||||
const EVENT_KEY = `.${DATA_KEY}`
|
||||
const DATA_ATTRIBUTE = `data-${NAME}`
|
||||
|
||||
const SELECTOR_DATA_COUNTUP = `[${DATA_ATTRIBUTE}]`
|
||||
const EVENT_START = `start${EVENT_KEY}`
|
||||
const EVENT_COMPLETE = `complete${EVENT_KEY}`
|
||||
|
||||
const DATA_ATTRIBUTE_ALIAS = `data-tblr-${NAME}`
|
||||
|
||||
const SELECTOR_DATA_COUNTUP = `[${DATA_ATTRIBUTE}], [${DATA_ATTRIBUTE_ALIAS}]`
|
||||
|
||||
const Default: ComponentConfig = {
|
||||
autoAnimate: true,
|
||||
startVal: 0,
|
||||
duration: 2,
|
||||
decimalPlaces: 0,
|
||||
useEasing: true,
|
||||
useGrouping: true,
|
||||
separator: ',',
|
||||
decimal: '.',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
format: 'number',
|
||||
formatter: null,
|
||||
}
|
||||
|
||||
const DefaultType: Record<'autoAnimate', string> = {
|
||||
const DefaultType: Record<keyof ComponentConfig, string> = {
|
||||
autoAnimate: 'boolean',
|
||||
startVal: 'number',
|
||||
duration: 'number',
|
||||
decimalPlaces: 'number',
|
||||
useEasing: 'boolean',
|
||||
useGrouping: 'boolean',
|
||||
separator: 'string',
|
||||
decimal: 'string',
|
||||
prefix: 'string',
|
||||
suffix: 'string',
|
||||
format: 'string',
|
||||
formatter: '(function|null)',
|
||||
}
|
||||
|
||||
/**
|
||||
* Helpers
|
||||
*/
|
||||
|
||||
// Strips thousands separators, currency symbols and other non-numeric
|
||||
// characters, so formatted targets like "1,234", "1 234" or "$99.5" parse.
|
||||
// A `time` value like "3:28" is read as minutes.
|
||||
const parseValue = (input: string | number, format: CountUpFormat): number => {
|
||||
if (typeof input === 'number') {
|
||||
return input
|
||||
}
|
||||
|
||||
if (format === 'time') {
|
||||
// Anchored and bounded on purpose: an unanchored `\d+` would scan from
|
||||
// every position of a long digit string.
|
||||
const match = /^\D{0,32}(\d{1,4}):(\d{1,2})/.exec(input)
|
||||
return match ? Number(match[1]) * 60 + Number(match[2]) : Number.NaN
|
||||
}
|
||||
|
||||
return Number.parseFloat(input.replace(/[^0-9.-]/g, ''))
|
||||
}
|
||||
|
||||
// easeOutExpo, the same curve countUp.js used
|
||||
const easeOut = (t: number): number => (t >= 1 ? 1 : ((1 - 2 ** (-10 * t)) * 1024) / 1023)
|
||||
|
||||
const reducedMotion = (): boolean => window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
/**
|
||||
* Class definition
|
||||
*
|
||||
* Animates the number written inside the element with countUp.js
|
||||
* (https://github.com/inorganik/countUp.js), bundled with `tabler.js`. Options
|
||||
* come from the `data-countup` attribute as JSON, or from the config object.
|
||||
* Animates the number written inside the element from `startVal` to it,
|
||||
* formatted with grouping, decimals, a prefix and a suffix. Options come from
|
||||
* the `data-countup` attribute as JSON, or from the config object.
|
||||
*/
|
||||
|
||||
class CountUp extends BaseComponent {
|
||||
declare _element: HTMLElement
|
||||
declare _config: ComponentConfig
|
||||
_plugin: CountUpPlugin | null = null
|
||||
_endVal = 0
|
||||
_value = 0
|
||||
_frame = 0
|
||||
_observer: IntersectionObserver | null = null
|
||||
// animation in progress: where it goes, how far it got
|
||||
_from = 0
|
||||
_elapsed = 0
|
||||
_running = false
|
||||
_paused = false
|
||||
|
||||
constructor(element: ElementSelector, config?: ComponentConfigInput) {
|
||||
super(element, config)
|
||||
@@ -55,21 +134,34 @@ class CountUp extends BaseComponent {
|
||||
return
|
||||
}
|
||||
|
||||
// Strip thousands separators, currency symbols and other non-numeric
|
||||
// characters so formatted targets like "1,234", "1 234" or "$99.5" parse.
|
||||
const value = Number.parseFloat((this._element.textContent ?? '').replace(/[^0-9.-]/g, ''))
|
||||
|
||||
const value = parseValue(this._element.textContent ?? '', this._config.format)
|
||||
if (Number.isNaN(value)) {
|
||||
return
|
||||
}
|
||||
|
||||
this._plugin = new CountUpPlugin(this._element, value, this._config)
|
||||
this._endVal = value
|
||||
this._value = this._config.startVal
|
||||
this._print(this._value)
|
||||
|
||||
// With `autoAnimate` the plugin starts itself once the element scrolls into
|
||||
// view, so only start by hand when it is off.
|
||||
if (!this._plugin.error && !this._config.autoAnimate && !this._config.enableScrollSpy) {
|
||||
this._plugin.start()
|
||||
if (!this._config.autoAnimate) {
|
||||
this.start()
|
||||
return
|
||||
}
|
||||
|
||||
// Wait for the element to scroll into view, then run once.
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
this.start()
|
||||
return
|
||||
}
|
||||
|
||||
this._observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
this._observer?.disconnect()
|
||||
this._observer = null
|
||||
this.start()
|
||||
}
|
||||
})
|
||||
this._observer.observe(this._element)
|
||||
}
|
||||
|
||||
// Getters
|
||||
@@ -77,7 +169,7 @@ class CountUp extends BaseComponent {
|
||||
return Default
|
||||
}
|
||||
|
||||
static get DefaultType(): Record<'autoAnimate', string> {
|
||||
static get DefaultType(): Record<keyof ComponentConfig, string> {
|
||||
return DefaultType
|
||||
}
|
||||
|
||||
@@ -87,23 +179,38 @@ class CountUp extends BaseComponent {
|
||||
|
||||
// Public
|
||||
start(): void {
|
||||
this._plugin?.start()
|
||||
this._animate(this._value, this._endVal)
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this._plugin?.reset()
|
||||
this._stop()
|
||||
this._value = this._config.startVal
|
||||
this._print(this._value)
|
||||
}
|
||||
|
||||
update(value: number | string): void {
|
||||
this._plugin?.update(value)
|
||||
const parsed = parseValue(value, this._config.format)
|
||||
if (Number.isNaN(parsed)) {
|
||||
return
|
||||
}
|
||||
|
||||
this._endVal = parsed
|
||||
this._animate(this._value, this._endVal)
|
||||
}
|
||||
|
||||
pauseResume(): void {
|
||||
this._plugin?.pauseResume()
|
||||
if (this._paused) {
|
||||
this._paused = false
|
||||
this._run()
|
||||
} else if (this._running) {
|
||||
this._paused = true
|
||||
cancelAnimationFrame(this._frame)
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._plugin?.onDestroy()
|
||||
this._stop()
|
||||
this._observer?.disconnect()
|
||||
super.dispose()
|
||||
}
|
||||
|
||||
@@ -112,7 +219,7 @@ class CountUp extends BaseComponent {
|
||||
// `data-countup='{"duration":4}'` carries the options as JSON; a bare
|
||||
// `data-countup` or invalid JSON means defaults.
|
||||
let dataOptions: Record<string, unknown> = {}
|
||||
const raw = element?.getAttribute(DATA_ATTRIBUTE)
|
||||
const raw = element?.getAttribute(DATA_ATTRIBUTE) ?? element?.getAttribute(DATA_ATTRIBUTE_ALIAS)
|
||||
|
||||
if (raw) {
|
||||
try {
|
||||
@@ -124,6 +231,80 @@ class CountUp extends BaseComponent {
|
||||
|
||||
return super._mergeConfigObj({ ...dataOptions, ...config }, element)
|
||||
}
|
||||
|
||||
_animate(from: number, to: number): void {
|
||||
this._stop()
|
||||
|
||||
// Users who asked for less motion get the final number at once.
|
||||
if (from === to || this._config.duration <= 0 || reducedMotion()) {
|
||||
this._finish(to)
|
||||
return
|
||||
}
|
||||
|
||||
this._from = from
|
||||
this._elapsed = 0
|
||||
this._running = true
|
||||
EventHandler.trigger(this._element, EVENT_START)
|
||||
this._run()
|
||||
}
|
||||
|
||||
_run(): void {
|
||||
const duration = this._config.duration * 1000
|
||||
let last = performance.now()
|
||||
|
||||
const step = (now: number): void => {
|
||||
this._elapsed += now - last
|
||||
last = now
|
||||
|
||||
const t = Math.min(1, this._elapsed / duration)
|
||||
const progress = this._config.useEasing ? easeOut(t) : t
|
||||
this._value = this._from + (this._endVal - this._from) * progress
|
||||
this._print(this._value)
|
||||
|
||||
if (t < 1) {
|
||||
this._frame = requestAnimationFrame(step)
|
||||
} else {
|
||||
this._finish(this._endVal)
|
||||
}
|
||||
}
|
||||
|
||||
this._frame = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
_finish(value: number): void {
|
||||
this._running = false
|
||||
this._paused = false
|
||||
this._value = value
|
||||
this._print(value)
|
||||
EventHandler.trigger(this._element, EVENT_COMPLETE)
|
||||
}
|
||||
|
||||
_stop(): void {
|
||||
cancelAnimationFrame(this._frame)
|
||||
this._running = false
|
||||
this._paused = false
|
||||
}
|
||||
|
||||
_print(value: number): void {
|
||||
this._element.textContent = this._format(value)
|
||||
}
|
||||
|
||||
_format(value: number): string {
|
||||
const { decimalPlaces, useGrouping, separator, decimal, prefix, suffix, format, formatter } = this._config
|
||||
if (formatter) {
|
||||
return formatter(value)
|
||||
}
|
||||
|
||||
if (format === 'time') {
|
||||
const minutes = Math.round(Math.abs(value))
|
||||
return `${value < 0 ? '-' : ''}${prefix}${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}${suffix}`
|
||||
}
|
||||
|
||||
const [integer, fraction] = Math.abs(value).toFixed(decimalPlaces).split('.') as [string, string | undefined]
|
||||
const grouped = useGrouping ? integer.replace(/\B(?=(\d{3})+(?!\d))/g, separator) : integer
|
||||
|
||||
return `${value < 0 ? '-' : ''}${prefix}${grouped}${fraction ? decimal + fraction : ''}${suffix}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { CountUp as CountUpPlugin } from 'countup.js'
|
||||
import { clearFixture, getFixture } from '../helpers/fixture'
|
||||
import CountUp from '../../src/countup'
|
||||
|
||||
vi.mock('countup.js', () => ({
|
||||
CountUp: vi.fn(function (this: Record<string, unknown>) {
|
||||
this.error = ''
|
||||
this.start = vi.fn()
|
||||
this.reset = vi.fn()
|
||||
this.update = vi.fn()
|
||||
this.pauseResume = vi.fn()
|
||||
this.onDestroy = vi.fn()
|
||||
}),
|
||||
}))
|
||||
|
||||
const plugin = vi.mocked(CountUpPlugin)
|
||||
|
||||
const lastInstance = (): Record<string, ReturnType<typeof vi.fn>> => plugin.mock.instances[plugin.mock.instances.length - 1] as unknown as Record<string, ReturnType<typeof vi.fn>>
|
||||
const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
describe('CountUp', () => {
|
||||
let fixtureEl: HTMLElement
|
||||
@@ -26,7 +12,6 @@ describe('CountUp', () => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
fixtureEl.innerHTML = '<h1 data-countup>30000</h1>'
|
||||
})
|
||||
|
||||
@@ -35,6 +20,8 @@ describe('CountUp', () => {
|
||||
})
|
||||
|
||||
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', () => {
|
||||
@@ -43,14 +30,21 @@ describe('CountUp', () => {
|
||||
})
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create the plugin with the parsed number and autoAnimate on', () => {
|
||||
it('should print the start value and wait for the viewport with autoAnimate', async () => {
|
||||
new CountUp(element())
|
||||
await wait(50)
|
||||
|
||||
expect(plugin).toHaveBeenCalledTimes(1)
|
||||
expect(plugin.mock.calls[0][0]).toBe(element())
|
||||
expect(plugin.mock.calls[0][1]).toBe(30000)
|
||||
expect(plugin.mock.calls[0][2]).toMatchObject({ autoAnimate: true })
|
||||
expect(lastInstance().start).not.toHaveBeenCalled()
|
||||
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', () => {
|
||||
@@ -58,72 +52,242 @@ describe('CountUp', () => {
|
||||
|
||||
const instance = new CountUp(element())
|
||||
|
||||
expect(plugin.mock.calls[0][2]).toMatchObject({ duration: 4, suffix: '%', autoAnimate: true })
|
||||
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>'
|
||||
|
||||
new CountUp(element(), { duration: 6 })
|
||||
const instance = new CountUp(element(), { duration: 6 })
|
||||
|
||||
expect(plugin.mock.calls[0][2]).toMatchObject({ 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(plugin).toHaveBeenCalledTimes(1)
|
||||
expect(element().textContent).toBe('0')
|
||||
})
|
||||
|
||||
it('should parse formatted numbers', () => {
|
||||
it('should parse formatted numbers', async () => {
|
||||
fixtureEl.innerHTML = '<h1 data-countup>$1,234.5</h1>'
|
||||
|
||||
new CountUp(element())
|
||||
quick({ decimalPlaces: 1, prefix: '$' })
|
||||
await wait(300)
|
||||
|
||||
expect(plugin.mock.calls[0][1]).toBe(1234.5)
|
||||
expect(element().textContent).toBe('$1,234.5')
|
||||
})
|
||||
|
||||
it('should not create the plugin when the text is not a number', () => {
|
||||
it('should leave text alone when it is not a number', () => {
|
||||
fixtureEl.innerHTML = '<h1 data-countup>soon</h1>'
|
||||
|
||||
new CountUp(element())
|
||||
|
||||
expect(plugin).not.toHaveBeenCalled()
|
||||
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 start by hand when autoAnimate is off', () => {
|
||||
new CountUp(element(), { autoAnimate: false })
|
||||
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(lastInstance().start).toHaveBeenCalledTimes(1)
|
||||
expect(element().textContent).toBe('77')
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
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 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 forward start, reset, update and pauseResume to the plugin', () => {
|
||||
const instance = new CountUp(element())
|
||||
it('should animate to a new value with update()', async () => {
|
||||
const instance = quick()
|
||||
await wait(300)
|
||||
instance.update('42')
|
||||
await wait(300)
|
||||
|
||||
instance.start()
|
||||
instance.reset()
|
||||
instance.update(42)
|
||||
instance.pauseResume()
|
||||
|
||||
expect(lastInstance().start).toHaveBeenCalledTimes(1)
|
||||
expect(lastInstance().reset).toHaveBeenCalledTimes(1)
|
||||
expect(lastInstance().update).toHaveBeenCalledWith(42)
|
||||
expect(lastInstance().pauseResume).toHaveBeenCalledTimes(1)
|
||||
expect(element().textContent).toBe('42')
|
||||
})
|
||||
|
||||
it('should destroy the plugin and remove the instance on dispose', () => {
|
||||
const instance = new CountUp(element())
|
||||
const el = element()
|
||||
const destroy = lastInstance().onDestroy
|
||||
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(destroy).toHaveBeenCalledTimes(1)
|
||||
expect(CountUp.getInstance(el)).toBeNull()
|
||||
expect(element().textContent).toBe(frozen)
|
||||
expect(CountUp.getInstance(element())).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -133,7 +297,6 @@ describe('CountUp', () => {
|
||||
const second = CountUp.getOrCreateInstance(element())
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(plugin).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -20,12 +20,6 @@
|
||||
"dist/nouislider.min.css"
|
||||
]
|
||||
},
|
||||
"countup": {
|
||||
"npm": "countup.js",
|
||||
"js": [
|
||||
"dist/countUp.umd.js"
|
||||
]
|
||||
},
|
||||
"lists": {
|
||||
"npm": "list.js",
|
||||
"js": [
|
||||
|
||||
@@ -253,7 +253,6 @@
|
||||
"apexcharts": "7.0.0",
|
||||
"choices.js": "^11.2.4",
|
||||
"clipboard": "^2.0.11",
|
||||
"countup.js": "^2.10.1",
|
||||
"driver.js": "^1.8.0",
|
||||
"dropzone": "^6.0.0-beta.2",
|
||||
"find-unused-sass-variables": "^6.2.1",
|
||||
|
||||
@@ -19,11 +19,11 @@ Write the final number as the text of the element and add `data-countup`. Tabler
|
||||
|
||||
## Installation
|
||||
|
||||
The [countUp.js](https://inorganik.github.io/countUp.js/) library is bundled with `tabler.js`, so there is nothing extra to install or load. For options beyond the ones below, see its website.
|
||||
Countup is part of `tabler.js` and has no dependency, so there is nothing extra to install or load.
|
||||
|
||||
## Usage
|
||||
|
||||
Add `data-countup` to any text element and write the target number inside it. The animation starts as soon as the number enters the viewport.
|
||||
Add `data-countup` to any text element and write the target number inside it. The animation starts as soon as the number enters the viewport. `data-tblr-countup` works the same way.
|
||||
|
||||
```html
|
||||
<h1 data-countup>30000</h1>
|
||||
@@ -107,9 +107,17 @@ Grouping is on by default, so thousands get a separator. Set `"useGrouping": fal
|
||||
<h1 data-countup='{"suffix":"%"}'>300</h1> <h1 data-countup='{"suffix":"‰"}'>300</h1>
|
||||
</Example>
|
||||
|
||||
### Time
|
||||
|
||||
Set `"format": "time"` for a value written as hours and minutes. The number counts in minutes and is printed as `h:mm`.
|
||||
|
||||
<Example vertical separated>
|
||||
<h1 data-countup='{"format":"time"}'>3:28</h1> <h1 data-countup='{"format":"time","suffix":" hrs"}'>12:05</h1>
|
||||
</Example>
|
||||
|
||||
## JavaScript
|
||||
|
||||
Tabler wraps the library in a `CountUp` component that works like the Bootstrap components: it is created once per element and stored on it. On page load Tabler creates one for every element with `data-countup`. This is the code that runs:
|
||||
Tabler ships a `CountUp` component that works like the Bootstrap components: it is created once per element and stored on it. On page load Tabler creates one for every element with `data-countup`. This is the code that runs:
|
||||
|
||||
<CodeDocs name="countup-init" file="core/js/src/countup.ts" />
|
||||
|
||||
@@ -119,6 +127,14 @@ Create the component yourself for an element added later. Options go in the `dat
|
||||
const countup = new tabler.CountUp(document.getElementById('total'), { duration: 4 });
|
||||
```
|
||||
|
||||
For any format the options cannot express, pass a `formatter` function. It gets the current number on every frame and returns the text to show:
|
||||
|
||||
```js
|
||||
new tabler.CountUp(document.getElementById('size'), {
|
||||
formatter: (value) => `${(value / 1024).toFixed(1)} GB`,
|
||||
});
|
||||
```
|
||||
|
||||
By default the animation starts when the element scrolls into view. Set `autoAnimate` to `false` to start it right away, or to control it yourself:
|
||||
|
||||
```js
|
||||
@@ -137,17 +153,31 @@ countup.start();
|
||||
| `getInstance(element)` | Static. Returns the component for the element, or `null`. |
|
||||
| `getOrCreateInstance(element, config)` | Static. Returns the component for the element and creates it when needed. |
|
||||
|
||||
`start.bs.countup` fires on the element when an animation begins, and `complete.bs.countup` when the number reaches its final value:
|
||||
|
||||
```js
|
||||
document.getElementById('total').addEventListener('complete.bs.countup', () => {
|
||||
console.log('done counting');
|
||||
});
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `autoAnimate` | `true` | Start when the element scrolls into view. `false` starts at once. |
|
||||
| `startVal` | `0` | The number to count from. |
|
||||
| `duration` | `2` | Length of the animation, in seconds. |
|
||||
| `decimalPlaces` | `0` | Decimals shown. |
|
||||
| `useEasing` | `true` | Slow down towards the end. `false` counts at a steady pace. |
|
||||
| `useGrouping` | `true` | Put a separator between thousands. |
|
||||
| `separator` | `,` | The thousands separator. |
|
||||
| `decimal` | `.` | The decimal separator. |
|
||||
| `prefix`, `suffix` | none | Text before and after the number. |
|
||||
| `format` | `number` | `time` reads and prints the value as `h:mm`, counting in minutes. |
|
||||
| `formatter` | none | A function that turns the current number into the text to show. Config object only. |
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Write the final number in the HTML, as the examples do. That is what a search engine indexes, what shows without JavaScript, and what a screen reader reads if it reaches the element before the animation starts.
|
||||
- Never put a countup inside an `aria-live` region. The text changes on every frame, so a live region would announce dozens of intermediate numbers.
|
||||
- The number on its own says nothing. Give it a visible label next to it, or an `aria-label` on the block that holds both.
|
||||
- Tabler does not check `prefers-reduced-motion`, so a countup animates even for users who asked for less motion. Skip the initialization for them:
|
||||
|
||||
```js
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
document.querySelectorAll('[data-countup]').forEach((el) => el.removeAttribute('data-countup'));
|
||||
}
|
||||
```
|
||||
|
||||
Run it before `tabler.js`, so the number simply stays at its final value.
|
||||
- Users who asked for reduced motion get the final number at once: the countup checks `prefers-reduced-motion` and skips the animation for them.
|
||||
Generated
-8
@@ -138,9 +138,6 @@ importers:
|
||||
clipboard:
|
||||
specifier: ^2.0.11
|
||||
version: 2.0.11
|
||||
countup.js:
|
||||
specifier: ^2.10.1
|
||||
version: 2.10.1
|
||||
driver.js:
|
||||
specifier: ^1.8.0
|
||||
version: 1.8.0
|
||||
@@ -2332,9 +2329,6 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
countup.js@2.10.1:
|
||||
resolution: {integrity: sha512-UHW/BsPDgVZfN919D4iu2HPv+jJxZUuaR3EhT0ScFZaD446GiQtkMTgOnOyJXdA2AewdlyUk9UvERlTHTbVpcw==}
|
||||
|
||||
cross-env@10.1.0:
|
||||
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -6868,8 +6862,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 7.0.2
|
||||
|
||||
countup.js@2.10.1: {}
|
||||
|
||||
cross-env@10.1.0:
|
||||
dependencies:
|
||||
'@epic-web/invariant': 1.0.0
|
||||
|
||||
@@ -17,16 +17,16 @@ import Configuration from '@shared/components/cards/Configuration.astro'
|
||||
<DefaultLayout title="Widgets" pageMenu="extra.widgets" pageLibs={['apexcharts']}>
|
||||
<div class="row row-cards">
|
||||
<div class="col-12 col-md-6 col-xxl-3 mb-4 mb-xxl-0">
|
||||
<StatCard title="Earned" value="$2,847" icon="credit-card" />
|
||||
<StatCard title="Earned" value="$2,847" icon="credit-card" countup />
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xxl-3 mb-4 mb-xxl-0">
|
||||
<StatCard title="Hours logged" value="42.7 hrs" icon="clock" />
|
||||
<StatCard title="Hours logged" value="42.7 hrs" icon="clock" countup />
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xxl-3 mb-4 mb-md-0">
|
||||
<StatCard title="Avg. time" value="3:28 hrs" icon="slideshow" />
|
||||
<StatCard title="Avg. time" value="3:28 hrs" icon="slideshow" countup />
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xxl-3">
|
||||
<StatCard title="Weekly growth" value="18.4%" icon="discount" />
|
||||
<StatCard title="Weekly growth" value="18.4%" icon="discount" countup />
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
---
|
||||
import Subheader from '@ui/Subheader.astro'
|
||||
import Avatar from '@ui/Avatar.astro'
|
||||
import { countupOptions } from '@shared/lib/countup'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
value?: string
|
||||
icon?: string
|
||||
/** data-countup: animate the value from zero when it scrolls into view */
|
||||
countup?: boolean
|
||||
}
|
||||
|
||||
const { title = 'Title', value = '', icon = 'user' } = Astro.props
|
||||
const { title = 'Title', value = '', icon = 'user', countup = false } = Astro.props
|
||||
|
||||
const countupAttr = countup ? countupOptions(value) : null
|
||||
---
|
||||
|
||||
<div class="card">
|
||||
@@ -16,7 +21,7 @@ const { title = 'Title', value = '', icon = 'user' } = Astro.props
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<Subheader as="h4" class="mb-1">{title}</Subheader>
|
||||
<div class="h3 m-0">{value}</div>
|
||||
<div class="h3 m-0" data-countup={countupAttr ? JSON.stringify(countupAttr) : undefined}>{value}</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<Avatar icon={icon} size="lg" />
|
||||
|
||||
@@ -23,12 +23,6 @@
|
||||
"description": "Lightweight JavaScript range slider with multi-touch support and extensive customization options.",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "CountUp.js",
|
||||
"url": "https://inorganik.github.io/countUp.js/",
|
||||
"description": "Animates numerical values by counting up to them.",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "List.js",
|
||||
"url": "https://listjs.com/",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { countupOptions } from './countup'
|
||||
|
||||
describe('countupOptions', () => {
|
||||
it('turns a currency into a prefix', () => {
|
||||
expect(countupOptions('$2,847')).toEqual({ prefix: '$' })
|
||||
})
|
||||
|
||||
it('turns a unit into a suffix and counts the decimals', () => {
|
||||
expect(countupOptions('42.7 hrs')).toEqual({ suffix: ' hrs', decimalPlaces: 1 })
|
||||
expect(countupOptions('18.4%')).toEqual({ suffix: '%', decimalPlaces: 1 })
|
||||
})
|
||||
|
||||
it('returns an empty object for a plain integer', () => {
|
||||
expect(countupOptions('30000')).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps a negative sign inside the number', () => {
|
||||
expect(countupOptions('-12.5%')).toEqual({ suffix: '%', decimalPlaces: 1 })
|
||||
})
|
||||
|
||||
it('recognizes an h:mm time', () => {
|
||||
expect(countupOptions('3:28 hrs')).toEqual({ format: 'time', suffix: ' hrs' })
|
||||
expect(countupOptions('~12:05')).toEqual({ format: 'time', prefix: '~' })
|
||||
})
|
||||
|
||||
it('returns null when there is no number', () => {
|
||||
expect(countupOptions('soon')).toBeNull()
|
||||
expect(countupOptions('')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
// Options for the `data-countup` attribute, derived from a formatted value.
|
||||
|
||||
export interface CountupOptions {
|
||||
prefix?: string
|
||||
suffix?: string
|
||||
decimalPlaces?: number
|
||||
/** `time` for an h:mm value, counted in minutes */
|
||||
format?: 'time'
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a formatted stat like "$2,847", "42.7 hrs", "18.4%" or "3:28 hrs"
|
||||
* into the countup options that reproduce it: the text before the number
|
||||
* becomes the prefix, the text after it the suffix, the decimals are counted
|
||||
* and an h:mm value gets the `time` format. Returns null when the value holds
|
||||
* no number, so the caller can skip the countup.
|
||||
*/
|
||||
export function countupOptions(value: string): CountupOptions | null {
|
||||
const time = /^([^\d-]*)(\d+:\d{2})(.*)$/.exec(value.trim())
|
||||
if (time) {
|
||||
const options: CountupOptions = { format: 'time' }
|
||||
if (time[1]) {
|
||||
options.prefix = time[1]
|
||||
}
|
||||
|
||||
if (time[3]) {
|
||||
options.suffix = time[3]
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
const match = /^([^\d-]*)(-?\d[\d,]*(?:\.\d+)?)(.*)$/.exec(value.trim())
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const prefix = match[1] ?? ''
|
||||
const number = match[2] ?? ''
|
||||
const suffix = match[3] ?? ''
|
||||
const decimals = number.split('.')[1]?.length ?? 0
|
||||
const options: CountupOptions = {}
|
||||
if (prefix) {
|
||||
options.prefix = prefix
|
||||
}
|
||||
|
||||
if (suffix) {
|
||||
options.suffix = suffix
|
||||
}
|
||||
|
||||
if (decimals > 0) {
|
||||
options.decimalPlaces = decimals
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
Reference in new issue
Block a user