dxb-count-to
Version:
A vanilla JavaScript counter animation plugin with IntersectionObserver integration
328 lines (273 loc) • 10.4 kB
JavaScript
/**
* DXB CountTo - A vanilla JavaScript counter plugin
* Replacement for jquery.countTo with IntersectionObserver integration
*/
(function(global) {
'use strict';
class DXBCountTo {
static DEFAULTS = {
from: 0, // the number the element should start at
to: 0, // the number the element should end at
speed: 1000, // how long it should take to count between the target numbers
refreshInterval: 100, // how often the element should be updated
decimals: 0, // the number of decimal places to show
formatter: null, // handler for formatting the value before rendering
seperator: '', // thousands separator
onUpdate: null, // callback method for every time the element is updated
onComplete: null, // callback method for when the element finishes updating
prefix: '', // prefix to add before the number
postfix: '' // suffix to add after the number
};
constructor(element, options = {}) {
if (!(element instanceof HTMLElement)) {
throw new Error('DXBCountTo requires an HTMLElement for the counter display');
}
this.element = element; // This is the inner display element
this.wrapperElement = options.wrapper || element.parentElement; // Get wrapper from options or assume parent
// Add an attribute to mark the wrapper element as initialized
if (this.wrapperElement && this.wrapperElement.hasAttribute('data-dxb-countto-initialized')) {
return; // Skip if already initialized
}
if (this.wrapperElement) {
this.wrapperElement.setAttribute('data-dxb-countto-initialized', 'true');
}
this.options = { ...DXBCountTo.DEFAULTS, ...this._dataOptions(), ...options };
this.observer = null;
this.interval = null;
this.isRunning = false;
// If no formatter is provided, use the default
if (!this.options.formatter) {
this.options.formatter = this._defaultFormatter.bind(this);
}
this._init();
this._setupObserver();
}
_init() {
this.value = this.options.from;
this.loops = Math.ceil(this.options.speed / this.options.refreshInterval);
this.loopCount = 0;
this.increment = (this.options.to - this.options.from) / this.loops;
}
_dataOptions() {
const options = {
from: this._getDataAttribute('from', 'number'),
to: this._getDataAttribute('to', 'number'),
speed: this._getDataAttribute('speed', 'number'),
refreshInterval: this._getDataAttribute('refresh-interval', 'number'),
decimals: this._getDataAttribute('decimals', 'number'),
seperator: this._getDataAttribute('seperator', 'string'),
prefix: this._getDataAttribute('prefix', 'string'),
postfix: this._getDataAttribute('postfix', 'string')
};
// Remove undefined options
Object.keys(options).forEach(key => {
if (options[key] === undefined) {
delete options[key];
}
});
return options;
}
_getDataAttribute(name, type) {
const value = this.element.dataset[name];
if (value === undefined) return undefined;
if (type === 'number') {
return parseFloat(value);
}
return value;
}
_defaultFormatter(value) {
let formattedValue = value.toFixed(this.options.decimals);
if (this.options.seperator) {
formattedValue = formattedValue.replace(/\B(?=(\d{3})+(?!\d))/g, this.options.seperator);
}
return this.options.prefix + formattedValue + this.options.postfix;
}
_update() {
this.value += this.increment;
this.loopCount++;
this._render();
if (typeof this.options.onUpdate === 'function') {
this.options.onUpdate.call(this.element, this.value);
}
if (this.loopCount >= this.loops) {
clearInterval(this.interval);
this.isRunning = false;
this.value = this.options.to;
this._render();
if (typeof this.options.onComplete === 'function') {
this.options.onComplete.call(this.element, this.value);
}
}
}
_render() {
const formattedValue = this.options.formatter.call(this, this.value, this.options);
this.element.textContent = formattedValue;
}
_setupObserver() {
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !this.isRunning) {
this.start();
}
});
}, {
root: null,
threshold: 0.1 // Start when just 10% of the element is visible for better responsiveness
});
this.observer.observe(this.element);
// Force a check immediately after setup for elements already in view
if (this._isElementInViewport(this.element) && !this.isRunning) {
this.start();
}
}
_isElementInViewport(el) {
const rect = el.getBoundingClientRect();
return (
rect.top <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.bottom >= 0
);
}
restart() {
this.stop();
this._init();
this.start();
}
start() {
if (this.isRunning) return;
this.stop();
this._render();
this.isRunning = true;
this.interval = setInterval(() => this._update(), this.options.refreshInterval);
}
stop() {
if (this.interval) {
clearInterval(this.interval);
this.isRunning = false;
}
}
toggle() {
if (this.isRunning) {
this.stop();
} else {
this.start();
}
}
// Clean up resources
destroy() {
this.stop();
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
// Remove initialized attribute from the wrapper
if (this.wrapperElement) {
this.wrapperElement.removeAttribute('data-dxb-countto-initialized');
}
}
// Static method to initialize all counters on the page
static initAll() {
const counters = [];
const wrapperElements = document.querySelectorAll('.dxb-countto:not([data-dxb-countto-initialized]), .az-counter:not([data-dxb-countto-initialized])');
wrapperElements.forEach(wrapper => {
// The actual counter element is the first child of the wrapper
const counterElement = wrapper.firstElementChild;
if (counterElement instanceof HTMLElement) {
counters.push(new DXBCountTo(counterElement, { wrapper: wrapper }));
}
});
return counters;
}
// Efficient method to check if an element is or contains a counter element
static hasCounterElement(element) {
// Only process div elements
if (element.tagName !== 'DIV') return false;
// Check if the element is a wrapper for a counter
return element.classList.contains('dxb-countto') ||
element.classList.contains('az-counter');
}
}
// Create a factory function to maintain similar API
function createDXBCountTo(elements, options) {
if (typeof elements === 'string') {
elements = document.querySelectorAll(elements);
} else if (elements instanceof HTMLElement) {
elements = [elements];
} else if (!Array.isArray(elements) && !(elements instanceof NodeList)) {
throw new Error('Invalid element selection');
}
return Array.from(elements).map(element => new DXBCountTo(element, options));
}
// Export to global namespace
global.DXBCountTo = DXBCountTo;
global.createDXBCountTo = createDXBCountTo;
// Add to dxprBuilder namespace if it exists
if (global.dxprBuilder) {
global.dxprBuilder.DXBCountTo = DXBCountTo;
global.dxprBuilder.createDXBCountTo = createDXBCountTo;
}
// Set up an optimized MutationObserver that only processes DIV elements
function setupMutationObserver() {
if (!window.MutationObserver) return;
// Throttle function to prevent too frequent refreshes
let throttleTimer = null;
const throttle = (callback, time) => {
if (throttleTimer) return;
throttleTimer = setTimeout(() => {
callback();
throttleTimer = null;
}, time);
};
const observer = new MutationObserver((mutations) => {
let shouldInit = false;
// Quick check for any relevant mutations to avoid unnecessary processing
for (let i = 0; i < mutations.length; i++) {
const mutation = mutations[i];
// Only process childList mutations with added nodes
if (mutation.type === 'childList' && mutation.addedNodes.length) {
for (let j = 0; j < mutation.addedNodes.length; j++) {
const node = mutation.addedNodes[j];
// Only process element nodes that are divs
if (node.nodeType === 1 && node.tagName === 'DIV') {
// Check if the added node is a wrapper for a counter
if (DXBCountTo.hasCounterElement(node)) {
shouldInit = true;
break;
}
}
}
if (shouldInit) break;
}
}
if (shouldInit) {
// Throttle the initialization to improve performance
throttle(() => {
DXBCountTo.initAll();
}, 500);
}
});
// Observe only additions of child elements in the body, not attribute changes
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: false,
characterData: false
});
// Store observer in window for potential cleanup
window.dxbCountToObserver = observer;
}
// Auto-initialize counters when the script is loaded
const autoInitialize = () => {
// Wait for the DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
DXBCountTo.initAll();
setupMutationObserver();
});
} else {
DXBCountTo.initAll();
setupMutationObserver();
}
};
// Auto-initialize but allow manual initialization
autoInitialize();
})(typeof window !== 'undefined' ? window : this);