From ea10f0e29633f27ae59065b1afab4b815e688cee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kuna?= <1282324+codecalm@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:08:18 +0200 Subject: [PATCH] Replace the countup.js package with an own implementation (#3053) --- .changeset/countup-own.md | 6 + core/js/src/countup.ts | 233 ++++++++++++++++++--- core/js/tests/unit/countup.spec.ts | 267 ++++++++++++++++++++----- core/libs.json | 6 - core/package.json | 1 - docs/content/ui/plugins/countup.mdx | 54 +++-- pnpm-lock.yaml | 8 - preview/pages/widgets.astro | 8 +- shared/components/cards/StatCard.astro | 9 +- shared/data/open-source.json | 6 - shared/lib/countup.test.ts | 31 +++ shared/lib/countup.ts | 56 ++++++ 12 files changed, 568 insertions(+), 117 deletions(-) create mode 100644 .changeset/countup-own.md create mode 100644 shared/lib/countup.test.ts create mode 100644 shared/lib/countup.ts diff --git a/.changeset/countup-own.md b/.changeset/countup-own.md new file mode 100644 index 000000000..6679d8583 --- /dev/null +++ b/.changeset/countup-own.md @@ -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`. diff --git a/core/js/src/countup.ts b/core/js/src/countup.ts index 9adf07c80..6f145cbcd 100644 --- a/core/js/src/countup.ts +++ b/core/js/src/countup.ts @@ -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 & Record @@ -23,30 +38,94 @@ type ComponentConfigInput = Partial & Record */ 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 = { 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 { 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 = {} - 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}` + } } /** diff --git a/core/js/tests/unit/countup.spec.ts b/core/js/tests/unit/countup.spec.ts index 63aefe51f..047e49aed 100644 --- a/core/js/tests/unit/countup.spec.ts +++ b/core/js/tests/unit/countup.spec.ts @@ -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) { - 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> => plugin.mock.instances[plugin.mock.instances.length - 1] as unknown as Record> +const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) describe('CountUp', () => { let fixtureEl: HTMLElement @@ -26,7 +12,6 @@ describe('CountUp', () => { }) beforeEach(() => { - vi.clearAllMocks() fixtureEl.innerHTML = '

30000

' }) @@ -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 = {}): 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 = '

300

' - 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 = '

300

' 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 = '

$1,234.5

' - 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 = '

soon

' 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 = '

300

' + + 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 = '

3.6

' + + quick() + await wait(300) + + expect(element().textContent).toBe('4') + }) + + it('should count at a steady pace without easing', async () => { + fixtureEl.innerHTML = '

1000

' + + 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 = '

-1234567.891

' + + 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 = '

3:28

' + + 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 = '

~12:05

' + expect(new CountUp(element(), { autoAnimate: false, duration: 0 })._endVal).toBe(725) + + fixtureEl.innerHTML = '

9999999999

' + 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) }) }) }) diff --git a/core/libs.json b/core/libs.json index 94b935b3e..d9499b5cc 100644 --- a/core/libs.json +++ b/core/libs.json @@ -20,12 +20,6 @@ "dist/nouislider.min.css" ] }, - "countup": { - "npm": "countup.js", - "js": [ - "dist/countUp.umd.js" - ] - }, "lists": { "npm": "list.js", "js": [ diff --git a/core/package.json b/core/package.json index d45ed0949..0207a0c97 100644 --- a/core/package.json +++ b/core/package.json @@ -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", diff --git a/docs/content/ui/plugins/countup.mdx b/docs/content/ui/plugins/countup.mdx index 3cfbbb69b..ab3d56cf6 100644 --- a/docs/content/ui/plugins/countup.mdx +++ b/docs/content/ui/plugins/countup.mdx @@ -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

30000

@@ -107,9 +107,17 @@ Grouping is on by default, so thousands get a separator. Set `"useGrouping": fal

300

300

+### Time + +Set `"format": "time"` for a value written as hours and minutes. The number counts in minutes and is printed as `h:mm`. + + +

3:28

12:05

+
+ ## 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: @@ -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. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b76862543..251b81fa8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/preview/pages/widgets.astro b/preview/pages/widgets.astro index 33bd5e771..54edb018f 100644 --- a/preview/pages/widgets.astro +++ b/preview/pages/widgets.astro @@ -17,16 +17,16 @@ import Configuration from '@shared/components/cards/Configuration.astro'
- +
- +
- +
- +
diff --git a/shared/components/cards/StatCard.astro b/shared/components/cards/StatCard.astro index 0ad85702d..fcb234a06 100644 --- a/shared/components/cards/StatCard.astro +++ b/shared/components/cards/StatCard.astro @@ -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 ---
@@ -16,7 +21,7 @@ const { title = 'Title', value = '', icon = 'user' } = Astro.props
{title} -
{value}
+
{value}
diff --git a/shared/data/open-source.json b/shared/data/open-source.json index f02b4ec15..f47284e20 100644 --- a/shared/data/open-source.json +++ b/shared/data/open-source.json @@ -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/", diff --git a/shared/lib/countup.test.ts b/shared/lib/countup.test.ts new file mode 100644 index 000000000..7587b4ae1 --- /dev/null +++ b/shared/lib/countup.test.ts @@ -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() + }) +}) diff --git a/shared/lib/countup.ts b/shared/lib/countup.ts new file mode 100644 index 000000000..329ee43d6 --- /dev/null +++ b/shared/lib/countup.ts @@ -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 +}