/* ======================================================== Wikivoyage JS Carousel (for Main Page) v1.0.0 (2026-10-21) Maintainer: - Nvdtn19 ======================================================== */ (function () { 'use strict'; function initCarousel(wrapper) { if (wrapper.dataset.initialized === 'true') { return; } wrapper.dataset.initialized = 'true'; var viewport = wrapper.querySelector('.wv-js-carousel-viewport'); var slides = Array.from(wrapper.querySelectorAll('.wv-js-carousel-slide')); var dotsContainer = wrapper.querySelector('.wv-js-carousel-dots'); var totalSlides = slides.length; // Exit early if no slides are present if (totalSlides === 0) { return; } // 1. Relocate navigation buttons outside viewport if nested in wikitext var btnPrev = wrapper.querySelector('.wv-js-carousel-prev'); var btnNext = wrapper.querySelector('.wv-js-carousel-next'); if (btnPrev && btnPrev.parentElement !== wrapper) { wrapper.appendChild(btnPrev); } if (btnNext && btnNext.parentElement !== wrapper) { wrapper.appendChild(btnNext); } // Graceful fallback for single slide (hide controls and disable auto-rotation) if (totalSlides === 1) { if (btnPrev) btnPrev.style.display = 'none'; if (btnNext) btnNext.style.display = 'none'; if (dotsContainer) dotsContainer.style.display = 'none'; slides[0].classList.add('active'); return; } // 2. Accessibility configuration wrapper.classList.add('is-carousel'); wrapper.setAttribute('role', 'region'); wrapper.setAttribute('aria-roledescription', 'carousel'); wrapper.setAttribute('aria-label', 'Carousel content'); if (viewport) { viewport.setAttribute('aria-live', 'polite'); } var currentIndex = 0; var timer = null; var isAnimating = false; var isHovered = false; // prefers-reduced-motion var motionQuery = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : null; var prefersReducedMotion = motionQuery ? motionQuery.matches : false; if (motionQuery && motionQuery.addEventListener) { motionQuery.addEventListener('change', function (e) { prefersReducedMotion = e.matches; }); } // 3. Pagination number buttons if (dotsContainer) { dotsContainer.innerHTML = ''; dotsContainer.setAttribute('role', 'tablist'); dotsContainer.setAttribute('aria-label', 'Slide selector'); } slides.forEach(function (slide, i) { slide.setAttribute('role', 'group'); slide.setAttribute('aria-roledescription', 'slide'); slide.setAttribute('aria-label', (i + 1) + ' of ' + totalSlides); if (dotsContainer) { var dot = document.createElement('button'); dot.type = 'button'; dot.setAttribute('role', 'tab'); dot.setAttribute('aria-selected', i === 0 ? 'true' : 'false'); dot.setAttribute('aria-label', 'Go to slide ' + (i + 1)); dot.setAttribute('tabindex', '0'); dot.setAttribute('data-index', i); // Assign index for event delegation dot.textContent = (i + 1); if (i === 0) { dot.classList.add('active'); } dotsContainer.appendChild(dot); } }); // 4. The slide transition function handles prefers-reduced-motion function goToSlide(targetIndex, direction) { if (isAnimating) { return; } if (targetIndex >= totalSlides) { targetIndex = 0; } if (targetIndex < 0) { targetIndex = totalSlides - 1; } var currentSlide = slides[currentIndex]; var nextSlide = slides[targetIndex]; // If the user has reduced motion enabled or it is the initial initialization: switch immediately if (!currentSlide || direction === 'init' || prefersReducedMotion) { slides.forEach(function (s) { s.classList.remove('active', 'anim-next-enter', 'anim-next-leave', 'anim-prev-enter', 'anim-prev-leave'); s.style.transform = ''; s.style.transition = ''; s.style.opacity = ''; }); nextSlide.classList.add('active'); updateDots(targetIndex); currentIndex = targetIndex; isAnimating = false; return; } isAnimating = true; var currentClass = direction === 'next' ? 'anim-next-leave' : 'anim-prev-leave'; var nextClass = direction === 'next' ? 'anim-next-enter' : 'anim-prev-enter'; currentSlide.classList.add(currentClass); currentSlide.classList.remove('active'); nextSlide.classList.add(nextClass, 'active'); updateDots(targetIndex); // Clean up animation classes automatically once CSS keyframe animation finishes on GPU var onAnimationEnd = function () { currentSlide.classList.remove(currentClass); nextSlide.classList.remove(nextClass); isAnimating = false; currentIndex = targetIndex; }; nextSlide.addEventListener('animationend', onAnimationEnd, { once: true }); } function updateDots(index) { if (!dotsContainer) return; var dots = Array.from(dotsContainer.querySelectorAll('button')); dots.forEach(function (dot, idx) { if (idx === index) { dot.classList.add('active'); dot.setAttribute('aria-selected', 'true'); } else { dot.classList.remove('active'); dot.setAttribute('aria-selected', 'false'); } }); } // 5. Real-time touch drag & snap logic var startX = 0, startY = 0, diffX = 0; var isDragging = false; var activeTargetSlide = null; var wrapperWidth = 0; // removes all inline styles from every inactive slide function resetInactiveSlides() { slides.forEach(function (slide, idx) { if (idx !== currentIndex) { slide.classList.remove('active', 'anim-next-enter', 'anim-next-leave', 'anim-prev-enter', 'anim-prev-leave'); slide.style.transform = ''; slide.style.transition = ''; slide.style.opacity = ''; slide.style.pointerEvents = ''; slide.style.zIndex = ''; } }); slides[currentIndex].style.transform = ''; slides[currentIndex].style.transition = ''; slides[currentIndex].style.opacity = ''; slides[currentIndex].style.pointerEvents = ''; slides[currentIndex].style.zIndex = ''; } wrapper.addEventListener('touchstart', function (e) { if (isAnimating) return; startX = e.touches[0].clientX; startY = e.touches[0].clientY; diffX = 0; isDragging = false; activeTargetSlide = null; wrapperWidth = wrapper.offsetWidth; clearInterval(timer); }, { passive: true }); wrapper.addEventListener('touchmove', function (e) { if (isAnimating || wrapperWidth === 0) return; var currentX = e.touches[0].clientX; var currentY = e.touches[0].clientY; var distX = currentX - startX; var distY = currentY - startY; // Skip if the user intentionally scrolls the page vertically if (!isDragging && Math.abs(distY) > Math.abs(distX)) return; if (Math.abs(distX) > 10) { isDragging = true; diffX = distX; var targetIdx = diffX < 0 ? (currentIndex + 1) % totalSlides : (currentIndex - 1 + totalSlides) % totalSlides; var currentSlide = slides[currentIndex]; var newTargetSlide = slides[targetIdx]; // If the user reverses swipe direction midway: immediately clean up the old target slide if (activeTargetSlide && activeTargetSlide !== newTargetSlide) { activeTargetSlide.style.transform = ''; activeTargetSlide.style.transition = ''; activeTargetSlide.style.opacity = ''; activeTargetSlide.style.pointerEvents = ''; activeTargetSlide.style.zIndex = ''; } activeTargetSlide = newTargetSlide; currentSlide.style.transition = 'none'; activeTargetSlide.style.transition = 'none'; activeTargetSlide.style.opacity = '1'; activeTargetSlide.style.pointerEvents = 'auto'; var baseOffset = diffX < 0 ? wrapperWidth : -wrapperWidth; currentSlide.style.transform = 'translateX(' + diffX + 'px)'; activeTargetSlide.style.transform = 'translateX(' + (baseOffset + diffX) + 'px)'; } }, { passive: true }); wrapper.addEventListener('touchend', function () { if (!isDragging || !activeTargetSlide || wrapperWidth === 0) { if (isDragging) { resetInactiveSlides(); } isDragging = false; resetTimer(); return; } // Lock the movement flag in both cases to prevent race conditions isAnimating = true; var threshold = wrapperWidth * 0.2; var currentSlide = slides[currentIndex]; var targetIdx = diffX < 0 ? (currentIndex + 1) % totalSlides : (currentIndex - 1 + totalSlides) % totalSlides; var finalTargetSlide = activeTargetSlide; currentSlide.style.transition = 'transform 0.25s ease-out'; finalTargetSlide.style.transition = 'transform 0.25s ease-out'; if (Math.abs(diffX) > threshold) { var finalCurrent = diffX < 0 ? -wrapperWidth : wrapperWidth; currentSlide.style.transform = 'translateX(' + finalCurrent + 'px)'; finalTargetSlide.style.transform = 'translateX(0px)'; setTimeout(function () { currentIndex = targetIdx; slides.forEach(function (s, idx) { s.style.transform = ''; s.style.transition = ''; s.style.opacity = ''; s.style.pointerEvents = ''; s.style.zIndex = ''; if (idx === currentIndex) { s.classList.add('active'); } else { s.classList.remove('active'); } }); updateDots(currentIndex); isAnimating = false; isDragging = false; activeTargetSlide = null; resetTimer(); }, 250); } else { // Rebound / Snap back currentSlide.style.transform = 'translateX(0px)'; finalTargetSlide.style.transform = diffX < 0 ? 'translateX(100%)' : 'translateX(-100%)'; setTimeout(function () { resetInactiveSlides(); isAnimating = false; isDragging = false; activeTargetSlide = null; resetTimer(); }, 250); } }); // 6. Autoplay timer: 10s default with hover and tab visibility detection function startTimer() { var rawInterval = parseInt(wrapper.dataset.interval, 10); // If set to 0 (or a negative value), disable autoplay completely if (rawInterval <= 0) { clearInterval(timer); return; } // If it is not specified or an invalid value is entered, default to 10 seconds; if a positive number is entered, enforce a minimum of 1000 ms var interval = Math.max(1000, isNaN(rawInterval) ? 10000 : rawInterval); clearInterval(timer); timer = setInterval(function () { // Automatically stop the timer if the carousel is removed from the DOM during editing if (!wrapper.isConnected) { clearInterval(timer); return; } if (!document.hidden && !isHovered && !prefersReducedMotion) { goToSlide(currentIndex + 1, 'next'); } }, interval); } function resetTimer() { clearInterval(timer); startTimer(); } wrapper.addEventListener('mouseenter', function () { isHovered = true; }); wrapper.addEventListener('mouseleave', function () { isHovered = false; resetTimer(); }); // 7. Navigation button handlers & keyboard accessibility wrapper.addEventListener('click', function (e) { if (isAnimating || isDragging) return; var navBtn = e.target.closest('.wv-js-carousel-prev, .wv-js-carousel-next'); if (navBtn) { var isNext = navBtn.classList.contains('wv-js-carousel-next'); goToSlide(isNext ? currentIndex + 1 : currentIndex - 1, isNext ? 'next' : 'prev'); resetTimer(); return; } var dotBtn = e.target.closest('.wv-js-carousel-dots button'); if (dotBtn && dotsContainer && dotsContainer.contains(dotBtn)) { var dotIndex = parseInt(dotBtn.getAttribute('data-index'), 10); if (!isNaN(dotIndex) && dotIndex !== currentIndex) { goToSlide(dotIndex, dotIndex > currentIndex ? 'next' : 'prev'); resetTimer(); } } }); // keyboard accessibility if (btnPrev) { btnPrev.setAttribute('role', 'button'); btnPrev.setAttribute('tabindex', '0'); btnPrev.setAttribute('aria-label', 'Previous slide'); } if (btnNext) { btnNext.setAttribute('role', 'button'); btnNext.setAttribute('tabindex', '0'); btnNext.setAttribute('aria-label', 'Next slide'); } wrapper.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { var targetBtn = e.target.closest('.wv-js-carousel-prev, .wv-js-carousel-next'); if (targetBtn) { e.preventDefault(); if (targetBtn.classList.contains('wv-js-carousel-prev')) { goToSlide(currentIndex - 1, 'prev'); } else { goToSlide(currentIndex + 1, 'next'); } resetTimer(); } } else if (e.key === 'ArrowLeft') { goToSlide(currentIndex - 1, 'prev'); resetTimer(); } else if (e.key === 'ArrowRight') { goToSlide(currentIndex + 1, 'next'); resetTimer(); } }); // 8. Initialize starting slide (random if data-randomize="true") and timer var isRandom = wrapper.dataset.randomize === 'true'; var startSlide = isRandom ? Math.floor(Math.random() * totalSlides) : 0; goToSlide(startSlide, 'init'); startTimer(); } if (window.mw && mw.hook) { mw.hook('wikipage.content').add(function ($content) { var wrappers = Array.from(($content ? $content[0] : document).querySelectorAll('.wv-js-carousel-wrapper')); wrappers.forEach(initCarousel); }); } else { document.addEventListener('DOMContentLoaded', function () { var wrappers = Array.from(document.querySelectorAll('.wv-js-carousel-wrapper')); wrappers.forEach(initCarousel); }); } })();