From c43d7743f1015b6e4d43a2eef02f0f12ac642a5a Mon Sep 17 00:00:00 2001 From: Milind More Date: Fri, 10 Jul 2026 15:59:00 +0530 Subject: [PATCH 1/3] feat: add logic to destroy or reset autoplay and autoscroll plugins on carousel interaction --- src/blocks/carousel/__tests__/view.test.ts | 86 ++++++++++++++++++++++ src/blocks/carousel/view.ts | 55 ++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/src/blocks/carousel/__tests__/view.test.ts b/src/blocks/carousel/__tests__/view.test.ts index 0daab79..2cc6318 100644 --- a/src/blocks/carousel/__tests__/view.test.ts +++ b/src/blocks/carousel/__tests__/view.test.ts @@ -257,6 +257,92 @@ 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 ); + + storeConfig.actions.scrollPrev(); + + expect( mockAutoplay.destroy ).toHaveBeenCalledTimes( 1 ); + expect( mockAutoScroll.destroy ).toHaveBeenCalledTimes( 1 ); + expect( mockAutoplay.reset ).not.toHaveBeenCalled(); + expect( mockAutoScroll.reset ).not.toHaveBeenCalled(); + + 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 ); + + storeConfig.actions.scrollPrev(); + + expect( mockAutoplay.destroy ).not.toHaveBeenCalled(); + expect( mockAutoScroll.destroy ).not.toHaveBeenCalled(); + expect( mockAutoplay.reset ).toHaveBeenCalledTimes( 1 ); + expect( mockAutoScroll.reset ).toHaveBeenCalledTimes( 1 ); + + document.body.removeChild( wrapper ); + } ); } ); describe( 'scrollNext', () => { diff --git a/src/blocks/carousel/view.ts b/src/blocks/carousel/view.ts index cade24b..8d7d9ee 100644 --- a/src/blocks/carousel/view.ts +++ b/src/blocks/carousel/view.ts @@ -125,6 +125,53 @@ const markForAnnouncement = (): void => { getContext().shouldAnnounce = true; }; +const stopPluginsOnInteraction = ( + embla: EmblaCarouselType, + context: CarouselContext, +): void => { + if ( typeof embla.plugins !== 'function' ) { + return; + } + const plugins = embla.plugins() as { + autoplay?: { stop?: () => void; destroy?: () => void; reset?: () => void }; + autoScroll?: { stop?: () => void; destroy?: () => void; reset?: () => void }; + }; + const autoplay = plugins.autoplay; + const autoScroll = plugins.autoScroll; + + if ( autoplay && typeof autoplay.stop === 'function' ) { + if ( + context.autoplay === true || + ( typeof context.autoplay === 'object' && + context.autoplay.stopOnInteraction ) + ) { + if ( typeof autoplay.destroy === 'function' ) { + autoplay.destroy(); + } else { + autoplay.stop(); + } + } else if ( typeof autoplay.reset === 'function' ) { + autoplay.reset(); + } + } + + if ( autoScroll && typeof autoScroll.stop === 'function' ) { + if ( + context.autoScroll === true || + ( typeof context.autoScroll === 'object' && + context.autoScroll.stopOnInteraction ) + ) { + if ( typeof autoScroll.destroy === 'function' ) { + autoScroll.destroy(); + } else { + autoScroll.stop(); + } + } else if ( typeof autoScroll.reset === 'function' ) { + autoScroll.reset(); + } + } +}; + store( 'rt-carousel/carousel', { state: { get canScrollPrev() { @@ -141,6 +188,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 +204,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 +226,8 @@ store( 'rt-carousel/carousel', { const element = getElementRef( getElement() ); const embla = getEmblaFromElement( element ); if ( embla ) { + stopPluginsOnInteraction( embla, context ); + if ( snap.index !== context.selectedIndex ) { markForAnnouncement(); } From 3f014d91f11d9b7ccab09c95477c423539ca68e0 Mon Sep 17 00:00:00 2001 From: Milind More Date: Fri, 10 Jul 2026 16:48:58 +0530 Subject: [PATCH 2/3] fix: update carousel plugin interaction logic to default stopOnInteraction to true --- src/blocks/carousel/__tests__/view.test.ts | 126 +++++++++++++++++++++ src/blocks/carousel/view.ts | 14 ++- 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/src/blocks/carousel/__tests__/view.test.ts b/src/blocks/carousel/__tests__/view.test.ts index 2cc6318..a5808ae 100644 --- a/src/blocks/carousel/__tests__/view.test.ts +++ b/src/blocks/carousel/__tests__/view.test.ts @@ -343,6 +343,47 @@ describe( 'Carousel View Module', () => { 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 ); + + storeConfig.actions.scrollPrev(); + + expect( mockAutoplay.destroy ).toHaveBeenCalledTimes( 1 ); + expect( mockAutoScroll.destroy ).toHaveBeenCalledTimes( 1 ); + + document.body.removeChild( wrapper ); + } ); } ); describe( 'scrollNext', () => { @@ -381,6 +422,47 @@ 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 ); + + storeConfig.actions.scrollNext(); + + expect( mockAutoplay.destroy ).toHaveBeenCalledTimes( 1 ); + expect( mockAutoScroll.destroy ).toHaveBeenCalledTimes( 1 ); + + document.body.removeChild( wrapper ); + } ); } ); describe( 'onDotClick', () => { @@ -442,6 +524,50 @@ 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 ); + + storeConfig.actions.onDotClick(); + + expect( mockAutoplay.destroy ).toHaveBeenCalledTimes( 1 ); + expect( mockAutoScroll.destroy ).toHaveBeenCalledTimes( 1 ); + + document.body.removeChild( wrapper ); + } ); } ); } ); diff --git a/src/blocks/carousel/view.ts b/src/blocks/carousel/view.ts index 8d7d9ee..18c1e5d 100644 --- a/src/blocks/carousel/view.ts +++ b/src/blocks/carousel/view.ts @@ -125,6 +125,12 @@ const markForAnnouncement = (): void => { getContext().shouldAnnounce = true; }; +type StoppablePlugin = { + stop?: () => void; + destroy?: () => void; + reset?: () => void; +}; + const stopPluginsOnInteraction = ( embla: EmblaCarouselType, context: CarouselContext, @@ -133,8 +139,8 @@ const stopPluginsOnInteraction = ( return; } const plugins = embla.plugins() as { - autoplay?: { stop?: () => void; destroy?: () => void; reset?: () => void }; - autoScroll?: { stop?: () => void; destroy?: () => void; reset?: () => void }; + autoplay?: StoppablePlugin; + autoScroll?: StoppablePlugin; }; const autoplay = plugins.autoplay; const autoScroll = plugins.autoScroll; @@ -143,7 +149,7 @@ const stopPluginsOnInteraction = ( if ( context.autoplay === true || ( typeof context.autoplay === 'object' && - context.autoplay.stopOnInteraction ) + context.autoplay.stopOnInteraction !== false ) ) { if ( typeof autoplay.destroy === 'function' ) { autoplay.destroy(); @@ -159,7 +165,7 @@ const stopPluginsOnInteraction = ( if ( context.autoScroll === true || ( typeof context.autoScroll === 'object' && - context.autoScroll.stopOnInteraction ) + context.autoScroll.stopOnInteraction !== false ) ) { if ( typeof autoScroll.destroy === 'function' ) { autoScroll.destroy(); From 0c476f22937ddfc5a69eec3d26f7d8353ae567b3 Mon Sep 17 00:00:00 2001 From: Milind More Date: Fri, 10 Jul 2026 17:33:21 +0530 Subject: [PATCH 3/3] refactor: prioritize autoplay and autoscroll destruction and ensure test cleanup with try-finally blocks --- src/blocks/carousel/__tests__/view.test.ts | 68 +++++++++++++--------- src/blocks/carousel/view.ts | 22 +++---- 2 files changed, 51 insertions(+), 39 deletions(-) diff --git a/src/blocks/carousel/__tests__/view.test.ts b/src/blocks/carousel/__tests__/view.test.ts index a5808ae..a6c5b30 100644 --- a/src/blocks/carousel/__tests__/view.test.ts +++ b/src/blocks/carousel/__tests__/view.test.ts @@ -291,14 +291,16 @@ describe( 'Carousel View Module', () => { ( getElement as jest.Mock ).mockReturnValue( { ref: button } ); document.body.appendChild( wrapper ); - storeConfig.actions.scrollPrev(); - - expect( mockAutoplay.destroy ).toHaveBeenCalledTimes( 1 ); - expect( mockAutoScroll.destroy ).toHaveBeenCalledTimes( 1 ); - expect( mockAutoplay.reset ).not.toHaveBeenCalled(); - expect( mockAutoScroll.reset ).not.toHaveBeenCalled(); + try { + storeConfig.actions.scrollPrev(); - document.body.removeChild( wrapper ); + 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', () => { @@ -334,14 +336,16 @@ describe( 'Carousel View Module', () => { ( getElement as jest.Mock ).mockReturnValue( { ref: button } ); document.body.appendChild( wrapper ); - storeConfig.actions.scrollPrev(); - - expect( mockAutoplay.destroy ).not.toHaveBeenCalled(); - expect( mockAutoScroll.destroy ).not.toHaveBeenCalled(); - expect( mockAutoplay.reset ).toHaveBeenCalledTimes( 1 ); - expect( mockAutoScroll.reset ).toHaveBeenCalledTimes( 1 ); + try { + storeConfig.actions.scrollPrev(); - document.body.removeChild( wrapper ); + 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)', () => { @@ -377,12 +381,14 @@ describe( 'Carousel View Module', () => { ( getElement as jest.Mock ).mockReturnValue( { ref: button } ); document.body.appendChild( wrapper ); - storeConfig.actions.scrollPrev(); - - expect( mockAutoplay.destroy ).toHaveBeenCalledTimes( 1 ); - expect( mockAutoScroll.destroy ).toHaveBeenCalledTimes( 1 ); + try { + storeConfig.actions.scrollPrev(); - document.body.removeChild( wrapper ); + expect( mockAutoplay.destroy ).toHaveBeenCalledTimes( 1 ); + expect( mockAutoScroll.destroy ).toHaveBeenCalledTimes( 1 ); + } finally { + document.body.removeChild( wrapper ); + } } ); } ); @@ -456,12 +462,14 @@ describe( 'Carousel View Module', () => { ( getElement as jest.Mock ).mockReturnValue( { ref: button } ); document.body.appendChild( wrapper ); - storeConfig.actions.scrollNext(); - - expect( mockAutoplay.destroy ).toHaveBeenCalledTimes( 1 ); - expect( mockAutoScroll.destroy ).toHaveBeenCalledTimes( 1 ); + try { + storeConfig.actions.scrollNext(); - document.body.removeChild( wrapper ); + expect( mockAutoplay.destroy ).toHaveBeenCalledTimes( 1 ); + expect( mockAutoScroll.destroy ).toHaveBeenCalledTimes( 1 ); + } finally { + document.body.removeChild( wrapper ); + } } ); } ); @@ -561,12 +569,14 @@ describe( 'Carousel View Module', () => { ( getElement as jest.Mock ).mockReturnValue( { ref: button } ); document.body.appendChild( wrapper ); - storeConfig.actions.onDotClick(); - - expect( mockAutoplay.destroy ).toHaveBeenCalledTimes( 1 ); - expect( mockAutoScroll.destroy ).toHaveBeenCalledTimes( 1 ); + try { + storeConfig.actions.onDotClick(); - document.body.removeChild( wrapper ); + 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 18c1e5d..3d7d925 100644 --- a/src/blocks/carousel/view.ts +++ b/src/blocks/carousel/view.ts @@ -145,15 +145,16 @@ const stopPluginsOnInteraction = ( const autoplay = plugins.autoplay; const autoScroll = plugins.autoScroll; - if ( autoplay && typeof autoplay.stop === 'function' ) { - if ( + if ( autoplay ) { + const shouldStop = context.autoplay === true || ( typeof context.autoplay === 'object' && - context.autoplay.stopOnInteraction !== false ) - ) { + context.autoplay.stopOnInteraction !== false ); + + if ( shouldStop ) { if ( typeof autoplay.destroy === 'function' ) { autoplay.destroy(); - } else { + } else if ( typeof autoplay.stop === 'function' ) { autoplay.stop(); } } else if ( typeof autoplay.reset === 'function' ) { @@ -161,15 +162,16 @@ const stopPluginsOnInteraction = ( } } - if ( autoScroll && typeof autoScroll.stop === 'function' ) { - if ( + if ( autoScroll ) { + const shouldStop = context.autoScroll === true || ( typeof context.autoScroll === 'object' && - context.autoScroll.stopOnInteraction !== false ) - ) { + context.autoScroll.stopOnInteraction !== false ); + + if ( shouldStop ) { if ( typeof autoScroll.destroy === 'function' ) { autoScroll.destroy(); - } else { + } else if ( typeof autoScroll.stop === 'function' ) { autoScroll.stop(); } } else if ( typeof autoScroll.reset === 'function' ) {