= {},
): 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/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;
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.
*/