Replace the countup.js package with an own implementation (#3053)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-09-14 20:08:18 +02:00
1 parent 62408b8c8f
commit ea10f0e296
12 files changed
+568 -117

No files matched your search

+6
View File
@@ -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
View File
@@ -5,15 +5,30 @@
* -------------------------------------------------------------------------- * --------------------------------------------------------------------------
*/ */
import { CountUp as CountUpPlugin, type CountUpOptions } from 'countup.js'
import BaseComponent from './bootstrap/base-component' import BaseComponent from './bootstrap/base-component'
import EventHandler from './bootstrap/dom/event-handler'
import SelectorEngine from './bootstrap/dom/selector-engine' import SelectorEngine from './bootstrap/dom/selector-engine'
import type { ElementSelector } from './bootstrap/types' import type { ElementSelector } from './bootstrap/types'
// Mapped copy of the plugin's interface, so it satisfies the `Record` base config type CountUpFormat = 'number' | 'time'
type ComponentConfig = { [K in keyof CountUpOptions]: CountUpOptions[K] } & {
type ComponentConfig = {
/** start when the element scrolls into view; off starts at once */
autoAnimate: boolean 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> type ComponentConfigInput = Partial<ComponentConfig> & Record<string, unknown>
@@ -23,30 +38,94 @@ type ComponentConfigInput = Partial<ComponentConfig> & Record<string, unknown>
*/ */
const NAME = 'countup' const NAME = 'countup'
const DATA_KEY = `bs.${NAME}`
const EVENT_KEY = `.${DATA_KEY}`
const DATA_ATTRIBUTE = `data-${NAME}` 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 = { const Default: ComponentConfig = {
autoAnimate: true, 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', 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 * Class definition
* *
* Animates the number written inside the element with countUp.js * Animates the number written inside the element from `startVal` to it,
* (https://github.com/inorganik/countUp.js), bundled with `tabler.js`. Options * formatted with grouping, decimals, a prefix and a suffix. Options come from
* come from the `data-countup` attribute as JSON, or from the config object. * the `data-countup` attribute as JSON, or from the config object.
*/ */
class CountUp extends BaseComponent { class CountUp extends BaseComponent {
declare _element: HTMLElement declare _element: HTMLElement
declare _config: ComponentConfig 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) { constructor(element: ElementSelector, config?: ComponentConfigInput) {
super(element, config) super(element, config)
@@ -55,21 +134,34 @@ class CountUp extends BaseComponent {
return return
} }
// Strip thousands separators, currency symbols and other non-numeric const value = parseValue(this._element.textContent ?? '', this._config.format)
// characters so formatted targets like "1,234", "1 234" or "$99.5" parse.
const value = Number.parseFloat((this._element.textContent ?? '').replace(/[^0-9.-]/g, ''))
if (Number.isNaN(value)) { if (Number.isNaN(value)) {
return 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 if (!this._config.autoAnimate) {
// view, so only start by hand when it is off. this.start()
if (!this._plugin.error && !this._config.autoAnimate && !this._config.enableScrollSpy) { return
this._plugin.start()
} }
// 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 // Getters
@@ -77,7 +169,7 @@ class CountUp extends BaseComponent {
return Default return Default
} }
static get DefaultType(): Record<'autoAnimate', string> { static get DefaultType(): Record<keyof ComponentConfig, string> {
return DefaultType return DefaultType
} }
@@ -87,23 +179,38 @@ class CountUp extends BaseComponent {
// Public // Public
start(): void { start(): void {
this._plugin?.start() this._animate(this._value, this._endVal)
} }
reset(): void { reset(): void {
this._plugin?.reset() this._stop()
this._value = this._config.startVal
this._print(this._value)
} }
update(value: number | string): void { 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 { 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 { dispose(): void {
this._plugin?.onDestroy() this._stop()
this._observer?.disconnect()
super.dispose() super.dispose()
} }
@@ -112,7 +219,7 @@ class CountUp extends BaseComponent {
// `data-countup='{"duration":4}'` carries the options as JSON; a bare // `data-countup='{"duration":4}'` carries the options as JSON; a bare
// `data-countup` or invalid JSON means defaults. // `data-countup` or invalid JSON means defaults.
let dataOptions: Record<string, unknown> = {} let dataOptions: Record<string, unknown> = {}
const raw = element?.getAttribute(DATA_ATTRIBUTE) const raw = element?.getAttribute(DATA_ATTRIBUTE) ?? element?.getAttribute(DATA_ATTRIBUTE_ALIAS)
if (raw) { if (raw) {
try { try {
@@ -124,6 +231,80 @@ class CountUp extends BaseComponent {
return super._mergeConfigObj({ ...dataOptions, ...config }, element) 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}`
}
} }
/** /**
+215 -52
View File
@@ -1,22 +1,8 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import { CountUp as CountUpPlugin } from 'countup.js'
import { clearFixture, getFixture } from '../helpers/fixture' import { clearFixture, getFixture } from '../helpers/fixture'
import CountUp from '../../src/countup' import CountUp from '../../src/countup'
vi.mock('countup.js', () => ({ const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
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>>
describe('CountUp', () => { describe('CountUp', () => {
let fixtureEl: HTMLElement let fixtureEl: HTMLElement
@@ -26,7 +12,6 @@ describe('CountUp', () => {
}) })
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks()
fixtureEl.innerHTML = '<h1 data-countup>30000</h1>' fixtureEl.innerHTML = '<h1 data-countup>30000</h1>'
}) })
@@ -35,6 +20,8 @@ describe('CountUp', () => {
}) })
const element = (): HTMLElement => fixtureEl.querySelector('h1')! 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', () => { describe('NAME', () => {
it('should return plugin name', () => { it('should return plugin name', () => {
@@ -43,14 +30,21 @@ describe('CountUp', () => {
}) })
describe('constructor', () => { 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()) new CountUp(element())
await wait(50)
expect(plugin).toHaveBeenCalledTimes(1) expect(element().textContent).toBe('0')
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 }) it('should count to the parsed number when autoAnimate is off', async () => {
expect(lastInstance().start).not.toHaveBeenCalled() 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', () => { it('should read options from the data-countup JSON', () => {
@@ -58,72 +52,242 @@ describe('CountUp', () => {
const instance = new CountUp(element()) const instance = new CountUp(element())
expect(plugin.mock.calls[0][2]).toMatchObject({ duration: 4, suffix: '%', autoAnimate: true })
expect(instance._config.duration).toBe(4) expect(instance._config.duration).toBe(4)
expect(element().textContent).toBe('0%')
}) })
it('should let the config object win over the attribute', () => { it('should let the config object win over the attribute', () => {
fixtureEl.innerHTML = '<h1 data-countup=\'{"duration":4}\'>300</h1>' 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', () => { it('should ignore invalid JSON', () => {
fixtureEl.innerHTML = '<h1 data-countup="{oops">300</h1>' fixtureEl.innerHTML = '<h1 data-countup="{oops">300</h1>'
expect(() => new CountUp(element())).not.toThrow() 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>' 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>' fixtureEl.innerHTML = '<h1 data-countup>soon</h1>'
new CountUp(element()) new CountUp(element())
expect(plugin).not.toHaveBeenCalled() expect(element().textContent).toBe('soon')
})
}) })
it('should start by hand when autoAnimate is off', () => { describe('autoAnimate', () => {
new CountUp(element(), { autoAnimate: false }) 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)
expect(lastInstance().start).toHaveBeenCalledTimes(1) 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')
})
})
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', () => { describe('public API', () => {
it('should forward start, reset, update and pauseResume to the plugin', () => { it('should animate to a new value with update()', async () => {
const instance = new CountUp(element()) const instance = quick()
await wait(300)
instance.update('42')
await wait(300)
instance.start() expect(element().textContent).toBe('42')
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)
}) })
it('should destroy the plugin and remove the instance on dispose', () => { it('should pause and resume', async () => {
const instance = new CountUp(element()) const instance = quick({ duration: 0.3 })
const el = element() await wait(100)
const destroy = lastInstance().onDestroy 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() instance.dispose()
const frozen = element().textContent
await wait(100)
expect(destroy).toHaveBeenCalledTimes(1) expect(element().textContent).toBe(frozen)
expect(CountUp.getInstance(el)).toBeNull() expect(CountUp.getInstance(element())).toBeNull()
}) })
}) })
@@ -133,7 +297,6 @@ describe('CountUp', () => {
const second = CountUp.getOrCreateInstance(element()) const second = CountUp.getOrCreateInstance(element())
expect(second).toBe(first) expect(second).toBe(first)
expect(plugin).toHaveBeenCalledTimes(1)
}) })
}) })
}) })
-6
View File
@@ -20,12 +20,6 @@
"dist/nouislider.min.css" "dist/nouislider.min.css"
] ]
}, },
"countup": {
"npm": "countup.js",
"js": [
"dist/countUp.umd.js"
]
},
"lists": { "lists": {
"npm": "list.js", "npm": "list.js",
"js": [ "js": [
-1
View File
@@ -253,7 +253,6 @@
"apexcharts": "7.0.0", "apexcharts": "7.0.0",
"choices.js": "^11.2.4", "choices.js": "^11.2.4",
"clipboard": "^2.0.11", "clipboard": "^2.0.11",
"countup.js": "^2.10.1",
"driver.js": "^1.8.0", "driver.js": "^1.8.0",
"dropzone": "^6.0.0-beta.2", "dropzone": "^6.0.0-beta.2",
"find-unused-sass-variables": "^6.2.1", "find-unused-sass-variables": "^6.2.1",
+42 -12
View File
@@ -19,11 +19,11 @@ Write the final number as the text of the element and add `data-countup`. Tabler
## Installation ## 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 ## 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 ```html
<h1 data-countup>30000</h1> <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> <h1 data-countup='{"suffix":"%"}'>300</h1> <h1 data-countup='{"suffix":"‰"}'>300</h1>
</Example> </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 ## 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" /> <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 }); 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: 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 ```js
@@ -137,17 +153,31 @@ countup.start();
| `getInstance(element)` | Static. Returns the component for the element, or `null`. | | `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. | | `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 ## 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. - 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. - 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. - 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: - Users who asked for reduced motion get the final number at once: the countup checks `prefers-reduced-motion` and skips the animation 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.
-8
View File
@@ -138,9 +138,6 @@ importers:
clipboard: clipboard:
specifier: ^2.0.11 specifier: ^2.0.11
version: 2.0.11 version: 2.0.11
countup.js:
specifier: ^2.10.1
version: 2.10.1
driver.js: driver.js:
specifier: ^1.8.0 specifier: ^1.8.0
version: 1.8.0 version: 1.8.0
@@ -2332,9 +2329,6 @@ packages:
typescript: typescript:
optional: true optional: true
countup.js@2.10.1:
resolution: {integrity: sha512-UHW/BsPDgVZfN919D4iu2HPv+jJxZUuaR3EhT0ScFZaD446GiQtkMTgOnOyJXdA2AewdlyUk9UvERlTHTbVpcw==}
cross-env@10.1.0: cross-env@10.1.0:
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -6868,8 +6862,6 @@ snapshots:
optionalDependencies: optionalDependencies:
typescript: 7.0.2 typescript: 7.0.2
countup.js@2.10.1: {}
cross-env@10.1.0: cross-env@10.1.0:
dependencies: dependencies:
'@epic-web/invariant': 1.0.0 '@epic-web/invariant': 1.0.0
+4 -4
View File
@@ -17,16 +17,16 @@ import Configuration from '@shared/components/cards/Configuration.astro'
<DefaultLayout title="Widgets" pageMenu="extra.widgets" pageLibs={['apexcharts']}> <DefaultLayout title="Widgets" pageMenu="extra.widgets" pageLibs={['apexcharts']}>
<div class="row row-cards"> <div class="row row-cards">
<div class="col-12 col-md-6 col-xxl-3 mb-4 mb-xxl-0"> <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>
<div class="col-12 col-md-6 col-xxl-3 mb-4 mb-xxl-0"> <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>
<div class="col-12 col-md-6 col-xxl-3 mb-4 mb-md-0"> <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>
<div class="col-12 col-md-6 col-xxl-3"> <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>
<div class="col-lg-6"> <div class="col-lg-6">
+7 -2
View File
@@ -1,14 +1,19 @@
--- ---
import Subheader from '@ui/Subheader.astro' import Subheader from '@ui/Subheader.astro'
import Avatar from '@ui/Avatar.astro' import Avatar from '@ui/Avatar.astro'
import { countupOptions } from '@shared/lib/countup'
interface Props { interface Props {
title?: string title?: string
value?: string value?: string
icon?: 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"> <div class="card">
@@ -16,7 +21,7 @@ const { title = 'Title', value = '', icon = 'user' } = Astro.props
<div class="row align-items-center"> <div class="row align-items-center">
<div class="col"> <div class="col">
<Subheader as="h4" class="mb-1">{title}</Subheader> <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>
<div class="col-auto"> <div class="col-auto">
<Avatar icon={icon} size="lg" /> <Avatar icon={icon} size="lg" />
-6
View File
@@ -23,12 +23,6 @@
"description": "Lightweight JavaScript range slider with multi-touch support and extensive customization options.", "description": "Lightweight JavaScript range slider with multi-touch support and extensive customization options.",
"license": "MIT" "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", "name": "List.js",
"url": "https://listjs.com/", "url": "https://listjs.com/",
+31
View File
@@ -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()
})
})
+56
View File
@@ -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
}