countdown-8.x-1.8/js/integrations/countdown.flip.integration.js
js/integrations/countdown.flip.integration.js
/**
* @file
* Integration for the PQINA Flip library.
*
* This file handles initialization and management of PQINA Flip instances
* using a unified settings resolver for all contexts (block, field, etc).
*/
(function (Drupal) {
'use strict';
/**
* Initialize a PQINA Flip timer.
*
* @param {Element} element
* The DOM element to initialize as a timer.
* @param {Object} drupalSettings
* The settings object from drupalSettings.
*/
function initializeFlip(element, drupalSettings) {
// Validate library availability.
if (typeof Tick === 'undefined' || !Tick || !Tick.DOM) {
console.error('Countdown: PQINA Flip requires Tick to be loaded.');
Drupal.countdown.utils.handleError(element, 'Tick library required for Flip not loaded.', 'flip');
return;
}
// Resolve settings from all sources using shared utility.
const settings = resolveFlipSettings(element, drupalSettings);
// Extract core configuration.
const targetDate = settings.target_date || element.dataset.countdownTarget;
const timezone = settings.timezone || element.dataset.countdownTimezone || 'UTC';
const direction = settings.direction || element.dataset.countdownDirection || 'countdown';
if (!targetDate) {
console.error('Countdown: No target date specified.');
Drupal.countdown.utils.handleError(element, 'No target date specified.', 'flip');
return;
}
// Calculate target timestamp.
const target = new Date(targetDate + ' ' + timezone);
const now = Date.now();
// Check if already expired for countdown.
if (direction === 'countdown' && target.getTime() <= now) {
Drupal.countdown.utils.showExpiredMessage(element, settings, 'flip');
return;
}
// Process format configuration.
let format = processFormatConfiguration(settings.format);
// Build the Flip markup structure with inline separators.
const markup = buildFlipMarkup(format, settings);
element.innerHTML = markup;
// Find the tick root element.
const tickRoot = element.querySelector('.tick');
if (!tickRoot) {
console.error('Countdown: Failed to create Flip markup.');
Drupal.countdown.utils.handleError(element, 'Failed to create Flip markup.', 'flip');
return;
}
// Apply theme and appearance settings.
applyFlipTheme(element, tickRoot, settings);
applySizeStyles(element, tickRoot, settings.size);
applyCustomStyles(element, tickRoot, settings);
// Apply custom CSS class if provided.
if (settings.customCssClass) {
tickRoot.classList.add(settings.customCssClass);
}
// Apply responsive mode.
if (settings.responsive === true) {
tickRoot.classList.add('flip-responsive');
}
// Initialize Tick DOM with Flip view.
let tickDom;
try {
// Store settings in a closure variable for the init handler.
const flipSettings = settings;
// Set up the init handler with settings closure.
window.handleFlipInit = function (tick) {
// Apply flip easing setting directly to the tick instance.
if (flipSettings.flipEasing) {
// Set the flipEasing on the style object that Tick uses internally.
if (tick.baseDefinition && tick.baseDefinition.presenter) {
tick.baseDefinition.presenter.style = tick.baseDefinition.presenter.style || {};
tick.baseDefinition.presenter.style.flipEasing = flipSettings.flipEasing;
}
// Also try setting on the root element's dataset for Tick to pick up.
tick.root.dataset.style = (tick.root.dataset.style || '') + ' flip-easing:' + flipSettings.flipEasing;
}
// Apply flip duration if specified.
if (flipSettings.flipDuration) {
if (tick.baseDefinition && tick.baseDefinition.presenter) {
tick.baseDefinition.presenter.style = tick.baseDefinition.presenter.style || {};
tick.baseDefinition.presenter.style.flipDuration = flipSettings.flipDuration;
}
}
// Apply credits setting to the tick instance.
if (flipSettings.showCredits === false) {
// Remove or hide the credits element if it exists.
const creditsEl = tick.root.querySelector('.tick-credits');
if (creditsEl) {
creditsEl.remove();
}
}
// Store reference for counter updates.
const element = tick.root.parentElement;
if (element) {
const instance = Drupal.countdown.instances.get(element);
if (instance && instance.counter && instance.counter.value) {
tick.value = instance.counter.value;
}
}
};
// Pass credits configuration to Tick.
const tickOptions = {
credits: settings.showCredits === true ? {
label: 'Powered by PQINA',
url: 'https://pqina.nl/?ref=credits'
} : false
};
// Add flip-specific styles to the tick root before initialization.
prepareTickStyles(tickRoot, settings);
// Create the Tick DOM instance.
tickDom = Tick.DOM.create(tickRoot, tickOptions);
}
catch (error) {
console.error('Countdown: Failed to initialize Flip with Tick DOM', error);
Drupal.countdown.utils.handleError(element, 'Failed to initialize Flip: ' + error.message, 'flip');
return;
}
// Apply animation settings to Flip elements after DOM creation.
applyAnimationSettings(tickRoot, settings);
// Create countdown configuration.
const countConfig = {
format: format,
interval: settings.updateInterval || 1000,
cascade: settings.cascade !== false
};
// Create countdown counter.
const counter = direction === 'countdown'
? Tick.count.down(targetDate, countConfig)
: Tick.count.up(targetDate, countConfig);
// Handle counter updates.
counter.onupdate = function (value) {
// Convert array value to object for Tick DOM split transformation.
const formattedValue = {};
format.forEach(function (unit, index) {
formattedValue[unit] = value[index] || 0;
});
// Update the DOM with the formatted value.
tickDom.value = formattedValue;
// Dispatch tick event.
Drupal.countdown.utils.dispatchEvent(element, 'tick', {
element: element,
library: 'flip',
value: value,
format: format
});
};
// Handle countdown completion.
counter.onended = function () {
const finishMessage = settings.finish_message || "Time's up!";
// Stop at zero if configured.
if (settings.stopAtZero === true) {
// Keep displaying zeros instead of replacing content.
const zeroValue = {};
format.forEach(function (unit) {
zeroValue[unit] = 0;
});
tickDom.value = zeroValue;
} else {
// Replace with finish message.
element.innerHTML = '<div class="flip-finish">' + finishMessage + '</div>';
}
element.classList.add('countdown-expired');
// Execute callback if provided.
if (typeof settings.onComplete === 'function') {
settings.onComplete.call(this, element);
}
// Dispatch complete event.
Drupal.countdown.utils.dispatchEvent(element, 'complete', {
element: element,
library: 'flip',
format: format
});
};
// Store instance for management.
Drupal.countdown.storeInstance(element, {
counter: counter,
dom: tickDom,
format: format,
settings: settings
});
// Mark as initialized.
element.classList.add('countdown-initialized');
element.classList.add('countdown-flip');
element.setAttribute('data-flip-format', format.join(','));
// Dispatch initialization event.
Drupal.countdown.utils.dispatchEvent(element, 'initialized', {
library: 'flip',
element: element,
settings: settings,
format: format
});
}
/**
* Resolve Flip-specific settings from multiple sources.
*
* @param {Element} element
* The countdown element.
* @param {Object} drupalSettings
* The drupalSettings object.
*
* @return {Object}
* The resolved settings object with normalized values.
*/
function resolveFlipSettings(element, drupalSettings) {
// Get base settings from shared utility.
let settings = Drupal.countdown.utils.resolveCountdownSettings(
element,
drupalSettings,
'flip'
);
// Normalize boolean values for all boolean settings using shared utility.
settings.leadingZeros = Drupal.countdown.utils.normalizeBoolean(settings.leadingZeros);
settings.showLabels = Drupal.countdown.utils.normalizeBoolean(settings.showLabels);
settings.autostart = Drupal.countdown.utils.normalizeBoolean(settings.autostart);
settings.responsive = Drupal.countdown.utils.normalizeBoolean(settings.responsive);
settings.stopAtZero = Drupal.countdown.utils.normalizeBoolean(settings.stopAtZero);
settings.showCredits = Drupal.countdown.utils.normalizeBoolean(settings.showCredits);
// Normalize numeric values for all numeric settings using shared utility.
settings.flipDuration = Drupal.countdown.utils.normalizeNumber(settings.flipDuration, 800);
settings.updateInterval = Drupal.countdown.utils.normalizeNumber(settings.updateInterval, 1000);
// Ensure shadow and rounded styles have defaults.
settings.shadowStyle = settings.shadowStyle || 'default';
settings.roundedStyle = settings.roundedStyle || 'default';
// Ensure flip easing has a default.
settings.flipEasing = settings.flipEasing || 'ease-out-bounce';
// Process format if needed.
if (!settings.format) {
settings.format = ['d', 'h', 'm', 's'];
}
return settings;
}
/**
* Process format configuration from various sources.
*
* Converts checkboxes object format to array format for Tick library.
*
* @param {*} format
* The format configuration (array, object, or string).
*
* @return {Array}
* The processed format array.
*/
function processFormatConfiguration(format) {
// Default format if not provided.
if (!format) {
return ['d', 'h', 'm', 's'];
}
// Handle format from checkboxes configuration.
if (typeof format === 'object' && !Array.isArray(format)) {
const selectedFormats = [];
const possibleFormats = ['y', 'M', 'w', 'd', 'h', 'm', 's'];
possibleFormats.forEach(function (key) {
if (format[key]) {
selectedFormats.push(key);
}
});
format = selectedFormats.length > 0 ? selectedFormats : ['d', 'h', 'm', 's'];
}
// Ensure format is an array.
if (typeof format === 'string') {
format = format.match(/[yMwdhms]/g) || ['d', 'h', 'm', 's'];
}
if (!Array.isArray(format)) {
format = ['d', 'h', 'm', 's'];
}
return format;
}
/**
* Prepare Tick styles before initialization.
*
* Sets data-style attributes that Tick reads during initialization.
*
* @param {Element} tickRoot
* The tick root element.
* @param {Object} settings
* The settings object.
*/
function prepareTickStyles(tickRoot, settings) {
// This allows Tick to read styles during setup.
const existingStyle = tickRoot.dataset.style || '';
const additionalStyles = [];
// Add flip easing to data-style for Tick to process.
if (settings.flipEasing && settings.flipEasing !== 'ease-out-bounce') {
additionalStyles.push('flip-easing:' + settings.flipEasing);
}
// Add flip duration if specified.
if (settings.flipDuration && settings.flipDuration !== 800) {
additionalStyles.push('flip-duration:' + settings.flipDuration);
}
// Combine with existing styles if any.
if (additionalStyles.length > 0) {
const newStyles = additionalStyles.join(' ');
tickRoot.dataset.style = existingStyle ? existingStyle + ' ' + newStyles : newStyles;
}
}
/**
* Build the Flip markup structure with inline separators.
*
* This function creates markup similar to the library's own examples,
* with tick-text-inline spans between time unit groups for separators.
*
* @param {Array} format
* The format array.
* @param {Object} settings
* The settings object.
*
* @return {string}
* The HTML markup string.
*/
function buildFlipMarkup(format, settings) {
const leadingZeros = settings.leadingZeros !== false;
const separator = settings.separator || '';
const showLabels = settings.showLabels === true;
// Compute data-style attributes based on settings.
const dataStyles = [];
// Handle shadow style settings.
if (settings.shadowStyle === 'none') {
dataStyles.push('shadow:none');
} else if (settings.shadowStyle === 'inner') {
dataStyles.push('shadow:inner');
}
// Handle rounded corner settings.
if (settings.roundedStyle === 'none') {
dataStyles.push('rounded:none');
} else if (settings.roundedStyle === 'panels') {
dataStyles.push('rounded:panels');
}
// Handle flip easing setting per Tick documentation.
if (settings.flipEasing && settings.flipEasing !== 'ease-out-bounce') {
dataStyles.push('flip-easing:' + settings.flipEasing);
}
// Add flip duration if not default.
if (settings.flipDuration && settings.flipDuration !== 800) {
dataStyles.push('flip-duration:' + settings.flipDuration);
}
// Build the data-style attribute string to apply to flip spans.
const dataStyleAttr = dataStyles.length > 0
? ' data-style="' + dataStyles.join(' ') + '"'
: '';
// Build tick root with initialization handler.
let markup = '<div class="tick" data-did-init="handleFlipInit">';
// Create the horizontal fit layout container.
markup += '<div data-layout="horizontal fit">';
// Build each time unit with separators between them.
format.forEach(function (unit, index) {
// Create the time unit group with proper data attributes.
markup += '<span data-key="' + unit + '" ';
markup += 'data-repeat="true" ';
markup += 'data-transform="pad(' + (leadingZeros ? '00' : '0') + ') -> split -> delay">';
markup += '<span data-view="flip"' + dataStyleAttr + '></span>';
markup += '</span>';
// Add separator between units if not the last unit.
if (separator && index < format.length - 1) {
markup += '<span class="tick-text-inline">' + escapeHtml(separator) + '</span>';
}
});
markup += '</div>'; // Close horizontal layout.
// Add labels below if enabled.
if (showLabels) {
markup += '<div data-layout="horizontal fit" class="tick-labels">';
format.forEach(function (unit, index) {
// Create label for this unit.
const labelText = getLabelForUnit(unit, settings.labels);
markup += '<span class="tick-label">' + escapeHtml(labelText) + '</span>';
// Add empty separator space for alignment if not the last unit.
if (separator && index < format.length - 1) {
markup += '<span class="tick-text-inline"> </span>';
}
});
markup += '</div>'; // Close labels layout.
}
markup += '</div>'; // Close tick root.
return markup;
}
/**
* Get the label text for a specific time unit.
*
* @param {string} unit
* The time unit key (y, M, w, d, h, m, s).
* @param {Object} labels
* The labels configuration object.
*
* @return {string}
* The label text for the unit.
*/
function getLabelForUnit(unit, labels) {
const defaultLabels = {
'y': 'Years',
'M': 'Months',
'w': 'Weeks',
'd': 'Days',
'h': 'Hours',
'm': 'Minutes',
's': 'Seconds'
};
// Map single letter units to full label keys.
const labelKeyMap = {
'y': 'years',
'M': 'months',
'w': 'weeks',
'd': 'days',
'h': 'hours',
'm': 'minutes',
's': 'seconds'
};
if (labels && labels[labelKeyMap[unit]]) {
return labels[labelKeyMap[unit]];
}
return defaultLabels[unit] || unit.toUpperCase();
}
/**
* Escape HTML entities in a string.
*
* @param {string} text
* The text to escape.
*
* @return {string}
* The escaped text.
*/
function escapeHtml(text) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return String(text).replace(/[&<>"']/g, function (m) {
return map[m];
});
}
/**
* Apply animation settings to Flip elements.
*
* This function applies settings that can be controlled via CSS after the
* Tick library has initialized.
*
* @param {Element} tickRoot
* The tick root element.
* @param {Object} settings
* The settings object.
*/
function applyAnimationSettings(tickRoot, settings) {
// Apply flip duration via CSS variables or inline styles.
const flipElements = tickRoot.querySelectorAll('[data-view="flip"]');
flipElements.forEach(function (flipEl) {
// Set transition duration if specified.
if (settings.flipDuration) {
flipEl.style.setProperty('--flip-duration', settings.flipDuration + 'ms');
// Also try to set on panel elements that might need it.
const panels = flipEl.querySelectorAll('.tick-flip-panel-front, .tick-flip-panel-back');
panels.forEach(function (panel) {
panel.style.transitionDuration = settings.flipDuration + 'ms';
});
}
});
}
/**
* Apply size styles to the Flip counter.
*
* @param {Element} element
* The countdown container element.
* @param {Element} tickRoot
* The tick root element.
* @param {string} size
* The size preset (xs, sm, md, lg, xl).
*/
function applySizeStyles(element, tickRoot, size) {
if (!size) {
return;
}
// Define size multipliers.
const sizeMap = {
'xs': 0.5,
'sm': 0.75,
'md': 1,
'lg': 1.5,
'xl': 2,
'responsive': 'auto'
};
const multiplier = sizeMap[size];
if (multiplier && multiplier !== 'auto') {
// Apply font-size scaling.
tickRoot.style.fontSize = multiplier + 'rem';
}
// Add size class for additional styling.
tickRoot.classList.add('flip-size-' + size);
}
/**
* Apply theme and appearance settings to the Flip counter.
*
* @param {Element} element
* The countdown container element.
* @param {Element} tickRoot
* The Tick root element.
* @param {Object} settings
* The settings object.
*/
function applyFlipTheme(element, tickRoot, settings) {
// Apply theme class.
const theme = settings.theme || 'dark';
tickRoot.classList.add('flip-theme-' + theme);
// Apply custom theme styles if custom theme selected.
if (theme === 'custom') {
// Create or update style element for custom theme.
const styleId = 'flip-custom-theme-' + element.id;
let styleEl = document.getElementById(styleId);
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = styleId;
document.head.appendChild(styleEl);
}
// Build custom CSS rules.
let css = '';
const selector = '#' + element.id + ' .tick';
// Apply font family.
if (settings.fontFamily) {
css += selector + ' { font-family: ' + settings.fontFamily + '; }\n';
}
// Apply text color.
if (settings.textColor) {
css += selector + ' .tick-flip-panel { color: ' + settings.textColor + '; }\n';
css += selector + ' .tick-label { color: ' + settings.textColor + '; }\n';
css += selector + ' .tick-text-inline { color: ' + settings.textColor + '; }\n';
}
// Apply background color.
if (settings.backgroundColor) {
css += selector + ' .tick-flip-panel { background-color: ' + settings.backgroundColor + '; }\n';
}
styleEl.textContent = css;
}
}
/**
* Apply custom styles including responsive scaling.
*
* @param {Element} element
* The countdown container element.
* @param {Element} tickRoot
* The tick root element.
* @param {Object} settings
* The settings object.
*/
function applyCustomStyles(element, tickRoot, settings) {
// Apply responsive scaling.
if (settings.responsive === true && settings.size !== 'responsive') {
// Add responsive wrapper styles.
const responsiveId = 'flip-responsive-' + element.id;
let responsiveStyle = document.getElementById(responsiveId);
if (!responsiveStyle) {
responsiveStyle = document.createElement('style');
responsiveStyle.id = responsiveId;
document.head.appendChild(responsiveStyle);
}
const responsiveCss = `
#${element.id} .flip-responsive {
font-size: 2.5vw;
}
@media (min-width: 768px) {
#${element.id} .flip-responsive {
font-size: 1.5vw;
}
}
@media (min-width: 1200px) {
#${element.id} .flip-responsive {
font-size: 1rem;
}
}
`;
responsiveStyle.textContent = responsiveCss;
}
// Add custom styles for labels positioning if enabled.
if (settings.showLabels === true) {
const labelsStyleId = 'flip-labels-' + element.id;
let labelsStyle = document.getElementById(labelsStyleId);
if (!labelsStyle) {
labelsStyle = document.createElement('style');
labelsStyle.id = labelsStyleId;
document.head.appendChild(labelsStyle);
}
// Style for labels container to align properly.
const labelsCss = `
#${element.id} .tick-labels {
margin-top: 0.5em;
}
#${element.id} .tick-labels .tick-label {
font-size: 0.375em;
text-align: center;
flex: 1;
}
#${element.id} .tick-labels .tick-text-inline {
width: auto;
flex: none;
}
`;
labelsStyle.textContent = labelsCss;
}
}
// Register the loader with the main countdown system.
Drupal.countdown.registerLoader('flip', initializeFlip);
})(Drupal);
