import { Swiper } from "swiper"; import { A11y, Autoplay, Keyboard, Navigation, Pagination, } from "swiper/modules"; import type { SwiperOptions } from "swiper/types"; import { CLASS_ACTIVE, CLASS_PAGINATION_CIRCLE, CLASS_PAGINATION_ITEM, CLASS_PAGINATION_SVG, CLASS_PAUSED, CLASS_PLAYING, CLASS_SLIDE, CLASS_TRACK, SELECTOR_NEXT, SELECTOR_PAGINATION, SELECTOR_PLAY_PAUSE, SELECTOR_PREV, SELECTOR_TAB, SELECTOR_TABS, SELECTOR_VIEWPORT, } from "./constants"; export const defaultConfig: SwiperOptions = { pagination: { clickable: true, bulletClass: CLASS_PAGINATION_ITEM, bulletActiveClass: "is-active", renderBullet: function (index: number, className: string) { return ``; }, }, slidesPerView: 1, loop: false, a11y: { enabled: true, prevSlideMessage: "Predchádzajúci snímok", nextSlideMessage: "Nasledujúci snímok", containerMessage: "Hero carousel so snímkami", containerRoleDescriptionMessage: "carousel", itemRoleDescriptionMessage: "snímok", firstSlideMessage: "Prvý snímok", lastSlideMessage: "Posledný snímok", slideLabelMessage: "Snímok", }, wrapperClass: CLASS_TRACK, slideClass: CLASS_SLIDE, slideActiveClass: CLASS_ACTIVE, }; export default class CarouselHero { element: HTMLElement; config: SwiperOptions; viewport!: HTMLElement; instance!: Swiper; tabs: HTMLElement[] = []; _dotAnimationJustStarted: boolean = false; _isDragging: boolean = false; private resizeRafId?: number; private _boundTabClick!: (e: Event) => void; private _boundPrevClick!: () => void; private _boundNextClick!: () => void; private _boundPlayPauseClick!: () => void; private _boundWindowResize!: () => void; constructor(element: HTMLElement, config?: Partial) { this.element = element; this.config = { ...defaultConfig, ...config }; (this.element as any).ODS_CarouselHero = this; this._boundTabClick = this._onTabClick.bind(this); this._boundPrevClick = this._onUserNavigation.bind(this); this._boundNextClick = this._onUserNavigation.bind(this); this._boundPlayPauseClick = this._onPlayPauseClick.bind(this); this._boundWindowResize = this._onWindowResize.bind(this); this.init(); return this; } init() { this.getElements(); this.setupConfig(); this.createSwiper(); this.setupEventListeners(); this.renderPaginationDots(); this.updateStates(); this.updatePlayPauseIcon(); if (this.hasAutoplay() && this.isAutoplayRunning()) { this.startDotAnimation(); } window.addEventListener("resize", this._boundWindowResize); } private _onWindowResize() { if (this.resizeRafId) { cancelAnimationFrame(this.resizeRafId); } this.resizeRafId = requestAnimationFrame(() => { this.resizeRafId = undefined; if (!this.instance || !this.viewport) return; if (this.viewport.clientWidth <= 0) return; this.instance.update(); this.updateStates(); this.updatePlayPauseIcon(); }); } getElements() { this.viewport = this.element.querySelector(SELECTOR_VIEWPORT)!; const tabsContainer = this.element.querySelector(SELECTOR_TABS); if (tabsContainer) { this.tabs = Array.from(tabsContainer.querySelectorAll(SELECTOR_TAB)); } } setupConfig() { const interval = this.element.hasAttribute("data-interval") ? parseInt(this.element.getAttribute("data-interval")!) || 0 : 0; if (typeof this.config.a11y === "object" && this.config.a11y !== null) { this.config.a11y = { ...this.config.a11y, slideRole: this.tabs.length > 0 ? "tabpanel" : "group", }; } if (this.element.hasAttribute("data-swiper-options")) { try { const customOptions = JSON.parse( this.element.getAttribute("data-swiper-options")!, ); this.config = { ...this.config, ...customOptions }; } catch (error) { console.warn("Invalid swiper options:", error); } } // Use rewind instead of loop to avoid DOM reordering at wrap points. // Loop clones make tab navigation jump through extra slides after a wrap. this.config.loop = false; this.config.rewind = true; // Setup autoplay if interval is specified if (interval >= 1000) { this.config.autoplay = { delay: interval, disableOnInteraction: false, pauseOnMouseEnter: false, waitForTransition: true, stopOnLastSlide: false, } as NonNullable; this.element.classList.add(CLASS_PLAYING); } } createSwiper() { const slides = this.viewport.querySelectorAll(`.${CLASS_SLIDE}`); const hasPagination = slides.length > 1; this.instance = new Swiper(this.viewport, { ...this.config, modules: [Navigation, Pagination, A11y, Keyboard, Autoplay], navigation: { nextEl: this.element.querySelector(SELECTOR_NEXT) as HTMLElement, prevEl: this.element.querySelector(SELECTOR_PREV) as HTMLElement, }, pagination: hasPagination ? { ...(this.config.pagination as object), el: this.element.querySelector(SELECTOR_PAGINATION) as HTMLElement, } : false, on: { slideChange: () => { this.updateStates(); if (this.isAutoplayRunning()) { this.restartDotAnimation(); } else { this.stopDotAnimation(); } }, autoplayStart: () => { this._onAutoplayStart(); }, autoplayStop: () => { this._onAutoplayStop(); }, touchStart: () => { this._isDragging = true; this._stopAutoplayIfRunning(); }, touchEnd: () => { this._isDragging = false; }, }, }); this.updatePlayPauseIcon(); } setupEventListeners() { this.tabs.forEach((tab) => { tab.removeEventListener("click", this._boundTabClick); tab.addEventListener("click", this._boundTabClick); }); const playPauseButton = this.element.querySelector(SELECTOR_PLAY_PAUSE); if (playPauseButton) { playPauseButton.removeEventListener("click", this._boundPlayPauseClick); playPauseButton.addEventListener("click", this._boundPlayPauseClick); } const prevButton = this.element.querySelector(SELECTOR_PREV); const nextButton = this.element.querySelector(SELECTOR_NEXT); if (prevButton) { prevButton.removeEventListener("click", this._boundPrevClick); prevButton.addEventListener("click", this._boundPrevClick); } if (nextButton) { nextButton.removeEventListener("click", this._boundNextClick); nextButton.addEventListener("click", this._boundNextClick); } } private _onUserNavigation() { this._stopAutoplayIfRunning(); this.stopDotAnimation(); } _onTabClick(e: Event) { e.preventDefault(); const tab = e.currentTarget as HTMLElement; const index = this.tabs.indexOf(tab); if (index !== -1 && index !== this.instance?.realIndex) { this._onUserNavigation(); this.goToSlide(index); } } private _onPlayPauseClick() { this.toggleAutoplay(); } // ====== Autoplay helpers ====== private isAutoplayRunning(): boolean { return !!(this.instance?.autoplay && this.instance.autoplay.running); } private _stopAutoplayIfRunning() { if (this.isAutoplayRunning()) { this.instance.autoplay.stop(); } } private _onAutoplayStart() { this.element.classList.add(CLASS_PLAYING); this.element.classList.remove(CLASS_PAUSED); this.updatePlayPauseIcon(); this.startDotAnimation(); } private _onAutoplayStop() { this.element.classList.remove(CLASS_PLAYING); this.element.classList.add(CLASS_PAUSED); this.updatePlayPauseIcon(); this.stopDotAnimation(); } toggleAutoplay() { if (!this.instance || !this.instance.autoplay) return; if (this.isAutoplayRunning()) { this.instance.autoplay.stop(); } else { // Update states to sync tabs and pagination with current slide position this.updateStates(); // Small delay to ensure slide position is stable before starting autoplay setTimeout(() => { if (this.instance && this.instance.autoplay) { this.instance.autoplay.start(); } }, 10); } } goToSlide(index: number) { if (!this.instance || this.instance.realIndex === index) return; if (this.instance.params.loop) { this.instance.slideToLoop(index); return; } this.instance.slideTo(index); } updateStates() { if (!this.instance) return; // Use realIndex for loop mode, fallback to activeIndex const realIndex = this.instance.realIndex ?? this.instance.activeIndex ?? 0; // Update tab states this.tabs.forEach((tab, index) => { const isActive = index === realIndex; tab.classList.toggle(CLASS_ACTIVE, isActive); tab.setAttribute("aria-selected", String(isActive)); tab.setAttribute("tabindex", isActive ? "0" : "-1"); }); this.scrollActiveTabIntoView(realIndex); // Manually update pagination bullets this.updatePaginationBullets(realIndex); } private updatePaginationBullets(activeIndex: number) { const paginationEl = this.element.querySelector(SELECTOR_PAGINATION); if (!paginationEl) return; const bullets = paginationEl.querySelectorAll(`.${CLASS_PAGINATION_ITEM}`); bullets.forEach((bullet, index) => { const isActive = index === activeIndex; bullet.classList.toggle("is-active", isActive); // For carousels without autoplay, manually show/hide the SVG if (!this.hasAutoplay()) { const svg = bullet.querySelector( `.${CLASS_PAGINATION_SVG}`, ) as HTMLElement; if (svg) { svg.style.display = isActive ? "block" : "none"; } } }); } scrollActiveTabIntoView(activeIndex: number) { if (!this.tabs.length || activeIndex < 0 || activeIndex >= this.tabs.length) return; const activeTab = this.tabs[activeIndex]; const tabsContainer = this.element.querySelector( SELECTOR_TABS, ) as HTMLElement; if (!tabsContainer || !activeTab) return; const containerRect = tabsContainer.getBoundingClientRect(); const tabRect = activeTab.getBoundingClientRect(); const fadeWidth = 56; const containerVisibleWidth = containerRect.width - fadeWidth; if ( tabRect.right > containerRect.left + containerVisibleWidth || tabRect.left < containerRect.left ) { const scrollLeft = tabRect.left - containerRect.left - containerVisibleWidth / 2 + tabRect.width / 2; tabsContainer.scrollBy({ left: scrollLeft, behavior: "smooth" }); } } updatePlayPauseIcon() { const playPauseButton = this.element.querySelector(SELECTOR_PLAY_PAUSE); if (!playPauseButton) return; const useElement = playPauseButton.querySelector("use"); if (useElement) { const isPlaying = this.isAutoplayRunning(); const newIcon = isPlaying ? "pause" : "play"; const currentHref = useElement.getAttribute("xlink:href") || ""; if (!currentHref.endsWith(`#${newIcon}`)) { const newHref = currentHref.replace(/#[\w-]+$/, `#${newIcon}`); useElement.setAttribute("xlink:href", newHref); } } } renderPaginationDots() { const paginationButtons = this._getPaginationButtons(); paginationButtons.forEach((button) => { const svg = this.createSvgDot(); button.innerHTML = ""; button.appendChild(svg); }); } startDotAnimation(force: boolean = false) { if (!this.hasAutoplay()) return; if (!force && !this.isAutoplayRunning()) return; if (this._dotAnimationJustStarted) return; this._dotAnimationJustStarted = true; const activeButton = this._getActiveBulletButton(); if (!activeButton) { this._dotAnimationJustStarted = false; return; } const svg = activeButton.querySelector( `.${CLASS_PAGINATION_SVG}`, ) as SVGSVGElement | null; if (!svg) { this._dotAnimationJustStarted = false; return; } const circle = svg.querySelector("circle") as SVGCircleElement | null; if (!circle) { this._dotAnimationJustStarted = false; return; } const duration = this.getAnimationDuration(); circle.style.animation = "none"; // Force reflow void activeButton.offsetHeight; circle.style.animation = `countdown linear ${duration}ms 1 forwards`; this._dotAnimationJustStarted = false; } restartDotAnimation() { this.stopDotAnimation(); setTimeout(() => { this.startDotAnimation(); }, 50); } stopDotAnimation() { const paginationButtons = this._getPaginationButtons(); paginationButtons.forEach((button) => { const svg = button.querySelector( `.${CLASS_PAGINATION_SVG}`, ) as SVGSVGElement | null; if (!svg) return; const circle = svg.querySelector("circle") as SVGCircleElement | null; if (!circle) return; circle.style.animation = "none"; }); this._dotAnimationJustStarted = false; } private _getPaginationButtons(): HTMLElement[] { return Array.from( this.element.querySelectorAll(`${SELECTOR_PAGINATION} > *`), ) as HTMLElement[]; } private _getActiveBulletClass(): string { const pagination = (this.instance?.params as any)?.pagination; if ( pagination && typeof pagination === "object" && pagination.bulletActiveClass ) { return String(pagination.bulletActiveClass); } const cfgPagination = this.config.pagination as any; if ( cfgPagination && typeof cfgPagination === "object" && cfgPagination.bulletActiveClass ) { return String(cfgPagination.bulletActiveClass); } return CLASS_ACTIVE; } private _getActiveBulletButton(): HTMLElement | null { const activeClass = this._getActiveBulletClass(); return this.element.querySelector( `${SELECTOR_PAGINATION} .${activeClass}`, ) as HTMLElement | null; } createSvgDot(): SVGSVGElement { const svgNS = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(svgNS, "svg"); svg.setAttribute("class", CLASS_PAGINATION_SVG); svg.setAttribute("width", "12"); svg.setAttribute("height", "12"); svg.setAttribute("viewBox", "0 0 12 12"); const circle = document.createElementNS(svgNS, "circle"); circle.setAttribute("class", CLASS_PAGINATION_CIRCLE); circle.setAttribute("r", "5"); circle.setAttribute("cx", "6"); circle.setAttribute("cy", "6"); svg.appendChild(circle); return svg; } // ====== Autoplay config helpers ====== hasAutoplay(): boolean { return !!( this.config.autoplay && typeof this.config.autoplay === "object" && (this.config.autoplay as any).delay >= 1000 ); } getAnimationDuration(): number { return (this.config.autoplay as any)?.delay || 0; } destroy() { this.tabs.forEach((tab) => { tab.removeEventListener("click", this._boundTabClick); }); const playPauseButton = this.element.querySelector(SELECTOR_PLAY_PAUSE); if (playPauseButton) { playPauseButton.removeEventListener("click", this._boundPlayPauseClick); } const prevButton = this.element.querySelector(SELECTOR_PREV); const nextButton = this.element.querySelector(SELECTOR_NEXT); if (prevButton) { prevButton.removeEventListener("click", this._boundPrevClick); } if (nextButton) { nextButton.removeEventListener("click", this._boundNextClick); } window.removeEventListener("resize", this._boundWindowResize); if (this.resizeRafId) { cancelAnimationFrame(this.resizeRafId); this.resizeRafId = undefined; } if (this.instance) { this.instance.destroy(true, true); } delete (this.element as any).ODS_CarouselHero; } static getInstance(el: HTMLElement): CarouselHero | null { return (el as any).ODS_CarouselHero || null; } }