From e5aaec83be55780bd830b650b7934f2749f13e1a Mon Sep 17 00:00:00 2001 From: Danish Shakeel Date: Fri, 3 Jul 2026 17:03:52 +0200 Subject: [PATCH 1/4] chore(deps): add embla-carousel-fade Needed to implement the fade transition option for the carousel block. --- package-lock.json | 12 +++++++++++- package.json | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index a009bfe..053547d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@wordpress/interactivity": "6.37.0", "embla-carousel": "8.6.0", "embla-carousel-autoplay": "8.6.0", + "embla-carousel-fade": "8.6.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -12734,6 +12735,15 @@ "embla-carousel": "8.6.0" } }, + "node_modules/embla-carousel-fade": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-fade/-/embla-carousel-fade-8.6.0.tgz", + "integrity": "sha512-qaYsx5mwCz72ZrjlsXgs1nKejSrW+UhkbOMwLgfRT7w2LtdEB03nPRI06GHuHv5ac2USvbEiX2/nAHctcDwvpg==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, "node_modules/emittery": { "version": "0.13.1", "dev": true, @@ -23494,7 +23504,7 @@ }, "node_modules/typescript": { "version": "5.9.3", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index be7dac6..52d4139 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@wordpress/interactivity": "6.37.0", "embla-carousel": "8.6.0", "embla-carousel-autoplay": "8.6.0", + "embla-carousel-fade": "8.6.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, From f882a3a2642e7bf6799141c8e15bb5133445f425 Mon Sep 17 00:00:00 2001 From: Danish Shakeel Date: Fri, 3 Jul 2026 17:04:12 +0200 Subject: [PATCH 2/4] feat(carousel): add fade transition option Adds a Transition setting (Slide | Fade) backed by embla-carousel-fade, wired the same way as the existing Autoplay plugin. Fade forces align/containScroll/dragFree/slidesToScroll to compatible values under the hood and hides the now-irrelevant inspector controls. Editor canvas loads the same plugin so the fade preview matches the frontend. Includes a deprecated save() entry so posts saved before this change keep validating. --- src/blocks/carousel/block.json | 5 + src/blocks/carousel/deprecated.tsx | 140 ++++++++++++++++++++++++++ src/blocks/carousel/edit.tsx | 109 ++++++++++++-------- src/blocks/carousel/embla-options.ts | 25 +++++ src/blocks/carousel/save.tsx | 2 + src/blocks/carousel/types.ts | 2 + src/blocks/carousel/view.ts | 26 +++-- src/blocks/carousel/viewport/edit.tsx | 38 ++++--- 8 files changed, 281 insertions(+), 66 deletions(-) diff --git a/src/blocks/carousel/block.json b/src/blocks/carousel/block.json index e0df7ca..e58de77 100644 --- a/src/blocks/carousel/block.json +++ b/src/blocks/carousel/block.json @@ -28,6 +28,11 @@ "type": "array", "default": [] }, + "transition": { + "type": "string", + "default": "slide", + "enum": ["slide", "fade"] + }, "loop": { "type": "boolean", "default": false diff --git a/src/blocks/carousel/deprecated.tsx b/src/blocks/carousel/deprecated.tsx index 2107d9a..f7af9d6 100644 --- a/src/blocks/carousel/deprecated.tsx +++ b/src/blocks/carousel/deprecated.tsx @@ -86,7 +86,147 @@ function SaveV200( { return
; } +/** + * v2.0.3 save — before the `transition` (slide/fade) attribute was added + * to context. + * + * @param {Object} root0 Component props. + * @param {CarouselAttributes} root0.attributes Block attributes. + */ +function SaveV203( { + attributes, +}: { + attributes: CarouselAttributes; +} ) { + const { + loop, + dragFree, + carouselAlign, + containScroll, + direction, + autoplay, + autoplayDelay, + autoplayStopOnInteraction, + autoplayStopOnMouseEnter, + ariaLabel, + slideGap, + axis, + height, + slidesToScroll, + } = attributes; + + const context = { + options: { + loop, + dragFree, + align: carouselAlign, + containScroll, + direction, + axis, + slidesToScroll: slidesToScroll === 'auto' ? 'auto' : parseInt( slidesToScroll, 10 ), + }, + autoplay: autoplay + ? { + delay: autoplayDelay, + stopOnInteraction: autoplayStopOnInteraction, + stopOnMouseEnter: autoplayStopOnMouseEnter, + } + : false, + isPlaying: !! autoplay, + timerIterationId: 0, + selectedIndex: -1, + scrollSnaps: [] as { index: number }[], + canScrollPrev: false, + canScrollNext: false, + scrollProgress: 0, + slideCount: 0, + /* translators: %d: slide number */ + ariaLabelPattern: __( 'Go to slide %d', 'rt-carousel' ), + /* translators: {{currentSlide}}: current slide number, {{totalSlides}}: total slide count. */ + countLabelPattern: __( + 'Slide {{currentSlide}} of {{totalSlides}}', + 'rt-carousel', + ), + announcement: '', + shouldAnnounce: false, + /* translators: {{currentSlide}}: current slide number, {{totalSlides}}: total slide count. */ + announcementPattern: __( + 'Slide {{currentSlide}} of {{totalSlides}}', + 'rt-carousel', + ), + }; + + const blockProps = useBlockProps.save( { + className: 'rt-carousel', + role: 'region', + 'aria-roledescription': 'carousel', + 'aria-label': ariaLabel, + dir: direction, + 'data-axis': axis, + 'data-loop': loop ? 'true' : undefined, + 'data-wp-interactive': 'rt-carousel/carousel', + 'data-wp-context': JSON.stringify( context ), + 'data-wp-init': 'callbacks.initCarousel', + style: { + '--rt-carousel-gap': `${ slideGap }px`, + '--rt-carousel-height': axis === 'y' ? height : undefined, + } as React.CSSProperties, + } ); + + const innerBlocksProps = useInnerBlocksProps.save( blockProps ) as ReturnType< + typeof useInnerBlocksProps.save + > & { + children: React.ReactNode; + }; + const { children, ...wrapperProps } = innerBlocksProps; + const announcementLiveRegion = ( + + ); + + return ( +
+ { children } + { announcementLiveRegion } +
+ ); +} + const deprecated = [ + { + attributes: { + allowedSlideBlocks: { type: 'array' as const, default: [] }, + loop: { type: 'boolean' as const, default: false }, + dragFree: { type: 'boolean' as const, default: false }, + carouselAlign: { type: 'string' as const, default: 'start' }, + containScroll: { type: 'string' as const, default: 'trimSnaps' }, + direction: { type: 'string' as const, default: 'ltr' }, + axis: { type: 'string' as const, default: 'x' }, + height: { type: 'string' as const, default: '300px' }, + autoplay: { type: 'boolean' as const, default: false }, + autoplayDelay: { type: 'number' as const, default: 4000 }, + autoplayStopOnInteraction: { type: 'boolean' as const, default: true }, + autoplayStopOnMouseEnter: { type: 'boolean' as const, default: false }, + ariaLabel: { type: 'string' as const, default: 'Carousel' }, + slideGap: { type: 'number' as const, default: 0 }, + slidesToScroll: { type: 'string' as const, default: '1' }, + }, + supports: { + interactivity: true, + align: [ 'wide', 'full' ], + html: false, + color: { + text: false, + background: true, + }, + }, + save: SaveV203, + }, { attributes: { allowedSlideBlocks: { type: 'array' as const, default: [] }, diff --git a/src/blocks/carousel/edit.tsx b/src/blocks/carousel/edit.tsx index f4f4be8..907a800 100644 --- a/src/blocks/carousel/edit.tsx +++ b/src/blocks/carousel/edit.tsx @@ -40,6 +40,7 @@ export default function Edit( { clientId: string; } ) { const { + transition, loop, dragFree, carouselAlign, @@ -170,6 +171,7 @@ export default function Edit( { const carouselOptions = useMemo( () => ( { + transition, loop, dragFree, align: carouselAlign, @@ -179,7 +181,7 @@ export default function Edit( { height, slidesToScroll: slidesToScroll === 'auto' ? 'auto' : parseInt( slidesToScroll, 10 ), } ), - [ loop, dragFree, carouselAlign, containScroll, direction, axis, height, slidesToScroll ], + [ transition, loop, dragFree, carouselAlign, containScroll, direction, axis, height, slidesToScroll ], ); const contextValue = useMemo( @@ -311,61 +313,84 @@ export default function Edit( { <> - setAttributes( { loop: value } ) } - help={ __( - 'Enables infinite scrolling of slides.', - 'rt-carousel', - ) } - /> - setAttributes( { dragFree: value } ) } - help={ __( 'Enables momentum scrolling.', 'rt-carousel' ) } - /> - setAttributes( { carouselAlign: value as CarouselAttributes[ 'carouselAlign' ] } ) - } - /> - - setAttributes( { containScroll: value as CarouselAttributes[ 'containScroll' ] } ) + setAttributes( { transition: value as CarouselAttributes[ 'transition' ] } ) } help={ __( - 'Prevents excess scrolling at the beginning or end.', + 'Choose how slides transition: sliding horizontally or cross-fading.', 'rt-carousel', ) } /> - setAttributes( { slidesToScroll: isAuto ? 'auto' : '1' } ) - } + label={ __( 'Loop', 'rt-carousel' ) } + checked={ loop } + onChange={ ( value ) => setAttributes( { loop: value } ) } help={ __( - 'Scrolls the number of slides currently visible in the viewport.', + 'Enables infinite scrolling of slides.', 'rt-carousel', ) } /> - { slidesToScroll !== 'auto' && ( + { transition !== 'fade' && ( + setAttributes( { dragFree: value } ) } + help={ __( 'Enables momentum scrolling.', 'rt-carousel' ) } + /> + ) } + { transition !== 'fade' && ( + + setAttributes( { carouselAlign: value as CarouselAttributes[ 'carouselAlign' ] } ) + } + /> + ) } + { transition !== 'fade' && ( + + setAttributes( { containScroll: value as CarouselAttributes[ 'containScroll' ] } ) + } + help={ __( + 'Prevents excess scrolling at the beginning or end.', + 'rt-carousel', + ) } + /> + ) } + { transition !== 'fade' && ( + + setAttributes( { slidesToScroll: isAuto ? 'auto' : '1' } ) + } + help={ __( + 'Scrolls the number of slides currently visible in the viewport.', + 'rt-carousel', + ) } + /> + ) } + { transition !== 'fade' && slidesToScroll !== 'auto' && ( { + if ( transition !== 'fade' ) { + return options; + } + + return { + ...options, + align: 'center', + containScroll: false, + dragFree: false, + slidesToScroll: 1, + }; +}; diff --git a/src/blocks/carousel/save.tsx b/src/blocks/carousel/save.tsx index 73ef6ca..d0113af 100644 --- a/src/blocks/carousel/save.tsx +++ b/src/blocks/carousel/save.tsx @@ -8,6 +8,7 @@ export default function Save( { attributes: CarouselAttributes; } ) { const { + transition, loop, dragFree, carouselAlign, @@ -26,6 +27,7 @@ export default function Save( { // Pass configuration to the frontend via data-wp-context const context: CarouselContext = { + transition, options: { loop, dragFree, diff --git a/src/blocks/carousel/types.ts b/src/blocks/carousel/types.ts index e05351b..5d43396 100644 --- a/src/blocks/carousel/types.ts +++ b/src/blocks/carousel/types.ts @@ -2,6 +2,7 @@ import type { EmblaOptionsType } from 'embla-carousel'; import type { BlockVerticalAlignmentToolbar } from '@wordpress/block-editor'; export type CarouselAttributes = { + transition: 'slide' | 'fade'; loop: boolean; dragFree: boolean; carouselAlign: 'start' | 'center' | 'end'; @@ -40,6 +41,7 @@ export interface BlockEditorSelectors { } export type CarouselContext = { + transition: 'slide' | 'fade'; options: EmblaOptionsType & { slidesToScroll?: number | 'auto'; }; diff --git a/src/blocks/carousel/view.ts b/src/blocks/carousel/view.ts index c2ae8e4..3f33f7b 100644 --- a/src/blocks/carousel/view.ts +++ b/src/blocks/carousel/view.ts @@ -4,12 +4,13 @@ import EmblaCarousel, { type EmblaCarouselType, } from 'embla-carousel'; import Autoplay, { type AutoplayOptionsType } from 'embla-carousel-autoplay'; +import Fade from 'embla-carousel-fade'; import type { CarouselContext } from './types'; import { DYNAMIC_LIST_CONTAINER_SELECTOR, CAROUSEL_SLIDE_SELECTOR, } from './dynamic-list-selectors'; -import { normalizeContainScroll } from './embla-options'; +import { normalizeContainScroll, applyTransitionOverrides } from './embla-options'; type ElementWithRef = { ref?: HTMLElement | null; @@ -295,17 +296,24 @@ store( 'rt-carousel/carousel', { slidesToScroll = rawOptions.slidesToScroll; } - const options: EmblaOptionsType = { - ...rawOptions, - align, - containScroll: normalizeContainScroll( rawOptions.containScroll ), - direction, - slidesToScroll, - container: dynamicListContainer || null, - }; + const options: EmblaOptionsType = applyTransitionOverrides( + { + ...rawOptions, + align, + containScroll: normalizeContainScroll( rawOptions.containScroll ), + direction, + slidesToScroll, + container: dynamicListContainer || null, + }, + context.transition, + ); const plugins = []; + if ( context.transition === 'fade' ) { + plugins.push( Fade() ); + } + if ( context.autoplay ) { plugins.push( Autoplay( context.autoplay as AutoplayOptionsType ) ); } diff --git a/src/blocks/carousel/viewport/edit.tsx b/src/blocks/carousel/viewport/edit.tsx index 1e1f3be..1dfe587 100644 --- a/src/blocks/carousel/viewport/edit.tsx +++ b/src/blocks/carousel/viewport/edit.tsx @@ -13,10 +13,11 @@ import type { CarouselViewportAttributes, BlockEditorSelectors } from '../types' import { useContext, useEffect, useRef, useCallback, useState } from '@wordpress/element'; import { useMergeRefs } from '@wordpress/compose'; import { EditorCarouselContext } from '../editor-context'; -import EmblaCarousel, { type EmblaCarouselType } from 'embla-carousel'; +import EmblaCarousel, { type EmblaCarouselType, type EmblaPluginType } from 'embla-carousel'; +import Fade from 'embla-carousel-fade'; import { useCarouselObservers } from '../hooks/useCarouselObservers'; import { DYNAMIC_LIST_CONTAINER_SELECTOR } from '../dynamic-list-selectors'; -import { normalizeContainScroll } from '../embla-options'; +import { normalizeContainScroll, applyTransitionOverrides } from '../embla-options'; const EMBLA_KEY = Symbol.for( 'carousel-system.carousel' ); @@ -211,19 +212,26 @@ export default function Edit( { // eslint-disable-next-line @typescript-eslint/no-explicit-any const options = carouselOptions as any; - embla = EmblaCarousel( viewport, { - loop: options?.loop ?? false, - dragFree: options?.dragFree ?? false, - containScroll: normalizeContainScroll( options?.containScroll ), - axis: options?.axis || 'x', - align: options?.align || 'start', - direction: options?.direction || 'ltr', - slidesToScroll: options?.slidesToScroll || 1, - container: dynamicListContainer || undefined, - watchDrag: false, // Clicks in slide gaps must not trigger Embla scroll in the editor. - watchSlides: false, // Gutenberg injects block UI nodes into .embla__container; Embla's built-in MutationObserver would call reInit() on those, corrupting slide order and transforms. - watchResize: false, // Replaced by a manual debounced ResizeObserver in useCarouselObservers. - } ); + const emblaOptions = applyTransitionOverrides( + { + loop: options?.loop ?? false, + dragFree: options?.dragFree ?? false, + containScroll: normalizeContainScroll( options?.containScroll ), + axis: options?.axis || 'x', + align: options?.align || 'start', + direction: options?.direction || 'ltr', + slidesToScroll: options?.slidesToScroll || 1, + container: dynamicListContainer || undefined, + watchDrag: false, // Clicks in slide gaps must not trigger Embla scroll in the editor. + watchSlides: false, // Gutenberg injects block UI nodes into .embla__container; Embla's built-in MutationObserver would call reInit() on those, corrupting slide order and transforms. + watchResize: false, // Replaced by a manual debounced ResizeObserver in useCarouselObservers. + }, + options?.transition || 'slide', + ); + + const plugins: EmblaPluginType[] = options?.transition === 'fade' ? [ Fade() ] : []; + + embla = EmblaCarousel( viewport, emblaOptions, plugins ); ( viewport as { [EMBLA_KEY]?: typeof embla } )[ EMBLA_KEY ] = embla; emblaApiRef.current = embla; From aac17e96b580d3ea81d943f9a035556886d89586 Mon Sep 17 00:00:00 2001 From: Danish Shakeel Date: Fri, 3 Jul 2026 17:04:45 +0200 Subject: [PATCH 3/4] test(carousel): cover fade transition behavior Covers the transition attribute/context shape, the Fade plugin being pushed only when transition is fade, and the inspector hiding slide-only controls when fade is active. --- src/blocks/carousel/__tests__/edit.test.tsx | 69 +++++++++++++++++ src/blocks/carousel/__tests__/types.test.ts | 29 +++++++ src/blocks/carousel/__tests__/view.test.ts | 84 +++++++++++++++++++++ tests/js/setup.ts | 13 ++++ 4 files changed, 195 insertions(+) diff --git a/src/blocks/carousel/__tests__/edit.test.tsx b/src/blocks/carousel/__tests__/edit.test.tsx index 62297fe..cbe02f4 100644 --- a/src/blocks/carousel/__tests__/edit.test.tsx +++ b/src/blocks/carousel/__tests__/edit.test.tsx @@ -9,6 +9,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import Edit from '../edit'; import type { CarouselAttributes } from '../types'; import type { ReactNode as MockReactNode } from 'react'; +import { SelectControl, ToggleControl, RangeControl } from '@wordpress/components'; let mockBlockCount = 0; @@ -142,6 +143,7 @@ jest.mock( '../components/TemplatePicker', () => ( { } ) ); const createAttributes = (): CarouselAttributes => ( { + transition: 'slide', loop: false, dragFree: false, carouselAlign: 'start', @@ -217,4 +219,71 @@ describe( 'Carousel Edit setup flow', () => { Object.defineProperty( globalThis, 'document', originalDocumentDescriptor ); } } ); + + it( 'renders a Transition select and hides slide-only controls when fade is active', () => { + render( + , + ); + + const selectLabels = ( SelectControl as unknown as jest.Mock ).mock.calls.map( + ( [ props ] ) => props.label, + ); + const toggleLabels = ( ToggleControl as unknown as jest.Mock ).mock.calls.map( + ( [ props ] ) => props.label, + ); + + expect( selectLabels ).toContain( 'Transition' ); + expect( selectLabels ).not.toContain( 'Alignment' ); + expect( selectLabels ).not.toContain( 'Contain Scroll' ); + expect( toggleLabels ).not.toContain( 'Free Drag' ); + expect( toggleLabels ).not.toContain( 'Scroll Auto' ); + expect( ( RangeControl as unknown as jest.Mock ).mock.calls.some( + ( [ props ] ) => props.label === 'Slides to Scroll', + ) ).toBe( false ); + } ); + + it( 'keeps slide-only controls visible when slide transition is active', () => { + render( + , + ); + + const selectLabels = ( SelectControl as unknown as jest.Mock ).mock.calls.map( + ( [ props ] ) => props.label, + ); + const toggleLabels = ( ToggleControl as unknown as jest.Mock ).mock.calls.map( + ( [ props ] ) => props.label, + ); + + expect( selectLabels ).toContain( 'Alignment' ); + expect( selectLabels ).toContain( 'Contain Scroll' ); + expect( toggleLabels ).toContain( 'Free Drag' ); + expect( toggleLabels ).toContain( 'Scroll Auto' ); + } ); + + it( 'calls setAttributes with the selected transition', () => { + const setAttributes = jest.fn(); + render( + , + ); + + const transitionCall = ( SelectControl as unknown as jest.Mock ).mock.calls.find( + ( [ props ] ) => props.label === 'Transition', + ); + + transitionCall[ 0 ].onChange( 'fade' ); + + expect( setAttributes ).toHaveBeenCalledWith( { transition: 'fade' } ); + } ); } ); diff --git a/src/blocks/carousel/__tests__/types.test.ts b/src/blocks/carousel/__tests__/types.test.ts index aed1c05..b25027f 100644 --- a/src/blocks/carousel/__tests__/types.test.ts +++ b/src/blocks/carousel/__tests__/types.test.ts @@ -18,6 +18,7 @@ describe( 'CarouselAttributes Type', () => { describe( 'Structure Validation', () => { it( 'should accept valid complete attributes', () => { const attributes: CarouselAttributes = { + transition: 'fade', loop: true, dragFree: false, carouselAlign: 'center', @@ -43,6 +44,7 @@ describe( 'CarouselAttributes Type', () => { it( 'should have all required properties', () => { const attributes: CarouselAttributes = { + transition: 'slide', loop: false, dragFree: true, carouselAlign: 'start', @@ -62,6 +64,7 @@ describe( 'CarouselAttributes Type', () => { // Verify all keys exist const requiredKeys = [ + 'transition', 'loop', 'dragFree', 'carouselAlign', @@ -85,6 +88,16 @@ describe( 'CarouselAttributes Type', () => { } ); } ); + describe( 'Transition Options', () => { + it.each( [ 'slide', 'fade' ] as const )( + 'should accept transition value: %s', + ( transition ) => { + const attributes: Partial< CarouselAttributes > = { transition }; + expect( attributes.transition ).toBe( transition ); + }, + ); + } ); + describe( 'Alignment Options', () => { it.each( [ 'start', 'center', 'end' ] as const )( 'should accept carouselAlign value: %s', @@ -240,6 +253,7 @@ describe( 'CarouselContext Type', () => { describe( 'Autoplay State', () => { it( 'should accept context with autoplay disabled', () => { const context: CarouselContext = { + transition: 'slide', options: { loop: false, align: 'start', @@ -262,6 +276,7 @@ describe( 'CarouselContext Type', () => { it( 'should accept context with autoplay configuration object', () => { const context: CarouselContext = { + transition: 'slide', options: { loop: true, align: 'center', @@ -296,6 +311,7 @@ describe( 'CarouselContext Type', () => { describe( 'Scroll State Management', () => { it( 'should track first slide state correctly', () => { const context: CarouselContext = { + transition: 'slide', options: { loop: false }, autoplay: false, isPlaying: false, @@ -316,6 +332,7 @@ describe( 'CarouselContext Type', () => { it( 'should track middle slide state correctly', () => { const context: CarouselContext = { + transition: 'slide', options: { loop: false }, autoplay: false, isPlaying: false, @@ -336,6 +353,7 @@ describe( 'CarouselContext Type', () => { it( 'should track last slide state correctly', () => { const context: CarouselContext = { + transition: 'slide', options: { loop: false }, autoplay: false, isPlaying: false, @@ -356,6 +374,7 @@ describe( 'CarouselContext Type', () => { it( 'should handle single slide carousel', () => { const context: CarouselContext = { + transition: 'slide', options: {}, autoplay: false, isPlaying: false, @@ -378,6 +397,7 @@ describe( 'CarouselContext Type', () => { describe( 'Timer and Animation State', () => { it( 'should track timerIterationId for animation resets', () => { const context: CarouselContext = { + transition: 'slide', options: {}, autoplay: { delay: 3000, stopOnInteraction: true, stopOnMouseEnter: false }, isPlaying: true, @@ -400,6 +420,7 @@ describe( 'CarouselContext Type', () => { it( 'should have initial timerIterationId of 0', () => { const context: CarouselContext = { + transition: 'slide', options: {}, autoplay: false, isPlaying: false, @@ -422,6 +443,7 @@ describe( 'CarouselContext Type', () => { const element = document.createElement( 'div' ); const context: CarouselContext = { + transition: 'slide', options: {}, autoplay: false, isPlaying: false, @@ -442,6 +464,7 @@ describe( 'CarouselContext Type', () => { it( 'should allow null ref', () => { const context: CarouselContext = { + transition: 'slide', options: {}, autoplay: false, isPlaying: false, @@ -461,6 +484,7 @@ describe( 'CarouselContext Type', () => { it( 'should work without ref property', () => { const context: CarouselContext = { + transition: 'slide', options: {}, autoplay: false, isPlaying: false, @@ -481,6 +505,7 @@ describe( 'CarouselContext Type', () => { describe( 'Embla Options Integration', () => { it( 'should accept slidesToScroll as number in options', () => { const context: CarouselContext = { + transition: 'slide', options: { loop: true, slidesToScroll: 2, @@ -502,6 +527,7 @@ describe( 'CarouselContext Type', () => { it( 'should accept slidesToScroll as "auto" in options', () => { const context: CarouselContext = { + transition: 'slide', options: { loop: true, slidesToScroll: 'auto', @@ -533,6 +559,7 @@ describe( 'CarouselContext Type', () => { patterns.forEach( ( pattern ) => { const context: CarouselContext = { + transition: 'slide', options: {}, autoplay: false, isPlaying: false, @@ -554,6 +581,7 @@ describe( 'CarouselContext Type', () => { describe( 'Scroll Snaps Array', () => { it( 'should accept empty scrollSnaps array', () => { const context: CarouselContext = { + transition: 'slide', options: {}, autoplay: false, isPlaying: false, @@ -580,6 +608,7 @@ describe( 'CarouselContext Type', () => { ]; const context: CarouselContext = { + transition: 'slide', options: {}, autoplay: false, isPlaying: false, diff --git a/src/blocks/carousel/__tests__/view.test.ts b/src/blocks/carousel/__tests__/view.test.ts index 599fca3..cb6be3f 100644 --- a/src/blocks/carousel/__tests__/view.test.ts +++ b/src/blocks/carousel/__tests__/view.test.ts @@ -15,6 +15,7 @@ import { store, getContext, getElement } from '@wordpress/interactivity'; import EmblaCarousel, { type EmblaCarouselType } from 'embla-carousel'; +import Fade from 'embla-carousel-fade'; // Symbol key used by the view.ts for Embla instances const EMBLA_KEY = Symbol.for( 'rt-carousel.carousel' ); @@ -57,6 +58,7 @@ const setEmblaOnViewport = ( const createMockContext = ( overrides: Partial = {}, ): CarouselContext => ( { + transition: 'slide', options: { loop: true }, autoplay: false, isPlaying: false, @@ -828,6 +830,88 @@ describe( 'Carousel View Module', () => { originalIntersectionObserver; } } ); + + it( 'includes the Fade plugin when transition is fade', () => { + const mockContext = createMockContext( { transition: 'fade' } ); + const { wrapper, viewport } = createMockCarouselDOM(); + const originalIntersectionObserver = window.IntersectionObserver; + + viewport.getBoundingClientRect = jest.fn( () => ( { + width: 100, + height: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + x: 0, + y: 0, + toJSON: () => ( {} ), + } ) ); + + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + ( getElement as jest.Mock ).mockReturnValue( { ref: wrapper } ); + ( EmblaCarousel as unknown as jest.Mock ).mockReturnValue( + createMockEmblaInstance( { + scrollProgress: jest.fn( () => 0 ), + slideNodes: jest.fn( () => [] ), + } ), + ); + delete ( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver; + + try { + storeConfig.callbacks.initCarousel(); + + const lastCall = ( EmblaCarousel as unknown as jest.Mock ).mock.calls.at( -1 ); + expect( lastCall?.[ 1 ] ).toMatchObject( { + align: 'center', + containScroll: false, + dragFree: false, + slidesToScroll: 1, + } ); + expect( lastCall?.[ 2 ] ).toContainEqual( ( Fade as unknown as jest.Mock ).mock.results.at( -1 )?.value ); + } finally { + ( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver = + originalIntersectionObserver; + } + } ); + + it( 'does not include the Fade plugin when transition is slide', () => { + const mockContext = createMockContext( { transition: 'slide' } ); + const { wrapper, viewport } = createMockCarouselDOM(); + const originalIntersectionObserver = window.IntersectionObserver; + + viewport.getBoundingClientRect = jest.fn( () => ( { + width: 100, + height: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + x: 0, + y: 0, + toJSON: () => ( {} ), + } ) ); + + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + ( getElement as jest.Mock ).mockReturnValue( { ref: wrapper } ); + ( EmblaCarousel as unknown as jest.Mock ).mockReturnValue( + createMockEmblaInstance( { + scrollProgress: jest.fn( () => 0 ), + slideNodes: jest.fn( () => [] ), + } ), + ); + delete ( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver; + + try { + storeConfig.callbacks.initCarousel(); + + const lastCall = ( EmblaCarousel as unknown as jest.Mock ).mock.calls.at( -1 ); + expect( lastCall?.[ 2 ] ).toEqual( [] ); + } finally { + ( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver = + originalIntersectionObserver; + } + } ); } ); } ); } ); diff --git a/tests/js/setup.ts b/tests/js/setup.ts index 59b467d..e2b7da9 100644 --- a/tests/js/setup.ts +++ b/tests/js/setup.ts @@ -48,6 +48,19 @@ jest.mock( 'embla-carousel-autoplay', () => ( { } ) ), } ) ); +/** + * Mock embla-carousel-fade module. + */ +jest.mock( 'embla-carousel-fade', () => ( { + __esModule: true, + default: jest.fn( () => ( { + name: 'fade', + options: {}, + init: jest.fn(), + destroy: jest.fn(), + } ) ), +} ) ); + /** * Mock WordPress block editor components. */ From 1aca048545f4dd9e47c21f483547b4617ca26b28 Mon Sep 17 00:00:00 2001 From: Danish Shakeel Date: Fri, 3 Jul 2026 17:05:18 +0200 Subject: [PATCH 4/4] docs(carousel): document transition attribute --- docs/API.md | 1 + docs/USAGE.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/API.md b/docs/API.md index 17efdd5..cbbf522 100644 --- a/docs/API.md +++ b/docs/API.md @@ -11,6 +11,7 @@ The following properties are exposed in the Interactivity API context: | Property | Type | Description | | :----------------- | :--------- | :--------------------------------------------------------------------------------------------------- | +| `transition` | `'slide'` or `'fade'` | The active transition style; determines whether the Fade plugin is loaded. | | `isPlaying` | `boolean` | `true` if Autoplay is currently running. | | `timerIterationId` | `number` | Increments every time the Autoplay timer resets (slide change). Bind to `key` to restart animations. | | `autoplay` | `boolean` or `{ delay, ... }` | Autoplay config or `false` if disabled. | diff --git a/docs/USAGE.md b/docs/USAGE.md index 7694af3..151d866 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -9,6 +9,7 @@ The parent block acts as the controller and wrapper. It handles configuration, s | Attribute | Type | Default | Description | | :-------------------------- | :------ | :------------ | :------------------------------------------ | +| `transition` | string | `'slide'` | Transition style: `'slide'` or `'fade'`. When `'fade'`, `carouselAlign`, `containScroll`, `dragFree`, and `slidesToScroll` are overridden at runtime (center/false/false/1). | | `loop` | boolean | `false` | Infinite scrolling. | | `dragFree` | boolean | `false` | Momentum scrolling. | | `carouselAlign` | string | `'start'` | Slide alignment (`start`, `center`, `end`). |