/** * @jest-environment jsdom */ import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel' import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures' import CarouselClient from '../../client' import { CarouselTrackingClient } from '../../client/tracking' jest.mock('embla-carousel', () => ({ __esModule: true, default: jest.fn(), })) jest.mock('../../client/tracking', () => ({ CarouselTrackingClient: jest.fn().mockImplementation(() => ({ destroy: jest.fn(), })), })) jest.mock('embla-carousel-wheel-gestures', () => ({ WheelGesturesPlugin: jest.fn().mockImplementation(() => ({})), })) type EmblaEvent = 'select' | 'reInit' const createMockEmblaApi = () => { const handlers: Partial void>>> = {} let canScrollPrev = false let canScrollNext = true let previousScrollSnap = 0 let selectedScrollSnap = 0 let scrollSnapList = [0, 1, 2] const api = { scrollPrev: jest.fn(), scrollNext: jest.fn(), canScrollPrev: jest.fn(() => canScrollPrev), canScrollNext: jest.fn(() => canScrollNext), scrollSnapList: jest.fn(() => scrollSnapList), previousScrollSnap: jest.fn(() => previousScrollSnap), selectedScrollSnap: jest.fn(() => selectedScrollSnap), on: jest.fn((event: EmblaEvent, handler: () => void) => { handlers[event] = [...(handlers[event] ?? []), handler] }), off: jest.fn(), destroy: jest.fn(), } return { api: api as unknown as EmblaCarouselType, emit: (event: EmblaEvent) => { handlers[event]?.forEach((handler) => handler()) }, setButtonState: (prev: boolean, next: boolean) => { canScrollPrev = prev canScrollNext = next }, setSelectedSnap: (previous: number, selected: number) => { previousScrollSnap = previous selectedScrollSnap = selected }, setScrollSnapList: (snaps: number[]) => { scrollSnapList = snaps }, } } const createCarouselWrapper = ({ includeViewport = true, includeProgress = true, }: { includeViewport?: boolean includeProgress?: boolean } = {}) => { const wrapper = document.createElement('div') wrapper.setAttribute('data-component', 'carousel-wrapper') if (includeViewport) { const viewport = document.createElement('div') viewport.className = 'cp-carousel__container' viewport.setAttribute('data-component', 'carousel') viewport.setAttribute('data-component-id', 'carousel-id') wrapper.appendChild(viewport) } const progress = document.createElement('div') progress.className = 'cp-carousel__progress' wrapper.appendChild(progress) if (includeProgress) { const progressIndicator = document.createElement('div') progressIndicator.className = 'cp-carousel__progress__indicator' progressIndicator.scrollTo = jest.fn() progress.appendChild(progressIndicator) } const progressButtons = document.createElement('div') progressButtons.className = 'cp-carousel__progress__buttons' progress.appendChild(progressButtons) const prevButton = document.createElement('button') prevButton.setAttribute('data-button', 'carousel-button-left') progressButtons.appendChild(prevButton) const nextButton = document.createElement('button') nextButton.setAttribute('data-button', 'carousel-button-right') progressButtons.appendChild(nextButton) document.body.appendChild(wrapper) return { wrapper, viewport: wrapper.querySelector('.cp-carousel__container'), prevButton, nextButton, progressIndicator: wrapper.querySelector( '.cp-carousel__progress__indicator' ), } } describe('CarouselClient', () => { const MockEmblaCarousel = jest.mocked(EmblaCarousel) const MockCarouselTrackingClient = jest.mocked(CarouselTrackingClient) beforeEach(() => { document.body.innerHTML = '' jest.clearAllMocks() }) it('initialises one client for each carousel wrapper within a root element', () => { const first = createCarouselWrapper() const second = createCarouselWrapper() const root = document.createElement('div') root.append(first.wrapper, second.wrapper) document.body.appendChild(root) const firstEmbla = createMockEmblaApi() const secondEmbla = createMockEmblaApi() MockEmblaCarousel.mockReturnValueOnce(firstEmbla.api).mockReturnValueOnce( secondEmbla.api ) const clients = CarouselClient.init(root) expect(clients).toHaveLength(2) expect(MockEmblaCarousel).toHaveBeenCalledTimes(2) expect(MockCarouselTrackingClient).toHaveBeenCalledWith(first.viewport) expect(MockCarouselTrackingClient).toHaveBeenCalledWith(second.viewport) }) it('does not initialise Embla when the wrapper has no viewport', () => { const { wrapper } = createCarouselWrapper({ includeViewport: false }) const client = new CarouselClient(wrapper) expect(MockEmblaCarousel).not.toHaveBeenCalled() expect(client.emblaApi).toBeUndefined() }) it('configures Embla and updates arrow button disabled states', () => { const { wrapper, viewport, prevButton, nextButton } = createCarouselWrapper() const mockEmbla = createMockEmblaApi() MockEmblaCarousel.mockReturnValue(mockEmbla.api) new CarouselClient(wrapper) expect(MockEmblaCarousel).toHaveBeenCalledWith( viewport, { loop: false, slidesToScroll: 'auto', containScroll: 'keepSnaps', skipSnaps: false, dragFree: true, }, [WheelGesturesPlugin({ target: viewport ? viewport : undefined })] ) expect(prevButton.hasAttribute('disabled')).toBe(true) expect(nextButton.hasAttribute('disabled')).toBe(false) mockEmbla.setButtonState(true, false) mockEmbla.emit('select') expect(prevButton.hasAttribute('disabled')).toBe(false) expect(nextButton.hasAttribute('disabled')).toBe(true) }) it('scrolls the carousel when the arrow buttons are clicked', () => { const { wrapper, prevButton, nextButton } = createCarouselWrapper() const mockEmbla = createMockEmblaApi() mockEmbla.setButtonState(true, true) MockEmblaCarousel.mockReturnValue(mockEmbla.api) new CarouselClient(wrapper) prevButton.click() nextButton.click() expect(mockEmbla.api.scrollPrev).toHaveBeenCalledTimes(1) expect(mockEmbla.api.scrollNext).toHaveBeenCalledTimes(1) }) it('creates progress dots and updates the selected dot on Embla select', () => { const { wrapper, progressIndicator } = createCarouselWrapper() const mockEmbla = createMockEmblaApi() mockEmbla.setScrollSnapList([0, 1, 2, 3, 4, 5]) MockEmblaCarousel.mockReturnValue(mockEmbla.api) new CarouselClient(wrapper) const initialDots = progressIndicator?.querySelectorAll( '.cp-carousel__progress__indicator-item' ) expect(initialDots).toHaveLength(6) expect( initialDots?.[0]?.classList.contains( 'cp-carousel__progress__indicator-item--selected' ) ).toBe(true) expect( initialDots?.[4]?.classList.contains( 'cp-carousel__progress__indicator-item--small' ) ).toBe(true) mockEmbla.setSelectedSnap(0, 3) mockEmbla.emit('select') const updatedDots = progressIndicator?.querySelectorAll( '.cp-carousel__progress__indicator-item' ) expect( updatedDots?.[0]?.classList.contains( 'cp-carousel__progress__indicator-item--selected' ) ).toBe(false) expect( updatedDots?.[3]?.classList.contains( 'cp-carousel__progress__indicator-item--selected' ) ).toBe(true) expect( updatedDots?.[1]?.classList.contains( 'cp-carousel__progress__indicator-item--small' ) ).toBe(true) expect(progressIndicator?.scrollTo).toHaveBeenCalledWith({ left: 14, behavior: 'smooth', }) }) it('recreates progress dots on resize and resets the indicator scroll position', () => { const { wrapper, progressIndicator } = createCarouselWrapper() const mockEmbla = createMockEmblaApi() MockEmblaCarousel.mockReturnValue(mockEmbla.api) new CarouselClient(wrapper) mockEmbla.setScrollSnapList([0, 1, 2, 3]) if (progressIndicator) { progressIndicator.scrollLeft = 42 } window.dispatchEvent(new Event('resize')) expect( progressIndicator?.querySelectorAll( '.cp-carousel__progress__indicator-item' ) ).toHaveLength(4) expect(progressIndicator?.scrollLeft).toBe(0) }) it('resets the arrow button states and progress dots on Embla reInit', () => { const { wrapper, prevButton, nextButton, progressIndicator } = createCarouselWrapper() const mockEmbla = createMockEmblaApi() MockEmblaCarousel.mockReturnValue(mockEmbla.api) new CarouselClient(wrapper) mockEmbla.setButtonState(true, false) mockEmbla.setScrollSnapList([0, 1, 2]) mockEmbla.emit('reInit') expect(prevButton.hasAttribute('disabled')).toBe(false) expect(nextButton.hasAttribute('disabled')).toBe(true) expect( progressIndicator?.querySelectorAll( '.cp-carousel__progress__indicator-item' ) ).toHaveLength(3) }) it('resets the arrow button states on resize when Embla reInit is not triggered', () => { const { wrapper, prevButton, nextButton } = createCarouselWrapper() const mockEmbla = createMockEmblaApi() MockEmblaCarousel.mockReturnValue(mockEmbla.api) new CarouselClient(wrapper) mockEmbla.setButtonState(true, false) window.dispatchEvent(new Event('resize')) expect(prevButton.hasAttribute('disabled')).toBe(false) expect(nextButton.hasAttribute('disabled')).toBe(true) }) it('removes event listeners, progress dots, Embla events and tracking on destroy', () => { const { wrapper, prevButton, progressIndicator } = createCarouselWrapper() const mockEmbla = createMockEmblaApi() MockEmblaCarousel.mockReturnValue(mockEmbla.api) const client = new CarouselClient(wrapper) const tracking = MockCarouselTrackingClient.mock.results[0]?.value as { destroy: jest.Mock } client.destroy() prevButton.click() window.dispatchEvent(new Event('resize')) expect(mockEmbla.api.scrollPrev).not.toHaveBeenCalled() expect(mockEmbla.api.off).toHaveBeenCalledWith( 'select', expect.any(Function) ) expect(mockEmbla.api.off).toHaveBeenCalledWith( 'reInit', expect.any(Function) ) expect(mockEmbla.api.destroy).toHaveBeenCalledTimes(1) expect(tracking.destroy).toHaveBeenCalledTimes(1) expect(progressIndicator?.childElementCount).toBe(0) }) it('listens for pointer events when ftMobileScrolling is true', () => { const { wrapper } = createCarouselWrapper() const mockEmbla = createMockEmblaApi() MockEmblaCarousel.mockReturnValue(mockEmbla.api) new CarouselClient(wrapper, true) expect(mockEmbla.api.on).toHaveBeenCalledWith( 'pointerDown', expect.any(Function) ) expect(mockEmbla.api.on).toHaveBeenCalledWith( 'pointerUp', expect.any(Function) ) }) it('does not listen for pointer events when ftMobileScrolling is false', () => { const { wrapper } = createCarouselWrapper() const mockEmbla = createMockEmblaApi() MockEmblaCarousel.mockReturnValue(mockEmbla.api) new CarouselClient(wrapper, false) expect(mockEmbla.api.on).not.toHaveBeenCalledWith( 'pointerDown', expect.any(Function) ) expect(mockEmbla.api.on).not.toHaveBeenCalledWith( 'pointerUp', expect.any(Function) ) }) })