diff --git a/docs/API.md b/docs/API.md index 17efdd5..2b2f569 100644 --- a/docs/API.md +++ b/docs/API.md @@ -5,6 +5,54 @@ This block uses the **WordPress Interactivity API** to manage state and logic. Y ## Store Namespace `rt-carousel/carousel` +## Embla Initialization Filters + +rtCarousel exposes JavaScript filters immediately before calling `EmblaCarousel( viewport, options, plugins )`, allowing runtime customization without changing saved block markup. + +```js +import { addFilter } from '@wordpress/hooks'; +import AutoHeight from 'embla-carousel-auto-height'; + +addFilter( + 'rtcamp.rtCarousel.emblaOptions', + 'my-plugin/custom-options', + ( options ) => ( { ...options, duration: 40 } ) +); + +addFilter( + 'rtcamp.rtCarousel.emblaPlugins', + 'my-plugin/auto-height', + ( plugins ) => [ ...plugins, AutoHeight() ] +); +``` + +Both filter callbacks receive the filtered value as their first argument and the filter context object as their second argument. `rtcamp.rtCarousel.emblaPlugins` also receives the filtered options on the `options` property of this object. + +rtCarousel also exposes an action after Embla has initialized so integrations can call Embla methods or subscribe to Embla events: + +```js +import { addAction } from '@wordpress/hooks'; + +addAction( + 'rtcamp.rtCarousel.emblaInit', + 'my-plugin/custom-events', + ( embla, { root } ) => { + embla.on( 'select', () => { + root.dataset.selectedSlide = embla.selectedScrollSnap().toString(); + } ); + } +); +``` + +| Property | Type | Description | +| :--- | :--- | :--- | +| `context` | `CarouselContext` | Interactivity API context for the carousel. | +| `root` | `HTMLElement` | Root `.rt-carousel` element. | +| `viewport` | `HTMLElement` | Embla viewport element. | +| `dynamicListContainer` | `HTMLElement \| null` | Query Loop or Terms Query template container when present. | +| `options` | `EmblaOptionsType` | Passed to `rtcamp.rtCarousel.emblaPlugins` and `rtcamp.rtCarousel.emblaInit`; contains filtered options. | +| `plugins` | `EmblaPluginType[]` | Only passed to `rtcamp.rtCarousel.emblaInit`; contains filtered plugins. | + ## Context (`CarouselContext`) The following properties are exposed in the Interactivity API context: diff --git a/src/blocks/carousel/__tests__/view.test.ts b/src/blocks/carousel/__tests__/view.test.ts index 22d8eff..13c1e96 100644 --- a/src/blocks/carousel/__tests__/view.test.ts +++ b/src/blocks/carousel/__tests__/view.test.ts @@ -24,6 +24,15 @@ type EmblaViewportElement = HTMLElement & { [EMBLA_KEY]?: EmblaCarouselType; }; +type HooksWindow = Window & { + wp?: { + hooks?: { + applyFilters?: jest.Mock; + doAction?: jest.Mock; + }; + }; +}; + import type { CarouselContext } from '../types'; // Import view to trigger store registration @@ -90,6 +99,20 @@ const createMockCarouselDOM = () => { return { wrapper, viewport, button }; }; +const mockVisibleViewport = ( viewport: HTMLElement ) => { + viewport.getBoundingClientRect = jest.fn( () => ( { + width: 100, + height: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + x: 0, + y: 0, + toJSON: () => ( {} ), + } ) ); +}; + /** * Helper to create mock Embla instance with all required methods. * @@ -107,6 +130,8 @@ const createMockEmblaInstance = ( overrides = {} ) => ( { canScrollNext: jest.fn( () => true ), selectedScrollSnap: jest.fn( () => 0 ), scrollSnapList: jest.fn( () => [ 0, 0.5, 1 ] ), + scrollProgress: jest.fn( () => 0 ), + slideNodes: jest.fn( () => [] ), ...overrides, } ); @@ -747,17 +772,7 @@ describe( 'Carousel View Module', () => { return mockEmbla; } ); - viewport.getBoundingClientRect = jest.fn( () => ( { - width: 100, - height: 0, - top: 0, - right: 0, - bottom: 0, - left: 0, - x: 0, - y: 0, - toJSON: () => ( {} ), - } ) ); + mockVisibleViewport( viewport ); ( getContext as jest.Mock ).mockReturnValue( mockContext ); ( getElement as jest.Mock ).mockReturnValue( { ref: wrapper } ); @@ -777,6 +792,163 @@ describe( 'Carousel View Module', () => { originalIntersectionObserver; } } ); + + it( 'should filter Embla options and plugins before initialization and fire init action', () => { + const mockContext = createMockContext( { + options: { duration: 25 }, + } ); + const { wrapper, viewport } = createMockCarouselDOM(); + const mockEmbla = createMockEmblaInstance(); + const originalIntersectionObserver = window.IntersectionObserver; + const extraPlugin = { + name: 'test-plugin', + options: {}, + init: jest.fn(), + destroy: jest.fn(), + }; + + mockVisibleViewport( viewport ); + + const applyFilters = jest.fn( ( hookName, value ) => { + if ( hookName === 'rtcamp.rtCarousel.emblaOptions' ) { + return { ...value, duration: 40 }; + } + if ( hookName === 'rtcamp.rtCarousel.emblaPlugins' ) { + return [ ...value, extraPlugin ]; + } + return value; + } ); + const doAction = jest.fn(); + + ( window as HooksWindow ).wp = { + hooks: { + applyFilters, + doAction, + }, + }; + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + ( getElement as jest.Mock ).mockReturnValue( { ref: wrapper } ); + ( EmblaCarousel as unknown as jest.Mock ).mockReturnValue( mockEmbla ); + delete ( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver; + + try { + storeConfig.callbacks.initCarousel(); + + expect( applyFilters ).toHaveBeenCalledTimes( 2 ); + expect( applyFilters ).toHaveBeenNthCalledWith( + 1, + 'rtcamp.rtCarousel.emblaOptions', + expect.objectContaining( { duration: 25 } ), + expect.objectContaining( { context: mockContext, root: wrapper, viewport } ), + ); + expect( EmblaCarousel ).toHaveBeenCalledWith( + viewport, + expect.objectContaining( { duration: 40 } ), + [ extraPlugin ], + ); + expect( doAction ).toHaveBeenCalledWith( + 'rtcamp.rtCarousel.emblaInit', + mockEmbla, + expect.objectContaining( { + context: mockContext, + root: wrapper, + viewport, + options: expect.objectContaining( { duration: 40 } ), + plugins: [ extraPlugin ], + } ), + ); + } finally { + ( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver = + originalIntersectionObserver; + delete ( window as HooksWindow ).wp; + } + } ); + + it( 'should keep original Embla options when the options filter returns undefined', () => { + const mockContext = createMockContext( { + options: { duration: 25 }, + } ); + const { wrapper, viewport } = createMockCarouselDOM(); + const mockEmbla = createMockEmblaInstance(); + const originalIntersectionObserver = window.IntersectionObserver; + const applyFilters = jest.fn( ( hookName, value ) => { + if ( hookName === 'rtcamp.rtCarousel.emblaOptions' ) { + return undefined; + } + return value; + } ); + + mockVisibleViewport( viewport ); + + ( window as HooksWindow ).wp = { + hooks: { + applyFilters, + }, + }; + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + ( getElement as jest.Mock ).mockReturnValue( { ref: wrapper } ); + ( EmblaCarousel as unknown as jest.Mock ).mockReturnValue( mockEmbla ); + delete ( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver; + + try { + storeConfig.callbacks.initCarousel(); + + expect( EmblaCarousel ).toHaveBeenCalledWith( + viewport, + expect.objectContaining( { duration: 25 } ), + [], + ); + } finally { + ( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver = + originalIntersectionObserver; + delete ( window as HooksWindow ).wp; + } + } ); + + it( 'should keep original Embla plugins when the plugins filter returns a non-array value', () => { + const mockContext = createMockContext( { + autoplay: { + delay: 3000, + stopOnInteraction: true, + stopOnMouseEnter: false, + }, + } ); + const { wrapper, viewport } = createMockCarouselDOM(); + const mockEmbla = createMockEmblaInstance(); + const originalIntersectionObserver = window.IntersectionObserver; + const applyFilters = jest.fn( ( hookName, value ) => { + if ( hookName === 'rtcamp.rtCarousel.emblaPlugins' ) { + return 'not-an-array'; + } + return value; + } ); + + mockVisibleViewport( viewport ); + + ( window as HooksWindow ).wp = { + hooks: { + applyFilters, + }, + }; + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + ( getElement as jest.Mock ).mockReturnValue( { ref: wrapper } ); + ( EmblaCarousel as unknown as jest.Mock ).mockReturnValue( mockEmbla ); + delete ( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver; + + try { + storeConfig.callbacks.initCarousel(); + + expect( EmblaCarousel ).toHaveBeenCalledWith( + viewport, + expect.any( Object ), + [ expect.objectContaining( { name: 'autoplay' } ) ], + ); + } finally { + ( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver = + originalIntersectionObserver; + delete ( window as HooksWindow ).wp; + } + } ); } ); } ); } ); diff --git a/src/blocks/carousel/block.json b/src/blocks/carousel/block.json index e0df7ca..2ebb4c7 100644 --- a/src/blocks/carousel/block.json +++ b/src/blocks/carousel/block.json @@ -90,5 +90,6 @@ "editorScript": "file:./index.js", "editorStyle": "file:./index.css", "style": "file:./style-index.css", + "viewScript": "wp-hooks", "viewScriptModule": "file:./view.js" -} \ No newline at end of file +} diff --git a/src/blocks/carousel/view.ts b/src/blocks/carousel/view.ts index 098cb63..8d6a3f8 100644 --- a/src/blocks/carousel/view.ts +++ b/src/blocks/carousel/view.ts @@ -2,6 +2,7 @@ import { store, getContext, getElement } from '@wordpress/interactivity'; import EmblaCarousel, { type EmblaOptionsType, type EmblaCarouselType, + type EmblaPluginType, } from 'embla-carousel'; import Autoplay, { type AutoplayOptionsType } from 'embla-carousel-autoplay'; import type { CarouselContext } from './types'; @@ -23,6 +24,58 @@ type EmblaViewportElement = HTMLElement & { export const emblaInstances = new WeakMap(); +type EmblaFilterContext = { + context: CarouselContext; + root: HTMLElement; + viewport: HTMLElement; + dynamicListContainer: HTMLElement | null; + options?: EmblaOptionsType; +}; + +type HooksWindow = Window & { + wp?: { + hooks?: { + applyFilters?: ( + hookName: string, + value: unknown, + ...args: unknown[] + ) => unknown; + doAction?: ( hookName: string, ...args: unknown[] ) => void; + }; + }; +}; + +const applyEmblaFilter = ( + hookName: string, + value: T, + filterContext: EmblaFilterContext, +): T => { + const applyFilters = ( window as HooksWindow ).wp?.hooks?.applyFilters; + + if ( typeof applyFilters !== 'function' ) { + return value; + } + + const result = applyFilters( hookName, value, filterContext ); + return result !== null && result !== undefined ? ( result as T ) : value; +}; + +const doEmblaAction = ( + hookName: string, + embla: EmblaCarouselType, + filterContext: EmblaFilterContext & { + plugins: EmblaPluginType[]; + }, +): void => { + const doAction = ( window as HooksWindow ).wp?.hooks?.doAction; + + if ( typeof doAction !== 'function' ) { + return; + } + + doAction( hookName, embla, filterContext ); +}; + const getElementRef = ( rawElement: unknown ): HTMLElement | null => { if ( rawElement instanceof HTMLElement ) { return rawElement; @@ -263,13 +316,35 @@ store( 'rt-carousel/carousel', { container: dynamicListContainer || null, }; - const plugins = []; + const plugins: EmblaPluginType[] = []; if ( context.autoplay ) { plugins.push( Autoplay( context.autoplay as AutoplayOptionsType ) ); } - const embla = EmblaCarousel( viewport, options, plugins ); + const filterContext: EmblaFilterContext = { + context, + root: element, + viewport, + dynamicListContainer, + }; + + const filteredOptions = applyEmblaFilter( + 'rtcamp.rtCarousel.emblaOptions', + options, + filterContext, + ); + + const filteredPlugins = applyEmblaFilter( + 'rtcamp.rtCarousel.emblaPlugins', + plugins, + { ...filterContext, options: filteredOptions }, + ); + const safePlugins = Array.isArray( filteredPlugins ) + ? filteredPlugins + : plugins; + + const embla = EmblaCarousel( viewport, filteredOptions, safePlugins ); emblaInstances.set( viewport, embla ); viewport[ EMBLA_KEY ] = embla; @@ -307,6 +382,15 @@ store( 'rt-carousel/carousel', { } ); updateState(); + doEmblaAction( + 'rtcamp.rtCarousel.emblaInit', + embla, + { + ...filterContext, + options: filteredOptions, + plugins: safePlugins, + }, + ); return () => { embla.destroy();