/** * KTUI - Free & Open-Source Tailwind UI Components by Keenthemes * Copyright 2025 by Keenthemes Inc */ import KTData from '../../helpers/data'; import KTComponent from '../component'; import { KTCarouselChangePayloadInterface, KTCarouselConfigInterface, KTCarouselInterface, } from './types'; declare global { interface Window { KTCarousel: typeof KTCarousel; } } const SELECTOR_VIEWPORT = '[data-kt-carousel-viewport]'; const SELECTOR_ITEM = '[data-kt-carousel-item]'; const SELECTOR_PREV = '[data-kt-carousel-prev]'; const SELECTOR_NEXT = '[data-kt-carousel-next]'; const SELECTOR_PAGINATION = '[data-kt-carousel-pagination]'; const SELECTOR_PAGINATION_ITEM = '[data-kt-carousel-pagination-item]'; const SELECTOR_THUMBS = '[data-kt-carousel-thumbnails]'; const SELECTOR_THUMB = '[data-kt-carousel-thumbnail]'; const SELECTOR_CURRENT = '[data-kt-carousel-current]'; const SELECTOR_TOTAL = '[data-kt-carousel-total]'; export class KTCarousel extends KTComponent implements KTCarouselInterface { protected override _name: string = 'carousel'; protected override _defaultConfig: KTCarouselConfigInterface = { autoplay: false, autoplayInterval: 4000, infiniteLoop: false, rtl: false, perView: 1, centered: false, draggable: false, snap: false, autoHeight: false, showScrollbar: false, lazy: false, pauseOnHover: true, changeEvent: 'kt.carousel.change', }; protected override _config: KTCarouselConfigInterface = this ._defaultConfig as KTCarouselConfigInterface; private _viewport: HTMLElement | null = null; private _slides: HTMLElement[] = []; private _index = 0; private _autoplayTimer: ReturnType | null = null; private _programmaticScroll = false; private _resizeObserver: ResizeObserver | null = null; private _prefersReducedMotion = false; private _onPrevClick: ((e: Event) => void) | null = null; private _onNextClick: ((e: Event) => void) | null = null; private _onScroll: (() => void) | null = null; private _onScrollEnd: (() => void) | null = null; private _onKeyDown: ((e: KeyboardEvent) => void) | null = null; private _onPointerDown: ((e: PointerEvent) => void) | null = null; private _onPointerMove: ((e: PointerEvent) => void) | null = null; private _onPointerUp: ((e: PointerEvent) => void) | null = null; private _paginationHandlers: Array<{ el: HTMLElement; fn: (e: Event) => void; }> = []; private _thumbHandlers: Array<{ el: HTMLElement; fn: (e: Event) => void }> = []; private _programmaticScrollPrevIndex: number | null = null; private _programmaticScrollTargetIndex: number | null = null; private _programmaticScrollUserInitiated = false; private _programmaticScrollFallbackTimer: ReturnType< typeof setTimeout > | null = null; private _scrollSyncRaf = 0; private _pauseAutoplay: (() => void) | null = null; private _resumeAutoplay: (() => void) | null = null; private _dragStartX = 0; private _dragStartScroll = 0; private _dragging = false; private _dragMoved = false; private _pointerId: number | null = null; private _prevButtons: HTMLElement[] = []; private _nextButtons: HTMLElement[] = []; private _currentLabels: HTMLElement[] = []; private _totalLabels: HTMLElement[] = []; constructor( element: HTMLElement, config: Partial | null = null, ) { super(); if (this._shouldSkipInit(element)) { return; } const viewport = KTCarousel._resolveViewportStatic(element); const slides = KTCarousel._collectSlidesStatic(viewport); if (!viewport || slides.length === 0) { return; } this._init(element); this._buildConfig(config); this._syncShowScrollbarAttribute(); this._viewport = viewport; this._slides = slides; this._prefersReducedMotion = this._readPrefersReducedMotion(); this._bindControls(); this._bindScrollSync(); this._bindKeyboard(); this._bindAutoplayHoverPause(); this._bindDraggable(); this._bindAutoHeight(); this._syncIndexFromScroll(); this._updateInfo(); this._updatePaginationState(); this._updateThumbState(false); this._startAutoplay(); } private _readPrefersReducedMotion(): boolean { if (typeof window === 'undefined' || !window.matchMedia) { return false; } return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } private _syncShowScrollbarAttribute(): void { if (!this._element) return; const show = this._getOption('showScrollbar') === true; if (show) { this._element.setAttribute('data-kt-carousel-show-scrollbar', 'true'); } else { this._element.removeAttribute('data-kt-carousel-show-scrollbar'); } } private static _resolveViewportStatic(root: HTMLElement): HTMLElement | null { const explicit = root.querySelector(SELECTOR_VIEWPORT); if (explicit) return explicit; return root; } private static _collectSlidesStatic( viewport: HTMLElement | null, ): HTMLElement[] { if (!viewport) return []; return Array.from(viewport.querySelectorAll(SELECTOR_ITEM)); } private _isRtl(): boolean { if (this._getOption('rtl') === true) return true; if (this._element?.getAttribute('dir') === 'rtl') return true; if ( this._viewport && getComputedStyle(this._viewport).direction === 'rtl' ) { return true; } return false; } private _scrollBehavior(): ScrollBehavior { return this._prefersReducedMotion ? 'auto' : 'smooth'; } private _effectiveDraggable(): boolean { if (this._getOption('snap') === true) return false; return this._getOption('draggable') === true; } private _bindControls(): void { if (!this._element) return; const root = this._element; this._prevButtons = Array.from( root.querySelectorAll(SELECTOR_PREV), ); this._nextButtons = Array.from( root.querySelectorAll(SELECTOR_NEXT), ); this._currentLabels = Array.from( root.querySelectorAll(SELECTOR_CURRENT), ); this._totalLabels = Array.from( root.querySelectorAll(SELECTOR_TOTAL), ); this._onPrevClick = (e: Event) => { e.preventDefault(); this.prev(true); }; this._onNextClick = (e: Event) => { e.preventDefault(); this.next(true); }; this._prevButtons.forEach((btn) => { btn.addEventListener('click', this._onPrevClick!); }); this._nextButtons.forEach((btn) => { btn.addEventListener('click', this._onNextClick!); }); this._bindIndexedClickStrips( SELECTOR_PAGINATION, SELECTOR_PAGINATION_ITEM, this._paginationHandlers, ); this._bindIndexedClickStrips( SELECTOR_THUMBS, SELECTOR_THUMB, this._thumbHandlers, ); } private _bindIndexedClickStrips( stripSelector: string, itemSelector: string, handlers: Array<{ el: HTMLElement; fn: (e: Event) => void }>, ): void { if (!this._element) return; this._element .querySelectorAll(stripSelector) .forEach((strip) => { strip.querySelectorAll(itemSelector).forEach((item, i) => { const fn = (e: Event) => { e.preventDefault(); this.goTo(i, true); }; item.addEventListener('click', fn); handlers.push({ el: item, fn }); }); }); } private _bindScrollSync(): void { if (!this._viewport) return; this._onScroll = () => { if (this._scrollSyncRaf) cancelAnimationFrame(this._scrollSyncRaf); this._scrollSyncRaf = requestAnimationFrame(() => { this._scrollSyncRaf = 0; const prev = this._index; this._applyScrollDerivedIndexChange(prev, { dispatch: !this._programmaticScroll, userInitiated: true, }); }); }; this._viewport.addEventListener('scroll', this._onScroll, { passive: true, }); this._onScrollEnd = () => { if (this._programmaticScroll) { const t = this._programmaticScrollTargetIndex; if (t !== null && this._nearestIndex() === t) { queueMicrotask(() => this._completeProgrammaticScroll()); } return; } const prev = this._index; this._applyScrollDerivedIndexChange(prev, { dispatch: true, userInitiated: true, }); }; this._viewport.addEventListener( 'scrollend', this._onScrollEnd as EventListener, ); } private _bindKeyboard(): void { if (!this._element) return; this._onKeyDown = (e: KeyboardEvent) => { if (!this._element?.contains(e.target as Node)) return; const t = e.target as HTMLElement | null; if ( t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable) ) { return; } if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; const rtl = this._isRtl(); const nextKey = rtl ? 'ArrowLeft' : 'ArrowRight'; const prevKey = rtl ? 'ArrowRight' : 'ArrowLeft'; if (e.key === nextKey) { e.preventDefault(); this.next(true); } else if (e.key === prevKey) { e.preventDefault(); this.prev(true); } }; this._element.addEventListener('keydown', this._onKeyDown); } private _bindAutoplayHoverPause(): void { if (!this._element || !this._viewport) return; if (this._getOption('pauseOnHover') !== true) return; this._pauseAutoplay = () => this._stopAutoplay(); this._resumeAutoplay = () => this._startAutoplay(); this._element.addEventListener('mouseenter', this._pauseAutoplay); this._element.addEventListener('mouseleave', this._resumeAutoplay); this._element.addEventListener('focusin', this._pauseAutoplay); this._element.addEventListener('focusout', this._resumeAutoplay); } private _bindDraggable(): void { if (!this._viewport || !this._effectiveDraggable()) return; this._onPointerDown = (e: PointerEvent) => { if (e.pointerType === 'mouse' && e.button !== 0) return; const t = e.target as HTMLElement | null; if (t && t.closest('button,a,input,textarea,select')) return; this._pointerId = e.pointerId; this._dragStartX = e.clientX; this._dragStartScroll = this._viewport!.scrollLeft; this._dragging = true; this._dragMoved = false; this._viewport!.setPointerCapture(e.pointerId); }; this._onPointerMove = (e: PointerEvent) => { if (!this._dragging || e.pointerId !== this._pointerId) return; const dx = e.clientX - this._dragStartX; if (Math.abs(dx) > 5) this._dragMoved = true; this._viewport!.scrollLeft = this._dragStartScroll - dx; }; this._onPointerUp = (e: PointerEvent) => { if (e.pointerId !== this._pointerId) return; this._dragging = false; this._pointerId = null; try { this._viewport!.releasePointerCapture(e.pointerId); } catch { /* ignore */ } }; this._viewport.addEventListener('pointerdown', this._onPointerDown); this._viewport.addEventListener('pointermove', this._onPointerMove); this._viewport.addEventListener('pointerup', this._onPointerUp); this._viewport.addEventListener('pointercancel', this._onPointerUp); } private _bindAutoHeight(): void { if (!this._viewport || this._getOption('autoHeight') !== true) return; this._applyAutoHeight(); this._observeActiveSlideForHeight(); } private _observeActiveSlideForHeight(): void { if (this._getOption('autoHeight') !== true) return; const active = this._slides[this._index]; if (!active || typeof ResizeObserver === 'undefined') return; if (!this._resizeObserver) { this._resizeObserver = new ResizeObserver(() => { this._applyAutoHeight(); }); } else { this._resizeObserver.disconnect(); } this._resizeObserver.observe(active); } private _applyAutoHeight(): void { if (!this._viewport || this._getOption('autoHeight') !== true) return; const slide = this._slides[this._index]; if (!slide) return; const h = slide.offsetHeight; if (h > 0) { this._viewport.style.height = `${h}px`; } } private _nearestIndex(): number { if (!this._viewport || this._slides.length === 0) return 0; const vp = this._viewport; const origin = vp.scrollLeft; let best = 0; let bestDist = Infinity; for (let i = 0; i < this._slides.length; i++) { const slide = this._slides[i]; const dist = Math.abs(slide.offsetLeft - origin); if (dist < bestDist) { bestDist = dist; best = i; } } return best; } private _syncIndexFromScroll(): void { if (this._slides.length === 0) return; this._index = this._nearestIndex(); } private _applyScrollDerivedIndexChange( prevIndex: number, options: { dispatch: boolean; userInitiated: boolean }, ): void { this._syncIndexFromScroll(); if (prevIndex === this._index) return; this._updateInfo(); this._updatePaginationState(); this._updateThumbState(false); this._applyAutoHeight(); this._observeActiveSlideForHeight(); if (options.dispatch) { this._dispatchChange(this._index, prevIndex, options.userInitiated); } } private _setStripItemsActiveState( handlers: Array<{ el: HTMLElement; fn: (e: Event) => void }>, activeDataAttr: string, ): void { handlers.forEach(({ el }, i) => { const active = i === this._index; if (active) { el.setAttribute('aria-current', 'true'); } else { el.removeAttribute('aria-current'); } el.toggleAttribute(activeDataAttr, active); }); } /** * Scroll the viewport only. Avoid `Element.scrollIntoView`, which walks * ancestor scroll containers and can pull the whole docs page to an embedded * carousel (e.g. autoplay ticking on `/docs/carousel`). */ private _scrollToIndex(index: number, behavior: ScrollBehavior): void { if (!this._viewport) return; const slide = this._slides[index]; if (!slide) return; this._programmaticScroll = true; const vp = this._viewport; const centered = this._getOption('centered') === true; const slideRect = slide.getBoundingClientRect(); const vpRect = vp.getBoundingClientRect(); let left = vp.scrollLeft + slideRect.left - vpRect.left; if (centered) { left -= (vp.clientWidth - slideRect.width) / 2; } const maxScroll = Math.max(0, vp.scrollWidth - vp.clientWidth); left = Math.max(0, Math.min(left, maxScroll)); try { vp.scrollTo({ left, top: vp.scrollTop, behavior }); } catch { vp.scrollLeft = left; } queueMicrotask(() => { if ( this._programmaticScroll && this._programmaticScrollTargetIndex !== null && this._nearestIndex() === this._programmaticScrollTargetIndex ) { this._completeProgrammaticScroll(); } }); this._clearProgrammaticScrollFallbackTimer(); const fallbackMs = behavior === 'smooth' ? 550 : 50; this._programmaticScrollFallbackTimer = setTimeout(() => { this._programmaticScrollFallbackTimer = null; this._completeProgrammaticScroll(); }, fallbackMs); } private _clearProgrammaticScrollFallbackTimer(): void { if (this._programmaticScrollFallbackTimer !== null) { clearTimeout(this._programmaticScrollFallbackTimer); this._programmaticScrollFallbackTimer = null; } } private _completeProgrammaticScroll(): void { if (!this._programmaticScroll) return; const prev = this._programmaticScrollPrevIndex; const targetIdx = this._programmaticScrollTargetIndex; const userInitiated = this._programmaticScrollUserInitiated; if (prev === null || targetIdx === null) { this._programmaticScroll = false; this._programmaticScrollPrevIndex = null; this._programmaticScrollTargetIndex = null; return; } if (this._scrollSyncRaf) { cancelAnimationFrame(this._scrollSyncRaf); this._scrollSyncRaf = 0; } this._clearProgrammaticScrollFallbackTimer(); this._programmaticScroll = false; this._programmaticScrollPrevIndex = null; this._programmaticScrollTargetIndex = null; this._programmaticScrollUserInitiated = false; this._index = targetIdx; this._updateInfo(); this._updatePaginationState(); this._updateThumbState(true); this._applyAutoHeight(); this._observeActiveSlideForHeight(); this._dispatchChange(targetIdx, prev, userInitiated); } public goTo(index: number, userInitiated = false): void { if (this._slides.length === 0) return; const n = this._slides.length; let target = index; if (target < 0) target = 0; if (target >= n) target = n - 1; const prev = this._index; if (target === prev) return; this._clearProgrammaticScrollFallbackTimer(); this._programmaticScrollPrevIndex = prev; this._programmaticScrollTargetIndex = target; this._programmaticScrollUserInitiated = userInitiated; this._scrollToIndex(target, this._scrollBehavior()); } public next(userInitiated = false): void { if (this._slides.length === 0) return; const n = this._slides.length; const last = n - 1; const infinite = this._getOption('infiniteLoop') === true; if (this._index >= last) { if (infinite) { this.goTo(0, userInitiated); return; } return; } this.goTo(this._index + 1, userInitiated); } public prev(userInitiated = false): void { if (this._slides.length === 0) return; const infinite = this._getOption('infiniteLoop') === true; if (this._index <= 0) { if (infinite) { this.goTo(this._slides.length - 1, userInitiated); return; } return; } this.goTo(this._index - 1, userInitiated); } public getIndex(): number { return this._index; } public getSlideCount(): number { return this._slides.length; } private _getChangeEventName(): string { const name = this._getOption('changeEvent'); return typeof name === 'string' && name.length > 0 ? name : 'kt.carousel.change'; } private _dispatchChange( index: number, prevIndex: number, userInitiated: boolean, ): void { if (!this._element) return; const payload: KTCarouselChangePayloadInterface = { index, prevIndex, userInitiated, }; const eventName = this._getChangeEventName(); this._fireEvent(eventName, payload); this._dispatchEvent(eventName, payload); } private _updateInfo(): void { if (!this._element) return; const total = this._slides.length; const cur = total === 0 ? 0 : this._index + 1; const curStr = String(cur); const totalStr = String(total); this._currentLabels.forEach((label) => { label.textContent = curStr; }); this._totalLabels.forEach((label) => { label.textContent = totalStr; }); } private _updatePaginationState(): void { this._setStripItemsActiveState( this._paginationHandlers, 'data-kt-carousel-pagination-active', ); } private _updateThumbState(alignStripSmooth: boolean): void { this._setStripItemsActiveState( this._thumbHandlers, 'data-kt-carousel-thumbnail-active', ); this._scrollActiveThumbnailsIntoView( alignStripSmooth ? this._scrollBehavior() : 'auto', ); } /** * Keep the active thumb visible inside each thumbnail strip by scrolling * that container only (no `scrollIntoView`, which can scroll ancestor pages). */ private _scrollActiveThumbnailsIntoView(behavior: ScrollBehavior): void { if (!this._element) return; const pad = 6; this._element .querySelectorAll(SELECTOR_THUMBS) .forEach((strip) => { const items = strip.querySelectorAll(SELECTOR_THUMB); const thumb = items[this._index]; if (!thumb) return; this._alignElementInScrollContainer(thumb, strip, pad, behavior); }); } private _alignElementInScrollContainer( el: HTMLElement, container: HTMLElement, pad: number, behavior: ScrollBehavior, ): void { const c = container.getBoundingClientRect(); const r = el.getBoundingClientRect(); let left = container.scrollLeft; let top = container.scrollTop; if (container.scrollWidth > container.clientWidth + 1) { if (r.left < c.left + pad) { left += r.left - c.left - pad; } else if (r.right > c.right - pad) { left += r.right - c.right + pad; } const maxL = Math.max(0, container.scrollWidth - container.clientWidth); left = Math.max(0, Math.min(left, maxL)); } if (container.scrollHeight > container.clientHeight + 1) { if (r.top < c.top + pad) { top += r.top - c.top - pad; } else if (r.bottom > c.bottom - pad) { top += r.bottom - c.bottom + pad; } const maxT = Math.max(0, container.scrollHeight - container.clientHeight); top = Math.max(0, Math.min(top, maxT)); } try { container.scrollTo({ left, top, behavior }); } catch { container.scrollLeft = left; container.scrollTop = top; } } private _startAutoplay(): void { this._stopAutoplay(); if (this._getOption('autoplay') !== true) return; if (this._prefersReducedMotion) return; if (this._slides.length <= 1) return; const raw = this._getOption('autoplayInterval'); const interval = typeof raw === 'number' && raw >= 200 ? raw : 4000; this._autoplayTimer = setInterval(() => { this.next(false); }, interval); } private _stopAutoplay(): void { if (this._autoplayTimer !== null) { clearInterval(this._autoplayTimer); this._autoplayTimer = null; } } public override dispose(): void { this._stopAutoplay(); this._clearProgrammaticScrollFallbackTimer(); if (this._scrollSyncRaf) { cancelAnimationFrame(this._scrollSyncRaf); this._scrollSyncRaf = 0; } this._programmaticScroll = false; this._programmaticScrollPrevIndex = null; this._programmaticScrollTargetIndex = null; if (this._resizeObserver) { this._resizeObserver.disconnect(); this._resizeObserver = null; } if (this._element) { if (this._onPrevClick) { this._prevButtons.forEach((btn) => btn.removeEventListener('click', this._onPrevClick!), ); } if (this._onNextClick) { this._nextButtons.forEach((btn) => btn.removeEventListener('click', this._onNextClick!), ); } this._paginationHandlers.forEach(({ el, fn }) => { el.removeEventListener('click', fn); }); this._paginationHandlers = []; this._thumbHandlers.forEach(({ el, fn }) => { el.removeEventListener('click', fn); }); this._thumbHandlers = []; if (this._onKeyDown) { this._element.removeEventListener('keydown', this._onKeyDown); } if (this._pauseAutoplay && this._resumeAutoplay) { this._element.removeEventListener('mouseenter', this._pauseAutoplay); this._element.removeEventListener('mouseleave', this._resumeAutoplay); this._element.removeEventListener('focusin', this._pauseAutoplay); this._element.removeEventListener('focusout', this._resumeAutoplay); } this._pauseAutoplay = null; this._resumeAutoplay = null; } if (this._viewport && this._onScroll) { this._viewport.removeEventListener('scroll', this._onScroll); } if (this._viewport && this._onScrollEnd) { this._viewport.removeEventListener('scrollend', this._onScrollEnd); } if (this._viewport && this._onPointerDown) { this._viewport.removeEventListener('pointerdown', this._onPointerDown); this._viewport.removeEventListener('pointermove', this._onPointerMove!); this._viewport.removeEventListener('pointerup', this._onPointerUp!); this._viewport.removeEventListener('pointercancel', this._onPointerUp!); } if (this._viewport && this._getOption('autoHeight') === true) { this._viewport.style.height = ''; } this._onPrevClick = null; this._onNextClick = null; this._onScroll = null; this._onScrollEnd = null; this._onKeyDown = null; this._onPointerDown = null; this._onPointerMove = null; this._onPointerUp = null; this._prevButtons = []; this._nextButtons = []; this._currentLabels = []; this._totalLabels = []; super.dispose(); } public static getInstance(element: HTMLElement): KTCarousel | null { if (!element) return null; if (KTData.has(element, 'carousel')) { return KTData.get(element, 'carousel') as KTCarousel; } return null; } public static getOrCreateInstance( element: HTMLElement, config?: Partial, ): KTCarousel | null { const existing = this.getInstance(element); if (existing) return existing; new KTCarousel(element, config ?? undefined); return this.getInstance(element); } public static createInstances(): void { document .querySelectorAll('[data-kt-carousel]') .forEach((el) => { if (el.getAttribute('data-kt-carousel-lazy') === 'true') { return; } new KTCarousel(el); }); } public static init(): void { KTCarousel.createInstances(); } } if (typeof window !== 'undefined') { window.KTCarousel = KTCarousel; }