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