diff --git a/package-lock.json b/package-lock.json index 053547d..f164ebb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@wordpress/icons": "11.5.0", "@wordpress/interactivity": "6.37.0", "embla-carousel": "8.6.0", + "embla-carousel-auto-scroll": "8.6.0", "embla-carousel-autoplay": "8.6.0", "embla-carousel-fade": "8.6.0", "react": "^18.3.1", @@ -12728,6 +12729,15 @@ "version": "8.6.0", "license": "MIT" }, + "node_modules/embla-carousel-auto-scroll": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-auto-scroll/-/embla-carousel-auto-scroll-8.6.0.tgz", + "integrity": "sha512-WT9fWhNXFpbQ6kP+aS07oF5IHYLZ1Dx4DkwgCY8Hv2ZyYd2KMCPfMV1q/cA3wFGuLO7GMgKiySLX90/pQkcOdQ==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, "node_modules/embla-carousel-autoplay": { "version": "8.6.0", "license": "MIT", @@ -23504,7 +23514,7 @@ }, "node_modules/typescript": { "version": "5.9.3", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 52d4139..7dd037c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@wordpress/icons": "11.5.0", "@wordpress/interactivity": "6.37.0", "embla-carousel": "8.6.0", + "embla-carousel-auto-scroll": "8.6.0", "embla-carousel-autoplay": "8.6.0", "embla-carousel-fade": "8.6.0", "react": "^18.3.1", diff --git a/src/blocks/carousel/__tests__/edit.test.tsx b/src/blocks/carousel/__tests__/edit.test.tsx index cbe02f4..716238b 100644 --- a/src/blocks/carousel/__tests__/edit.test.tsx +++ b/src/blocks/carousel/__tests__/edit.test.tsx @@ -159,6 +159,12 @@ const createAttributes = (): CarouselAttributes => ( { ariaLabel: 'Carousel', slidesToScroll: '1', slideGap: 0, + autoScroll: false, + autoScrollSpeed: 2, + autoScrollDirection: 'forward' as const, + autoScrollStartDelay: 1000, + autoScrollStopOnInteraction: true, + autoScrollStopOnMouseEnter: false, } ); describe( 'Carousel Edit setup flow', () => { @@ -220,6 +226,16 @@ describe( 'Carousel Edit setup flow', () => { } } ); + it( 'should have correct default autoScroll attributes', () => { + const attributes = createAttributes(); + expect( attributes.autoScroll ).toBe( false ); + expect( attributes.autoScrollSpeed ).toBe( 2 ); + expect( attributes.autoScrollDirection ).toBe( 'forward' ); + expect( attributes.autoScrollStartDelay ).toBe( 1000 ); + expect( attributes.autoScrollStopOnInteraction ).toBe( true ); + expect( attributes.autoScrollStopOnMouseEnter ).toBe( false ); + } ); + it( 'renders a Transition select and hides slide-only controls when fade is active', () => { render( { ariaLabel: 'Image carousel', slideGap: 16, slidesToScroll: '1', + autoScroll: false, + autoScrollSpeed: 2, + autoScrollDirection: 'forward', + autoScrollStartDelay: 1000, + autoScrollStopOnInteraction: true, + autoScrollStopOnMouseEnter: false, }; expect( attributes ).toBeDefined(); @@ -60,6 +66,12 @@ describe( 'CarouselAttributes Type', () => { ariaLabel: '', slideGap: 0, slidesToScroll: 'auto', + autoScroll: false, + autoScrollSpeed: 2, + autoScrollDirection: 'forward', + autoScrollStartDelay: 1000, + autoScrollStopOnInteraction: true, + autoScrollStopOnMouseEnter: false, }; // Verify all keys exist @@ -268,6 +280,7 @@ describe( 'CarouselContext Type', () => { scrollProgress: 0, slideCount: 2, ariaLabelPattern: 'Go to slide %d', + autoScroll: false, }; expect( context.autoplay ).toBe( false ); @@ -295,6 +308,7 @@ describe( 'CarouselContext Type', () => { scrollProgress: 0.5, slideCount: 3, ariaLabelPattern: 'Slide %d of 3', + autoScroll: false, }; expect( context.autoplay ).not.toBe( false ); @@ -323,6 +337,7 @@ describe( 'CarouselContext Type', () => { scrollProgress: 0, slideCount: 3, ariaLabelPattern: 'Slide %d', + autoScroll: false, }; expect( context.selectedIndex ).toBe( 0 ); @@ -344,6 +359,7 @@ describe( 'CarouselContext Type', () => { scrollProgress: 0.5, slideCount: 3, ariaLabelPattern: 'Slide %d', + autoScroll: false, }; expect( context.selectedIndex ).toBe( 1 ); @@ -365,6 +381,7 @@ describe( 'CarouselContext Type', () => { scrollProgress: 1, slideCount: 3, ariaLabelPattern: 'Slide %d', + autoScroll: false, }; expect( context.selectedIndex ).toBe( 2 ); @@ -386,6 +403,7 @@ describe( 'CarouselContext Type', () => { scrollProgress: 0, slideCount: 1, ariaLabelPattern: 'Slide %d', + autoScroll: false, }; expect( context.scrollSnaps ).toHaveLength( 1 ); @@ -409,6 +427,7 @@ describe( 'CarouselContext Type', () => { scrollProgress: 0, slideCount: 1, ariaLabelPattern: 'Slide %d', + autoScroll: false, }; expect( context.timerIterationId ).toBe( 5 ); @@ -432,6 +451,7 @@ describe( 'CarouselContext Type', () => { scrollProgress: 0, slideCount: 0, ariaLabelPattern: 'Slide %d', + autoScroll: false, }; expect( context.timerIterationId ).toBe( 0 ); @@ -456,6 +476,7 @@ describe( 'CarouselContext Type', () => { slideCount: 0, ariaLabelPattern: 'Slide %d', ref: element, + autoScroll: false, }; expect( context.ref ).toBe( element ); @@ -477,6 +498,7 @@ describe( 'CarouselContext Type', () => { slideCount: 0, ariaLabelPattern: 'Slide %d', ref: null, + autoScroll: false, }; expect( context.ref ).toBeNull(); @@ -496,6 +518,7 @@ describe( 'CarouselContext Type', () => { scrollProgress: 0, slideCount: 0, ariaLabelPattern: 'Slide %d', + autoScroll: false, }; expect( context.ref ).toBeUndefined(); @@ -520,6 +543,7 @@ describe( 'CarouselContext Type', () => { scrollProgress: 0, slideCount: 0, ariaLabelPattern: 'Slide %d', + autoScroll: false, }; expect( context.options.slidesToScroll ).toBe( 2 ); @@ -542,6 +566,7 @@ describe( 'CarouselContext Type', () => { scrollProgress: 0, slideCount: 0, ariaLabelPattern: 'Slide %d', + autoScroll: false, }; expect( context.options.slidesToScroll ).toBe( 'auto' ); @@ -571,6 +596,7 @@ describe( 'CarouselContext Type', () => { scrollProgress: 0, slideCount: 0, ariaLabelPattern: pattern, + autoScroll: false, }; expect( context.ariaLabelPattern ).toBe( pattern ); @@ -593,6 +619,7 @@ describe( 'CarouselContext Type', () => { scrollProgress: 0, slideCount: 0, ariaLabelPattern: 'Slide %d', + autoScroll: false, }; expect( context.scrollSnaps ).toHaveLength( 0 ); @@ -620,6 +647,7 @@ describe( 'CarouselContext Type', () => { scrollProgress: 0, slideCount: 5, ariaLabelPattern: 'Slide %d', + autoScroll: false, }; expect( context.scrollSnaps ).toHaveLength( 5 ); diff --git a/src/blocks/carousel/__tests__/view.test.ts b/src/blocks/carousel/__tests__/view.test.ts index cb6be3f..0daab79 100644 --- a/src/blocks/carousel/__tests__/view.test.ts +++ b/src/blocks/carousel/__tests__/view.test.ts @@ -70,6 +70,7 @@ const createMockContext = ( scrollProgress: 0, slideCount: 3, ariaLabelPattern: 'Go to slide %d', + autoScroll: false, ...overrides, } ); @@ -1094,4 +1095,34 @@ describe( 'Edge Cases and Error Handling', () => { expect( mockContext.scrollSnaps ).toHaveLength( 100 ); expect( mockContext.selectedIndex ).toBe( 50 ); } ); + + it( 'should handle autoScroll configuration', () => { + const mockContextWithAutoScroll = createMockContext( { + autoScroll: { + speed: 3, + direction: 'forward', + startDelay: 500, + stopOnInteraction: false, + stopOnMouseEnter: true, + stopOnFocusIn: false, + }, + } ); + + expect( mockContextWithAutoScroll.autoScroll ).toEqual( { + speed: 3, + direction: 'forward', + startDelay: 500, + stopOnInteraction: false, + stopOnMouseEnter: true, + stopOnFocusIn: false, + } ); + } ); + + it( 'should handle autoScroll disabled', () => { + const mockContext = createMockContext( { + autoScroll: false, + } ); + + expect( mockContext.autoScroll ).toBe( false ); + } ); } ); diff --git a/src/blocks/carousel/block.json b/src/blocks/carousel/block.json index e58de77..8abc478 100644 --- a/src/blocks/carousel/block.json +++ b/src/blocks/carousel/block.json @@ -90,6 +90,31 @@ "slidesToScroll": { "type": "string", "default": "1" + }, + "autoScroll": { + "type": "boolean", + "default": false + }, + "autoScrollSpeed": { + "type": "number", + "default": 2 + }, + "autoScrollDirection": { + "type": "string", + "default": "forward", + "enum": ["forward", "backward"] + }, + "autoScrollStartDelay": { + "type": "number", + "default": 1000 + }, + "autoScrollStopOnInteraction": { + "type": "boolean", + "default": true + }, + "autoScrollStopOnMouseEnter": { + "type": "boolean", + "default": false } }, "editorScript": "file:./index.js", diff --git a/src/blocks/carousel/edit.tsx b/src/blocks/carousel/edit.tsx index 907a800..0f2ca83 100644 --- a/src/blocks/carousel/edit.tsx +++ b/src/blocks/carousel/edit.tsx @@ -55,6 +55,12 @@ export default function Edit( { autoplayStopOnMouseEnter, ariaLabel, slidesToScroll = '1', + autoScroll, + autoScrollSpeed, + autoScrollDirection, + autoScrollStartDelay, + autoScrollStopOnInteraction, + autoScrollStopOnMouseEnter, } = attributes; const [ emblaApi, setEmblaApi ] = useState(); @@ -331,11 +337,11 @@ export default function Edit( { setAttributes( { loop: value } ) } - help={ __( - 'Enables infinite scrolling of slides.', - 'rt-carousel', - ) } + help={ autoScroll && autoScrollDirection === 'backward' + ? __( 'Loop is required for backward auto scroll.', 'rt-carousel' ) + : __( 'Enables infinite scrolling of slides.', 'rt-carousel' ) } /> { transition !== 'fade' && ( setAttributes( { autoplay: value } ) } + onChange={ ( value ) => { + setAttributes( { + autoplay: value, + autoScroll: value ? false : autoScroll, + } ); + } } /> { autoplay && ( <> @@ -485,6 +496,71 @@ export default function Edit( { ) } + + setAttributes( { + autoScroll: value, + autoplay: value ? false : autoplay, + loop: ( value && autoScrollDirection === 'backward' ) ? true : loop, + } ) } + /> + { autoScroll && ( <> + + setAttributes( { autoScrollSpeed: value ?? 2 } ) + } + min={ 1 } + max={ 10 } + /> + + setAttributes( { + autoScrollDirection: value as CarouselAttributes['autoScrollDirection'], + loop: value === 'backward' ? true : loop, + } ) + } + /> + + setAttributes( { autoScrollStartDelay: value ?? 1000 } ) + } + min={ 0 } + max={ 10000 } + step={ 100 } + /> + + setAttributes( { autoScrollStopOnInteraction: value } ) + } + help={ __( 'Stop auto scroll when user interacts with carousel.', 'rt-carousel' ) } + /> + + setAttributes( { autoScrollStopOnMouseEnter: value } ) + } + help={ __( 'Stop auto scroll when mouse hovers over carousel.', 'rt-carousel' ) } + /> + ) } + ; @@ -67,4 +73,12 @@ export type CarouselContext = { ref?: HTMLElement | null; slideCount: number; initialized?: boolean; + autoScroll: boolean | { + speed: number; + direction: 'forward' | 'backward'; + startDelay: number; + stopOnInteraction: boolean; + stopOnMouseEnter: boolean; + stopOnFocusIn: boolean; + }; }; diff --git a/src/blocks/carousel/view.ts b/src/blocks/carousel/view.ts index 3f33f7b..cade24b 100644 --- a/src/blocks/carousel/view.ts +++ b/src/blocks/carousel/view.ts @@ -4,6 +4,8 @@ import EmblaCarousel, { type EmblaCarouselType, } from 'embla-carousel'; import Autoplay, { type AutoplayOptionsType } from 'embla-carousel-autoplay'; +import AutoScroll, { type AutoScrollOptionsType } from 'embla-carousel-auto-scroll'; + import Fade from 'embla-carousel-fade'; import type { CarouselContext } from './types'; import { @@ -318,6 +320,10 @@ store( 'rt-carousel/carousel', { plugins.push( Autoplay( context.autoplay as AutoplayOptionsType ) ); } + if ( context.autoScroll ) { + plugins.push( AutoScroll( context.autoScroll as AutoScrollOptionsType ) ); + } + const embla = EmblaCarousel( viewport, options, plugins ); emblaInstances.set( viewport, embla );