diff --git a/README.md b/README.md index 2de6152..eefdbe6 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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? diff --git a/docs/API.md b/docs/API.md index 2150abd..7948caa 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 }, ...]`. diff --git a/readme.txt b/readme.txt index 42f9984..1ae6ea2 100644 --- a/readme.txt +++ b/readme.txt @@ -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 = @@ -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? = diff --git a/src/blocks/carousel/__tests__/embla-options.test.ts b/src/blocks/carousel/__tests__/embla-options.test.ts new file mode 100644 index 0000000..20e6fd4 --- /dev/null +++ b/src/blocks/carousel/__tests__/embla-options.test.ts @@ -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 } }, + } ); + } ); +} ); diff --git a/src/blocks/carousel/__tests__/view.test.ts b/src/blocks/carousel/__tests__/view.test.ts index 9c019d9..8c267ca 100644 --- a/src/blocks/carousel/__tests__/view.test.ts +++ b/src/blocks/carousel/__tests__/view.test.ts @@ -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 @@ -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'; @@ -1417,6 +1419,104 @@ describe( 'Carousel View Module', () => { originalIntersectionObserver; } } ); + + describe( 'reduced motion', () => { + const runInit = ( + contextOverrides: Partial, + 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 ); + } ); + } ); } ); } ); } ); diff --git a/src/blocks/carousel/embla-options.ts b/src/blocks/carousel/embla-options.ts index 2702a6c..fffdfad 100644 --- a/src/blocks/carousel/embla-options.ts +++ b/src/blocks/carousel/embla-options.ts @@ -38,3 +38,28 @@ export const applyTransitionOverrides = ( slidesToScroll: 1, }; }; + +export const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'; + +type WithBreakpoints = { breakpoints?: Record }; + +/** + * 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 = ( + options: T, + overrides: NonNullable[ string ], +): T => { + const { [ REDUCED_MOTION_QUERY ]: existing, ...others } = options.breakpoints ?? {}; + + return { + ...options, + breakpoints: { + ...others, + [ REDUCED_MOTION_QUERY ]: { ...existing, ...overrides }, + }, + }; +}; diff --git a/src/blocks/carousel/view.ts b/src/blocks/carousel/view.ts index 9060405..6d2e30d 100644 --- a/src/blocks/carousel/view.ts +++ b/src/blocks/carousel/view.ts @@ -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; @@ -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 = []; @@ -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 ); @@ -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(); diff --git a/src/blocks/carousel/viewport/edit.tsx b/src/blocks/carousel/viewport/edit.tsx index ea55c80..7ee24d3 100644 --- a/src/blocks/carousel/viewport/edit.tsx +++ b/src/blocks/carousel/viewport/edit.tsx @@ -17,7 +17,11 @@ import EmblaCarousel, { type EmblaCarouselType, type EmblaPluginType } from 'emb import Fade from 'embla-carousel-fade'; import { useCarouselObservers } from '../hooks/useCarouselObservers'; import { DYNAMIC_LIST_CONTAINER_SELECTOR } from '../dynamic-list-selectors'; -import { normalizeContainScroll, applyTransitionOverrides } from '../embla-options'; +import { + normalizeContainScroll, + applyTransitionOverrides, + withReducedMotion, +} from '../embla-options'; const EMBLA_KEY = Symbol.for( 'carousel-system.carousel' ); @@ -220,22 +224,25 @@ export default function Edit( { // eslint-disable-next-line @typescript-eslint/no-explicit-any const options = carouselOptions as any; - 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, - duration: options?.duration, - 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 emblaOptions = withReducedMotion( + 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, + duration: options?.duration, + 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', + ), + { duration: 0 }, ); const plugins: EmblaPluginType[] = options?.transition === 'fade' ? [ Fade() ] : [];