diff --git a/src/blocks/carousel/__tests__/view.test.ts b/src/blocks/carousel/__tests__/view.test.ts index 0daab79..a6c5b30 100644 --- a/src/blocks/carousel/__tests__/view.test.ts +++ b/src/blocks/carousel/__tests__/view.test.ts @@ -257,6 +257,139 @@ describe( 'Carousel View Module', () => { consoleSpy.mockRestore(); } ); + + it( 'should stop (destroy) autoplay and autoscroll if stopOnInteraction is true', () => { + const { wrapper, viewport, button } = createMockCarouselDOM(); + const mockAutoplay = { stop: jest.fn(), destroy: jest.fn(), reset: jest.fn() }; + const mockAutoScroll = { stop: jest.fn(), destroy: jest.fn(), reset: jest.fn() }; + const mockEmbla = createMockEmblaInstance( { + plugins: jest.fn( () => ( { + autoplay: mockAutoplay, + autoScroll: mockAutoScroll, + } ) ), + } ); + + setEmblaOnViewport( viewport, mockEmbla ); + + const mockContext = createMockContext( { + autoplay: { + delay: 3000, + stopOnInteraction: true, + stopOnMouseEnter: true, + }, + autoScroll: { + speed: 1, + direction: 'forward', + startDelay: 0, + stopOnInteraction: true, + stopOnMouseEnter: true, + stopOnFocusIn: true, + }, + } ); + + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + ( getElement as jest.Mock ).mockReturnValue( { ref: button } ); + document.body.appendChild( wrapper ); + + try { + storeConfig.actions.scrollPrev(); + + expect( mockAutoplay.destroy ).toHaveBeenCalledTimes( 1 ); + expect( mockAutoScroll.destroy ).toHaveBeenCalledTimes( 1 ); + expect( mockAutoplay.reset ).not.toHaveBeenCalled(); + expect( mockAutoScroll.reset ).not.toHaveBeenCalled(); + } finally { + document.body.removeChild( wrapper ); + } + } ); + + it( 'should reset autoplay and autoscroll if stopOnInteraction is false', () => { + const { wrapper, viewport, button } = createMockCarouselDOM(); + const mockAutoplay = { stop: jest.fn(), destroy: jest.fn(), reset: jest.fn() }; + const mockAutoScroll = { stop: jest.fn(), destroy: jest.fn(), reset: jest.fn() }; + const mockEmbla = createMockEmblaInstance( { + plugins: jest.fn( () => ( { + autoplay: mockAutoplay, + autoScroll: mockAutoScroll, + } ) ), + } ); + + setEmblaOnViewport( viewport, mockEmbla ); + + const mockContext = createMockContext( { + autoplay: { + delay: 3000, + stopOnInteraction: false, + stopOnMouseEnter: true, + }, + autoScroll: { + speed: 1, + direction: 'forward', + startDelay: 0, + stopOnInteraction: false, + stopOnMouseEnter: true, + stopOnFocusIn: true, + }, + } ); + + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + ( getElement as jest.Mock ).mockReturnValue( { ref: button } ); + document.body.appendChild( wrapper ); + + try { + storeConfig.actions.scrollPrev(); + + expect( mockAutoplay.destroy ).not.toHaveBeenCalled(); + expect( mockAutoScroll.destroy ).not.toHaveBeenCalled(); + expect( mockAutoplay.reset ).toHaveBeenCalledTimes( 1 ); + expect( mockAutoScroll.reset ).toHaveBeenCalledTimes( 1 ); + } finally { + document.body.removeChild( wrapper ); + } + } ); + + it( 'should stop (destroy) autoplay and autoscroll if stopOnInteraction is omitted/undefined (defaults to true)', () => { + const { wrapper, viewport, button } = createMockCarouselDOM(); + const mockAutoplay = { stop: jest.fn(), destroy: jest.fn(), reset: jest.fn() }; + const mockAutoScroll = { stop: jest.fn(), destroy: jest.fn(), reset: jest.fn() }; + const mockEmbla = createMockEmblaInstance( { + plugins: jest.fn( () => ( { + autoplay: mockAutoplay, + autoScroll: mockAutoScroll, + } ) ), + } ); + + setEmblaOnViewport( viewport, mockEmbla ); + + const mockContext = createMockContext( { + autoplay: { + delay: 3000, + // stopOnInteraction omitted + stopOnMouseEnter: true, + } as unknown as CarouselContext[ 'autoplay' ], + autoScroll: { + speed: 1, + direction: 'forward', + startDelay: 0, + // stopOnInteraction omitted + stopOnMouseEnter: true, + stopOnFocusIn: true, + } as unknown as CarouselContext[ 'autoScroll' ], + } ); + + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + ( getElement as jest.Mock ).mockReturnValue( { ref: button } ); + document.body.appendChild( wrapper ); + + try { + storeConfig.actions.scrollPrev(); + + expect( mockAutoplay.destroy ).toHaveBeenCalledTimes( 1 ); + expect( mockAutoScroll.destroy ).toHaveBeenCalledTimes( 1 ); + } finally { + document.body.removeChild( wrapper ); + } + } ); } ); describe( 'scrollNext', () => { @@ -295,6 +428,49 @@ describe( 'Carousel View Module', () => { consoleSpy.mockRestore(); } ); + + it( 'should stop autoplay and autoscroll if stopOnInteraction is true', () => { + const { wrapper, viewport, button } = createMockCarouselDOM(); + const mockAutoplay = { stop: jest.fn(), destroy: jest.fn(), reset: jest.fn() }; + const mockAutoScroll = { stop: jest.fn(), destroy: jest.fn(), reset: jest.fn() }; + const mockEmbla = createMockEmblaInstance( { + plugins: jest.fn( () => ( { + autoplay: mockAutoplay, + autoScroll: mockAutoScroll, + } ) ), + } ); + + setEmblaOnViewport( viewport, mockEmbla ); + + const mockContext = createMockContext( { + autoplay: { + delay: 3000, + stopOnInteraction: true, + stopOnMouseEnter: true, + }, + autoScroll: { + speed: 1, + direction: 'forward', + startDelay: 0, + stopOnInteraction: true, + stopOnMouseEnter: true, + stopOnFocusIn: true, + }, + } ); + + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + ( getElement as jest.Mock ).mockReturnValue( { ref: button } ); + document.body.appendChild( wrapper ); + + try { + storeConfig.actions.scrollNext(); + + expect( mockAutoplay.destroy ).toHaveBeenCalledTimes( 1 ); + expect( mockAutoScroll.destroy ).toHaveBeenCalledTimes( 1 ); + } finally { + document.body.removeChild( wrapper ); + } + } ); } ); describe( 'onDotClick', () => { @@ -356,6 +532,52 @@ describe( 'Carousel View Module', () => { document.body.removeChild( wrapper ); } ); + + it( 'should stop autoplay and autoscroll if stopOnInteraction is true', () => { + const { wrapper, viewport, button } = createMockCarouselDOM(); + const mockAutoplay = { stop: jest.fn(), destroy: jest.fn(), reset: jest.fn() }; + const mockAutoScroll = { stop: jest.fn(), destroy: jest.fn(), reset: jest.fn() }; + const mockEmbla = createMockEmblaInstance( { + plugins: jest.fn( () => ( { + autoplay: mockAutoplay, + autoScroll: mockAutoScroll, + } ) ), + } ); + + setEmblaOnViewport( viewport, mockEmbla ); + + const mockContext = createMockContext( { + autoplay: { + delay: 3000, + stopOnInteraction: true, + stopOnMouseEnter: true, + }, + autoScroll: { + speed: 1, + direction: 'forward', + startDelay: 0, + stopOnInteraction: true, + stopOnMouseEnter: true, + stopOnFocusIn: true, + }, + } ); + ( mockContext as CarouselContext & { snap?: { index: number } } ).snap = { + index: 2, + }; + + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + ( getElement as jest.Mock ).mockReturnValue( { ref: button } ); + document.body.appendChild( wrapper ); + + try { + storeConfig.actions.onDotClick(); + + expect( mockAutoplay.destroy ).toHaveBeenCalledTimes( 1 ); + expect( mockAutoScroll.destroy ).toHaveBeenCalledTimes( 1 ); + } finally { + document.body.removeChild( wrapper ); + } + } ); } ); } ); diff --git a/src/blocks/carousel/view.ts b/src/blocks/carousel/view.ts index cade24b..3d7d925 100644 --- a/src/blocks/carousel/view.ts +++ b/src/blocks/carousel/view.ts @@ -125,6 +125,61 @@ const markForAnnouncement = (): void => { getContext().shouldAnnounce = true; }; +type StoppablePlugin = { + stop?: () => void; + destroy?: () => void; + reset?: () => void; +}; + +const stopPluginsOnInteraction = ( + embla: EmblaCarouselType, + context: CarouselContext, +): void => { + if ( typeof embla.plugins !== 'function' ) { + return; + } + const plugins = embla.plugins() as { + autoplay?: StoppablePlugin; + autoScroll?: StoppablePlugin; + }; + const autoplay = plugins.autoplay; + const autoScroll = plugins.autoScroll; + + if ( autoplay ) { + const shouldStop = + context.autoplay === true || + ( typeof context.autoplay === 'object' && + context.autoplay.stopOnInteraction !== false ); + + if ( shouldStop ) { + if ( typeof autoplay.destroy === 'function' ) { + autoplay.destroy(); + } else if ( typeof autoplay.stop === 'function' ) { + autoplay.stop(); + } + } else if ( typeof autoplay.reset === 'function' ) { + autoplay.reset(); + } + } + + if ( autoScroll ) { + const shouldStop = + context.autoScroll === true || + ( typeof context.autoScroll === 'object' && + context.autoScroll.stopOnInteraction !== false ); + + if ( shouldStop ) { + if ( typeof autoScroll.destroy === 'function' ) { + autoScroll.destroy(); + } else if ( typeof autoScroll.stop === 'function' ) { + autoScroll.stop(); + } + } else if ( typeof autoScroll.reset === 'function' ) { + autoScroll.reset(); + } + } +}; + store( 'rt-carousel/carousel', { state: { get canScrollPrev() { @@ -141,6 +196,9 @@ store( 'rt-carousel/carousel', { const element = getElementRef( getElement() ); const embla = getEmblaFromElement( element ); if ( embla ) { + const context = getContext(); + stopPluginsOnInteraction( embla, context ); + if ( embla.canScrollPrev() ) { markForAnnouncement(); } @@ -154,6 +212,9 @@ store( 'rt-carousel/carousel', { const element = getElementRef( getElement() ); const embla = getEmblaFromElement( element ); if ( embla ) { + const context = getContext(); + stopPluginsOnInteraction( embla, context ); + if ( embla.canScrollNext() ) { markForAnnouncement(); } @@ -173,6 +234,8 @@ store( 'rt-carousel/carousel', { const element = getElementRef( getElement() ); const embla = getEmblaFromElement( element ); if ( embla ) { + stopPluginsOnInteraction( embla, context ); + if ( snap.index !== context.selectedIndex ) { markForAnnouncement(); }