///////////////////////////////////////////// // Standalone Carousel (Embla-powered) ///////////////////////////////////////////// (function () { "use strict"; function debounce(fn, wait) { let timeoutId; return (...args) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn(...args), wait); }; } function handleError(error, context) { console.error(`[${context}]`, error); } const logger = { log: (...args) => console.log(...args), warn: (...args) => console.warn(...args), }; const _libraryPromises = {}; function isLibraryLoaded(name) { if (name === "embla") return typeof window.EmblaCarousel !== "undefined"; return false; } function loadLibrary(name) { if (isLibraryLoaded(name)) return Promise.resolve(); if (_libraryPromises[name]) return _libraryPromises[name]; const urls = { embla: "https://cdn.jsdelivr.net/npm/embla-carousel@8/embla-carousel.umd.js", }; const url = urls[name]; if (!url) return Promise.reject(new Error(`Unknown library: ${name}`)); _libraryPromises[name] = new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = url; script.onload = () => resolve(); script.onerror = () => reject(new Error(`Failed to load ${url}`)); document.head.appendChild(script); }); return _libraryPromises[name]; } let carouselLibraryLoaded = false; let loadPromise = null; let pendingSliders = []; let carouselObserver = null; let carouselUid = 0; const syncedSliderGroups = new Map(); const VIEWPORT_SELECTOR = "[carousel-wrapper]"; const CONTAINER_SELECTOR = "[carousel]"; const BTNS_WRAPPER_SELECTOR = "[carousel-btns]"; const PREV_BTN_SELECTOR = '[carousel-prev-btn], [carousel-btns] [aria-label="Previous slide"]'; const NEXT_BTN_SELECTOR = '[carousel-next-btn], [carousel-btns] [aria-label="Next slide"]'; const NAV_BTN_FALLBACK_SELECTOR = "[carousel-btn], button"; const DOTS_CONTAINER_SELECTOR = "[carousel-dots]"; const SLIDE_BTN_SELECTOR = "[data-slide-btn]"; const SLIDE_FALLBACK_SELECTOR = "[carousel-item]"; const PROGRESS_BAR_SELECTOR = "[carousel-progress-bar]"; const PROGRESS_FILL_SELECTOR = "[carousel-progress-fill]"; const MOBILE_MAX_WIDTH = 767; const TABLET_MAX_WIDTH = 991; function prefersReducedMotion() { return ( typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches ); } function resetFadeInlineStyles(slider) { const carouselContainer = slider.querySelector(CONTAINER_SELECTOR); if (!carouselContainer) return; carouselContainer.style.position = ""; carouselContainer.style.display = ""; carouselContainer.style.height = ""; carouselContainer.style.transform = ""; carouselContainer.style.transition = ""; Array.from(carouselContainer.children).forEach((slide) => { slide.style.position = ""; slide.style.inset = ""; slide.style.width = ""; slide.style.opacity = ""; slide.style.zIndex = ""; slide.style.willChange = ""; slide.style.transition = ""; slide.style.pointerEvents = ""; }); } function getCarouselTokens(slider) { const attrValue = slider.getAttribute("data-carousel"); if (!attrValue) return []; return attrValue .toLowerCase() .split(/[\s,|]+/) .map((token) => token.trim()) .filter(Boolean); } function isFadeCarousel(slider) { return ( slider.hasAttribute("data-fade") || slider.getAttribute("data-effect") === "fade" || getCarouselTokens(slider).includes("fade") ); } const ensureFadeCarouselAnimateStyles = (() => { let injected = false; const CSS_TEXT = `\n[carousel-text-animate] { opacity: 0; transform: translateY(12px); transition: opacity 0.5s ease, transform 0.5s ease; }\n.is-active [carousel-text-animate] { opacity: 1; transform: translateY(0); }\n.is-active [carousel-text-animate]:nth-child(1) { transition-delay: 0.3s; }\n.is-active [carousel-text-animate]:nth-child(2) { transition-delay: 0.45s; }\n.is-active [carousel-text-animate]:nth-child(3) { transition-delay: 0.6s; }\n[carousel-img-animate] { opacity: 0; transform: scale(0.95); transition: opacity 0.6s ease, transform 0.6s ease; }\n.is-active [carousel-img-animate] { opacity: 1; transform: scale(1); transition-delay: 0.2s; }\n`; return () => { if (injected || typeof document === "undefined") return; injected = true; const style = document.createElement("style"); style.setAttribute("data-carousel-fade-animate", "true"); style.textContent = CSS_TEXT; document.head.appendChild(style); }; })(); const ensureDotButtonResetStyles = (() => { let injected = false; const CSS_TEXT = `\n:where(button[carousel-dot]) {\n appearance: none; -webkit-appearance: none; background: none; border: 0;\n border-radius: 0; padding: 0; margin: 0; font: inherit; color: inherit;\n line-height: inherit; cursor: pointer;\n}\n`; return () => { if (injected || typeof document === "undefined") return; injected = true; const style = document.createElement("style"); style.setAttribute("data-carousel-dot-reset", "true"); style.textContent = CSS_TEXT; document.head.appendChild(style); }; })(); const ensureActiveInViewStyles = (() => { let injected = false; const CSS_TEXT = `\n[data-carousel][data-active-in-view] [carousel] > * { cursor: pointer; }\n[data-carousel][data-active-in-view] [carousel] > .is-active { position: relative; z-index: 2; }\n`; return () => { if (injected || typeof document === "undefined") return; injected = true; const style = document.createElement("style"); style.setAttribute("data-carousel-active-in-view-style", "true"); style.textContent = CSS_TEXT; document.head.appendChild(style); }; })(); function shouldInitForViewport(slider) { const tokens = getCarouselTokens(slider); const onlyMobile = tokens.includes("mobile"); const onlyTablet = tokens.includes("tablet"); if (!onlyMobile && !onlyTablet) return true; if (typeof window === "undefined") return true; if (onlyMobile) return window.matchMedia(`(max-width: ${MOBILE_MAX_WIDTH}px)`).matches; return window.matchMedia(`(max-width: ${TABLET_MAX_WIDTH}px)`).matches; } async function loadCarouselLibrary() { if (carouselLibraryLoaded || isLibraryLoaded("embla")) return Promise.resolve(); if (loadPromise) return loadPromise; loadPromise = (async () => { try { await loadLibrary("embla"); if (typeof window.EmblaCarousel === "undefined") { throw new Error("Carousel library failed to load"); } carouselLibraryLoaded = true; if (pendingSliders.length > 0) { logger.log( `Initializing ${pendingSliders.length} pending carousel(s)...`, ); initializeCarousels(pendingSliders); pendingSliders = []; } return true; } catch (error) { handleError(error, "Carousel Library Loader"); loadPromise = null; throw error; } })(); return loadPromise; } async function loadAndInitSlider(slider) { if (slider._carouselInitialized) return; if (!carouselLibraryLoaded && !pendingSliders.includes(slider)) { pendingSliders.push(slider); } if (!carouselLibraryLoaded) { await loadCarouselLibrary(); } if (carouselLibraryLoaded && !slider._carouselInitialized) { initializeCarousels([slider]); } } function initCarousel() { const sliders = document.querySelectorAll("[data-carousel]"); if (!sliders.length) return; if (Array.from(sliders).some(isFadeCarousel)) ensureFadeCarouselAnimateStyles(); if ( Array.from(sliders).some((s) => s.hasAttribute("data-active-in-view")) ) { ensureActiveInViewStyles(); } logger.log( `⏳ Found ${sliders.length} carousel(s) - will load when visible...`, ); if (carouselObserver) carouselObserver.disconnect(); const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; const slider = entry.target; if (!shouldInitForViewport(slider)) return; observer.unobserve(slider); slider.setAttribute("data-carousel-observed", "true"); loadAndInitSlider(slider); }); }, { root: null, rootMargin: "200px", threshold: 0, }, ); carouselObserver = observer; sliders.forEach((slider) => observer.observe(slider)); } function initializeCarousels(sliderList) { if (!sliderList || !sliderList.length) return; sliderList.forEach((slider) => { if (slider._carouselInitialized) return; if (!shouldInitForViewport(slider)) return; const carouselRoot = slider.querySelector(VIEWPORT_SELECTOR) || slider; const carouselViewport = slider.querySelector(VIEWPORT_SELECTOR) || carouselRoot; const carouselContainer = slider.querySelector(CONTAINER_SELECTOR); if (!carouselViewport) { logger.warn("Carousel viewport not found in slider:", slider); return; } const resolvedContainer = carouselContainer || carouselViewport.children[0]; if (!resolvedContainer) { logger.warn( "Carousel has no slides container — check DOM structure:", slider, ); return; } slider._carouselInitialized = true; const navButtonsWrapper = slider.querySelector(BTNS_WRAPPER_SELECTOR); const fallbackNavButtons = navButtonsWrapper ? Array.from( navButtonsWrapper.querySelectorAll(NAV_BTN_FALLBACK_SELECTOR), ) : []; const explicitNextBtn = slider.querySelector("[slider-next-btn]") || slider.querySelector(NEXT_BTN_SELECTOR); const explicitPrevBtn = slider.querySelector("[slider-prev-btn]") || slider.querySelector(PREV_BTN_SELECTOR); const prevBtn = explicitPrevBtn || fallbackNavButtons[0] || null; let nextBtn = explicitNextBtn || (fallbackNavButtons.length > 1 ? fallbackNavButtons[1] : fallbackNavButtons[0]) || null; if (prevBtn && nextBtn && prevBtn === nextBtn) nextBtn = null; let slideButtons = []; let slideButtonIndices = []; const customProgressBar = slider.querySelector(PROGRESS_BAR_SELECTOR); const syncId = slider.getAttribute("data-sync"); const centerMode = slider.hasAttribute("data-center"); const centerBounds = slider.hasAttribute("data-center-bounds"); const clickToCenter = slider.hasAttribute("data-click-center"); const activeInView = slider.hasAttribute("data-active-in-view"); const slideAllView = slider.hasAttribute("data-slide-all-view"); const loopMode = slider.hasAttribute("data-loop"); const disableDrag = slider.hasAttribute("data-no-drag"); const dragFree = slider.hasAttribute("data-drag-free"); const autoplayEnabled = slider.hasAttribute("data-autoplay"); const autoplayStopOnInteraction = slider.hasAttribute( "data-autoplay-stop-on-interaction", ); const pauseOnHover = slider.hasAttribute("data-pause-on-hover"); const fadeMode = isFadeCarousel(slider); const verticalMode = slider.hasAttribute("data-vertical"); const slideClassesEnabled = slider.hasAttribute("data-slide-classes"); const alignAttr = slider.getAttribute("data-align"); const autoplayDelayAttr = slider.getAttribute("data-autoplay-delay"); const autoplayDelay = Number.isFinite( Number.parseInt(autoplayDelayAttr, 10), ) ? Number.parseInt(autoplayDelayAttr, 10) : 8e3; const fadeDurationAttr = slider.getAttribute("data-fade-duration"); const fadeDuration = Number.isFinite( Number.parseInt(fadeDurationAttr, 10), ) ? Math.max(150, Number.parseInt(fadeDurationAttr, 10)) : 650; const fadeEase = slider.getAttribute("data-fade-ease") || "cubic-bezier(0.22, 1, 0.36, 1)"; // --- NEW: starting slide index support --- // Usage:
starts on the 3rd slide (0-based). // Accepts negative values to count from the end, e.g. "-1" for the last slide. // If data-center is set and no explicit data-start-index is given, default to // the middle slide instead of slide 0. const startIndexAttr = slider.getAttribute("data-start-index"); let requestedStartIndex = Number.parseInt(startIndexAttr, 10); if (!Number.isFinite(requestedStartIndex)) { if (centerMode) { const initialSlideCount = resolvedContainer.children.length; requestedStartIndex = initialSlideCount > 0 ? Math.floor((initialSlideCount - 1) / 2) : 0; } else { requestedStartIndex = 0; } } const carouselOptions = { container: resolvedContainer, align: alignAttr || (centerMode ? "center" : "start"), containScroll: fadeMode ? "keepSnaps" : centerMode && !centerBounds ? false : centerMode ? "keepSnaps" : "trimSnaps", loop: fadeMode ? false : loopMode, draggable: fadeMode ? false : !disableDrag, watchDrag: fadeMode ? false : !disableDrag, dragFree: fadeMode ? false : dragFree, axis: verticalMode ? "y" : "x", watchResize: !fadeMode, // "auto" tells Embla to scroll by however many slides are // currently visible in the viewport (a full "page" at a time) // instead of the default one-slide-per-interaction behavior. slidesToScroll: fadeMode ? 1 : slideAllView ? "auto" : 1, // Embla resolves negative indices and clamps out-of-range values // internally, so we can pass the raw parsed value straight through. startIndex: requestedStartIndex, }; let carouselApi = null; const cleanupTasks = []; let scrollToIndex = () => {}; let restartAutoplay = null; let lastButtonStateKey = null; let progressRafId = null; let progressStartTime = null; let progressResumeOffset = 0; let interactionHold = false; let progressFillEl = null; let fadeLayoutSlideCount = 0; function getProgressFill() { if (!customProgressBar) return null; if (progressFillEl) return progressFillEl; progressFillEl = customProgressBar.querySelector( PROGRESS_FILL_SELECTOR, ); if (!progressFillEl && customProgressBar.children.length === 1) { progressFillEl = customProgressBar.firstElementChild; progressFillEl.setAttribute("carousel-progress-fill", ""); } return progressFillEl; } function cancelProgressBar(reset = true) { if (progressRafId !== null) { cancelAnimationFrame(progressRafId); progressRafId = null; } progressStartTime = null; if (reset && customProgressBar) { const fill = getProgressFill(); if (fill) { fill.style.transition = "none"; fill.style.width = "0%"; } } } function startProgressBar(resume = false) { if (!customProgressBar || !autoplayEnabled) return; if (prefersReducedMotion()) return; const fill = getProgressFill(); if (!fill) return; if (!resume) { progressResumeOffset = 0; fill.style.transition = "none"; fill.style.width = "0%"; fill.offsetWidth; } if (interactionHold) return; progressStartTime = null; function tick(timestamp) { if (progressStartTime === null) { progressStartTime = timestamp - progressResumeOffset; progressResumeOffset = 0; } const elapsed = timestamp - progressStartTime; const progress = Math.min((elapsed / autoplayDelay) * 100, 100); fill.style.width = `${progress}%`; if (carouselApi) { const totalSlides = carouselApi.scrollSnapList().length; const currentIndex = carouselApi.selectedScrollSnap(); customProgressBar.setAttribute( "data-progress", Math.round(progress), ); customProgressBar.setAttribute( "data-current-slide", currentIndex + 1, ); customProgressBar.setAttribute("data-total-slides", totalSlides); } if (progress < 100) { progressRafId = requestAnimationFrame(tick); return; } progressRafId = null; progressStartTime = null; if (carouselApi) { if (fadeMode) stepFade(1); else if (carouselApi.canScrollNext()) carouselApi.scrollNext(); else carouselApi.scrollTo(0); } } progressRafId = requestAnimationFrame(tick); } function updateStaticProgressBar() { if (!customProgressBar || autoplayEnabled || !carouselApi) return; const fill = getProgressFill(); if (!fill) return; const totalSlides = carouselApi.scrollSnapList().length; const currentIndex = carouselApi.selectedScrollSnap(); const progress = totalSlides > 1 ? Math.round((currentIndex / (totalSlides - 1)) * 100) : 100; fill.style.width = `${progress}%`; customProgressBar.setAttribute("data-progress", progress); customProgressBar.setAttribute("data-current-slide", currentIndex + 1); customProgressBar.setAttribute("data-total-slides", totalSlides); } function getSlides() { if (carouselContainer) return Array.from(carouselContainer.children); return Array.from(slider.querySelectorAll(SLIDE_FALLBACK_SELECTOR)); } // Maps each scroll-snap ("page") to the slide indices it contains. // Falls back to one-slide-per-snap if Embla's internal registry // isn't available for some reason. function getSnapSlideIndices() { if (!carouselApi) return []; try { const engine = typeof carouselApi.internalEngine === "function" ? carouselApi.internalEngine() : null; if ( engine && Array.isArray(engine.slideRegistry) && engine.slideRegistry.length ) { return engine.slideRegistry; } } catch (_) {} return carouselApi.scrollSnapList().map((_, i) => [i]); } // Returns the slide indices that should be treated as "active" right // now. In data-slide-all-view mode this is every slide in the current // page/group; otherwise it's just the single selected slide. function getActiveIndices() { if (!carouselApi) return []; if ( slideAllView && !fadeMode && typeof carouselApi.slidesInView === "function" ) { const inView = carouselApi.slidesInView(); if (inView.length) return inView; } return [carouselApi.selectedScrollSnap()]; } function stepFade(direction) { if (!carouselApi) return; const totalSlides = carouselApi.scrollSnapList().length; if (totalSlides < 2) return; const currentIndex = carouselApi.selectedScrollSnap(); scrollToIndex( (currentIndex + direction + totalSlides) % totalSlides, true, ); } function applyFadeLayout(remeasure = false) { if (!fadeMode || !carouselContainer) return; const slides = getSlides(); if (!slides.length) return; carouselContainer.style.transform = "none"; carouselContainer.style.transition = "none"; const alreadyLaidOut = !remeasure && fadeLayoutSlideCount === slides.length && slides.every((s) => s.style.position === "absolute"); if (alreadyLaidOut) return; const previousHeight = carouselContainer.style.height; if (remeasure && fadeLayoutSlideCount > 0) { carouselContainer.style.height = ""; carouselContainer.style.display = ""; carouselContainer.style.position = ""; slides.forEach((slide) => { slide.style.position = ""; slide.style.inset = ""; slide.style.width = ""; }); } const maxHeight = slides.reduce( (max, slide) => Math.max(max, slide.offsetHeight), 0, ); carouselViewport.style.overflow = "hidden"; carouselContainer.style.position = "relative"; carouselContainer.style.display = "block"; if (maxHeight > 0) carouselContainer.style.height = `${maxHeight}px`; else if (previousHeight) carouselContainer.style.height = previousHeight; slides.forEach((slide) => { slide.style.position = "absolute"; slide.style.inset = "0"; slide.style.width = "100%"; slide.style.willChange = "opacity"; slide.style.transition = `opacity ${fadeDuration}ms ${fadeEase}`; }); fadeLayoutSlideCount = slides.length; } function ensureDots() { const dotsContainer = slider.querySelector(DOTS_CONTAINER_SELECTOR); const slides = getSlides(); if (!slides.length) return; // In slide-all-view mode, dots represent pages (groups of slides) // rather than individual slides. const groupIndices = slideAllView && !fadeMode ? getSnapSlideIndices() : null; const dotCount = groupIndices && groupIndices.length ? groupIndices.length : slides.length; if (dotsContainer) { const existingDots = dotsContainer.querySelectorAll(SLIDE_BTN_SELECTOR); if (existingDots.length > 0) { if (existingDots.length !== dotCount) { logger.warn( `Carousel has ${dotCount} ${groupIndices ? "page(s)" : "slide(s)"} but ${existingDots.length} dot buttons. Button count should match.`, ); } } else { dotsContainer.innerHTML = ""; ensureDotButtonResetStyles(); const fragment = document.createDocumentFragment(); for (let index = 0; index < dotCount; index += 1) { const dot = document.createElement("button"); dot.type = "button"; dot.className = "carousel-dot"; dot.setAttribute("carousel-dot", ""); dot.setAttribute("data-slide-btn", ""); dot.setAttribute("aria-label", `Go to slide ${index + 1}`); fragment.appendChild(dot); } dotsContainer.appendChild(fragment); } } slideButtons = Array.from(slider.querySelectorAll(SLIDE_BTN_SELECTOR)); slideButtons.sort((a, b) => { const aIndex = a.hasAttribute("data-slide-index") ? Number.parseInt(a.getAttribute("data-slide-index"), 10) : null; const bIndex = b.hasAttribute("data-slide-index") ? Number.parseInt(b.getAttribute("data-slide-index"), 10) : null; if (aIndex !== null && bIndex !== null) return aIndex - bIndex; if (aIndex !== null) return -1; if (bIndex !== null) return 1; const position = a.compareDocumentPosition(b); return position & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; }); slideButtons = slideButtons.slice(0, dotCount); const claimedIndices = new Set(); slideButtons.forEach((button) => { if (button.hasAttribute("data-slide-index")) { claimedIndices.add( Number.parseInt(button.getAttribute("data-slide-index"), 10), ); } }); let nextFreeIndex = 0; slideButtonIndices = slideButtons.map((button, arrayIndex) => { if (button.hasAttribute("data-slide-index")) { return Number.parseInt(button.getAttribute("data-slide-index"), 10); } if (groupIndices) { // Point each auto-generated dot at the first slide of its page, // so scrollTo() lands on the correct group. const targetSlides = groupIndices[arrayIndex]; return targetSlides && targetSlides.length ? targetSlides[0] : arrayIndex; } while (claimedIndices.has(nextFreeIndex)) nextFreeIndex += 1; claimedIndices.add(nextFreeIndex); return nextFreeIndex; }); } function updateActiveSlides() { if (!carouselApi) return; const slides = getSlides(); if (!slides.length) return; if (fadeMode) { if (!carouselContainer) { logger.warn( "Fade mode requires a carousel container inside the slider.", ); return; } applyFadeLayout(); } const activeIndices = getActiveIndices(); const activeIndex = activeIndices.length ? activeIndices[0] : carouselApi.selectedScrollSnap(); const visibleIndices = slideClassesEnabled && carouselApi.slidesInView ? carouselApi.slidesInView() : null; slides.forEach((slide, index) => { Array.from(slide.classList).forEach((className) => { if (/^(upcoming|upcomming|passed)-\d+$/.test(className)) slide.classList.remove(className); }); slide.classList.toggle("is-active", activeIndices.includes(index)); if (fadeMode) { const isActive = index === activeIndex; slide.style.opacity = isActive ? "1" : "0"; slide.style.zIndex = isActive ? "2" : "1"; slide.style.pointerEvents = isActive ? "auto" : "none"; if (isActive) { slide.removeAttribute("aria-hidden"); slide.inert = false; } else { if (slide.contains(document.activeElement)) slider.focus({ preventScroll: true, }); slide.setAttribute("aria-hidden", "true"); slide.inert = true; } } if (!slideClassesEnabled) return; slide.classList.toggle( "is-visible", fadeMode ? index === activeIndex : visibleIndices.includes(index), ); slide.classList.toggle("is-passed", index < activeIndex); slide.classList.toggle("is-upcoming", index > activeIndex); slide.classList.toggle("is-prev", index === activeIndex - 1); slide.classList.toggle("is-next", index === activeIndex + 1); if (index > activeIndex) slide.classList.add(`upcoming-${index - activeIndex}`); else if (index < activeIndex) slide.classList.add(`passed-${activeIndex - index}`); }); updateSlideButtons(activeIndex); } function updateSlideButtons(activeIndex) { if (!slideButtons.length) return; let activeButton = null; slideButtons.forEach((button, arrayIndex) => { const buttonIndex = slideButtonIndices[arrayIndex] ?? arrayIndex; const isActive = buttonIndex === activeIndex; button.classList.toggle("is-active", isActive); if (isActive) { activeButton = button; button.setAttribute("aria-current", "true"); } else { button.removeAttribute("aria-current"); } }); keepActiveSlideButtonInView(activeButton); } function keepActiveSlideButtonInView(activeButton) { if (!activeButton) return; const scrollParent = findHorizontalScrollParent(activeButton); if (!scrollParent) return; const parentRect = scrollParent.getBoundingClientRect(); const buttonRect = activeButton.getBoundingClientRect(); const sidePadding = 8; const isOutOfView = buttonRect.left < parentRect.left + sidePadding || buttonRect.right > parentRect.right - sidePadding; if (!isOutOfView) return; const parentCenter = parentRect.left + parentRect.width / 2; const buttonCenter = buttonRect.left + buttonRect.width / 2; const delta = buttonCenter - parentCenter; const targetScrollLeft = Math.max( 0, Math.min( scrollParent.scrollWidth - scrollParent.clientWidth, scrollParent.scrollLeft + delta, ), ); scrollParent.scrollTo({ left: targetScrollLeft, behavior: prefersReducedMotion() ? "auto" : "smooth", }); } function findHorizontalScrollParent(element) { let current = element.parentElement; while (current && current !== slider) { const styles = window.getComputedStyle(current); const overflowX = styles.overflowX; const hasOverflowingContent = current.scrollWidth > current.clientWidth + 1; const canScrollX = hasOverflowingContent && (overflowX === "auto" || overflowX === "scroll" || overflowX === "hidden"); if (canScrollX) return current; current = current.parentElement; } return null; } function updateButtonStates() { if (!carouselApi) return; const canPrev = carouselApi.canScrollPrev(); const canNext = carouselApi.canScrollNext(); const slides = getSlides(); const effectiveCanPrev = fadeMode && slides.length > 1 ? true : canPrev; const effectiveCanNext = fadeMode && slides.length > 1 ? true : canNext; const bothDisabled = !effectiveCanPrev && !effectiveCanNext; const stateKey = JSON.stringify({ effectiveCanPrev: effectiveCanPrev, effectiveCanNext: effectiveCanNext, bothDisabled: bothDisabled, hasNavWrapper: Boolean(navButtonsWrapper), hasPrev: Boolean(prevBtn), hasNext: Boolean(nextBtn), }); if (stateKey === lastButtonStateKey) return; lastButtonStateKey = stateKey; if (navButtonsWrapper) { navButtonsWrapper.style.display = bothDisabled ? "none" : ""; navButtonsWrapper.setAttribute("aria-hidden", String(bothDisabled)); } if (prevBtn) { prevBtn.style.pointerEvents = effectiveCanPrev ? "auto" : "none"; prevBtn.style.opacity = effectiveCanPrev ? "1" : "0.5"; prevBtn.style.display = bothDisabled ? "none" : ""; prevBtn.setAttribute("aria-disabled", String(!effectiveCanPrev)); prevBtn.setAttribute("tabindex", effectiveCanPrev ? "0" : "-1"); } if (nextBtn) { nextBtn.style.pointerEvents = effectiveCanNext ? "auto" : "none"; nextBtn.style.opacity = effectiveCanNext ? "1" : "0.5"; nextBtn.style.display = bothDisabled ? "none" : ""; nextBtn.setAttribute("aria-disabled", String(!effectiveCanNext)); nextBtn.setAttribute("tabindex", effectiveCanNext ? "0" : "-1"); } } try { carouselApi = window.EmblaCarousel(carouselViewport, carouselOptions); carouselViewport._carousel = carouselApi; slider._carouselInstance = carouselApi; scrollToIndex = (index, jump = false) => { if (carouselApi) carouselApi.scrollTo(index, jump); }; ensureDots(); const slides = getSlides(); if ( !slideAllView && slideButtons.length !== slides.length && slideButtons.length > 0 ) { logger.warn( `Carousel has ${slides.length} slides but ${slideButtons.length} slide buttons. Button indices may not match slide indices.`, ); } if (!carouselViewport.id) { carouselUid += 1; carouselViewport.id = `carousel-viewport-${carouselUid}`; } resolvedContainer.setAttribute("aria-live", "polite"); slides.forEach((slide, index) => { if (!slide.hasAttribute("role")) slide.setAttribute("role", "group"); if (!slide.hasAttribute("aria-roledescription")) slide.setAttribute("aria-roledescription", "slide"); if (!slide.hasAttribute("aria-label")) slide.setAttribute( "aria-label", `${index + 1} of ${slides.length}`, ); }); // For fade-mode carousels the visual "active" slide is driven entirely // by updateActiveSlides()/applyFadeLayout(), which read the current // selectedScrollSnap(). Embla's startIndex option already moves that // selection before this point, so no extra jump call is required here // — but we still lay things out fresh so the correct slide fades in // as visible on first paint instead of momentarily flashing slide 0. updateButtonStates(); updateActiveSlides(); if (!autoplayEnabled) updateStaticProgressBar(); if (autoplayEnabled) { let autoplayIntervalId = null; let autoplayStoppedByInteraction = false; const setLiveRegion = (rotating) => resolvedContainer.setAttribute( "aria-live", rotating ? "off" : "polite", ); const startAutoplay = (resumeProgress = false) => { if (interactionHold) return; if (prefersReducedMotion()) return; setLiveRegion(true); if (customProgressBar) { startProgressBar(resumeProgress); return; } if (autoplayIntervalId) return; autoplayIntervalId = window.setInterval(() => { if (!carouselApi) return; if (fadeMode) { stepFade(1); return; } if (carouselApi.canScrollNext()) carouselApi.scrollNext(); else carouselApi.scrollTo(0); }, autoplayDelay); }; const stopAutoplay = () => { setLiveRegion(false); cancelProgressBar(true); if (autoplayIntervalId) { window.clearInterval(autoplayIntervalId); autoplayIntervalId = null; } }; restartAutoplay = () => { if (autoplayStopOnInteraction) return; stopAutoplay(); startAutoplay(); }; slider._carouselAutoplay = { suspend: stopAutoplay, resume: () => { stopAutoplay(); startAutoplay(); }, }; cleanupTasks.push(() => { delete slider._carouselAutoplay; }); startAutoplay(); if (autoplayStopOnInteraction) { const stopHandler = () => { autoplayStoppedByInteraction = true; stopAutoplay(); }; slider.addEventListener("pointerdown", stopHandler); slider.addEventListener("keydown", stopHandler); cleanupTasks.push(() => { slider.removeEventListener("pointerdown", stopHandler); slider.removeEventListener("keydown", stopHandler); }); } if (pauseOnHover) { let pointerOver = false; let focusWithin = false; const updateHold = () => { const shouldHold = pointerOver || focusWithin; if (shouldHold === interactionHold) return; interactionHold = shouldHold; if (shouldHold) { setLiveRegion(false); if (customProgressBar) { if (progressRafId !== null && progressStartTime !== null) { progressResumeOffset = performance.now() - progressStartTime; } cancelProgressBar(false); } else if (autoplayIntervalId) { window.clearInterval(autoplayIntervalId); autoplayIntervalId = null; } } else if (!autoplayStoppedByInteraction) { startAutoplay(true); } }; const pointerEnterHandler = () => { pointerOver = true; updateHold(); }; const pointerLeaveHandler = () => { pointerOver = false; updateHold(); }; const focusInHandler = () => { focusWithin = true; updateHold(); }; const focusOutHandler = (event) => { if (event.relatedTarget && slider.contains(event.relatedTarget)) return; focusWithin = false; updateHold(); }; slider.addEventListener("mouseenter", pointerEnterHandler); slider.addEventListener("mouseleave", pointerLeaveHandler); slider.addEventListener("focusin", focusInHandler); slider.addEventListener("focusout", focusOutHandler); cleanupTasks.push(() => { slider.removeEventListener("mouseenter", pointerEnterHandler); slider.removeEventListener("mouseleave", pointerLeaveHandler); slider.removeEventListener("focusin", focusInHandler); slider.removeEventListener("focusout", focusOutHandler); }); } cleanupTasks.push(() => stopAutoplay()); } const onSelect = () => { updateButtonStates(); updateActiveSlides(); if (autoplayEnabled && customProgressBar) { cancelProgressBar(false); startProgressBar(); } else if (autoplayEnabled && !customProgressBar && restartAutoplay) { restartAutoplay(); } else if (!autoplayEnabled) { updateStaticProgressBar(); } }; const onScroll = () => { if (fadeMode) applyFadeLayout(); }; const onReInit = () => { ensureDots(); updateButtonStates(); updateActiveSlides(); if (!autoplayEnabled) updateStaticProgressBar(); }; carouselApi.on("select", onSelect); carouselApi.on("scroll", onScroll); carouselApi.on("reInit", onReInit); const onSlidesInView = () => { if (slideClassesEnabled) updateActiveSlides(); }; if (slideClassesEnabled) carouselApi.on("slidesInView", onSlidesInView); cleanupTasks.push(() => { carouselApi.off("select", onSelect); carouselApi.off("scroll", onScroll); carouselApi.off("reInit", onReInit); if (slideClassesEnabled) carouselApi.off("slidesInView", onSlidesInView); }); if (fadeMode) { const SWIPE_THRESHOLD = 50; let swipeStartX = null; let swipeStartY = null; let isSwiping = false; const onPointerDown = (e) => { if (e.pointerType === "mouse" && e.button !== 0) return; swipeStartX = e.clientX; swipeStartY = e.clientY; isSwiping = true; }; const onPointerUp = (e) => { if (!isSwiping || swipeStartX === null) return; isSwiping = false; const deltaX = e.clientX - swipeStartX; const deltaY = e.clientY - swipeStartY; swipeStartX = null; swipeStartY = null; if (Math.abs(deltaY) > Math.abs(deltaX)) return; if (Math.abs(deltaX) < SWIPE_THRESHOLD) return; stepFade(deltaX < 0 ? 1 : -1); if (restartAutoplay) restartAutoplay(); }; const onPointerCancel = () => { isSwiping = false; swipeStartX = null; swipeStartY = null; }; carouselViewport.addEventListener("pointerdown", onPointerDown); carouselViewport.addEventListener("pointerup", onPointerUp); carouselViewport.addEventListener("pointercancel", onPointerCancel); cleanupTasks.push(() => { carouselViewport.removeEventListener("pointerdown", onPointerDown); carouselViewport.removeEventListener("pointerup", onPointerUp); carouselViewport.removeEventListener( "pointercancel", onPointerCancel, ); }); } if (dragFree && !fadeMode) { const DRAG_THRESHOLD = 0.05; const snapAfterDrag = () => { if (!carouselApi) return; const snapList = carouselApi.scrollSnapList(); const currentIndex = carouselApi.selectedScrollSnap(); const lastIndex = snapList.length - 1; const progress = carouselApi.scrollProgress(); const currentSnap = snapList[currentIndex]; const dragDelta = progress - currentSnap; let targetIndex = currentIndex; if (dragDelta > DRAG_THRESHOLD) { targetIndex = loopMode ? (currentIndex + 1) % snapList.length : Math.min(currentIndex + 1, lastIndex); } else if (dragDelta < -DRAG_THRESHOLD) { targetIndex = loopMode ? (currentIndex - 1 + snapList.length) % snapList.length : Math.max(currentIndex - 1, 0); } carouselApi.scrollTo(targetIndex); if (restartAutoplay) restartAutoplay(); }; carouselApi.on("pointerUp", snapAfterDrag); cleanupTasks.push(() => carouselApi.off("pointerUp", snapAfterDrag)); } const resizeHandler = debounce(() => { if (!fadeMode || !carouselApi) return; const currentIndex = carouselApi.selectedScrollSnap(); resetFadeInlineStyles(slider); carouselApi.reInit(); const total = carouselApi.scrollSnapList().length; if (currentIndex > 0 && currentIndex < total) carouselApi.scrollTo(currentIndex, true); }, 150); window.addEventListener("resize", resizeHandler); cleanupTasks.push(() => window.removeEventListener("resize", resizeHandler), ); if (nextBtn) { const nextHandler = () => { if (fadeMode) { stepFade(1); return; } carouselApi.scrollNext(); updateButtonStates(); if (restartAutoplay) restartAutoplay(); }; nextBtn.addEventListener("click", nextHandler); if (!nextBtn.hasAttribute("aria-label")) nextBtn.setAttribute("aria-label", "Next slide"); if (nextBtn.tagName !== "BUTTON") nextBtn.setAttribute("role", "button"); cleanupTasks.push(() => nextBtn.removeEventListener("click", nextHandler), ); } if (prevBtn) { const prevHandler = () => { if (fadeMode) { stepFade(-1); return; } carouselApi.scrollPrev(); updateButtonStates(); if (restartAutoplay) restartAutoplay(); }; prevBtn.addEventListener("click", prevHandler); if (!prevBtn.hasAttribute("aria-label")) prevBtn.setAttribute("aria-label", "Previous slide"); if (prevBtn.tagName !== "BUTTON") prevBtn.setAttribute("role", "button"); cleanupTasks.push(() => prevBtn.removeEventListener("click", prevHandler), ); } if (slideButtons.length) { slideButtons.forEach((button, index) => { const resolvedIndex = slideButtonIndices[index] ?? index; const targetIndex = Math.max( 0, Math.min(resolvedIndex, slides.length - 1), ); const clickHandler = () => { scrollToIndex(targetIndex, fadeMode); if (restartAutoplay) restartAutoplay(); }; const keyHandler = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); clickHandler(); } }; button.addEventListener("click", clickHandler); button.addEventListener("keydown", keyHandler); if (button.tagName !== "BUTTON") { button.setAttribute("role", "button"); if (!button.hasAttribute("tabindex")) button.setAttribute("tabindex", "0"); } if (!button.hasAttribute("aria-label")) button.setAttribute( "aria-label", `Go to slide ${targetIndex + 1}`, ); if (!button.hasAttribute("aria-controls")) button.setAttribute("aria-controls", carouselViewport.id); cleanupTasks.push(() => { button.removeEventListener("click", clickHandler); button.removeEventListener("keydown", keyHandler); }); }); } if (clickToCenter || activeInView) { getSlides().forEach((slide, index) => { const slideHandler = () => scrollToIndex(index, fadeMode); slide.addEventListener("click", slideHandler); cleanupTasks.push(() => slide.removeEventListener("click", slideHandler), ); }); } if (!slider._keyboardSetup) { const keyboardHandler = (event) => { const nextKey = verticalMode ? "ArrowDown" : "ArrowRight"; const prevKey = verticalMode ? "ArrowUp" : "ArrowLeft"; if (event.key === nextKey) { event.preventDefault(); if (fadeMode) stepFade(1); else carouselApi.scrollNext(); if (restartAutoplay) restartAutoplay(); } else if (event.key === prevKey) { event.preventDefault(); if (fadeMode) stepFade(-1); else carouselApi.scrollPrev(); if (restartAutoplay) restartAutoplay(); } }; slider.addEventListener("keydown", keyboardHandler); slider.tabIndex = 0; slider.setAttribute("role", "region"); slider.setAttribute("aria-roledescription", "carousel"); if ( !slider.hasAttribute("aria-label") && !slider.hasAttribute("aria-labelledby") ) { slider.setAttribute("aria-label", "Featured content"); } slider._keyboardSetup = true; cleanupTasks.push(() => slider.removeEventListener("keydown", keyboardHandler), ); } registerSyncedSlider(syncId, carouselApi); slider._carouselCleanup = cleanupTasks; } catch (error) { cleanupTasks.forEach((cleanup) => { try { cleanup(); } catch (_) {} }); if (carouselApi) { try { carouselApi.destroy(); } catch (_) {} } delete slider._keyboardSetup; slider._carouselInitialized = false; slider._carouselInstance = null; if (carouselViewport) carouselViewport._carousel = null; handleError(error, "Carousel Initialization"); } }); logger.log(`✅ ${sliderList.length} carousel(s) initialized`); } function getCarouselElementFromRoot(element) { if (!element) return null; return element.hasAttribute("data-carousel") ? element : element.querySelector?.("[data-carousel]") || null; } function getCarouselAutoplayControls(selector) { const element = typeof selector === "string" ? document.querySelector(selector) : selector; const carouselRoot = getCarouselElementFromRoot(element) || element; return carouselRoot?._carouselAutoplay || null; } function getCarouselInstance(selector) { const element = typeof selector === "string" ? document.querySelector(selector) : selector; if (!element) return null; return ( element._carouselInstance || element._carousel || element.querySelector?.(VIEWPORT_SELECTOR)?._carousel || null ); } async function ensureCarouselInitialized(selector) { const root = typeof selector === "string" ? document.querySelector(selector) : selector; const slider = getCarouselElementFromRoot(root) || root; if (!slider) return null; const existing = getCarouselInstance(slider); if (existing) return existing; await loadAndInitSlider(slider); return new Promise((resolve) => { let attempts = 0; const maxAttempts = 200; const tick = () => { const api = getCarouselInstance(slider); if (api) { resolve(api); return; } if (attempts >= maxAttempts) { resolve(null); return; } attempts += 1; window.setTimeout(tick, 50); }; tick(); }); } function reinitCarouselsIn(container) { container.querySelectorAll("[data-carousel]").forEach((slider) => { if (slider._carouselInitialized) { resetFadeInlineStyles(slider); if (Array.isArray(slider._carouselCleanup)) { slider._carouselCleanup.forEach((fn) => { try { fn(); } catch (_) {} }); slider._carouselCleanup = null; } const viewport = slider.querySelector(VIEWPORT_SELECTOR); const api = slider._carouselInstance || viewport?._carousel; if (api) { try { api.destroy(); } catch (_) {} } slider._carouselInitialized = false; slider._carouselInstance = null; if (viewport) viewport._carousel = null; delete slider._keyboardSetup; } loadAndInitSlider(slider); }); } function destroyCarousels() { if (carouselObserver) { carouselObserver.disconnect(); carouselObserver = null; } document.querySelectorAll("[data-carousel]").forEach((slider) => { if (Array.isArray(slider._carouselCleanup)) { slider._carouselCleanup.forEach((cleanup) => { try { cleanup(); } catch (error) { handleError(error, "Carousel Cleanup"); } }); slider._carouselCleanup = null; } const viewport = slider.querySelector?.(VIEWPORT_SELECTOR) || null; const carouselApi = slider._carouselInstance || viewport?._carousel || null; if (carouselApi) carouselApi.destroy(); resetFadeInlineStyles(slider); slider._carouselInitialized = false; slider._carouselInstance = null; if (viewport) viewport._carousel = null; delete slider._keyboardSetup; }); syncedSliderGroups.clear(); } function registerSyncedSlider(syncId, carouselApi) { if (!syncId || !carouselApi) return; if (!syncedSliderGroups.has(syncId)) syncedSliderGroups.set(syncId, new Set()); const group = syncedSliderGroups.get(syncId); group.add(carouselApi); const syncHandler = () => { const targetIndex = carouselApi.selectedScrollSnap(); group.forEach((otherCarousel) => { if (otherCarousel === carouselApi) return; if (otherCarousel.selectedScrollSnap() === targetIndex) return; otherCarousel.scrollTo(targetIndex); }); }; carouselApi.on("select", syncHandler); carouselApi.on("reInit", syncHandler); carouselApi.on("destroy", () => { carouselApi.off("select", syncHandler); carouselApi.off("reInit", syncHandler); group.delete(carouselApi); if (group.size === 0) syncedSliderGroups.delete(syncId); }); } window.CarouselControls = { getInstance: getCarouselInstance, getAutoplayControls: getCarouselAutoplayControls, ensureInitialized: ensureCarouselInitialized, reinitIn: reinitCarouselsIn, destroyAll: destroyCarousels, }; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initCarousel); } else { initCarousel(); } })();