vvjb-1.0.x-dev/js/vvjb.js
js/vvjb.js
/**
* @file
* Views Vanilla JavaScript Basic Carousel.
*
* Enhanced with play/pause, progress bar, page counter, keyboard navigation,
* and accessibility features following Drupal 11 best practices.
*
* Filename: vvjb.js
* Website: https://www.flashwebcenter.com
* Developer: Alaa Haddad https://www.alaahaddad.com.
*/
((Drupal, drupalSettings, once) => {
'use strict';
/**
* Debounce utility function for performance optimization.
*
* @param {Function} func
* The function to debounce.
* @param {number} delay
* The delay in milliseconds.
*
* @return {Function}
* The debounced function.
*/
function debounce(func, delay) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => func.apply(this, args), delay);
};
}
/**
* Detect if carousel is in RTL mode.
*
* @param {HTMLElement} element
* The element to check for RTL.
*
* @return {boolean}
* True if RTL mode is detected.
*/
function isRTL(element) {
return element.closest('[dir="rtl"]') !== null ||
document.documentElement.dir === 'rtl' ||
document.body.dir === 'rtl' ||
getComputedStyle(element).direction === 'rtl';
}
/**
* SVG icon definitions for UI elements.
*/
const SVG_ICONS = {
play: `<svg class="svg-play" xmlns="http://www.w3.org/2000/svg" viewBox="80 -880 800 800" fill="currentColor" aria-hidden="true">
<path d="m380-300 280-180-280-180v360ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"></path>
</svg>`,
pause: `<svg class="svg-pause" xmlns="http://www.w3.org/2000/svg" viewBox="80 -880 800 800" fill="currentColor" aria-hidden="true">
<path d="M360-320h80v-320h-80v320Zm160 0h80v-320h-80v320ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"></path>
</svg>`
};
/**
* Main Drupal behavior for VVJB carousel.
*/
Drupal.behaviors.vvjbCarousel = {
attach(context) {
const carousels = once('vvjbCarousel', '.vvjb-inner', context);
carousels.forEach(initCarousel);
},
/**
* Detach behavior for cleanup.
*/
detach(context, settings, trigger) {
if (trigger === 'unload') {
const carousels = context.querySelectorAll('.vvjb-inner[data-vvjb-initialized="true"]');
carousels.forEach(container => {
if (container.vvjbState) {
cleanupCarousel(container.vvjbState);
delete container.vvjbState;
}
});
}
}
};
/**
* Initialize a single carousel instance.
*
* @param {HTMLElement} carouselInner
* The carousel inner container element.
*/
function initCarousel(carouselInner) {
// Security: Validate required DOM structure.
const wrapper = carouselInner.querySelector('.vvjb-carousel-wrapper');
const itemsContainer = wrapper?.querySelector('.vvjb-items');
if (!wrapper || !itemsContainer) {
if (typeof console !== 'undefined' && console.error) {
console.error('VVJB: Required DOM structure missing', carouselInner);
}
return;
}
// Create state object.
const state = createState(carouselInner);
// Security: Validate state creation.
if (!state || !state.items.length) {
if (typeof console !== 'undefined' && console.warn) {
console.warn('VVJB: Invalid state or no items found', carouselInner);
}
return;
}
// Store state on container for external access and cleanup.
carouselInner.vvjbState = state;
// Initialize features based on configuration.
initializeFeatures(state);
// Initial layout update.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
updateLayout(state);
createDots(state);
bindDots(state);
// Initialize deep linking if enabled.
if (state.config.deeplinkEnabled && state.config.deeplinkId) {
initializeDeepLinking(state);
}
// Remove hidden attribute to make carousel visible.
state.container.removeAttribute('hidden');
// Small delay for final layout adjustment.
setTimeout(() => {
updateLayout(state);
}, 100);
});
});
// Mark as initialized.
carouselInner.setAttribute('data-vvjb-initialized', 'true');
}
/**
* Parse integer with defensive validation allowing zero as valid.
*
* @param {string} value
* The string value to parse.
* @param {number} defaultValue
* The default value if parsing fails.
*
* @return {number}
* The parsed integer, allowing 0 as valid.
*/
function parseIntAllowZero(value, defaultValue) {
const parsed = parseInt(value, 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : defaultValue;
}
/**
* Parse integer with defensive validation and minimum value enforcement.
*
* @param {string} value
* The string value to parse.
* @param {number} defaultValue
* The default value if parsing fails.
* @param {number} minValue
* The minimum allowed value.
*
* @return {number}
* The parsed and validated integer.
*/
function parseIntSafe(value, defaultValue, minValue = 0) {
const parsed = parseInt(value, 10);
return Number.isFinite(parsed) && parsed >= minValue ? parsed : defaultValue;
}
/**
* Create carousel state object with all configuration and references.
*
* @param {HTMLElement} container
* The carousel inner container.
*
* @return {Object|null}
* The state object or null if creation fails.
*/
function createState(container) {
const wrapper = container.querySelector('.vvjb-carousel-wrapper');
const itemsContainer = wrapper.querySelector('.vvjb-items');
// Security: Validate required elements exist.
if (!wrapper || !itemsContainer) {
return null;
}
const items = itemsContainer.querySelectorAll('.vvjb-item');
// Security: Validate we have items.
if (!items.length) {
return null;
}
const nextButton = container.querySelector('.vvjb-next');
const prevButton = container.querySelector('.vvjb-prev');
const announcer = container.querySelector('.vvjb-carousel-announcer');
// Read configuration from data attributes with sanitization.
const dataset = itemsContainer.dataset;
// Performance: Parse integers once and store.
// DEFENSIVE PARSING - Respecting semantic meaning of each field:
// Breakpoints: SELECT field with preset values (576, 768, 992, 1200, 1400)
// Never 0, must be one of the valid presets
const breakpointParsed = parseInt(dataset.breakpoints, 10);
const validBreakpoints = [576, 768, 992, 1200, 1400];
const breakpoints = validBreakpoints.includes(breakpointParsed) ? breakpointParsed : 992;
// Items: MIN = 1 (cannot be 0, must show at least 1 item)
const itemsSmall = parseIntSafe(dataset.smallScreen, 1, 1);
const itemsBig = parseIntSafe(dataset.bigScreen, 3, 1);
// Total slides: Derived from actual items, fallback to items.length
const totalSlides = parseIntSafe(dataset.totalSlides, items.length, 1);
// Slide time: 0 = DISABLE autoplay (valid semantic meaning)
// Range: 0-15000, where 0 means "no autoplay"
const slideTime = parseIntAllowZero(dataset.slideTime, 5000);
// Gap: 0 = NO gap between items (valid semantic meaning)
const gap = parseIntAllowZero(dataset.gap, 0);
// Read feature flags from vvjb-inner data attributes.
const innerDataset = container.dataset;
const showPlayPause = innerDataset.showPlayPause === 'true';
const showProgressBar = innerDataset.showProgressBar === 'true';
const showPageCounter = innerDataset.showPageCounter === 'true';
const enableKeyboardNav = innerDataset.enableKeyboardNav === 'true';
const enableTouchSwipe = innerDataset.enableTouchSwipe === 'true';
const enablePauseOnHover = innerDataset.enablePauseOnHover === 'true';
const pauseOnReducedMotion = innerDataset.pauseOnReducedMotion === 'true';
// Get UI elements for new features.
const playPauseButton = container.querySelector('.vvjb-play-pause-button');
const progressBar = container.querySelector('.vvjb-progress-bar');
const currentPageSpan = container.querySelector('.vvjb-current-page');
const totalPagesSpan = container.querySelector('.vvjb-total-pages');
// Deep linking configuration.
const deeplinkEnabled = dataset.deeplinkEnabled === 'true';
const deeplinkId = dataset.deeplinkId || '';
// Detect RTL mode - ADD THIS LINE
const rtlMode = isRTL(container);
// Build configuration object.
const config = {
rawOrientation: dataset.orientation || 'horizontal',
orientation: 'horizontal',
breakpoints,
itemsSmall,
itemsBig,
totalSlides,
slideTime,
gap,
looping: dataset.carouselLoop === '1',
navigation: dataset.navigation || 'both',
deeplinkEnabled,
deeplinkId,
};
// Build feature flags object.
const features = {
showPlayPause,
showProgressBar,
showPageCounter,
enableKeyboardNav,
enableTouchSwipe,
enablePauseOnHover,
pauseOnReducedMotion,
};
return {
container,
wrapper,
itemsContainer,
items,
nextButton,
prevButton,
announcer,
playPauseButton,
progressBar,
currentPageSpan,
totalPagesSpan,
config,
features,
pageIndex: 0,
isPaused: false,
autoSlideTimer: null,
progressIntervalId: null,
pageStartTime: null,
observers: [],
isRTL: rtlMode,
};
}
/**
* Initialize all enabled features for the carousel.
*
* @param {Object} state
* The carousel state object.
*/
function initializeFeatures(state) {
// Always bind navigation arrows.
bindNavigation(state);
// Conditionally bind touch/swipe.
if (state.features.enableTouchSwipe) {
bindTouch(state);
}
// Initialize auto-slide if enabled.
if (state.config.slideTime > 0) {
bindAutoSlide(state);
}
// Conditionally bind keyboard navigation.
if (state.features.enableKeyboardNav) {
bindKeyboard(state);
}
// Initialize play/pause button if enabled.
if (state.features.showPlayPause && state.playPauseButton) {
initPlayPauseButton(state);
}
// Initialize progress bar if enabled.
if (state.features.showProgressBar && state.progressBar) {
initProgressBar(state);
}
// Initialize page counter if enabled.
if (state.features.showPageCounter && state.currentPageSpan) {
initPageCounter(state);
}
// Apply reduced motion if enabled.
if (state.features.pauseOnReducedMotion) {
applyReducedMotion(state);
}
// Setup ResizeObserver for responsive layout.
if ('ResizeObserver' in window && state.wrapper) {
const observer = new ResizeObserver(() => updateLayout(state));
observer.observe(state.wrapper);
state.observers.push(observer);
}
// Setup window resize handler.
const refreshDots = debounce(() => {
createDots(state);
bindDots(state);
}, 300);
const resizeHandler = () => {
clampPageIndex(state);
updateLayout(state);
refreshDots();
};
window.addEventListener('resize', resizeHandler);
state.resizeHandler = resizeHandler;
}
/**
* Initialize play/pause button functionality.
*
* @param {Object} state
* The carousel state object.
*/
function initPlayPauseButton(state) {
if (!state.playPauseButton) {
return;
}
// Set initial state.
updatePlayPauseButton(state);
// Bind click event.
state.playPauseButton.addEventListener('click', () => {
togglePlayPause(state);
});
}
/**
* Initialize progress bar functionality.
*
* @param {Object} state
* The carousel state object.
*/
function initProgressBar(state) {
if (!state.progressBar) {
return;
}
// Set initial ARIA attributes.
state.progressBar.setAttribute('role', 'progressbar');
state.progressBar.setAttribute('aria-valuemin', '0');
state.progressBar.setAttribute('aria-valuemax', '100');
state.progressBar.setAttribute('aria-valuenow', '0');
}
/**
* Initialize page counter functionality.
*
* @param {Object} state
* The carousel state object.
*/
function initPageCounter(state) {
if (!state.currentPageSpan || !state.totalPagesSpan) {
return;
}
// Initial update (will calculate and set both current and total).
updatePageCounter(state);
}
/**
* Update play/pause button appearance and state.
*
* @param {Object} state
* The carousel state object.
*/
function updatePlayPauseButton(state) {
if (!state.playPauseButton) {
return;
}
const isPaused = state.isPaused;
// Security: Use textContent for screen reader text, innerHTML for SVG only.
state.playPauseButton.innerHTML = isPaused ? SVG_ICONS.play : SVG_ICONS.pause;
// Update ARIA label for accessibility.
const label = isPaused
? Drupal.t('Play carousel')
: Drupal.t('Pause carousel');
state.playPauseButton.setAttribute('aria-label', label);
}
/**
* Start progress bar animation.
*
* @param {Object} state
* The carousel state object.
*/
function startProgressBar(state) {
if (!state.features.showProgressBar || !state.progressBar) {
return;
}
// Performance: Don't start if already running.
if (state.progressIntervalId) {
return;
}
// Security: Validate slideTime.
if (state.config.slideTime <= 0) {
return;
}
state.pageStartTime = Date.now();
// Performance: Use requestAnimationFrame for smoother animation.
const updateProgress = () => {
if (state.isPaused || !state.pageStartTime) {
stopProgressBar(state);
return;
}
const elapsed = Date.now() - state.pageStartTime;
const progress = Math.min(100, (elapsed / state.config.slideTime) * 100);
// Update CSS custom property for animation.
state.progressBar.style.setProperty('--progress', `${progress}%`);
// Update ARIA value for accessibility.
state.progressBar.setAttribute('aria-valuenow', Math.round(progress));
if (progress < 100) {
state.progressIntervalId = requestAnimationFrame(updateProgress);
} else {
stopProgressBar(state);
}
};
state.progressIntervalId = requestAnimationFrame(updateProgress);
}
/**
* Stop progress bar animation.
*
* @param {Object} state
* The carousel state object.
*/
function stopProgressBar(state) {
if (state.progressIntervalId) {
cancelAnimationFrame(state.progressIntervalId);
state.progressIntervalId = null;
}
}
/**
* Reset progress bar to zero.
*
* @param {Object} state
* The carousel state object.
*/
function resetProgressBar(state) {
if (!state.progressBar) {
return;
}
stopProgressBar(state);
state.progressBar.style.setProperty('--progress', '0%');
state.progressBar.setAttribute('aria-valuenow', '0');
}
/**
* Update page counter display.
*
* Recalculates total pages based on current viewport size and updates both
* current and total page numbers. This ensures the counter stays accurate
* when the carousel is resized or orientation changes.
*
* @param {Object} state
* The carousel state object.
*/
function updatePageCounter(state) {
if (!state.currentPageSpan || !state.totalPagesSpan) {
return;
}
// Recalculate total pages based on current items visible.
const itemsVisible = getItemsVisible(state);
const totalPages = Math.ceil(state.config.totalSlides / itemsVisible);
// Security: Use textContent to prevent XSS.
state.currentPageSpan.textContent = state.pageIndex + 1;
state.totalPagesSpan.textContent = totalPages;
}
/**
* Toggle play/pause state.
*
* @param {Object} state
* The carousel state object.
*/
function togglePlayPause(state) {
state.isPaused = !state.isPaused;
// Toggle is-paused class for CSS styling (e.g., hiding progress bar).
if (state.isPaused) {
state.container.classList.add('is-paused');
} else {
state.container.classList.remove('is-paused');
}
updatePlayPauseButton(state);
if (state.isPaused) {
stopAutoSlide(state);
stopProgressBar(state);
// Reset progress bar after fade animation completes.
if (state.progressBar) {
setTimeout(() => {
if (state.isPaused) { // Only reset if still paused.
resetProgressBar(state);
}
}, 300); // Match CSS transition duration.
}
} else {
startAutoSlide(state);
startProgressBar(state);
}
}
/**
* Apply reduced motion preferences.
*
* @param {Object} state
* The carousel state object.
*/
function applyReducedMotion(state) {
// Security: Check if matchMedia is available.
if (!window.matchMedia) {
return;
}
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
// Auto-pause for accessibility.
state.isPaused = true;
// Add paused class for CSS styling.
state.container.classList.add('is-paused');
// Stop any auto-slide.
stopAutoSlide(state);
// Add CSS class for styling.
state.container.classList.add('reduced-motion');
// Update play/pause button if available.
if (state.playPauseButton) {
updatePlayPauseButton(state);
}
// Announce to screen reader.
if (state.announcer) {
state.announcer.textContent = Drupal.t('Carousel paused due to reduced motion preference');
}
}
}
/**
* Resolve orientation based on configuration and viewport.
*
* @param {Object} state
* The carousel state object.
*
* @return {string}
* The resolved orientation ('horizontal' or 'vertical').
*/
function resolveOrientation(state) {
const raw = state.config.rawOrientation;
const bp = state.config.breakpoints;
if (raw === 'vertical') {
return 'vertical';
}
if (raw === 'hybrid' && window.innerWidth <= bp) {
return 'vertical';
}
return 'horizontal';
}
/**
* Get item size (width or height based on orientation).
*
* @param {HTMLElement} item
* The carousel item element.
* @param {boolean} isVertical
* Whether the orientation is vertical.
*
* @return {number}
* The item size in pixels.
*/
function getItemSize(item, isVertical) {
return isVertical ? item.offsetHeight : item.offsetWidth;
}
/**
* Calculate number of items visible in viewport.
*
* @param {Object} state
* The carousel state object.
*
* @return {number}
* Number of visible items.
*/
function getItemsVisible(state) {
const isVertical = state.config.orientation === 'vertical';
const isHybrid = state.config.rawOrientation === 'hybrid';
const isSmallScreen = window.innerWidth <= state.config.breakpoints;
const max = isSmallScreen ? state.config.itemsSmall : state.config.itemsBig;
// Vertical mode: always return max.
if (isVertical) {
return max;
}
// Hybrid mode: act like vertical on small screens.
if (isHybrid && isSmallScreen) {
return max;
}
// Horizontal: dynamic fitting logic.
const containerSize = state.wrapper.offsetWidth;
const gap = state.config.gap || 0;
let total = 0;
let count = 0;
for (let i = 0; i < state.items.length; i++) {
const itemSize = state.items[i].offsetWidth;
if (i > 0) {
total += gap;
}
total += itemSize;
if (total > containerSize) {
break;
}
count++;
if (count >= max) {
break;
}
}
return Math.max(1, count);
}
/**
* Get array of currently visible items.
*
* @param {Object} state
* The carousel state object.
*
* @return {Array}
* Array of visible item elements.
*/
function getVisibleItems(state) {
const itemsVisible = getItemsVisible(state);
const start = state.pageIndex * itemsVisible;
return Array.from(state.items).slice(start, start + itemsVisible);
}
/**
* Calculate scroll offset for current page.
*
* @param {Object} state
* The carousel state object.
*
* @return {number}
* The scroll offset in pixels.
*/
function getGroupOffset(state) {
const itemsVisible = getItemsVisible(state);
const isVertical = state.config.orientation === 'vertical';
const gap = state.config.gap || 0;
let offset = 0;
for (let page = 0; page < state.pageIndex; page++) {
const start = page * itemsVisible;
const end = start + itemsVisible;
for (let i = start; i < end && i < state.items.length; i++) {
const size = getItemSize(state.items[i], isVertical);
offset += size;
if (i < end - 1) {
offset += gap;
}
}
if (end < state.items.length) {
offset += gap;
}
}
return offset;
}
/**
* Set wrapper size based on visible items.
*
* @param {Object} state
* The carousel state object.
*/
function setWrapperSize(state) {
const itemsVisible = getItemsVisible(state);
if (!state.items.length) {
return;
}
const visibleItems = getVisibleItems(state);
const gap = state.config.gap || 0;
const totalGap = (itemsVisible - 1) * gap;
if (state.config.orientation === 'vertical') {
const totalHeight = visibleItems.reduce((sum, item) => {
return sum + getItemSize(item, true);
}, 0);
state.wrapper.style.maxHeight = `${totalHeight + totalGap}px`;
state.wrapper.style.height = '';
state.wrapper.style.maxWidth = '';
state.centerOffset = 0;
}
else {
const totalWidth = visibleItems.reduce((sum, item, idx) => {
return sum + getItemSize(item, false) + (idx > 0 ? gap : 0);
}, 0);
// Calculate centering offset.
const wrapperWidth = state.wrapper.offsetWidth;
const centerOffset = (wrapperWidth - totalWidth) / 2;
state.centerOffset = centerOffset > 0 ? centerOffset : 0;
state.wrapper.style.maxWidth = '';
state.wrapper.style.width = '';
state.wrapper.style.maxHeight = '';
}
// Make wrapper visible if hidden.
if (state.wrapper.style.visibility !== 'visible') {
state.wrapper.style.visibility = 'visible';
state.wrapper.classList.add('is-visible');
}
}
/**
* Scroll carousel to current page.
*
* @param {Object} state
* The carousel state object.
*/
function scrollToPage(state) {
const offset = getGroupOffset(state);
const centering = state.centerOffset || 0;
if (state.config.orientation === 'vertical') {
state.itemsContainer.style.transform = `translateY(${-offset}px)`;
}
else {
// Reverse transform direction in RTL for horizontal carousels
const transform = state.isRTL ? -(centering - offset) : (centering - offset);
state.itemsContainer.style.transform = `translateX(${transform}px)`;
}
}
/**
* Update carousel layout and ARIA attributes.
*
* @param {Object} state
* The carousel state object.
*/
function updateLayout(state) {
state.config.orientation = resolveOrientation(state);
setWrapperSize(state);
const itemsVisible = getItemsVisible(state);
const start = state.pageIndex * itemsVisible;
const end = start + itemsVisible;
// Update item states and ARIA attributes.
state.items.forEach((item, i) => {
const isActive = i >= start && i < end;
// Update classes.
item.classList.toggle('active-slide', isActive);
item.classList.toggle('non-active-slide', !isActive);
// Update ARIA attributes for accessibility.
item.setAttribute('aria-hidden', !isActive);
// Add inert attribute to non-active items.
if (isActive) {
item.removeAttribute('inert');
}
else {
item.setAttribute('inert', '');
}
// Manage tabindex for interactive elements.
const interactiveElements = item.querySelectorAll('a, button, input, textarea, select, [tabindex]');
interactiveElements.forEach(el => {
el.setAttribute('tabindex', isActive ? '0' : '-1');
});
});
// Update dots if navigation uses them.
if (state.config.navigation === 'dots' || state.config.navigation === 'both') {
const dots = state.container.querySelectorAll('.vvjb-carousel-dot');
dots.forEach((dot, index) => {
const isActive = index === state.pageIndex;
dot.classList.toggle('active-slide', isActive);
dot.setAttribute('aria-current', isActive ? 'true' : 'false');
});
}
updateNavVisibility(state);
announceVisible(state, start, end);
scrollToPage(state);
// Update page counter if enabled.
if (state.features.showPageCounter) {
updatePageCounter(state);
}
// Restart progress bar if not paused.
if (state.features.showProgressBar && !state.isPaused) {
resetProgressBar(state);
startProgressBar(state);
}
}
/**
* Update navigation button visibility.
*
* @param {Object} state
* The carousel state object.
*/
function updateNavVisibility(state) {
if (state.config.navigation !== 'arrows' && state.config.navigation !== 'both') {
return;
}
const itemsVisible = getItemsVisible(state);
const maxPage = Math.ceil(state.config.totalSlides / itemsVisible) - 1;
if (!state.config.looping) {
if (state.prevButton) {
state.prevButton.classList.toggle('vvjb-hidden', state.pageIndex === 0);
}
if (state.nextButton) {
state.nextButton.classList.toggle('vvjb-hidden', state.pageIndex >= maxPage);
}
}
else {
if (state.prevButton) {
state.prevButton.classList.remove('vvjb-hidden');
}
if (state.nextButton) {
state.nextButton.classList.remove('vvjb-hidden');
}
}
}
/**
* Announce visible slides to screen readers.
*
* @param {Object} state
* The carousel state object.
* @param {number} start
* Start index of visible items.
* @param {number} end
* End index of visible items.
*/
function announceVisible(state, start, end) {
if (!state.announcer) {
return;
}
const total = state.config.totalSlides;
// Security: Use textContent to prevent XSS.
state.announcer.textContent = Drupal.t('Showing slides @start to @end of @total', {
'@start': start + 1,
'@end': Math.min(end, total),
'@total': total,
});
}
/**
* Navigate to next page.
*
* @param {Object} state
* The carousel state object.
*/
function nextSlide(state) {
const itemsVisible = getItemsVisible(state);
const totalPages = Math.ceil(state.config.totalSlides / itemsVisible);
state.pageIndex = (state.pageIndex >= totalPages - 1)
? (state.config.looping ? 0 : totalPages - 1)
: state.pageIndex + 1;
updateLayout(state);
updateDeepLinkHash(state);
}
/**
* Navigate to previous page.
*
* @param {Object} state
* The carousel state object.
*/
function prevSlide(state) {
const itemsVisible = getItemsVisible(state);
const maxPage = Math.ceil(state.config.totalSlides / itemsVisible) - 1;
state.pageIndex = (state.pageIndex > 0)
? state.pageIndex - 1
: (state.config.looping ? maxPage : 0);
updateLayout(state);
updateDeepLinkHash(state);
}
/**
* Navigate to first page.
*
* @param {Object} state
* The carousel state object.
*/
function goToFirstPage(state) {
state.pageIndex = 0;
updateLayout(state);
updateDeepLinkHash(state);
}
/**
* Navigate to last page.
*
* @param {Object} state
* The carousel state object.
*/
function goToLastPage(state) {
const itemsVisible = getItemsVisible(state);
const maxPage = Math.ceil(state.config.totalSlides / itemsVisible) - 1;
state.pageIndex = maxPage;
updateLayout(state);
updateDeepLinkHash(state);
}
/**
* Bind navigation button events.
*
* @param {Object} state
* The carousel state object.
*/
function bindNavigation(state) {
const shouldAutoplay = state.config.slideTime > 0 &&
state.config.slideTime >= 1000 &&
state.config.slideTime <= 15000;
const resetAutoSlide = () => {
if (shouldAutoplay && state.autoSlideTimer) {
stopAutoSlide(state);
if (!state.isPaused) {
startAutoSlide(state);
}
}
};
state.nextButton?.addEventListener('click', () => {
nextSlide(state);
resetAutoSlide();
});
state.prevButton?.addEventListener('click', () => {
prevSlide(state);
resetAutoSlide();
});
}
/**
* Bind touch/swipe gesture events.
*
* @param {Object} state
* The carousel state object.
*/
function bindTouch(state) {
let startX = 0;
let startY = 0;
state.wrapper.addEventListener('touchstart', e => {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
}, { passive: true });
state.wrapper.addEventListener('touchend', e => {
const deltaX = e.changedTouches[0].clientX - startX;
const deltaY = e.changedTouches[0].clientY - startY;
// Horizontal swipe detection
if (Math.abs(deltaX) > Math.abs(deltaY)) {
// Reverse swipe direction in RTL mode
if (state.isRTL) {
if (deltaX < -50) {
prevSlide(state); // Swipe left = previous in RTL
}
else if (deltaX > 50) {
nextSlide(state); // Swipe right = next in RTL
}
}
else {
if (deltaX < -50) {
nextSlide(state); // Swipe left = next in LTR
}
else if (deltaX > 50) {
prevSlide(state); // Swipe right = previous in LTR
}
}
}
// Vertical swipe detection for vertical orientation
else if (state.config.orientation === 'vertical') {
if (deltaY < -50) {
nextSlide(state);
}
else if (deltaY > 50) {
prevSlide(state);
}
}
}, { passive: true });
}
/**
* Create dot navigation elements.
*
* @param {Object} state
* The carousel state object.
*/
function createDots(state) {
const navigationType = state.config.navigation;
const dotsContainer = state.container.querySelector('.vvjb-carousel-dots');
if (!dotsContainer) {
return;
}
if (navigationType !== 'dots' && navigationType !== 'both') {
dotsContainer.innerHTML = '';
return;
}
// Performance: Use DocumentFragment for efficient DOM manipulation.
const fragment = document.createDocumentFragment();
const itemsVisible = getItemsVisible(state);
const totalPages = Math.ceil(state.config.totalSlides / itemsVisible);
// Check if deep linking is enabled.
const useDeepLinks = state.config.deeplinkEnabled && state.config.deeplinkId;
for (let i = 0; i < totalPages; i++) {
let dotElement;
if (useDeepLinks) {
// Create anchor link for deep linking.
dotElement = document.createElement('a');
dotElement.href = `#carousel-${state.config.deeplinkId}-${i + 1}`;
dotElement.className = 'vvjb-carousel-dot';
dotElement.setAttribute('role', 'button');
} else {
// Create button (original behavior).
dotElement = document.createElement('button');
dotElement.className = 'vvjb-carousel-dot';
dotElement.type = 'button';
}
// Security: Use textContent for ARIA label.
const label = Drupal.t('Go to slide group @num', {'@num': i + 1});
dotElement.setAttribute('aria-label', label);
dotElement.dataset.slideGroup = i;
fragment.appendChild(dotElement);
}
// Clear and append all at once for performance.
dotsContainer.innerHTML = '';
dotsContainer.appendChild(fragment);
}
/**
* Bind dot navigation click events.
*
* @param {Object} state
* The carousel state object.
*/
function bindDots(state) {
if (state.config.navigation !== 'dots' && state.config.navigation !== 'both') {
return;
}
const dotsContainer = state.container.querySelector('.vvjb-carousel-dots');
if (!dotsContainer) {
return;
}
dotsContainer.querySelectorAll('.vvjb-carousel-dot').forEach(dot => {
dot.addEventListener('click', () => {
const groupIndex = parseInt(dot.dataset.slideGroup, 10);
// Security: Validate group index.
if (!Number.isFinite(groupIndex) || groupIndex < 0) {
return;
}
state.pageIndex = groupIndex;
updateLayout(state);
updateDeepLinkHash(state);
});
});
}
/**
* Start auto-slide functionality.
*
* @param {Object} state
* The carousel state object.
*/
function startAutoSlide(state) {
let interval = parseInt(state.config.slideTime, 10);
if (interval === 0) {
return;
}
// Security: Validate interval range.
if (!Number.isFinite(interval) || interval < 1000 || interval > 15000) {
interval = 5000;
}
stopAutoSlide(state);
state.autoSlideTimer = setInterval(() => {
if (!state.isPaused) {
nextSlide(state);
}
}, interval);
// Start progress bar if enabled.
if (state.features.showProgressBar) {
startProgressBar(state);
}
}
/**
* Stop auto-slide functionality.
*
* @param {Object} state
* The carousel state object.
*/
function stopAutoSlide(state) {
if (state.autoSlideTimer) {
clearInterval(state.autoSlideTimer);
state.autoSlideTimer = null;
}
stopProgressBar(state);
}
/**
* Bind auto-slide with visibility and hover detection.
*
* @param {Object} state
* The carousel state object.
*/
function bindAutoSlide(state) {
let isVisible = true;
const start = () => {
if (!state.isPaused) {
startAutoSlide(state);
}
};
const stop = () => {
stopAutoSlide(state);
};
// Initial start.
start();
// Pause on hover if enabled.
if (state.features.enablePauseOnHover) {
state.wrapper.addEventListener('mouseenter', stop);
state.wrapper.addEventListener('mouseleave', () => {
if (isVisible && !state.isPaused) {
start();
}
});
}
// Pause when document is hidden.
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
stop();
}
else if (isVisible && !state.isPaused) {
start();
}
});
// Pause when carousel is out of viewport.
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
isVisible = entries[0].isIntersecting;
if (isVisible && !state.isPaused) {
start();
}
else {
stop();
}
}, {
threshold: 0.3
});
observer.observe(state.container);
state.observers.push(observer);
}
}
/**
* Bind keyboard navigation events.
*
* @param {Object} state
* The carousel state object.
*/
function bindKeyboard(state) {
// Make container focusable for keyboard navigation.
state.container.setAttribute('tabindex', '0');
state.container.addEventListener('keydown', (e) => {
// Skip if focused on input/textarea/contenteditable.
if (e.target.matches('input, textarea, [contenteditable="true"]')) {
return;
}
const orientation = state.config.orientation;
let handled = false;
// Arrow key navigation (orientation-aware and RTL-aware)
if (orientation === 'horizontal') {
// Reverse left/right arrows in RTL mode
if (state.isRTL) {
if (e.key === 'ArrowRight') {
e.preventDefault();
prevSlide(state); // Right arrow = previous in RTL
handled = true;
}
else if (e.key === 'ArrowLeft') {
e.preventDefault();
nextSlide(state); // Left arrow = next in RTL
handled = true;
}
}
else {
if (e.key === 'ArrowRight') {
e.preventDefault();
nextSlide(state); // Right arrow = next in LTR
handled = true;
}
else if (e.key === 'ArrowLeft') {
e.preventDefault();
prevSlide(state); // Left arrow = previous in LTR
handled = true;
}
}
}
else if (orientation === 'vertical') {
// Vertical orientation - no RTL changes needed
if (e.key === 'ArrowDown') {
e.preventDefault();
nextSlide(state);
handled = true;
}
else if (e.key === 'ArrowUp') {
e.preventDefault();
prevSlide(state);
handled = true;
}
}
// Space key: toggle play/pause.
else if (e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault();
if (state.features.showPlayPause) {
togglePlayPause(state);
}
handled = true;
}
// Home key: go to first page.
else if (e.key === 'Home') {
e.preventDefault();
goToFirstPage(state);
handled = true;
}
// End key: go to last page.
else if (e.key === 'End') {
e.preventDefault();
goToLastPage(state);
handled = true;
}
// Restart auto-slide after manual navigation.
if (handled && !state.isPaused && state.config.slideTime > 0) {
stopAutoSlide(state);
startAutoSlide(state);
}
});
}
/**
* Clamp page index to valid range.
*
* @param {Object} state
* The carousel state object.
*/
function clampPageIndex(state) {
const itemsVisible = getItemsVisible(state);
const maxPage = Math.ceil(state.config.totalSlides / itemsVisible) - 1;
state.pageIndex = Math.min(state.pageIndex, maxPage);
}
/**
* Clean up carousel resources.
*
* @param {Object} state
* The carousel state object.
*/
function cleanupCarousel(state) {
// Stop timers.
stopAutoSlide(state);
stopProgressBar(state);
// Disconnect observers.
if (state.observers && state.observers.length) {
state.observers.forEach(observer => {
if (observer && typeof observer.disconnect === 'function') {
observer.disconnect();
}
});
state.observers = [];
}
// Remove resize handler.
if (state.resizeHandler) {
window.removeEventListener('resize', state.resizeHandler);
}
// Remove hash change listener.
if (state.eventHandlers && state.eventHandlers.hashChange) {
window.removeEventListener('hashchange', state.eventHandlers.hashChange);
}
}
/**
* Initialize deep linking functionality.
*
* @param {Object} state
* The carousel state object.
*/
function initializeDeepLinking(state) {
const deeplinkId = state.config.deeplinkId;
// Check URL hash on page load.
const hash = window.location.hash;
if (hash && hash.startsWith(`#carousel-${deeplinkId}-`)) {
const slideNumber = parseInt(hash.split('-').pop(), 10);
if (Number.isFinite(slideNumber) && slideNumber > 0) {
const itemsVisible = getItemsVisible(state);
const totalPages = Math.ceil(state.config.totalSlides / itemsVisible);
const pageIndex = slideNumber - 1;
if (pageIndex >= 0 && pageIndex < totalPages) {
state.pageIndex = pageIndex;
updateLayout(state);
} else {
// Invalid slide number - log warning and clear hash
if (typeof console !== 'undefined' && console.warn) {
console.warn(`VVJB: Invalid slide number ${slideNumber} in URL. Valid range: 1-${totalPages}. Defaulting to page 1.`);
}
// Clear invalid hash
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', window.location.pathname + window.location.search);
}
}
} else {
// Invalid hash format
if (typeof console !== 'undefined' && console.warn) {
console.warn(`VVJB: Invalid hash format "${hash}". Expected format: #carousel-${deeplinkId}-[number]`);
}
}
} else if (hash && hash.startsWith('#carousel-')) {
// Hash is for a different carousel - silently ignore (multiple carousels on page)
// No warning needed as this is normal behavior
}
// Listen for hash changes.
const hashChangeHandler = function() {
const hash = window.location.hash;
if (hash && hash.startsWith(`#carousel-${deeplinkId}-`)) {
const slideNumber = parseInt(hash.split('-').pop(), 10);
if (Number.isFinite(slideNumber) && slideNumber > 0) {
const itemsVisible = getItemsVisible(state);
const totalPages = Math.ceil(state.config.totalSlides / itemsVisible);
const pageIndex = slideNumber - 1;
if (pageIndex >= 0 && pageIndex < totalPages) {
state.pageIndex = pageIndex;
updateLayout(state);
} else {
// Invalid slide number during navigation
if (typeof console !== 'undefined' && console.warn) {
console.warn(`VVJB: Invalid slide number ${slideNumber}. Valid range: 1-${totalPages}.`);
}
}
}
}
};
window.addEventListener('hashchange', hashChangeHandler);
// Store handler for cleanup.
if (!state.eventHandlers) {
state.eventHandlers = {};
}
state.eventHandlers.hashChange = hashChangeHandler;
}
/**
* Update URL hash when navigating (without adding to history).
*
* @param {Object} state
* The carousel state object.
*/
function updateDeepLinkHash(state) {
if (state.config.deeplinkEnabled && state.config.deeplinkId) {
const newHash = `#carousel-${state.config.deeplinkId}-${state.pageIndex + 1}`;
if (window.location.hash !== newHash) {
history.replaceState(null, '', newHash);
}
}
}
/**
* Public API for external carousel control.
*/
Drupal.vvjb = Drupal.vvjb || {};
/**
* Helper to get container by identifier.
*/
function getContainerByIdentifier(identifier) {
let container = document.querySelector(`[data-deeplink-id="${identifier}"]`);
if (!container) {
container = document.querySelector(identifier);
}
return container;
}
/**
* Helper to get state from identifier.
*/
function getStateFromIdentifier(identifier) {
const container = getContainerByIdentifier(identifier);
if (!container) return null;
const inner = container.closest('.vvjb-inner');
return inner ? inner.vvjbState : null;
}
/**
* Navigate to specific slide page.
*/
Drupal.vvjb.goToSlide = function(identifier, slideNumber) {
const state = getStateFromIdentifier(identifier);
if (!state) {
console.warn(`VVJB: Carousel "${identifier}" not found`);
return false;
}
const itemsVisible = getItemsVisible(state);
const totalPages = Math.ceil(state.config.totalSlides / itemsVisible);
const pageIndex = slideNumber - 1;
if (pageIndex < 0 || pageIndex >= totalPages) {
console.warn(`VVJB: Invalid slide ${slideNumber}. Must be between 1 and ${totalPages}`);
return false;
}
state.pageIndex = pageIndex;
updateLayout(state);
updateDeepLinkHash(state);
return true;
};
/**
* Get current slide page number.
*/
Drupal.vvjb.getCurrentSlide = function(identifier) {
const state = getStateFromIdentifier(identifier);
return state ? state.pageIndex + 1 : null;
};
/**
* Get total number of slide pages.
*/
Drupal.vvjb.getTotalSlides = function(identifier) {
const state = getStateFromIdentifier(identifier);
if (!state) return null;
const itemsVisible = getItemsVisible(state);
return Math.ceil(state.config.totalSlides / itemsVisible);
};
/**
* Navigate to next slide.
*/
Drupal.vvjb.nextSlide = function(identifier) {
const state = getStateFromIdentifier(identifier);
if (!state) return false;
nextSlide(state);
return true;
};
/**
* Navigate to previous slide.
*/
Drupal.vvjb.prevSlide = function(identifier) {
const state = getStateFromIdentifier(identifier);
if (!state) return false;
prevSlide(state);
return true;
};
/**
* Pause carousel.
*/
Drupal.vvjb.pause = function(identifier) {
const state = getStateFromIdentifier(identifier);
if (!state || state.isPaused) return false;
togglePlayPause(state);
return true;
};
/**
* Resume carousel.
*/
Drupal.vvjb.resume = function(identifier) {
const state = getStateFromIdentifier(identifier);
if (!state || !state.isPaused) return false;
togglePlayPause(state);
return true;
};
/**
* Get carousel instance.
*/
Drupal.vvjb.getInstance = function(containerOrSelector) {
const container = typeof containerOrSelector === 'string'
? document.querySelector(containerOrSelector)
: containerOrSelector;
if (!container) return null;
const inner = container.closest('.vvjb-inner');
return inner ? inner.vvjbState : null;
};
})(Drupal, drupalSettings, once);
