User:Nvdtn19/Gadget-Carousel.js

/* ========================================================    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);         });     } })();