countdown-8.x-1.8/js/countdown.integration.js
js/countdown.integration.js
/**
* @file
* Main integration script for countdown timers.
*
* Manages initialization, library loading, and lifecycle of countdown
* instances. Respects per-element configuration over global settings.
*/
(function (Drupal, drupalSettings, once) {
'use strict';
/**
* Countdown integration manager.
*
* Uses Object.assign to safely merge with any existing instance.
*
* @namespace
*/
Drupal.countdown = Object.assign(Drupal.countdown || {}, {
/**
* Track countdown instances per element.
*
* @type {WeakMap}
*/
instances: Drupal.countdown && Drupal.countdown.instances || new WeakMap(),
/**
* Track loaded libraries to prevent duplicate loading.
*
* @type {Set}
*/
loadedLibraries: Drupal.countdown && Drupal.countdown.loadedLibraries || new Set(),
/**
* Library-specific loader functions.
*
* @type {Object}
*/
loaders: Drupal.countdown && Drupal.countdown.loaders || {},
/**
* Shared utility functions for all integrations.
*
* @namespace
*/
utils: {
/**
* Normalize boolean values from various sources.
*
* @param {*} value
* The value to normalize.
*
* @return {boolean}
* The normalized boolean value.
*/
normalizeBoolean: function (value) {
// Handle numeric 0/1 values.
if (value === 0 || value === '0') {
return false;
}
if (value === 1 || value === '1') {
return true;
}
// Handle string booleans.
if (value === 'false') {
return false;
}
if (value === 'true') {
return true;
}
// Default boolean conversion.
return !!value;
},
/**
* Normalize numeric values from various sources.
*
* @param {*} value
* The value to normalize.
* @param {number} defaultValue
* The default value if conversion fails.
*
* @return {number}
* The normalized numeric value.
*/
normalizeNumber: function (value, defaultValue) {
if (typeof value === 'number') {
return value;
}
if (typeof value === 'string') {
const parsed = parseInt(value, 10);
return isNaN(parsed) ? defaultValue : parsed;
}
return defaultValue;
},
/**
* Auto-detect the countdown type from element.
*
* @param {Element} element
* The countdown element.
*
* @return {string}
* The detected type: 'block', 'field', 'view', or 'unknown'.
*/
detectCountdownType: function (element) {
if (element.dataset.blockId || element.classList.contains('countdown-block')) {
return 'block';
}
if (element.dataset.fieldId || element.classList.contains('countdown-field')) {
return 'field';
}
if (element.dataset.viewId || element.classList.contains('countdown-view')) {
return 'view';
}
return 'unknown';
},
/**
* Resolve countdown settings from multiple sources with priority.
*
* Priority chain (highest to lowest):
* 1. Data attributes on element
* 2. Context-specific settings (block/field/views)
* 3. Global countdown settings
* 4. Default values
*
* @param {Element} element
* The countdown element.
* @param {Object} drupalSettings
* The drupalSettings object.
* @param {string} libraryName
* The library identifier (e.g., 'flip', 'tick').
*
* @return {Object}
* The resolved settings object with all values normalized.
*/
resolveCountdownSettings: function (element, drupalSettings, libraryName) {
let settings = {};
const countdown = drupalSettings.countdown || {};
// Ensure library name is provided.
if (!libraryName) {
libraryName = element.dataset.countdownLibrary || 'countdown';
}
// Priority 1: Extract data attributes (highest priority).
const dataSettings = {};
const dataPrefix = 'data-' + libraryName.replace('_', '-') + '-';
for (const attr of element.attributes) {
if (attr.name.startsWith(dataPrefix)) {
const key = attr.name.replace(dataPrefix, '').replace(/-/g, '_');
// Convert string booleans to actual booleans.
dataSettings[key] = attr.value === 'true' ? true :
attr.value === 'false' ? false :
attr.value;
}
}
// Priority 2: Context-specific settings.
const countdownType = this.detectCountdownType(element);
switch (countdownType) {
case 'block':
const blockId = element.dataset.blockId;
if (countdown.blocks && countdown.blocks[blockId]) {
const blockSettings = countdown.blocks[blockId];
// Merge common and library-specific settings.
Object.assign(settings, blockSettings.settings || {});
// Library-specific override if available.
if (blockSettings.settings && blockSettings.settings[libraryName]) {
Object.assign(settings, blockSettings.settings[libraryName]);
}
}
break;
case 'field':
const fieldId = element.dataset.fieldId;
if (countdown.fields && countdown.fields[fieldId]) {
const fieldSettings = countdown.fields[fieldId];
Object.assign(settings, fieldSettings.settings || {});
if (fieldSettings.settings && fieldSettings.settings[libraryName]) {
Object.assign(settings, fieldSettings.settings[libraryName]);
}
}
break;
case 'view':
const viewId = element.dataset.viewId;
if (countdown.views && countdown.views[viewId]) {
const viewSettings = countdown.views[viewId];
Object.assign(settings, viewSettings.settings || {});
if (viewSettings.settings && viewSettings.settings[libraryName]) {
Object.assign(settings, viewSettings.settings[libraryName]);
}
}
break;
}
// Priority 3: Global library settings (fallback).
if (countdown.global && countdown.global[libraryName]) {
settings = Object.assign({}, countdown.global[libraryName], settings);
}
// Priority 4: Library configuration from drupalSettings.
if (countdown.libraryConfig) {
settings = Object.assign({}, countdown.libraryConfig, settings);
}
// Priority 5: Library-specific settings from drupalSettings.
if (countdown.settings) {
settings = Object.assign({}, countdown.settings, settings);
}
// Priority 6: Apply data attributes (override all).
Object.assign(settings, dataSettings);
// Extract target date from element if not in settings.
if (!settings.target_date) {
settings.target_date = element.dataset.countdownTarget;
}
// Extract timezone if available.
if (!settings.timezone && element.dataset.countdownTimezone) {
settings.timezone = element.dataset.countdownTimezone;
}
// Parse element-level JSON settings if present.
if (element.dataset.countdownSettings) {
try {
const elementSettings = JSON.parse(element.dataset.countdownSettings);
Object.assign(settings, elementSettings);
}
catch (e) {
console.warn('Countdown: Failed to parse element settings', e);
}
}
return settings;
},
/**
* Handle error conditions with consistent event dispatching.
*
* @param {Element} element
* The countdown element.
* @param {string} message
* The error message.
* @param {string} library
* The library identifier.
*/
handleError: function (element, message, library) {
console.error('Countdown [' + library + ']:', message);
element.dispatchEvent(new CustomEvent('countdown:error', {
detail: {
message: message,
library: library || 'unknown'
}
}));
},
/**
* Dispatch a custom countdown event.
*
* @param {Element} element
* The countdown element.
* @param {string} eventType
* The event type (e.g., 'initialized', 'tick', 'complete').
* @param {Object} detail
* Additional event details.
*/
dispatchEvent: function (element, eventType, detail) {
element.dispatchEvent(new CustomEvent('countdown:' + eventType, {
detail: detail
}));
},
/**
* Check if countdown has expired based on target date.
*
* @param {string} targetDate
* The target date string.
* @param {string} timezone
* The timezone (optional).
*
* @return {boolean}
* True if expired, false otherwise.
*/
isExpired: function (targetDate, timezone) {
if (!targetDate) {
return true;
}
const target = new Date(targetDate + (timezone ? ' ' + timezone : ''));
return target.getTime() <= Date.now();
},
/**
* Display expired message in element.
*
* This function is used by all library integrations to display the
* finish message when countdown completes. All libraries use the same
* finish_message field from buildConfigurationForm.
*
* @param {Element} element
* The countdown element.
* @param {Object} settings
* The settings object containing finish_message.
* @param {string} library
* The library identifier.
*/
showExpiredMessage: function (element, settings, library) {
// Get the finish message with fallback.
const message = settings.finish_message || settings.finishMessage || "Time's up!";
// Create the expired message display.
element.innerHTML = '<div class="countdown-display countdown-expired">' + message + '</div>';
element.classList.add('countdown-expired');
// Dispatch the complete event for other scripts to listen.
this.dispatchEvent(element, 'complete', {
element: element,
library: library
});
}
},
/**
* Store an instance for an element.
*
* @param {Element} element
* The countdown element.
* @param {Object} instance
* The instance object to store.
*/
storeInstance: function (element, instance) {
this.instances.set(element, instance);
},
/**
* Check if a specific library integration is loaded.
*
* @param {string} library
* The library identifier.
*
* @return {boolean}
* True if the library integration is loaded.
*/
isLoaded: function (library) {
return this.loadedLibraries.has(library);
},
/**
* Load a specific library integration script dynamically.
*
* @param {string} library
* The library identifier to load.
* @param {Function} callback
* Callback to execute after loading.
*/
loadIntegration: function (library, callback) {
// Skip if already loaded.
if (this.isLoaded(library)) {
if (callback && typeof callback === 'function') {
callback();
}
return;
}
// Get the integration base path from drupalSettings.
let integrationBasePath = '';
if (drupalSettings.countdown && drupalSettings.countdown.integrationBasePath) {
integrationBasePath = drupalSettings.countdown.integrationBasePath;
} else {
// Fallback to module path if available.
const modulePath = (drupalSettings.countdown && drupalSettings.countdown.modulePath)
? drupalSettings.countdown.modulePath
: '/modules/contrib/countdown';
integrationBasePath = modulePath + '/js/integrations';
}
// Build the script filename based on the library.
let scriptFile = '';
switch (library) {
case 'countdown':
scriptFile = 'countdown.core.integration';
break;
case 'flipclock':
scriptFile = 'countdown.flipclock.integration';
break;
case 'flipdown':
scriptFile = 'countdown.flipdown.integration';
break;
case 'flip':
scriptFile = 'countdown.flip.integration';
break;
case 'tick':
scriptFile = 'countdown.tick.integration';
break;
default:
console.warn('Countdown: Unknown library', library);
return;
}
// Check if we should use minified version.
const useMinified = document.querySelector('script[src*="countdown.integration.min.js"]') !== null;
const extension = useMinified ? '.min.js' : '.js';
// Build full script path.
const scriptPath = integrationBasePath + '/' + scriptFile + extension;
// Create and append the script element.
const script = document.createElement('script');
script.src = scriptPath;
script.async = true;
script.defer = true;
script.onload = () => {
this.loadedLibraries.add(library);
if (callback && typeof callback === 'function') {
callback();
}
};
script.onerror = () => {
console.error('Countdown: Failed to load integration for', library, 'from', scriptPath);
};
document.head.appendChild(script);
},
/**
* Initialize countdown timers in a context (backward compatible).
*
* @param {HTMLElement|Document} context
* The context to search for countdown timers.
* @param {Object} settings
* The drupalSettings object.
*/
initialize: function (context, settings) {
// Find all uninitialized countdown timers in context.
const timers = once('countdown-init', '.countdown', context);
timers.forEach((element) => {
let library = element.dataset.countdownLibrary;
if (library) {
// Mark as initialized early to prevent race conditions.
element.classList.add('countdown-initialized');
// Call the element-specific initialization.
this.initializeCountdown(element, settings);
}
});
},
/**
* Initialize a countdown on a specific element.
*
* @param {HTMLElement} element
* The countdown element.
* @param {Object} settings
* The drupalSettings object.
*/
initializeCountdown: function (element, settings) {
// Stop existing instance if present.
if (this.instances.has(element)) {
this.stop(element);
}
// Determine library with proper precedence.
let library = element.dataset.countdownLibrary;
// Check for block context if no library specified.
if (!library && element.dataset.blockId &&
settings.countdown &&
settings.countdown.blocks &&
settings.countdown.blocks[element.dataset.blockId]) {
library = settings.countdown.blocks[element.dataset.blockId].library;
}
// Check for field context.
if (!library && element.dataset.fieldId &&
settings.countdown &&
settings.countdown.fields &&
settings.countdown.fields[element.dataset.fieldId]) {
library = settings.countdown.fields[element.dataset.fieldId].library;
}
// Fall back to global settings.
if (!library && settings.countdown && settings.countdown.activeLibrary) {
library = settings.countdown.activeLibrary;
}
if (!library) {
console.warn('Countdown: No library specified for element', element);
element.dispatchEvent(new CustomEvent('countdown:error', {
detail: { message: 'No countdown library specified' }
}));
return;
}
// Listen for initialization complete to remove loading placeholder.
element.addEventListener('countdown:initialized', function (e) {
const loadingElement = e.target.querySelector('.countdown-display .countdown-loading');
if (loadingElement) {
loadingElement.remove();
}
}, { once: true });
// Listen for errors to update placeholder with error message.
element.addEventListener('countdown:error', function (e) {
const loadingElement = e.target.querySelector('.countdown-display .countdown-loading');
if (loadingElement) {
loadingElement.textContent = e.detail && e.detail.message ?
e.detail.message : 'Failed to initialize countdown.';
}
}, { once: true });
// Check if integration is loaded, if not load it first.
if (!this.isLoaded(library)) {
this.loadIntegration(library, () => {
// After loading, check if loader was registered.
if (this.loaders[library]) {
this.loaders[library](element, settings);
}
});
} else if (this.loaders[library]) {
// Integration already loaded, call loader directly.
this.loaders[library](element, settings);
}
},
/**
* Stop and cleanup a countdown instance.
*
* @param {HTMLElement} element
* The countdown element.
*/
stop: function (element) {
// Get stored instance.
const instance = this.instances.get(element);
if (instance) {
// Call library-specific cleanup if available.
if (instance.stop && typeof instance.stop === 'function') {
instance.stop();
}
// Handle different instance structures.
if (instance.instance && instance.instance.stop) {
instance.instance.stop();
}
if (instance.counter && instance.counter.stop) {
instance.counter.stop();
}
// Clear any intervals stored in dataset.
if (element.dataset.countdownInterval) {
clearInterval(element.dataset.countdownInterval);
delete element.dataset.countdownInterval;
}
// Remove from instances map.
this.instances.delete(element);
// Remove initialized class.
element.classList.remove('countdown-initialized');
// Dispatch stopped event.
element.dispatchEvent(new CustomEvent('countdown:stopped', {
detail: { element: element }
}));
}
},
/**
* Register a library-specific loader function.
*
* @param {string} library
* The library identifier.
* @param {Function} loader
* The loader function.
*/
registerLoader: function (library, loader) {
this.loaders[library] = loader;
}
});
/**
* Countdown integration behavior.
*
* @type {Drupal~behavior}
*/
Drupal.behaviors.countdownIntegration = {
attach: function (context, settings) {
// Use the backward-compatible initialize method.
Drupal.countdown.initialize(context, settings);
},
detach: function (context, settings, trigger) {
// Clean up countdown instances when elements are removed.
if (trigger === 'unload') {
const timers = context.querySelectorAll('.countdown.countdown-initialized');
timers.forEach(function (element) {
Drupal.countdown.stop(element);
});
}
}
};
})(Drupal, drupalSettings, once);
