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