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;
}
}