Skip to content

Commit 1a0dd9c

Browse files
committed
feat: add Embla initialization hooks
1 parent b0cf807 commit 1a0dd9c

4 files changed

Lines changed: 229 additions & 14 deletions

File tree

docs/API.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,54 @@ 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+
rtCarousel also exposes an action after Embla has initialized so integrations can call Embla methods or subscribe to Embla events:
32+
33+
```js
34+
import { addAction } from '@wordpress/hooks';
35+
36+
addAction(
37+
'rtcamp.carouselKit.emblaInit',
38+
'my-plugin/custom-events',
39+
( embla, { root } ) => {
40+
embla.on( 'select', () => {
41+
root.dataset.selectedSlide = embla.selectedScrollSnap().toString();
42+
} );
43+
}
44+
);
45+
```
46+
47+
| Property | Type | Description |
48+
| :--- | :--- | :--- |
49+
| `context` | `CarouselContext` | Interactivity API context for the carousel. |
50+
| `root` | `HTMLElement` | Root `.rt-carousel` element. |
51+
| `viewport` | `HTMLElement` | Embla viewport element. |
52+
| `dynamicListContainer` | `HTMLElement \| null` | Query Loop or Terms Query template container when present. |
53+
| `options` | `EmblaOptionsType` | Passed to `rtcamp.carouselKit.emblaPlugins` and `rtcamp.carouselKit.emblaInit`; contains filtered options. |
54+
| `plugins` | `EmblaPluginType[]` | Only passed to `rtcamp.carouselKit.emblaInit`; contains filtered plugins. |
55+
856
## Context (`CarouselContext`)
957

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

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

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

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

2938
// Import view to trigger store registration
@@ -90,6 +99,20 @@ const createMockCarouselDOM = () => {
9099
return { wrapper, viewport, button };
91100
};
92101

102+
const mockVisibleViewport = ( viewport: HTMLElement ) => {
103+
viewport.getBoundingClientRect = jest.fn( () => ( {
104+
width: 100,
105+
height: 0,
106+
top: 0,
107+
right: 0,
108+
bottom: 0,
109+
left: 0,
110+
x: 0,
111+
y: 0,
112+
toJSON: () => ( {} ),
113+
} ) );
114+
};
115+
93116
/**
94117
* Helper to create mock Embla instance with all required methods.
95118
*
@@ -107,6 +130,8 @@ const createMockEmblaInstance = ( overrides = {} ) => ( {
107130
canScrollNext: jest.fn( () => true ),
108131
selectedScrollSnap: jest.fn( () => 0 ),
109132
scrollSnapList: jest.fn( () => [ 0, 0.5, 1 ] ),
133+
scrollProgress: jest.fn( () => 0 ),
134+
slideNodes: jest.fn( () => [] ),
110135
...overrides,
111136
} );
112137

@@ -747,17 +772,7 @@ describe( 'Carousel View Module', () => {
747772
return mockEmbla;
748773
} );
749774

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-
} ) );
775+
mockVisibleViewport( viewport );
761776

762777
( getContext as jest.Mock ).mockReturnValue( mockContext );
763778
( getElement as jest.Mock ).mockReturnValue( { ref: wrapper } );
@@ -777,6 +792,77 @@ describe( 'Carousel View Module', () => {
777792
originalIntersectionObserver;
778793
}
779794
} );
795+
796+
it( 'should filter Embla options and plugins before initialization and fire init action', () => {
797+
const mockContext = createMockContext( {
798+
options: { duration: 25 },
799+
} );
800+
const { wrapper, viewport } = createMockCarouselDOM();
801+
const mockEmbla = createMockEmblaInstance();
802+
const originalIntersectionObserver = window.IntersectionObserver;
803+
const extraPlugin = {
804+
name: 'test-plugin',
805+
options: {},
806+
init: jest.fn(),
807+
destroy: jest.fn(),
808+
};
809+
810+
mockVisibleViewport( viewport );
811+
812+
const applyFilters = jest.fn( ( hookName, value ) => {
813+
if ( hookName === 'rtcamp.carouselKit.emblaOptions' ) {
814+
return { ...value, duration: 40 };
815+
}
816+
if ( hookName === 'rtcamp.carouselKit.emblaPlugins' ) {
817+
return [ ...value, extraPlugin ];
818+
}
819+
return value;
820+
} );
821+
const doAction = jest.fn();
822+
823+
( window as HooksWindow ).wp = {
824+
hooks: {
825+
applyFilters,
826+
doAction,
827+
},
828+
};
829+
( getContext as jest.Mock ).mockReturnValue( mockContext );
830+
( getElement as jest.Mock ).mockReturnValue( { ref: wrapper } );
831+
( EmblaCarousel as unknown as jest.Mock ).mockReturnValue( mockEmbla );
832+
delete ( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver;
833+
834+
try {
835+
storeConfig.callbacks.initCarousel();
836+
837+
expect( applyFilters ).toHaveBeenCalledTimes( 2 );
838+
expect( applyFilters ).toHaveBeenNthCalledWith(
839+
1,
840+
'rtcamp.carouselKit.emblaOptions',
841+
expect.objectContaining( { duration: 25 } ),
842+
expect.objectContaining( { context: mockContext, root: wrapper, viewport } ),
843+
);
844+
expect( EmblaCarousel ).toHaveBeenCalledWith(
845+
viewport,
846+
expect.objectContaining( { duration: 40 } ),
847+
[ extraPlugin ],
848+
);
849+
expect( doAction ).toHaveBeenCalledWith(
850+
'rtcamp.carouselKit.emblaInit',
851+
mockEmbla,
852+
expect.objectContaining( {
853+
context: mockContext,
854+
root: wrapper,
855+
viewport,
856+
options: expect.objectContaining( { duration: 40 } ),
857+
plugins: [ extraPlugin ],
858+
} ),
859+
);
860+
} finally {
861+
( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver =
862+
originalIntersectionObserver;
863+
delete ( window as HooksWindow ).wp;
864+
}
865+
} );
780866
} );
781867
} );
782868
} );

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: 82 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,57 @@ 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+
doAction?: ( hookName: string, ...args: unknown[] ) => void;
44+
};
45+
};
46+
};
47+
48+
const applyEmblaFilter = <T>(
49+
hookName: string,
50+
value: T,
51+
filterContext: EmblaFilterContext,
52+
): T => {
53+
const applyFilters = ( window as HooksWindow ).wp?.hooks?.applyFilters;
54+
55+
if ( typeof applyFilters !== 'function' ) {
56+
return value;
57+
}
58+
59+
return applyFilters( hookName, value, filterContext ) as T;
60+
};
61+
62+
const doEmblaAction = (
63+
hookName: string,
64+
embla: EmblaCarouselType,
65+
filterContext: EmblaFilterContext & {
66+
plugins: EmblaPluginType[];
67+
},
68+
): void => {
69+
const doAction = ( window as HooksWindow ).wp?.hooks?.doAction;
70+
71+
if ( typeof doAction !== 'function' ) {
72+
return;
73+
}
74+
75+
doAction( hookName, embla, filterContext );
76+
};
77+
2678
const getElementRef = ( rawElement: unknown ): HTMLElement | null => {
2779
if ( rawElement instanceof HTMLElement ) {
2880
return rawElement;
@@ -263,13 +315,32 @@ store( 'rt-carousel/carousel', {
263315
container: dynamicListContainer || null,
264316
};
265317

266-
const plugins = [];
318+
const plugins: EmblaPluginType[] = [];
267319

268320
if ( context.autoplay ) {
269321
plugins.push( Autoplay( context.autoplay as AutoplayOptionsType ) );
270322
}
271323

272-
const embla = EmblaCarousel( viewport, options, plugins );
324+
const filterContext: EmblaFilterContext = {
325+
context,
326+
root: element,
327+
viewport,
328+
dynamicListContainer,
329+
};
330+
331+
const filteredOptions = applyEmblaFilter(
332+
'rtcamp.carouselKit.emblaOptions',
333+
options,
334+
filterContext,
335+
);
336+
337+
const filteredPlugins = applyEmblaFilter(
338+
'rtcamp.carouselKit.emblaPlugins',
339+
plugins,
340+
{ ...filterContext, options: filteredOptions },
341+
);
342+
343+
const embla = EmblaCarousel( viewport, filteredOptions, filteredPlugins );
273344

274345
emblaInstances.set( viewport, embla );
275346
viewport[ EMBLA_KEY ] = embla;
@@ -307,6 +378,15 @@ store( 'rt-carousel/carousel', {
307378
} );
308379

309380
updateState();
381+
doEmblaAction(
382+
'rtcamp.carouselKit.emblaInit',
383+
embla,
384+
{
385+
...filterContext,
386+
options: filteredOptions,
387+
plugins: filteredPlugins,
388+
},
389+
);
310390

311391
return () => {
312392
embla.destroy();

0 commit comments

Comments
 (0)