mirror of
https://github.com/tabler/tabler.git
synced 2026-10-21 15:24:19 +04:00
358 lines
9.9 KiB
TypeScript
358 lines
9.9 KiB
TypeScript
/**
|
|
* --------------------------------------------------------------------------
|
|
* Tabler countup.ts
|
|
* Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE)
|
|
* --------------------------------------------------------------------------
|
|
*/
|
|
|
|
import BaseComponent from './bootstrap/base-component'
|
|
import EventHandler from './bootstrap/dom/event-handler'
|
|
import { initAll } from './bootstrap/util/component-functions'
|
|
import type { ElementSelector } from './bootstrap/types'
|
|
|
|
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>
|
|
|
|
/**
|
|
* Constants
|
|
*/
|
|
|
|
const NAME = 'countup'
|
|
const DATA_KEY = `bs.${NAME}`
|
|
const EVENT_KEY = `.${DATA_KEY}`
|
|
const DATA_ATTRIBUTE = `data-${NAME}`
|
|
|
|
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<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
|
|
*/
|
|
|
|
// deprecated(2.0): countUp.js took `"duration":"3"` or `"useGrouping":"false"`
|
|
// from `data-countup` without complaint. The typed config would reject them, so
|
|
// attribute values that are plainly a number or a boolean are read as one.
|
|
const coerceOptions = (options: Record<string, unknown>): Record<string, unknown> => {
|
|
const result: Record<string, unknown> = { ...options }
|
|
|
|
for (const [key, value] of Object.entries(options)) {
|
|
const expected = DefaultType[key as keyof ComponentConfig]
|
|
|
|
if (typeof value === 'string' && expected === 'number' && value.trim() !== '' && !Number.isNaN(Number(value))) {
|
|
result[key] = Number(value)
|
|
} else if (typeof value === 'string' && expected === 'boolean' && (value === 'true' || value === 'false')) {
|
|
result[key] = value === 'true'
|
|
} else if (typeof value === 'number' && expected === 'string') {
|
|
result[key] = String(value)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// 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 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
|
|
_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
|
|
_started = false
|
|
|
|
constructor(element: ElementSelector, config?: ComponentConfigInput) {
|
|
super(element, config)
|
|
|
|
if (!this._element) {
|
|
return
|
|
}
|
|
|
|
const value = parseValue(this._element.textContent ?? '', this._config.format)
|
|
if (Number.isNaN(value)) {
|
|
return
|
|
}
|
|
|
|
this._endVal = value
|
|
this._value = this._config.startVal
|
|
this._print(this._value)
|
|
|
|
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) => this._observerCallback(entries))
|
|
this._observer.observe(this._element)
|
|
}
|
|
|
|
// Getters
|
|
static get Default(): ComponentConfig {
|
|
return Default
|
|
}
|
|
|
|
static get DefaultType(): Record<keyof ComponentConfig, string> {
|
|
return DefaultType
|
|
}
|
|
|
|
static get NAME(): string {
|
|
return NAME
|
|
}
|
|
|
|
// Public
|
|
start(): void {
|
|
this._animate(this._value, this._endVal)
|
|
}
|
|
|
|
reset(): void {
|
|
this._stop()
|
|
// Lets the observer start the animation again when the element comes into view.
|
|
this._started = false
|
|
this._value = this._config.startVal
|
|
this._print(this._value)
|
|
}
|
|
|
|
update(value: number | string): void {
|
|
const parsed = parseValue(value, this._config.format)
|
|
if (Number.isNaN(parsed)) {
|
|
return
|
|
}
|
|
|
|
this._endVal = parsed
|
|
this._animate(this._value, this._endVal)
|
|
}
|
|
|
|
pauseResume(): void {
|
|
if (this._paused) {
|
|
this._paused = false
|
|
this._run()
|
|
} else if (this._running) {
|
|
this._paused = true
|
|
cancelAnimationFrame(this._frame)
|
|
}
|
|
}
|
|
|
|
dispose(): void {
|
|
this._stop()
|
|
this._observer?.disconnect()
|
|
super.dispose()
|
|
}
|
|
|
|
// Private
|
|
_mergeConfigObj(config?: Record<string, unknown>, element?: HTMLElement): Record<string, unknown> {
|
|
// `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) ?? element?.getAttribute(DATA_ATTRIBUTE_ALIAS)
|
|
|
|
if (raw) {
|
|
try {
|
|
const parsed: unknown = JSON.parse(raw)
|
|
// Only an object carries options; `null`, a number or an array is ignored.
|
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
dataOptions = parsed as Record<string, unknown>
|
|
}
|
|
} catch {
|
|
// ignore invalid JSON
|
|
}
|
|
}
|
|
|
|
return super._mergeConfigObj({ ...coerceOptions(dataOptions), ...config }, element)
|
|
}
|
|
|
|
_observerCallback(entries: IntersectionObserverEntry[]): void {
|
|
if (entries.some((entry) => entry.isIntersecting)) {
|
|
this._observer?.disconnect()
|
|
this._observer = null
|
|
|
|
// update() may have already animated it while it was off screen.
|
|
if (!this._started) {
|
|
this.start()
|
|
}
|
|
}
|
|
}
|
|
|
|
_animate(from: number, to: number): void {
|
|
this._stop()
|
|
this._started = true
|
|
|
|
// 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') {
|
|
// Rounded away from zero on both sides, so -90.5 and 90.5 give the same
|
|
// minutes; a value that rounds to 0 stays unsigned (-0 < 0 is false).
|
|
const rounded = Math.sign(value) * Math.round(Math.abs(value))
|
|
const minutes = Math.abs(rounded)
|
|
return `${rounded < 0 ? '-' : ''}${prefix}${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}${suffix}`
|
|
}
|
|
|
|
const fixed = value.toFixed(decimalPlaces)
|
|
const [integer, fraction] = fixed.replace('-', '').split('.') as [string, string | undefined]
|
|
const grouped = useGrouping ? integer.replace(/\B(?=(\d{3})+(?!\d))/g, separator) : integer
|
|
|
|
return `${Number(fixed) < 0 ? '-' : ''}${prefix}${grouped}${fraction ? decimal + fraction : ''}${suffix}`
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Data API implementation
|
|
*/
|
|
|
|
// js-docs-start countup-init
|
|
initAll(SELECTOR_DATA_COUNTUP, CountUp)
|
|
// js-docs-end countup-init
|
|
|
|
export default CountUp
|