import { Swiper } from "swiper"; import { A11y, Keyboard, Mousewheel, Navigation, Pagination, Scrollbar, } from "swiper/modules"; import type { SwiperOptions } from "swiper/types"; import Tooltip from "../Tooltip/Tooltip.static"; import { CLASS_ACTIVE, CLASS_BLEED_RIGHT, CLASS_BLEED_RIGHT_NO_LEFT, CLASS_DOT, CLASS_SCROLLBAR_DRAG, CLASS_SCROLLBAR_HORIZONTAL, CLASS_SLIDE, CLASS_SLIDE_NEXT, CLASS_SLIDE_PREV, CLASS_TRACK, CLASS_VIEWPORT_WRAPPER, SELECTOR_ACTIVE, SELECTOR_DOTS, SELECTOR_NEXT, SELECTOR_PREV, SELECTOR_SCROLLBAR, SELECTOR_TRACK, SELECTOR_VIEWPORT, } from "./constants"; interface ExternalControlsState { isAtStart: boolean; isAtEnd: boolean; } export const defaultConfig: SwiperOptions = { navigation: { nextEl: SELECTOR_NEXT, prevEl: SELECTOR_PREV, }, pagination: { el: SELECTOR_DOTS, clickable: true, bulletClass: CLASS_DOT, bulletActiveClass: CLASS_ACTIVE, renderBullet: (index, className) => { return ``; }, }, scrollbar: { el: SELECTOR_SCROLLBAR, draggable: true, enabled: true, horizontalClass: CLASS_SCROLLBAR_HORIZONTAL, dragClass: CLASS_SCROLLBAR_DRAG, hide: false, }, slidesPerView: 1.2, spaceBetween: 20, mousewheel: { forceToAxis: true, sensitivity: 1, }, a11y: { enabled: true, scrollOnFocus: false, prevSlideMessage: "Predchádzajúci snímok", nextSlideMessage: "Nasledujúci snímok", paginationBulletMessage: "Prejsť na snímok {index}", containerMessage: "Carousel so snímkami", containerRoleDescriptionMessage: "carousel", itemRoleDescriptionMessage: "snímok", firstSlideMessage: "Prvý snímok", lastSlideMessage: "Posledný snímok", slideLabelMessage: "Snímok", }, loop: false, cssMode: false, direction: "horizontal", breakpoints: { 1240: { slidesPerView: 1.6, }, }, wrapperClass: CLASS_TRACK, slideClass: CLASS_SLIDE, slideActiveClass: CLASS_ACTIVE, slideNextClass: CLASS_SLIDE_NEXT, slidePrevClass: CLASS_SLIDE_PREV, }; export default class Carousel { element: HTMLElement; config: SwiperOptions; viewport!: HTMLElement; track!: HTMLElement; instance!: Swiper; carouselId?: string; private isScrollbarDragging = false; private externalControlsState: ExternalControlsState | null = null; private resizeObserver?: ResizeObserver; private resizeRafId?: number; private boundWindowResizeHandler: () => void; private bleedResizeHandler?: () => void; private static readonly OVERFLOW_EPSILON_PX = 1; private getViewportWrapper(): HTMLElement | null { return this.element.querySelector( `.${CLASS_VIEWPORT_WRAPPER}`, ) as HTMLElement | null; } private isBleedRight(): boolean { return ( this.element.classList.contains(CLASS_BLEED_RIGHT) || this.element.classList.contains(CLASS_BLEED_RIGHT_NO_LEFT) ); } private getSlidesPerView(): number { return Number(this.instance?.params?.slidesPerView) || 1; } private setScrollbarVisible(visible: boolean): void { const scrollbarEl = this.element.querySelector( SELECTOR_SCROLLBAR, ) as HTMLElement | null; if (scrollbarEl) { scrollbarEl.style.display = visible ? "" : "none"; } } private getSlidesContentWidth(): number { if (!this.instance) { return this.track?.scrollWidth || 0; } const slides = Array.from(this.instance.slides) as HTMLElement[]; const widthsSum = slides.reduce((sum, slide) => sum + slide.offsetWidth, 0); const spaceBetween = Number(this.instance.params?.spaceBetween) || 0; const gaps = Math.max(slides.length - 1, 0) * spaceBetween; const measuredWidth = widthsSum + gaps; // Fallback for early lifecycle/test DOM where slides can report 0 width. if (widthsSum <= 0 || measuredWidth <= 0) { return this.track?.scrollWidth || 0; } return measuredWidth; } private getOverflowWidth(viewportWrapper?: HTMLElement): number { const isBleedRight = this.isBleedRight(); const containerWidth = isBleedRight ? this.element.clientWidth || viewportWrapper?.clientWidth || 0 : this.viewport?.clientWidth || 0; if (containerWidth <= 0) { return 0; } const contentWidth = this.getSlidesContentWidth(); return contentWidth - containerWidth; } private hasScrollableContent(viewportWrapper?: HTMLElement): boolean { if (!this.instance || !this.instance.params) { return false; } const overflowWidth = this.getOverflowWidth(viewportWrapper); if (overflowWidth !== 0) { return overflowWidth > Carousel.OVERFLOW_EPSILON_PX; } // Fallback for hidden/zero-width initialization. const slidesCount = this.instance.slides.length; const slidesPerView = this.getSlidesPerView(); return slidesCount > slidesPerView; } constructor(element: HTMLElement, config?: Partial) { const scrollbarEl = element.querySelector( SELECTOR_SCROLLBAR, ) as HTMLElement | null; const configScrollbar = typeof config?.scrollbar === "object" && config.scrollbar !== null ? config.scrollbar : undefined; const defaultScrollbar = typeof defaultConfig.scrollbar === "object" && defaultConfig.scrollbar !== null ? defaultConfig.scrollbar : undefined; this.element = element; this.config = { ...defaultConfig, ...config, scrollbar: { ...((defaultConfig.scrollbar ?? {}) as object), ...((config?.scrollbar ?? {}) as object), el: scrollbarEl, enabled: scrollbarEl ? (configScrollbar?.enabled ?? defaultScrollbar?.enabled) : false, }, }; this.handleSlideChange = this.handleSlideChange.bind(this); this.updateTooltipPosition = this.updateTooltipPosition.bind(this); this.boundWindowResizeHandler = this.handleWindowResize.bind(this); (this.element as any).ODS_Carousel = this; requestAnimationFrame(() => { this.init(); }); return this; } init() { this.getElements(); if (this.element.hasAttribute("data-swiper-options")) { this.getCustomOptions(); } const isBleedRight = this.isBleedRight(); this.instance = new Swiper(this.viewport, { ...this.config, // Swiper watchOverflow can mis-detect with slides offsets; manage bleed-right overflow ourselves. watchOverflow: isBleedRight ? false : this.config.watchOverflow, enabled: false, modules: [Navigation, Pagination, Scrollbar, A11y, Keyboard, Mousewheel], on: { slideChange: this.handleSlideChange, slideChangeTransitionEnd: () => { this.updateExternalControlsState(); }, scrollbarDragStart: () => { this.isScrollbarDragging = true; }, scrollbarDragEnd: () => { this.isScrollbarDragging = false; this.updateExternalControlsState(true); }, }, }); this.updateCarouselEnabledState(); if (this.isBleedRight()) { this.adjustConfigForBleedRight(); this.fixBleedRightScrollbar(); } // Sync scrollbar position after initialization (important for initialSlide config) this.syncScrollbar(); if (this.instance && typeof this.instance.on === "function") { this.instance.on("resize", () => { this.updateCarouselEnabledState(); }); } this.observeVisibilityChanges(); window.addEventListener("resize", this.boundWindowResizeHandler); this.initExternalControls(); // Trigger a final update to notify listeners (like SameHeight) that carousel is ready requestAnimationFrame(() => { if (this.instance) { this.instance.update(); } }); } private handleWindowResize(): void { 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.updateCarouselEnabledState(); this.bleedResizeHandler?.(); this.updateExternalControlsState(); this.instance.scrollbar?.updateSize(); }); } /** * Sync scrollbar position with current slide. * This ensures the scrollbar reflects the correct position when carousel is initialized with initialSlide. */ syncScrollbar() { requestAnimationFrame(() => { if (this.instance?.scrollbar) { this.instance.scrollbar.updateSize(); } }); } private observeVisibilityChanges(): void { if (typeof ResizeObserver === "undefined") { return; } this.resizeObserver?.disconnect(); this.resizeObserver = new ResizeObserver(() => { if (!this.instance || !this.viewport) { return; } if (this.viewport.clientWidth <= 0) { return; } this.instance.update(); this.updateCarouselEnabledState(); this.instance.scrollbar?.updateSize(); }); this.resizeObserver.observe(this.viewport); } /** * Fix scrollbar drag size and position for bleed-right carousels. * Overrides Swiper's default scrollbar calculations to work correctly with bleed-right layouts. */ fixBleedRightScrollbar() { const updateScrollbar = () => { const viewportWrapper = this.getViewportWrapper(); const scrollbar = this.instance?.scrollbar; const swiper = this.instance; if (!viewportWrapper || !scrollbar || !scrollbar.dragEl || !swiper) { return; } const hasScrollableContent = this.hasScrollableContent(viewportWrapper); if (!hasScrollableContent) { this.setScrollbarVisible(false); return; } this.setScrollbarVisible(true); const scrollbarWidth = scrollbar.el.offsetWidth; const viewportWidth = this.instance?.width || this.element.clientWidth || viewportWrapper.clientWidth; const contentWidth = this.getSlidesContentWidth(); const minTranslate = typeof swiper.minTranslate === "function" ? swiper.minTranslate() : 0; const maxTranslate = typeof swiper.maxTranslate === "function" ? swiper.maxTranslate() : 0; const translateRange = Math.max(Math.abs(maxTranslate - minTranslate), 0); const normalizedContentWidth = Math.max( contentWidth, viewportWidth + translateRange, ); const visibleRatio = normalizedContentWidth > 0 ? Math.min(viewportWidth / normalizedContentWidth, 1) : 1; const dragSize = visibleRatio * scrollbarWidth; const finalDragSize = Math.max(dragSize, 30); scrollbar.dragEl.style.width = `${finalDragSize}px`; scrollbar.dragEl.style.display = ""; const scrollRatio = Math.min(Math.max(swiper.progress || 0, 0), 1); const maxDragTranslate = Math.max(scrollbarWidth - finalDragSize, 0); const dragTranslateX = scrollRatio * maxDragTranslate; scrollbar.dragEl.style.transform = `translate3d(${dragTranslateX}px, 0, 0)`; }; // Initial update (double requestAnimationFrame ensures DOM is fully ready) requestAnimationFrame(() => { requestAnimationFrame(updateScrollbar); }); // Keep scrollbar in sync with carousel state changes this.instance.on("progress", updateScrollbar); this.instance.on("slideChange", updateScrollbar); this.instance.on("resize", updateScrollbar); this.instance.on("update", updateScrollbar); this.instance.on("setTranslate", updateScrollbar); } getElements() { this.viewport = this.element.querySelector(SELECTOR_VIEWPORT)!; this.track = this.viewport.querySelector(SELECTOR_TRACK)!; const paginationEl = this.element.querySelector( SELECTOR_DOTS, ) as HTMLElement | null; if (paginationEl && !paginationEl.hasAttribute("role")) { paginationEl.setAttribute("role", "tablist"); } const elements = { navigation: { ...((this.config.navigation ?? {}) as object), nextEl: this.element.querySelector(SELECTOR_NEXT) as HTMLElement | null, prevEl: this.element.querySelector(SELECTOR_PREV) as HTMLElement | null, }, pagination: { ...((this.config.pagination ?? {}) as object), el: paginationEl, clickable: true, bulletClass: CLASS_DOT, bulletActiveClass: CLASS_ACTIVE, }, }; this.config = { ...this.config, ...elements, }; } getCustomOptions() { const passedSwiperOptions = this.element.getAttribute( "data-swiper-options", ); if (!passedSwiperOptions) return; const parsedSwiperOptions = JSON.parse(passedSwiperOptions); this.config = { ...this.config, ...parsedSwiperOptions, }; } /** * Enable or disable the carousel based on whether content requires scrolling. * Carousel is enabled only when there are more slides than can fit in the current viewport. */ private updateCarouselEnabledState(): void { if (!this.instance || !this.instance.params) return; const viewportWrapper = this.getViewportWrapper(); const hasScrollableContent = this.hasScrollableContent( viewportWrapper || undefined, ); if (hasScrollableContent) { this.instance.enable(); } else { this.instance.disable(); } this.setScrollbarVisible(this.instance.enabled); } private applyBleedInsets(viewportWrapper: HTMLElement): void { if (!this.instance || !this.instance.params) { return; } const containerWidth = this.element.clientWidth || viewportWrapper.clientWidth || 0; const viewportWidth = document.documentElement?.clientWidth || window.innerWidth || 0; const wrapperRect = viewportWrapper.getBoundingClientRect(); const rootRect = this.element.getBoundingClientRect(); const isBleedRightNoLeft = this.element.classList.contains( CLASS_BLEED_RIGHT_NO_LEFT, ); const stableLeftInsetSource = rootRect.left > 0 ? rootRect.left : wrapperRect.left; const leftInset = Math.max(Math.floor(stableLeftInsetSource), 0); const shellWidth = isBleedRightNoLeft ? Math.max(Math.floor(viewportWidth - leftInset), containerWidth, 0) : Math.max(Math.floor(viewportWidth), containerWidth, 0); const shellMarginLeft = isBleedRightNoLeft ? 0 : -leftInset; const bleedViewportWidth = Math.max( isBleedRightNoLeft ? shellWidth : shellWidth - leftInset, containerWidth, 0, ); const projectedContentWidth = this.getSlidesContentWidth(); const projectedOverflowWidth = projectedContentWidth - containerWidth; const slidesPerView = this.getSlidesPerView(); const slidesCount = this.instance.slides.length; const hasIntrinsicOverflow = projectedContentWidth > 0 ? projectedOverflowWidth > Carousel.OVERFLOW_EPSILON_PX : slidesCount > slidesPerView; const shouldDisableBleed = !hasIntrinsicOverflow; const shouldEnableBleedClass = !shouldDisableBleed; const hadBleedClass = this.isBleedRight(); const nextBleedViewportWidth = shouldDisableBleed ? "100%" : `${bleedViewportWidth}px`; const nextBleedMarginLeft = shouldDisableBleed || isBleedRightNoLeft ? "0px" : `${leftInset}px`; const nextBleedShellWidth = shouldDisableBleed ? "100%" : `${shellWidth}px`; const nextBleedShellMarginLeft = shouldDisableBleed ? "0px" : `${shellMarginLeft}px`; const geometryChanged = hadBleedClass !== shouldEnableBleedClass || this.instance.params.width !== (shouldDisableBleed ? undefined : containerWidth) || this.element.style.getPropertyValue("--carousel-bleed-viewport-width") !== nextBleedViewportWidth || this.element.style.getPropertyValue("--carousel-bleed-margin-left") !== nextBleedMarginLeft || this.element.style.getPropertyValue("--carousel-bleed-shell-width") !== nextBleedShellWidth || this.element.style.getPropertyValue( "--carousel-bleed-shell-margin-left", ) !== nextBleedShellMarginLeft; if (!isBleedRightNoLeft) { this.element.classList.toggle(CLASS_BLEED_RIGHT, shouldEnableBleedClass); } const baseWidth = shouldDisableBleed ? undefined : containerWidth; this.instance.params.width = baseWidth; if (this.instance.originalParams) { this.instance.originalParams.width = baseWidth; } this.instance.params.slidesOffsetBefore = 0; this.instance.params.slidesOffsetAfter = 0; this.element.style.setProperty( "--carousel-bleed-viewport-width", nextBleedViewportWidth, ); this.element.style.setProperty( "--carousel-bleed-margin-left", nextBleedMarginLeft, ); this.element.style.setProperty( "--carousel-bleed-shell-width", nextBleedShellWidth, ); this.element.style.setProperty( "--carousel-bleed-shell-margin-left", nextBleedShellMarginLeft, ); if (geometryChanged) { this.instance.update(); } this.updateCarouselEnabledState(); } /** * Configure bleed-right carousel to extend beyond the container edge. * Calculates the exact offset needed for the carousel to reach the viewport edge * while keeping the last slide aligned with the container edge when scrolled to the end. * When slidesPerView is a whole number (integer), the bleed effect is disabled. */ adjustConfigForBleedRight() { requestAnimationFrame(() => { if (!this.instance) return; const viewportWrapper = this.getViewportWrapper(); if (!viewportWrapper) return; const updateBleedState = () => { this.applyBleedInsets(viewportWrapper); }; this.bleedResizeHandler = updateBleedState; updateBleedState(); this.instance.on("resize", updateBleedState); }); } /** * Handle carousel slide change events. * Updates tooltip positions and accessibility states for the active slide. */ handleSlideChange() { const activeSlide = this.track.querySelector(SELECTOR_ACTIVE); const nonActiveSlides = this.track.querySelectorAll( `.${CLASS_SLIDE}:not(${SELECTOR_ACTIVE})`, ); if (activeSlide) { this.updateTooltipPosition(activeSlide as HTMLElement); } if (nonActiveSlides.length > 0) { this.hideAllTooltips(nonActiveSlides); } this.updateExternalControlsState(); } /** * Update tooltip positions for elements within a slide. */ updateTooltipPosition(element: HTMLElement) { const tooltipTriggers = element.querySelectorAll( '[data-tooltip-trigger="true"]', ); if (tooltipTriggers.length > 0) { tooltipTriggers.forEach((tooltipElement) => { const tooltipId = tooltipElement.getAttribute("aria-describedby"); if (!tooltipId) return; const tooltipDiv = document.getElementById(tooltipId); if (!tooltipDiv) return; const tooltipInstance = Tooltip.getInstance(tooltipDiv); if (tooltipInstance) { tooltipInstance.update(); } }); } } /** * Hide all tooltips for non-active slides. */ hideAllTooltips(elements: NodeListOf) { elements.forEach((element) => { const allTooltips = element.querySelectorAll( '[data-tooltip-trigger="true"]', ); if (allTooltips.length > 0) { allTooltips.forEach((tooltipElement) => { const tooltipId = tooltipElement.getAttribute("aria-describedby"); if (!tooltipId) return; const tooltipDiv = document.getElementById(tooltipId); if (!tooltipDiv) return; const tooltipInstance = Tooltip.getInstance(tooltipDiv); if (tooltipInstance) { tooltipInstance.hide(); (tooltipElement as HTMLElement).blur(); } }); } }); } /** * Initialize external navigation controls that reference this carousel via data attributes. * Supports prev/next buttons placed anywhere in the DOM. */ initExternalControls() { const carouselId = this.element.dataset.carouselId || this.element.id; if (!carouselId) { return; } this.carouselId = carouselId; const controlElements = document.querySelectorAll( `[data-carousel-controls="${carouselId}"]`, ); controlElements.forEach((control) => { const htmlControl = control as HTMLElement; if (htmlControl.hasAttribute("data-carousel-initialized")) { return; } if ((htmlControl as any)._carouselClickHandler) { htmlControl.removeEventListener( "click", (htmlControl as any)._carouselClickHandler, ); } const action = htmlControl.dataset.carouselAction; const clickHandler = (e: Event) => { e.preventDefault(); if (htmlControl.hasAttribute("disabled")) { return; } if (action === "next") { this.slideNext(); } else if (action === "prev") { this.slidePrev(); } }; if (action === "next" || action === "prev") { htmlControl.addEventListener("click", clickHandler); htmlControl.setAttribute( "aria-label", action === "next" ? (typeof this.config.a11y === "object" ? this.config.a11y?.nextSlideMessage : undefined) || "Nasledujúci snímok" : (typeof this.config.a11y === "object" ? this.config.a11y?.prevSlideMessage : undefined) || "Predchádzajúci snímok", ); htmlControl.setAttribute("type", "button"); (htmlControl as any)._carouselClickHandler = clickHandler; } htmlControl.setAttribute("data-carousel-initialized", "true"); }); this.updateExternalControlsState(); } /** * Update the disabled state of external navigation controls. * Controls are disabled at the start/end of the carousel based on slide position. */ updateExternalControlsState(force = false) { if (!this.carouselId || !this.instance) { return; } if (!this.instance.params) { return; } if (this.isScrollbarDragging && !force) { return; } const isDisabled = !this.instance.enabled; const isAtStart = isDisabled || this.instance.isBeginning; const isAtEnd = isDisabled || this.instance.isEnd; this.externalControlsState = { isAtStart, isAtEnd }; this.applyExternalControlsState(this.externalControlsState); } private applyExternalControlsState(state: ExternalControlsState) { const { isAtStart, isAtEnd } = state; const prevControls = document.querySelectorAll( `[data-carousel-controls="${this.carouselId}"][data-carousel-action="prev"]`, ); const nextControls = document.querySelectorAll( `[data-carousel-controls="${this.carouselId}"][data-carousel-action="next"]`, ); prevControls.forEach((control) => { const htmlControl = control as HTMLElement; if (isAtStart) { htmlControl.setAttribute("disabled", ""); htmlControl.setAttribute("aria-disabled", "true"); htmlControl.style.cursor = "not-allowed"; } else { htmlControl.removeAttribute("disabled"); htmlControl.setAttribute("aria-disabled", "false"); htmlControl.style.cursor = "pointer"; } }); nextControls.forEach((control) => { const htmlControl = control as HTMLElement; if (isAtEnd) { htmlControl.setAttribute("disabled", ""); htmlControl.setAttribute("aria-disabled", "true"); htmlControl.style.cursor = "not-allowed"; } else { htmlControl.removeAttribute("disabled"); htmlControl.setAttribute("aria-disabled", "false"); htmlControl.style.cursor = "pointer"; } }); } /** * Navigate to the next slide. */ slideNext() { if (this.instance) { this.instance.slideNext(); } } /** * Navigate to the previous slide. */ slidePrev() { if (this.instance) { this.instance.slidePrev(); } } /** * Get the current active slide index. */ getActiveIndex(): number { return this.instance ? this.instance.activeIndex : 0; } /** * Register a callback for slide change events. */ onSlideChange(callback: (activeIndex: number) => void) { if (this.instance) { this.instance.on("slideChange", () => { callback(this.instance.activeIndex); }); } } destroy() { // Clean up external controls if (this.carouselId) { const controlElements = document.querySelectorAll( `[data-carousel-controls="${this.carouselId}"]`, ); controlElements.forEach((control) => { const htmlControl = control as HTMLElement; if ((htmlControl as any)._carouselClickHandler) { htmlControl.removeEventListener( "click", (htmlControl as any)._carouselClickHandler, ); delete (htmlControl as any)._carouselClickHandler; } htmlControl.removeAttribute("data-carousel-initialized"); }); } window.removeEventListener("resize", this.boundWindowResizeHandler); if (this.resizeRafId) { cancelAnimationFrame(this.resizeRafId); this.resizeRafId = undefined; } if (this.instance && this.bleedResizeHandler) { this.instance.off("resize", this.bleedResizeHandler); this.bleedResizeHandler = undefined; } if (this.instance) { this.instance.destroy(); } this.isScrollbarDragging = false; this.externalControlsState = null; this.resizeObserver?.disconnect(); this.resizeObserver = undefined; if ((this.element as any).ODS_Carousel === this) { delete (this.element as any).ODS_Carousel; } } update() { if (this.instance) { this.instance.update(); } } static getInstance(el: HTMLElement): Carousel | null { return el && (el as any).ODS_Carousel ? (el as any).ODS_Carousel : null; } /** * Find a carousel instance by its ID or data-carousel-id attribute. */ static getInstanceById(carouselId: string): Carousel | null { const element = document.querySelector(`[data-carousel-id="${carouselId}"]`) || document.getElementById(carouselId); return element ? this.getInstance(element as HTMLElement) : null; } }