Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Easily create dynamic, accessible, and customizable carousels for any content ty
- **High Performance**: Viewport & Slide Engine powered by Embla Carousel.
- **Interactivity API**: Reactive state management with `data-wp-interactive`.
- **Dynamic Content**: Full support for WordPress **Query Loop** and **Terms Query** blocks.
- **Accessibility**: W3C-compliant roles, labels, and keyboard navigation.
- **Accessibility**: W3C-compliant roles, labels, keyboard navigation, and reduced-motion support.
- **RTL Support**: Built-in support for Right-to-Left languages.

## Documentation
Expand Down Expand Up @@ -85,7 +85,7 @@ Do not place Query Loop or Terms Query inside a Carousel Slide block. Their gene

### Is it accessible?

Yes. The carousel follows W3C accessibility guidelines with proper ARIA roles, labels, and full keyboard navigation support.
Yes. The carousel follows W3C accessibility guidelines with proper ARIA roles, labels, and full keyboard navigation support. It also respects the reduced-motion setting: slides change without animation and Autoplay / Auto Scroll do not start.

### Can I have multiple carousels on the same page?

Expand Down
3 changes: 3 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ Gutenberg's `InnerBlocks` can isolate React Contexts, causing state sync issues
2. **Find**: Child components (Controls/Dots) attempt to find the API via Context first. If missing, they traverse the DOM up to the common wrapper (`.rt-carousel`) and then search for the sibling `.embla` viewport.
3. **Bind**: A retry mechanism (`setTimeout` + `useEffect`) ensures the Viewport has finished initializing before binding listeners.

### Reduced Motion
When the visitor requests reduced motion (`prefers-reduced-motion: reduce`), slide changes are instant and Autoplay / Auto Scroll do not start. This is applied through Embla `breakpoints`, so it also follows changes to the setting while the page is open.

### Dots Implementation
The Carousel Dots block demonstrates a pattern for iterating over data with the Interactivity API:
1. **Data Source**: `context.scrollSnaps` is populated in `view.ts` by mapping Embla's snap list to objects: `[{ index: 0 }, { index: 1 }, ...]`.
Expand Down
4 changes: 2 additions & 2 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ A modular, high-performance carousel block for WordPress, powered by the Interac
* **High Performance** – Powered by Embla Carousel v8
* **Interactivity API** – Reactive state management with zero hydration overhead
* **Query Loop Support** – Each post in a Query Loop becomes a slide automatically
* **Accessibility** – W3C-compliant roles, labels, and keyboard navigation
* **Accessibility** – W3C-compliant roles, labels, keyboard navigation, and reduced-motion support
* **RTL Support** – Built-in support for Right-to-Left languages

= Block Components =
Expand Down Expand Up @@ -69,7 +69,7 @@ Yes. Add a Query Loop inside the Carousel Viewport, and each post becomes a slid

= Is it accessible? =

Yes. The carousel follows W3C accessibility guidelines with proper ARIA roles, labels, and full keyboard navigation.
Yes. The carousel follows W3C accessibility guidelines with proper ARIA roles, labels, and full keyboard navigation. It also respects the reduced-motion setting: slides change without animation and Autoplay / Auto Scroll do not start.

= Can I have multiple carousels on the same page? =

Expand Down
62 changes: 62 additions & 0 deletions src/blocks/carousel/__tests__/embla-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { EmblaOptionsType } from 'embla-carousel';
import { REDUCED_MOTION_QUERY, withReducedMotion } from '../embla-options';

describe( 'withReducedMotion', () => {
it( 'adds the overrides under the reduced-motion breakpoint', () => {
const options: EmblaOptionsType = { loop: true };
const result = withReducedMotion( options, { duration: 0 } );

expect( result ).toEqual( {
loop: true,
breakpoints: { [ REDUCED_MOTION_QUERY ]: { duration: 0 } },
} );
} );

it( 'keeps other breakpoints', () => {
const result = withReducedMotion(
{ breakpoints: { '(min-width: 768px)': { loop: true } } },
{ duration: 0 },
);

expect( result.breakpoints ).toEqual( {
'(min-width: 768px)': { loop: true },
[ REDUCED_MOTION_QUERY ]: { duration: 0 },
} );
} );

it( 'merges into an existing reduced-motion entry', () => {
const result = withReducedMotion(
{ breakpoints: { [ REDUCED_MOTION_QUERY ]: { loop: false } } },
{ duration: 0 },
);

expect( result.breakpoints?.[ REDUCED_MOTION_QUERY ] ).toEqual( {
loop: false,
duration: 0,
} );
} );

it( 'puts the reduced-motion entry last so it wins', () => {
const result = withReducedMotion(
{
breakpoints: {
[ REDUCED_MOTION_QUERY ]: { loop: false },
'(min-width: 768px)': { duration: 25 },
},
},
{ duration: 0 },
);

expect( Object.keys( result.breakpoints ?? {} ).at( -1 ) ).toBe( REDUCED_MOTION_QUERY );
} );

it( 'does not mutate its input', () => {
const input = { breakpoints: { '(min-width: 768px)': { loop: true } } };

withReducedMotion( input, { duration: 0 } );

expect( input ).toEqual( {
breakpoints: { '(min-width: 768px)': { loop: true } },
} );
} );
} );
100 changes: 100 additions & 0 deletions src/blocks/carousel/__tests__/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { store, getContext, getElement } from '@wordpress/interactivity';

import EmblaCarousel, { type EmblaCarouselType } from 'embla-carousel';
import Fade from 'embla-carousel-fade';
import Autoplay from 'embla-carousel-autoplay';
import AutoScroll from 'embla-carousel-auto-scroll';

// Symbol key used by the view.ts for Embla instances
Expand All @@ -27,6 +28,7 @@ type EmblaViewportElement = HTMLElement & {
};

import type { CarouselContext } from '../types';
import { REDUCED_MOTION_QUERY } from '../embla-options';

// Import view to trigger store registration
import '../view';
Expand Down Expand Up @@ -1417,6 +1419,104 @@ describe( 'Carousel View Module', () => {
originalIntersectionObserver;
}
} );

describe( 'reduced motion', () => {
const runInit = (
contextOverrides: Partial<CarouselContext>,
emblaOverrides = {},
) => {
const mockContext = createMockContext( contextOverrides );
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( () => [] ),
...emblaOverrides,
} ),
);
delete ( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver;

try {
storeConfig.callbacks.initCarousel();
} finally {
( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver =
originalIntersectionObserver;
}

return mockContext;
};

const autoplay = { delay: 3000, stopOnInteraction: true, stopOnMouseEnter: false };

it( 'makes slide changes instant', () => {
runInit( {} );

const lastCall = ( EmblaCarousel as unknown as jest.Mock ).mock.calls.at( -1 );
expect( lastCall?.[ 1 ].breakpoints[ REDUCED_MOTION_QUERY ] ).toEqual( {
duration: 0,
} );
} );

it( 'does not start Autoplay on init or after interaction', () => {
runInit( { autoplay } );

expect( ( Autoplay as unknown as jest.Mock ).mock.calls.at( -1 )?.[ 0 ] ).toEqual( {
...autoplay,
breakpoints: { [ REDUCED_MOTION_QUERY ]: { playOnInit: false, stopOnInteraction: true } },
} );
} );

it( 'does not start Auto Scroll on init or after interaction', () => {
runInit( {
autoScroll: {
speed: 2,
direction: 'forward',
startDelay: 1000,
stopOnInteraction: true,
stopOnMouseEnter: false,
stopOnFocusIn: true,
},
} );

expect(
( AutoScroll as unknown as jest.Mock ).mock.calls.at( -1 )?.[ 0 ].breakpoints,
).toEqual( { [ REDUCED_MOTION_QUERY ]: { playOnInit: false, stopOnInteraction: true } } );
} );

it( 'sets isPlaying to false when Autoplay did not start', () => {
const context = runInit(
{ autoplay, isPlaying: true },
{ plugins: jest.fn( () => ( { autoplay: { isPlaying: () => false } } ) ) },
);

expect( context.isPlaying ).toBe( false );
} );

it( 'keeps isPlaying true when Autoplay started', () => {
const context = runInit(
{ autoplay, isPlaying: true },
{ plugins: jest.fn( () => ( { autoplay: { isPlaying: () => true } } ) ) },
);

expect( context.isPlaying ).toBe( true );
} );
} );
} );
} );
} );
Expand Down
25 changes: 25 additions & 0 deletions src/blocks/carousel/embla-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,28 @@ export const applyTransitionOverrides = (
slidesToScroll: 1,
};
};

export const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';

type WithBreakpoints = { breakpoints?: Record<string, object> };

/**
* Embla applies `breakpoints` at runtime, including when the visitor changes the setting.
*
* @param {Object} options Embla core or plugin options.
* @param {Object} overrides Options to use when reduced motion is requested.
*/
export const withReducedMotion = <T extends WithBreakpoints>(
options: T,
overrides: NonNullable<T[ 'breakpoints' ]>[ string ],
): T => {
const { [ REDUCED_MOTION_QUERY ]: existing, ...others } = options.breakpoints ?? {};

return {
...options,
breakpoints: {
...others,
[ REDUCED_MOTION_QUERY ]: { ...existing, ...overrides },
},
};
};
56 changes: 38 additions & 18 deletions src/blocks/carousel/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import {
DYNAMIC_LIST_CONTAINER_SELECTOR,
CAROUSEL_SLIDE_SELECTOR,
} from './dynamic-list-selectors';
import { normalizeContainScroll, applyTransitionOverrides } from './embla-options';
import {
normalizeContainScroll,
applyTransitionOverrides,
withReducedMotion,
} from './embla-options';

type ElementWithRef = {
ref?: HTMLElement | null;
Expand Down Expand Up @@ -408,16 +412,19 @@ store( 'rt-carousel/carousel', {
slidesToScroll = rawOptions.slidesToScroll;
}

const options: EmblaOptionsType = applyTransitionOverrides(
{
...rawOptions,
align,
containScroll: normalizeContainScroll( rawOptions.containScroll ),
direction,
slidesToScroll,
container: dynamicListContainer || null,
},
context.transition,
const options: EmblaOptionsType = withReducedMotion(
applyTransitionOverrides(
{
...rawOptions,
align,
containScroll: normalizeContainScroll( rawOptions.containScroll ),
direction,
slidesToScroll,
container: dynamicListContainer || null,
},
context.transition,
),
{ duration: 0 },
);

const plugins = [];
Expand All @@ -427,11 +434,25 @@ store( 'rt-carousel/carousel', {
}

if ( context.autoplay ) {
plugins.push( Autoplay( context.autoplay as AutoplayOptionsType ) );
plugins.push(
Autoplay(
withReducedMotion( context.autoplay as AutoplayOptionsType, {
playOnInit: false,
stopOnInteraction: true,
} ),
),
);
}

if ( context.autoScroll ) {
plugins.push( AutoScroll( context.autoScroll as AutoScrollOptionsType ) );
plugins.push(
AutoScroll(
withReducedMotion( context.autoScroll as AutoScrollOptionsType, {
playOnInit: false,
stopOnInteraction: true,
} ),
),
);
}

const embla = EmblaCarousel( viewport, options, plugins );
Expand Down Expand Up @@ -479,11 +500,10 @@ store( 'rt-carousel/carousel', {
context.isPlaying = false;
} );

// Auto Scroll emits its first `autoScroll:play` while Embla is
// being created, before the listener above is attached.
if ( embla.plugins?.()?.autoScroll?.isPlaying() ) {
context.isPlaying = true;
}
// Autoplay and Auto Scroll start while Embla is being created,
// before the listeners above are attached.
const { autoplay, autoScroll } = embla.plugins?.() ?? {};
context.isPlaying = !! ( autoplay?.isPlaying() || autoScroll?.isPlaying() );

updateState();

Expand Down
Loading
Loading