Skip to content

Commit 2d40494

Browse files
committed
feat: add Embla initialization filters
1 parent b0cf807 commit 2d40494

4 files changed

Lines changed: 172 additions & 14 deletions

File tree

docs/API.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,37 @@ This block uses the **WordPress Interactivity API** to manage state and logic. Y
55
## Store Namespace
66
`rt-carousel/carousel`
77

8+
## Embla Initialization Filters
9+
10+
rtCarousel exposes JavaScript filters immediately before calling `EmblaCarousel( viewport, options, plugins )`, allowing runtime customization without changing saved block markup.
11+
12+
```js
13+
import { addFilter } from '@wordpress/hooks';
14+
import AutoHeight from 'embla-carousel-auto-height';
15+
16+
addFilter(
17+
'rtcamp.carouselKit.emblaOptions',
18+
'my-plugin/custom-options',
19+
( options ) => ( { ...options, duration: 40 } )
20+
);
21+
22+
addFilter(
23+
'rtcamp.carouselKit.emblaPlugins',
24+
'my-plugin/auto-height',
25+
( plugins ) => [ ...plugins, AutoHeight() ]
26+
);
27+
```
28+
29+
Both filters receive the carousel context object as the third argument. `rtcamp.carouselKit.emblaPlugins` also receives the filtered options on `options`.
30+
31+
| Property | Type | Description |
32+
| :--- | :--- | :--- |
33+
| `context` | `CarouselContext` | Interactivity API context for the carousel. |
34+
| `root` | `HTMLElement` | Root `.rt-carousel` element. |
35+
| `viewport` | `HTMLElement` | Embla viewport element. |
36+
| `dynamicListContainer` | `HTMLElement \| null` | Query Loop or Terms Query template container when present. |
37+
| `options` | `EmblaOptionsType` | Only passed to `rtcamp.carouselKit.emblaPlugins`; contains filtered options. |
38+
839
## Context (`CarouselContext`)
940

1041
The following properties are exposed in the Interactivity API context:

src/blocks/carousel/__tests__/view.test.ts

Lines changed: 83 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ type EmblaViewportElement = HTMLElement & {
2424
[EMBLA_KEY]?: EmblaCarouselType;
2525
};
2626

27+
type HooksWindow = Window & {
28+
wp?: {
29+
hooks?: {
30+
applyFilters?: jest.Mock;
31+
};
32+
};
33+
};
34+
2735
import type { CarouselContext } from '../types';
2836

2937
// Import view to trigger store registration
@@ -90,6 +98,20 @@ const createMockCarouselDOM = () => {
9098
return { wrapper, viewport, button };
9199
};
92100

101+
const mockVisibleViewport = ( viewport: HTMLElement ) => {
102+
viewport.getBoundingClientRect = jest.fn( () => ( {
103+
width: 100,
104+
height: 0,
105+
top: 0,
106+
right: 0,
107+
bottom: 0,
108+
left: 0,
109+
x: 0,
110+
y: 0,
111+
toJSON: () => ( {} ),
112+
} ) );
113+
};
114+
93115
/**
94116
* Helper to create mock Embla instance with all required methods.
95117
*
@@ -107,6 +129,8 @@ const createMockEmblaInstance = ( overrides = {} ) => ( {
107129
canScrollNext: jest.fn( () => true ),
108130
selectedScrollSnap: jest.fn( () => 0 ),
109131
scrollSnapList: jest.fn( () => [ 0, 0.5, 1 ] ),
132+
scrollProgress: jest.fn( () => 0 ),
133+
slideNodes: jest.fn( () => [] ),
110134
...overrides,
111135
} );
112136

@@ -747,17 +771,7 @@ describe( 'Carousel View Module', () => {
747771
return mockEmbla;
748772
} );
749773

750-
viewport.getBoundingClientRect = jest.fn( () => ( {
751-
width: 100,
752-
height: 0,
753-
top: 0,
754-
right: 0,
755-
bottom: 0,
756-
left: 0,
757-
x: 0,
758-
y: 0,
759-
toJSON: () => ( {} ),
760-
} ) );
774+
mockVisibleViewport( viewport );
761775

762776
( getContext as jest.Mock ).mockReturnValue( mockContext );
763777
( getElement as jest.Mock ).mockReturnValue( { ref: wrapper } );
@@ -777,6 +791,64 @@ describe( 'Carousel View Module', () => {
777791
originalIntersectionObserver;
778792
}
779793
} );
794+
795+
it( 'should filter Embla options and plugins before initialization', () => {
796+
const mockContext = createMockContext( {
797+
options: { duration: 25 },
798+
} );
799+
const { wrapper, viewport } = createMockCarouselDOM();
800+
const mockEmbla = createMockEmblaInstance();
801+
const originalIntersectionObserver = window.IntersectionObserver;
802+
const extraPlugin = {
803+
name: 'test-plugin',
804+
options: {},
805+
init: jest.fn(),
806+
destroy: jest.fn(),
807+
};
808+
809+
mockVisibleViewport( viewport );
810+
811+
const applyFilters = jest.fn( ( hookName, value ) => {
812+
if ( hookName === 'rtcamp.carouselKit.emblaOptions' ) {
813+
return { ...value, duration: 40 };
814+
}
815+
if ( hookName === 'rtcamp.carouselKit.emblaPlugins' ) {
816+
return [ ...value, extraPlugin ];
817+
}
818+
return value;
819+
} );
820+
821+
( window as HooksWindow ).wp = {
822+
hooks: {
823+
applyFilters,
824+
},
825+
};
826+
( getContext as jest.Mock ).mockReturnValue( mockContext );
827+
( getElement as jest.Mock ).mockReturnValue( { ref: wrapper } );
828+
( EmblaCarousel as unknown as jest.Mock ).mockReturnValue( mockEmbla );
829+
delete ( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver;
830+
831+
try {
832+
storeConfig.callbacks.initCarousel();
833+
834+
expect( applyFilters ).toHaveBeenCalledTimes( 2 );
835+
expect( applyFilters ).toHaveBeenNthCalledWith(
836+
1,
837+
'rtcamp.carouselKit.emblaOptions',
838+
expect.objectContaining( { duration: 25 } ),
839+
expect.objectContaining( { context: mockContext, root: wrapper, viewport } ),
840+
);
841+
expect( EmblaCarousel ).toHaveBeenCalledWith(
842+
viewport,
843+
expect.objectContaining( { duration: 40 } ),
844+
[ extraPlugin ],
845+
);
846+
} finally {
847+
( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver =
848+
originalIntersectionObserver;
849+
delete ( window as HooksWindow ).wp;
850+
}
851+
} );
780852
} );
781853
} );
782854
} );

src/blocks/carousel/block.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,5 +90,6 @@
9090
"editorScript": "file:./index.js",
9191
"editorStyle": "file:./index.css",
9292
"style": "file:./style-index.css",
93+
"viewScript": "wp-hooks",
9394
"viewScriptModule": "file:./view.js"
94-
}
95+
}

src/blocks/carousel/view.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { store, getContext, getElement } from '@wordpress/interactivity';
22
import EmblaCarousel, {
33
type EmblaOptionsType,
44
type EmblaCarouselType,
5+
type EmblaPluginType,
56
} from 'embla-carousel';
67
import Autoplay, { type AutoplayOptionsType } from 'embla-carousel-autoplay';
78
import type { CarouselContext } from './types';
@@ -23,6 +24,40 @@ type EmblaViewportElement = HTMLElement & {
2324

2425
export const emblaInstances = new WeakMap<HTMLElement, EmblaCarouselType>();
2526

27+
type EmblaFilterContext = {
28+
context: CarouselContext;
29+
root: HTMLElement;
30+
viewport: HTMLElement;
31+
dynamicListContainer: HTMLElement | null;
32+
options?: EmblaOptionsType;
33+
};
34+
35+
type HooksWindow = Window & {
36+
wp?: {
37+
hooks?: {
38+
applyFilters?: (
39+
hookName: string,
40+
value: unknown,
41+
...args: unknown[]
42+
) => unknown;
43+
};
44+
};
45+
};
46+
47+
const applyEmblaFilter = <T>(
48+
hookName: string,
49+
value: T,
50+
filterContext: EmblaFilterContext,
51+
): T => {
52+
const applyFilters = ( window as HooksWindow ).wp?.hooks?.applyFilters;
53+
54+
if ( typeof applyFilters !== 'function' ) {
55+
return value;
56+
}
57+
58+
return applyFilters( hookName, value, filterContext ) as T;
59+
};
60+
2661
const getElementRef = ( rawElement: unknown ): HTMLElement | null => {
2762
if ( rawElement instanceof HTMLElement ) {
2863
return rawElement;
@@ -263,13 +298,32 @@ store( 'rt-carousel/carousel', {
263298
container: dynamicListContainer || null,
264299
};
265300

266-
const plugins = [];
301+
const plugins: EmblaPluginType[] = [];
267302

268303
if ( context.autoplay ) {
269304
plugins.push( Autoplay( context.autoplay as AutoplayOptionsType ) );
270305
}
271306

272-
const embla = EmblaCarousel( viewport, options, plugins );
307+
const filterContext: EmblaFilterContext = {
308+
context,
309+
root: element,
310+
viewport,
311+
dynamicListContainer,
312+
};
313+
314+
const filteredOptions = applyEmblaFilter(
315+
'rtcamp.carouselKit.emblaOptions',
316+
options,
317+
filterContext,
318+
);
319+
320+
const filteredPlugins = applyEmblaFilter(
321+
'rtcamp.carouselKit.emblaPlugins',
322+
plugins,
323+
{ ...filterContext, options: filteredOptions },
324+
);
325+
326+
const embla = EmblaCarousel( viewport, filteredOptions, filteredPlugins );
273327

274328
emblaInstances.set( viewport, embla );
275329
viewport[ EMBLA_KEY ] = embla;

0 commit comments

Comments
 (0)