// SPDX-FileCopyrightText: 2025-2026 Alpin Insight Solutions GmbH & Co. KG
// SPDX-License-Identifier: AGPL-3.0-only
/**
* Carousel component for Insight UI.
*
* A responsive image/content carousel with support for autoplay, pagination dots,
* touch gestures, keyboard navigation, and RTL layouts.
*
* @example
* // HTML structure
*
*
* // JavaScript initialization
* Carousel.initAll();
*/
export class Carousel {
/** @type {WeakMap} Weak references to prevent multiple initialization */
static instances = new WeakMap();
/**
* Creates a new Carousel instance.
*
* @param {HTMLElement} element - The carousel container element with data-insight-carousel attribute
*/
constructor(element) {
// If an instance for this element already exists, return it
if (Carousel.instances.has(element)) {
return Carousel.instances.get(element);
}
this.element = element;
this.isRTL =
this.element.dir === "rtl" ||
document.documentElement.dir === "rtl" ||
getComputedStyle(this.element).direction === "rtl";
this.track = element.querySelector(".carousel-track");
this.items = [...element.querySelectorAll(".carousel-item")];
this.prevBtn = element.querySelector(".carousel-prev");
this.nextBtn = element.querySelector(".carousel-next");
this.dotsContainer = element.querySelector(".carousel-dots");
this.indexText = element.querySelector(".carousel-index-text");
this.autoplayEnabled = element.dataset.autoplay === "true";
this.showDots = element.dataset.showDots === "true";
this.showIndex = element.dataset.showIndex === "true";
this.itemsPerSlide = parseInt(element.dataset.itemsPerSlide || "1");
this.totalSlides = Math.ceil(this.items.length / this.itemsPerSlide);
this.index = 0;
this.autoplayInterval = null;
// Bind handlers for proper cleanup
this.boundPrevClick = () => { this.prev(); this.restartAutoplay(); };
this.boundNextClick = () => { this.next(); this.restartAutoplay(); };
this.boundTouchStart = (e) => { this.startX = e.touches[0].clientX; };
this.boundTouchEnd = this.handleTouchEnd.bind(this);
this.boundWindowResize = () => this.resizeItems();
this.boundKeyDown = this.handleKeyDown.bind(this);
this.boundMouseEnter = () => this.pauseAutoplay();
this.boundMouseLeave = () => this.resumeAutoplay();
this.boundFocusIn = () => this.pauseAutoplay();
this.boundFocusOut = () => this.resumeAutoplay();
this.boundDotClicks = [];
this.autoplayPaused = false;
this.dots = [...this.element.querySelectorAll(".carousel-dot")];
this.dots.forEach((dot, i) => {
const handler = () => {
this.index = i;
this.update();
this.restartAutoplay();
};
this.boundDotClicks.push({ element: dot, handler });
dot.addEventListener("click", handler);
});
this.init();
this.element.__insightInstance = this;
Carousel.instances.set(this.element, this);
debugLog("New carousel created: ", this.element);
}
/**
* Initialize the carousel with start values e.g. index = 0, to show the first page.
* Add EventListeners to the 'next' and 'previous' Buttons and for the resize event.
* Add MutationObserver for changes of the "dir" attribute of the tag.
* If autoplay is activated, start the autoplay interval.
*/
init() {
this.resizeItems();
if (this.showIndex) this.updateIndexText();
this.update();
this.prevBtn.addEventListener("click", this.boundPrevClick);
this.nextBtn.addEventListener("click", this.boundNextClick);
this.element.addEventListener("touchstart", this.boundTouchStart);
this.element.addEventListener("touchend", this.boundTouchEnd);
this.element.addEventListener("keydown", this.boundKeyDown);
// Pause autoplay on hover/focus for users who need more time
if (this.autoplayEnabled) {
this.element.addEventListener("mouseenter", this.boundMouseEnter);
this.element.addEventListener("mouseleave", this.boundMouseLeave);
this.element.addEventListener("focusin", this.boundFocusIn);
this.element.addEventListener("focusout", this.boundFocusOut);
this.startAutoplay();
}
window.addEventListener("resize", this.boundWindowResize);
// Add MutationObserver for "dir" changes
this.dirObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (
mutation.type === 'attributes' &&
mutation.attributeName === 'dir'
) {
this.isRTL = document.documentElement.getAttribute('dir') === "rtl";
this.update();
}
});
});
this.dirObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['dir']
});
}
/**
* Handles touch end events for swipe navigation.
*
* @param {TouchEvent} e - The touch end event
*/
handleTouchEnd(e) {
const endX = e.changedTouches[0].clientX;
const diff = endX - this.startX;
if (Math.abs(diff) > 50) {
if ((diff > 0) !== this.isRTL) {
this.prev();
} else {
this.next();
}
this.restartAutoplay();
}
}
/**
* Handles keyboard events for carousel navigation.
*
* @param {KeyboardEvent} e - The keyboard event
*/
handleKeyDown(e) {
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
if (this.isRTL) { this.next(); } else { this.prev(); }
this.restartAutoplay();
break;
case 'ArrowRight':
e.preventDefault();
if (this.isRTL) { this.prev(); } else { this.next(); }
this.restartAutoplay();
break;
case 'Home':
e.preventDefault();
this.index = 0;
this.update();
this.restartAutoplay();
break;
case 'End':
e.preventDefault();
this.index = this.totalSlides - 1;
this.update();
this.restartAutoplay();
break;
}
}
/**
* Pauses autoplay when user hovers or focuses the carousel.
*/
pauseAutoplay() {
if (this.autoplayEnabled && !this.autoplayPaused) {
this.stopAutoplay();
this.autoplayPaused = true;
}
}
/**
* Resumes autoplay when user stops hovering or focus leaves the carousel.
*/
resumeAutoplay() {
if (this.autoplayEnabled && this.autoplayPaused) {
this.startAutoplay();
this.autoplayPaused = false;
}
}
/**
* Resize elements to fit the carousel width.
*/
resizeItems() {
this.items.forEach(item => {
item.style.flex = `0 0 ${100 / this.itemsPerSlide}%`;
});
}
/**
* Translate the carousel items to show the elements of current page.
* Update the pagination bubbles and the page number in the bottom corner.
*/
update() {
const offset = this.index * 100 * (this.isRTL ? 1 : -1);
this.track.style.transform = `translateX(${offset}%)`;
if (this.showDots) this.updateDots();
if (this.showIndex) this.updateIndexText();
}
/**
* Update current page index and translate the carousel items to show the elements of
* the next page. Update the pagination bubbles and the page number in the bottom corner.
*/
next() {
this.index = (this.index + 1) % this.totalSlides;
this.update();
}
/**
* Update current page index and translate the carousel items to show the elements of
* the previous page. Update the pagination bubbles and the page number in the bottom corner.
*/
prev() {
this.index = (this.index - 1 + this.totalSlides) % this.totalSlides;
this.update();
}
/**
* Start autoplay of the carousel, which switches to the next page every 5 seconds.
*/
startAutoplay() {
this.autoplayInterval = setInterval(() => this.next(), 5000);
}
/**
* Stop autoplay by clearing interval.
*/
stopAutoplay() {
clearInterval(this.autoplayInterval);
}
/**
* Stop the autoplay loop and start it again, to reset the interval (only if autoplay is activated).
*/
restartAutoplay() {
if (this.autoplayEnabled) {
this.stopAutoplay();
this.startAutoplay();
}
}
/**
* Set the corresponding css classes to the pagination bubbles, to highlight the active page.
*/
updateDots() {
this.dots.forEach((dot, i) => {
dot.classList.toggle("bg-insight-primary", i === this.index);
dot.classList.toggle("bg-transparent", i !== this.index);
});
}
/**
* Update current page number in the bottom corner (if shown).
*/
updateIndexText() {
if (this.indexText) {
const text = gettext('Page %(current)s / %(total)s');
this.indexText.textContent = interpolate(text, {
current: this.index + 1,
total: this.totalSlides
}, true);
}
}
/**
* Destroys the carousel instance and removes all event listeners.
* Call this before removing the element from DOM.
*/
destroy() {
debugLog("Destroy carousel: ", this.element);
// Stop autoplay interval
this.stopAutoplay();
// Remove button listeners
this.prevBtn.removeEventListener("click", this.boundPrevClick);
this.nextBtn.removeEventListener("click", this.boundNextClick);
// Remove touch listeners
this.element.removeEventListener("touchstart", this.boundTouchStart);
this.element.removeEventListener("touchend", this.boundTouchEnd);
// Remove keyboard listener
this.element.removeEventListener("keydown", this.boundKeyDown);
// Remove hover/focus listeners for autoplay pause
this.element.removeEventListener("mouseenter", this.boundMouseEnter);
this.element.removeEventListener("mouseleave", this.boundMouseLeave);
this.element.removeEventListener("focusin", this.boundFocusIn);
this.element.removeEventListener("focusout", this.boundFocusOut);
// Remove window resize listener
window.removeEventListener("resize", this.boundWindowResize);
// Remove dot click handlers
this.boundDotClicks.forEach(({ element, handler }) => {
element.removeEventListener("click", handler);
});
this.boundDotClicks = [];
// Disconnect RTL observer
if (this.dirObserver) {
this.dirObserver.disconnect();
this.dirObserver = null;
}
Carousel.instances.delete(this.element);
delete this.element.__insightInstance;
this.element = null;
}
/**
* Initializes all carousel instances on the page.
* Finds all elements with `data-insight-carousel` attribute and creates Carousel instances.
*
* @static
*/
static initAll() {
document.querySelectorAll('[data-insight-carousel]').forEach(el => new Carousel(el));
}
}