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
48 changes: 48 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
194 changes: 183 additions & 11 deletions src/blocks/carousel/__tests__/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
Expand All @@ -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,
} );

Expand Down Expand Up @@ -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 } );
Expand All @@ -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;
}
} );
} );
} );
} );
Expand Down
3 changes: 2 additions & 1 deletion src/blocks/carousel/block.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,5 +90,6 @@
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css",
"viewScript": "wp-hooks",
"viewScriptModule": "file:./view.js"
}
}
Loading