countdown-8.x-1.8/js/lib/effects/countdown.effects.js
js/lib/effects/countdown.effects.js
/**
* CountdownTimer Effects Module - Consolidated digit animation effects
* @version 1.0.0-alpha2
* @license MIT
*
* Provides multiple digit-rendering animations: slide, fade, swap, bounce
* Shared engine with per-effect strategies for optimal performance
*/
(function (root, factory) {
'use strict';
if (typeof define === 'function' && define.amd) {
define([], factory);
} else if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
factory();
}
}(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// Gracefully handle missing core
const CountdownTimer = (typeof window !== 'undefined' && window.CountdownTimer) ||
(typeof global !== 'undefined' && global.CountdownTimer);
if (!CountdownTimer || typeof CountdownTimer.registerStyle !== 'function') {
console.warn('[CountdownTimer.Effects] Core library not found or missing registerStyle');
return;
}
/**
* Effect strategies define animation behavior per effect type
* @private
*/
const EFFECT_STRATEGIES = {
slide: {
name: 'slide',
duration: 300,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
getInitialState: (direction) => ({
transform: `translateY(${-direction * 100}%)`,
opacity: '0'
}),
getTargetState: (direction) => ({
transform: `translateY(${direction * 100}%)`,
opacity: '0'
})
},
fade: {
name: 'fade',
duration: 250,
easing: 'ease-in-out',
getInitialState: () => ({
transform: 'translateY(0)',
opacity: '0'
}),
getTargetState: () => ({
transform: 'translateY(0)',
opacity: '0'
})
},
swap: {
name: 'swap',
duration: 100,
easing: 'linear',
getInitialState: () => ({
transform: 'scale(0.8)',
opacity: '0'
}),
getTargetState: () => ({
transform: 'scale(1.2)',
opacity: '0'
})
},
bounce: {
name: 'bounce',
duration: 400,
easing: 'cubic-bezier(0.68, -0.55, 0.265, 1.55)',
getInitialState: (direction) => ({
transform: `translateY(${-direction * 120}%)`,
opacity: '0'
}),
getTargetState: (direction) => ({
transform: `translateY(${direction * 120}%)`,
opacity: '0'
})
}
};
/**
* Unified effects renderer with shared digit engine
* @class
*/
class EffectsRenderer {
/**
* @param {CountdownTimer} timer - Timer instance
* @param {Object} options - Effect options
* @param {string} [options.effect='slide'] - Effect name
* @param {number} [options.duration] - Override duration in ms
* @param {string} [options.easing] - Override CSS easing
*/
constructor(timer, options = {}) {
this.timer = timer;
this.element = timer._element;
// Determine effect from options or config
const effectName = options.effect || timer.config.style || 'slide';
this.strategy = EFFECT_STRATEGIES[effectName] || EFFECT_STRATEGIES.slide;
this.options = {
duration: options.duration || this.strategy.duration,
easing: options.easing || this.strategy.easing,
...options
};
this.prevFormatted = '';
this.isCountdown = timer.config.mode === 'countdown';
this.digitElements = [];
this.transitions = new Map();
this.timeouts = new Map();
this.isDestroyed = false;
this._setupDOM();
}
/**
* Setup DOM container with appropriate classes and attributes
* @private
*/
_setupDOM() {
if (!this.element || this.isDestroyed) return;
// Clear and setup container
this.element.textContent = '';
this.element.classList.add('ct-effect');
this.element.classList.add(`ct-effect--${this.strategy.name}`);
// Set CSS variables for customization
this.element.style.setProperty('--ct-duration', `${this.options.duration}ms`);
this.element.style.setProperty('--ct-easing', this.options.easing);
// Accessibility attributes
this.element.setAttribute('role', 'timer');
this.element.setAttribute('aria-live', 'polite');
this.element.setAttribute('aria-atomic', 'true');
}
/**
* Main render method called on each tick
* @param {string} formatted - Formatted time string
* @param {Object} components - Time components
*/
render(formatted, components) {
if (!this.element || this.isDestroyed) return;
// Detect millisecond precision for snap behavior
const decimalIndex = formatted.indexOf('.');
const hasMilliseconds = decimalIndex > -1;
// Ensure digit containers match string length
this._ensureDigitContainers(formatted.length);
// Update each character
for (let i = 0; i < formatted.length; i++) {
const char = formatted[i];
const prevChar = i < this.prevFormatted.length ? this.prevFormatted[i] : undefined;
const isMillisecondDigit = hasMilliseconds && i > decimalIndex;
if (char !== prevChar) {
if (isMillisecondDigit) {
// Snap update for millisecond digits (no animation)
this._snapDigit(i, char);
} else {
// Animated update for regular digits
this._animateDigit(i, char, prevChar);
}
}
}
this.prevFormatted = formatted;
// Update ARIA label for accessibility
this.element.setAttribute('aria-label', this._getAriaLabel(components));
}
/**
* Ensure we have the right number of digit containers
* @private
*/
_ensureDigitContainers(count) {
// Add containers if needed
while (this.digitElements.length < count) {
const container = document.createElement('span');
container.className = 'ct-effect__digit';
container.dataset.digitIndex = this.digitElements.length;
// Add initial layer
const layer = document.createElement('span');
layer.className = 'ct-effect__layer ct-effect__layer--current';
layer.textContent = ' ';
container.appendChild(layer);
this.element.appendChild(container);
this.digitElements.push(container);
}
// Remove extra containers
while (this.digitElements.length > count) {
const container = this.digitElements.pop();
this._cleanupDigit(container);
container.remove();
}
}
/**
* Instantly update a digit without animation (for milliseconds)
* @private
*/
_snapDigit(index, newChar) {
const container = this.digitElements[index];
if (!container || this.isDestroyed) return;
// Clean up any pending animations
this._cleanupDigit(container);
// Get or create current layer
let currentLayer = container.querySelector('.ct-effect__layer--current');
if (!currentLayer) {
currentLayer = document.createElement('span');
currentLayer.className = 'ct-effect__layer ct-effect__layer--current';
container.appendChild(currentLayer);
}
// Remove any other layers
const allLayers = container.querySelectorAll('.ct-effect__layer');
allLayers.forEach(layer => {
if (layer !== currentLayer) {
layer.remove();
}
});
// Direct update with no animation
currentLayer.textContent = newChar;
currentLayer.style.transform = '';
currentLayer.style.opacity = '';
currentLayer.style.willChange = '';
currentLayer.classList.remove('ct-effect__layer--animating');
}
/**
* Animate a digit change using the current effect strategy
* @private
*/
_animateDigit(index, newChar, oldChar) {
const container = this.digitElements[index];
if (!container || this.isDestroyed) return;
const isMotionOk = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Clean up existing transitions
this._cleanupDigit(container);
// Get or create current layer
let currentLayer = container.querySelector('.ct-effect__layer--current');
if (!currentLayer) {
currentLayer = document.createElement('span');
currentLayer.className = 'ct-effect__layer ct-effect__layer--current';
currentLayer.textContent = oldChar || ' ';
container.appendChild(currentLayer);
}
if (!isMotionOk || !oldChar) {
// No animation for reduced motion or initial render
currentLayer.textContent = newChar;
return;
}
// Create incoming layer
const incomingLayer = document.createElement('span');
incomingLayer.className = 'ct-effect__layer ct-effect__layer--incoming';
incomingLayer.textContent = newChar;
// Get animation states from strategy
const direction = this.isCountdown ? -1 : 1;
const initialState = this.strategy.getInitialState(direction);
const targetState = this.strategy.getTargetState(direction);
// Set initial state for incoming layer
Object.assign(incomingLayer.style, initialState);
incomingLayer.style.willChange = 'transform, opacity';
// Add to container
container.appendChild(incomingLayer);
// Force layout to ensure initial state is applied
void container.offsetHeight;
// Add animation class
currentLayer.classList.add('ct-effect__layer--animating');
incomingLayer.classList.add('ct-effect__layer--animating');
// Apply will-change to current layer
currentLayer.style.willChange = 'transform, opacity';
// Start animations
requestAnimationFrame(() => {
if (this.isDestroyed) return;
// Move current layer out
Object.assign(currentLayer.style, targetState);
// Move incoming layer in
incomingLayer.style.transform = 'translateY(0) scale(1)';
incomingLayer.style.opacity = '1';
});
// Track transition for cleanup
const transitionData = {
currentLayer,
incomingLayer,
container,
completed: false,
transitionEndHandler: null
};
this.transitions.set(index, transitionData);
// Cleanup handler
const cleanup = () => {
if (transitionData.completed || this.isDestroyed) return;
transitionData.completed = true;
// Remove old layer
if (currentLayer.parentNode === container) {
currentLayer.remove();
}
// Reset incoming layer to become current
incomingLayer.classList.remove('ct-effect__layer--animating', 'ct-effect__layer--incoming');
incomingLayer.classList.add('ct-effect__layer--current');
incomingLayer.style.transform = '';
incomingLayer.style.opacity = '';
incomingLayer.style.willChange = '';
// Clear timeout
const timeoutId = this.timeouts.get(index);
if (timeoutId) {
clearTimeout(timeoutId);
this.timeouts.delete(index);
}
this.transitions.delete(index);
};
// Use transitionend with timeout fallback
const transitionEndHandler = (e) => {
if (e.target === incomingLayer && e.propertyName === 'transform') {
cleanup();
}
};
incomingLayer.addEventListener('transitionend', transitionEndHandler);
transitionData.transitionEndHandler = { element: incomingLayer, handler: transitionEndHandler };
// Safety timeout (50ms buffer)
const timeoutId = setTimeout(cleanup, this.options.duration + 50);
this.timeouts.set(index, timeoutId);
}
/**
* Clean up any pending animations for a digit
* @private
*/
_cleanupDigit(container) {
const index = parseInt(container.dataset.digitIndex, 10);
// Clear timeout
const timeoutId = this.timeouts.get(index);
if (timeoutId) {
clearTimeout(timeoutId);
this.timeouts.delete(index);
}
// Clean up transition
const transition = this.transitions.get(index);
if (transition && !transition.completed) {
transition.completed = true;
// Remove event listeners
if (transition.transitionEndHandler) {
const { element, handler } = transition.transitionEndHandler;
element.removeEventListener('transitionend', handler);
}
// Clean up layers
if (transition.currentLayer) {
transition.currentLayer.classList.remove('ct-effect__layer--animating');
transition.currentLayer.style.willChange = '';
if (transition.currentLayer.parentNode) {
transition.currentLayer.remove();
}
}
if (transition.incomingLayer) {
transition.incomingLayer.classList.remove('ct-effect__layer--animating', 'ct-effect__layer--incoming');
transition.incomingLayer.classList.add('ct-effect__layer--current');
transition.incomingLayer.style.transform = '';
transition.incomingLayer.style.opacity = '';
transition.incomingLayer.style.willChange = '';
}
this.transitions.delete(index);
}
}
/**
* Generate ARIA label for accessibility
* @private
*/
_getAriaLabel(components) {
const parts = [];
if (components.days > 0) parts.push(`${components.days} day${components.days !== 1 ? 's' : ''}`);
if (components.hours > 0) parts.push(`${components.hours} hour${components.hours !== 1 ? 's' : ''}`);
if (components.minutes > 0) parts.push(`${components.minutes} minute${components.minutes !== 1 ? 's' : ''}`);
if (components.seconds > 0) parts.push(`${components.seconds} second${components.seconds !== 1 ? 's' : ''}`);
if (components.milliseconds > 0 && parts.length === 0) parts.push(`${components.milliseconds} milliseconds`);
return parts.join(', ') || '0 seconds';
}
/**
* Clean up and destroy the renderer
*/
destroy() {
this.isDestroyed = true;
// Clean up all timeouts
for (const timeoutId of this.timeouts.values()) {
clearTimeout(timeoutId);
}
this.timeouts.clear();
// Clean up all transitions
for (const transition of this.transitions.values()) {
if (!transition.completed) {
transition.completed = true;
// Remove listeners
if (transition.transitionEndHandler) {
const { element, handler } = transition.transitionEndHandler;
element.removeEventListener('transitionend', handler);
}
if (transition.currentLayer && transition.currentLayer.parentNode) {
transition.currentLayer.remove();
}
if (transition.incomingLayer) {
transition.incomingLayer.style.willChange = '';
}
}
}
this.transitions.clear();
// Clean up DOM
if (this.element) {
this.element.classList.remove('ct-effect', `ct-effect--${this.strategy.name}`);
this.element.style.removeProperty('--ct-duration');
this.element.style.removeProperty('--ct-easing');
this.element.removeAttribute('role');
this.element.removeAttribute('aria-live');
this.element.removeAttribute('aria-atomic');
this.element.removeAttribute('aria-label');
this.element.textContent = '';
}
this.digitElements = [];
this.timer = null;
this.element = null;
}
}
// Register all effects
Object.keys(EFFECT_STRATEGIES).forEach(effectName => {
CountdownTimer.registerStyle(effectName, EffectsRenderer);
});
// Also support the newer 'effect' naming if registerEffect exists
if (typeof CountdownTimer.registerEffect === 'function') {
Object.keys(EFFECT_STRATEGIES).forEach(effectName => {
CountdownTimer.registerEffect(effectName, EffectsRenderer);
});
}
return EffectsRenderer;
}));
