import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel' import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures' import { CarouselTrackingClient } from './tracking' /** * CarouselClient * @description Track the view of a rendered carousel component * @param {HTMLElement} el - The carousel component */ class CarouselClient { tracking: CarouselTrackingClient | null emblaApi: EmblaCarouselType | undefined prevButtonNode: HTMLElement | null nextButtonNode: HTMLElement | null progressIndicator: HTMLElement | null progressDots: HTMLElement[] | null ftMobileScrolling: boolean | undefined constructor(el: HTMLElement, ftMobileScrolling?: boolean) { this.ftMobileScrolling = ftMobileScrolling this.tracking = null const carouselNode = el.querySelector( '[data-component="carousel"]:not([data-initialised])' ) if (carouselNode) { this.tracking = new CarouselTrackingClient(carouselNode) } const wrapperNode = el const viewportNode = wrapperNode.querySelector( '.cp-carousel__container' ) this.prevButtonNode = null this.nextButtonNode = null this.progressIndicator = null this.progressDots = null if (!viewportNode) { return } this.prevButtonNode = wrapperNode.querySelector( '[data-button="carousel-button-left"]' ) this.nextButtonNode = wrapperNode.querySelector( '[data-button="carousel-button-right"]' ) this.emblaApi = EmblaCarousel( viewportNode, { loop: false, slidesToScroll: 'auto', containScroll: 'keepSnaps', skipSnaps: false, dragFree: true, }, [WheelGesturesPlugin({ target: viewportNode })] ) // this is required for the app to know when the carousel is being touched and therefore not to scroll the page if (this.ftMobileScrolling) { this.emblaApi.on('pointerDown', this.carouselTouched) this.emblaApi.on('pointerUp', this.carouselReleased) } this.prevButtonNode?.addEventListener('click', this.scrollPrev, false) this.nextButtonNode?.addEventListener('click', this.scrollNext, false) this.toggleArrowButtons() this.emblaApi?.on('select', this.toggleArrowButtons) this.emblaApi?.on('reInit', this.toggleArrowButtons) window.addEventListener('resize', this.toggleArrowButtons, false) this.progressIndicator = wrapperNode.querySelector( '.cp-carousel__progress__indicator' ) if (this.progressIndicator) { this.initDotButtons() this.emblaApi?.on('select', this.toggleDotButtons) this.emblaApi?.on('reInit', this.initDotButtons) window.addEventListener('resize', this.initDotButtons, false) } } private carouselTouched = (): void => { // this is only valid in the ft-app // it won't do anyting in next-article if (!window.ftmob_scrolling) { window.ftmob_scrolling = this } } private carouselReleased = (): void => { // this is only valid in the ft-app // it won't do anyting in next-article if (window.ftmob_scrolling === this) { window.ftmob_scrolling = false } } private scrollPrev = (): void => { this.emblaApi?.scrollPrev() } private scrollNext = (): void => { this.emblaApi?.scrollNext() } private toggleArrowButtons = (): void => { const setButtonState = ( button: HTMLElement | null, enabled: boolean ): void => { button?.toggleAttribute('disabled', !enabled) } setButtonState(this.prevButtonNode, this.emblaApi?.canScrollPrev() ?? false) setButtonState(this.nextButtonNode, this.emblaApi?.canScrollNext() ?? false) } private createDotButtonHtml = (): HTMLElement[] => { const snapList = this.emblaApi?.scrollSnapList() ?? [] if (!this.progressIndicator) return [] this.progressIndicator.innerHTML = snapList.reduce( (acc) => acc + ``, '' ) return Array.from( this.progressIndicator.querySelectorAll( '.cp-carousel__progress__indicator-item' ) ?? [] ) } private toggleDotButtons = (): void => { if (!this.progressDots) return const previous = this.emblaApi?.previousScrollSnap() ?? 0 const selected = this.emblaApi?.selectedScrollSnap() ?? 0 this.progressDots[previous]?.classList.remove( 'cp-carousel__progress__indicator-item--selected' ) this.progressDots[selected]?.classList.add( 'cp-carousel__progress__indicator-item--selected' ) const dotsCount = Array.from(this.progressDots ?? []).length if (dotsCount <= 5) return // if there are more than 5 dots, we want to keep the outermost visible dots smaller // when not the actual end dots to give the visual impression of scolling through a carousel this.progressDots?.forEach((dot, index) => { if (selected === undefined) return const isSmall = dotsCount > 5 && (selected < 3 ? index === 4 : selected > dotsCount - 4 ? index === dotsCount - 5 : index === selected - 2 || index === selected + 2) dot.classList.toggle( 'cp-carousel__progress__indicator-item--small', isSmall ) }) if (previous !== selected) { this.scrollProgressIndicatorToSelected() } } private getProgressIndicatorScrollPosition = ( selected: number, dotsCount: number ): number => { const dotWidth = 6 const dotGap = 8 const firstVisibleIndex = selected < 3 ? 0 : selected > dotsCount - 4 ? dotsCount - 5 : selected - 2 return firstVisibleIndex * (dotWidth + dotGap) } private scrollProgressIndicatorToSelected = (): void => { const selected = this.emblaApi?.selectedScrollSnap() ?? 0 const dotsCount = this.progressDots?.length ?? 0 this.progressIndicator?.scrollTo({ left: this.getProgressIndicatorScrollPosition(selected, dotsCount), behavior: 'smooth', }) } private initDotButtons = (): void => { this.progressDots = this.createDotButtonHtml() this.progressIndicator && (this.progressIndicator.scrollLeft = 0) this.toggleDotButtons() } private destroyDotButtons = (): void => { this.emblaApi?.off('select', this.toggleDotButtons) this.emblaApi?.off('reInit', this.initDotButtons) this.progressIndicator?.replaceChildren() this.progressDots = null this.progressIndicator = null } static init( rootElement: Document | HTMLElement = document, ftMobileScrolling?: boolean ): CarouselClient[] { return Array.from( rootElement.querySelectorAll('[data-component="carousel-wrapper"]') ).map((el) => new CarouselClient(el as HTMLElement, ftMobileScrolling)) } destroy(): void { this.prevButtonNode?.removeEventListener('click', this.scrollPrev, false) this.nextButtonNode?.removeEventListener('click', this.scrollNext, false) this.emblaApi?.off('select', this.toggleArrowButtons) this.emblaApi?.off('reInit', this.toggleArrowButtons) window.removeEventListener('resize', this.toggleArrowButtons, false) this.destroyDotButtons() this.emblaApi?.destroy() this.tracking?.destroy() window.removeEventListener('resize', this.initDotButtons, false) } } export default CarouselClient