diff --git a/package-lock.json b/package-lock.json
index 8ad7308..6880d92 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,7 @@
"@wordpress/data": "^10.10.0",
"@wordpress/dom-ready": "^4.37.0",
"@wordpress/element": "6.38.0",
+ "@wordpress/hooks": "4.41.0",
"@wordpress/i18n": "^6.10.0",
"@wordpress/icons": "11.5.0",
"@wordpress/interactivity": "6.37.0",
@@ -9825,9 +9826,9 @@
}
},
"node_modules/@wordpress/hooks": {
- "version": "4.38.0",
- "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.38.0.tgz",
- "integrity": "sha512-nrLo2semyTID4yIlu9/DSKVM9v61Mgrkyr+MNj7LgzlD3PuGjYNzXVh5+ngfgPoKVdhV3kzFhda+1PZ8SK8cYg==",
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.41.0.tgz",
+ "integrity": "sha512-WDbLcLA3DOcjDGNLcxHZTPyhltWd/75G2hxFphe/hzcJUNmgysDTSSXO/bBrIWf6rwWD6TS3ejCaGC9J6DwYiw==",
"license": "GPL-2.0-or-later",
"engines": {
"node": ">=18.12.0",
diff --git a/package.json b/package.json
index 09076c5..7d4b493 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,7 @@
"@wordpress/data": "^10.10.0",
"@wordpress/dom-ready": "^4.37.0",
"@wordpress/element": "6.38.0",
+ "@wordpress/hooks": "4.41.0",
"@wordpress/i18n": "^6.10.0",
"@wordpress/icons": "11.5.0",
"@wordpress/interactivity": "6.37.0",
diff --git a/src/blocks/carousel/__tests__/templates.test.ts b/src/blocks/carousel/__tests__/templates.test.ts
new file mode 100644
index 0000000..9167ae9
--- /dev/null
+++ b/src/blocks/carousel/__tests__/templates.test.ts
@@ -0,0 +1,124 @@
+/**
+ * Unit tests for slide template definitions and the template registry.
+ *
+ * Verifies:
+ * - All default templates have the required shape
+ * - Template inner blocks produce valid BlockInstance arrays
+ * - Query Loop template is flagged correctly
+ * - The `rtcamp.carouselKit.slideTemplates` filter hook is applied
+ *
+ * @package
+ */
+
+import { getSlideTemplates } from '../templates';
+
+/* ── Mocks ────────────────────────────────────────────────────────────────── */
+
+// Provide a minimal createBlock mock that returns a plain object.
+jest.mock( '@wordpress/blocks', () => ( {
+ createBlock: jest.fn( ( name: string, attrs = {}, inner = [] ) => ( {
+ name,
+ attributes: attrs,
+ innerBlocks: inner,
+ clientId: `mock-${ name }-${ Math.random().toString( 36 ).slice( 2, 8 ) }`,
+ } ) ),
+} ) );
+
+jest.mock( '@wordpress/hooks', () => ( {
+ applyFilters: jest.fn( ( _hookName: string, value: unknown ) => value ),
+} ) );
+
+jest.mock( '@wordpress/i18n', () => ( {
+ __: jest.fn( ( str: string ) => str ),
+} ) );
+
+/* ── Tests ────────────────────────────────────────────────────────────────── */
+
+describe( 'Slide Templates', () => {
+ describe( 'getSlideTemplates()', () => {
+ it( 'returns an array of templates', () => {
+ const templates = getSlideTemplates();
+ expect( Array.isArray( templates ) ).toBe( true );
+ expect( templates.length ).toBeGreaterThanOrEqual( 5 );
+ } );
+
+ it( 'applies the rtcamp.carouselKit.slideTemplates filter', () => {
+ const { applyFilters } = require( '@wordpress/hooks' );
+ getSlideTemplates();
+ expect( applyFilters ).toHaveBeenCalledWith(
+ 'rtcamp.carouselKit.slideTemplates',
+ expect.any( Array ),
+ );
+ } );
+ } );
+
+ describe( 'Template Shape', () => {
+ const templates = getSlideTemplates();
+
+ it.each( templates.map( ( t ) => [ t.name, t ] ) )(
+ 'template "%s" has required properties',
+ ( _name, template ) => {
+ expect( typeof template.name ).toBe( 'string' );
+ expect( template.name.length ).toBeGreaterThan( 0 );
+ expect( typeof template.label ).toBe( 'string' );
+ expect( typeof template.description ).toBe( 'string' );
+ expect( typeof template.icon ).toBe( 'object' );
+ expect( typeof template.innerBlocks ).toBe( 'function' );
+ },
+ );
+
+ it( 'each template has a unique name', () => {
+ const names = templates.map( ( t ) => t.name );
+ expect( new Set( names ).size ).toBe( names.length );
+ } );
+ } );
+
+ describe( 'Default Templates', () => {
+ const templates = getSlideTemplates();
+ const byName = ( name: string ) =>
+ templates.find( ( t ) => t.name === name )!;
+
+ it( 'blank template produces a paragraph block', () => {
+ const blocks = byName( 'blank' ).innerBlocks();
+ expect( blocks ).toHaveLength( 1 );
+ expect( blocks[ 0 ].name ).toBe( 'core/paragraph' );
+ } );
+
+ it( 'image template produces an image block', () => {
+ const blocks = byName( 'image' ).innerBlocks();
+ expect( blocks ).toHaveLength( 1 );
+ expect( blocks[ 0 ].name ).toBe( 'core/image' );
+ } );
+
+ it( 'hero template produces a cover with heading, paragraph, and button', () => {
+ const blocks = byName( 'hero' ).innerBlocks();
+ expect( blocks ).toHaveLength( 1 );
+ expect( blocks[ 0 ].name ).toBe( 'core/cover' );
+ const inner = blocks[ 0 ].innerBlocks;
+ expect( inner ).toHaveLength( 3 );
+ expect( inner[ 0 ].name ).toBe( 'core/heading' );
+ expect( inner[ 1 ].name ).toBe( 'core/paragraph' );
+ expect( inner[ 2 ].name ).toBe( 'core/buttons' );
+ } );
+
+ it( 'image-caption template produces an image and a paragraph', () => {
+ const blocks = byName( 'image-caption' ).innerBlocks();
+ expect( blocks ).toHaveLength( 2 );
+ expect( blocks[ 0 ].name ).toBe( 'core/image' );
+ expect( blocks[ 1 ].name ).toBe( 'core/paragraph' );
+ } );
+
+ it( 'query-loop template is flagged as isQueryLoop', () => {
+ const ql = byName( 'query-loop' );
+ expect( ql.isQueryLoop ).toBe( true );
+ } );
+
+ it( 'non-query-loop templates are not flagged as isQueryLoop', () => {
+ templates
+ .filter( ( t ) => t.name !== 'query-loop' )
+ .forEach( ( t ) => {
+ expect( t.isQueryLoop ).toBeFalsy();
+ } );
+ } );
+ } );
+} );
diff --git a/src/blocks/carousel/components/TemplatePicker.tsx b/src/blocks/carousel/components/TemplatePicker.tsx
new file mode 100644
index 0000000..8275616
--- /dev/null
+++ b/src/blocks/carousel/components/TemplatePicker.tsx
@@ -0,0 +1,53 @@
+/**
+ * TemplatePicker — grid of slide template options shown during block setup.
+ *
+ * @package
+ */
+
+import { __ } from '@wordpress/i18n';
+import { Button, Icon } from '@wordpress/components';
+import type { SlideTemplate } from '../templates';
+
+interface TemplatePickerProps {
+ templates: SlideTemplate[];
+ onSelect: ( template: SlideTemplate ) => void;
+ onBack: () => void;
+}
+
+export default function TemplatePicker( {
+ templates,
+ onSelect,
+ onBack,
+}: TemplatePickerProps ) {
+ return (
+
+
+ { templates.map( ( template ) => (
+
+ ) ) }
+
+
+
+ );
+}
diff --git a/src/blocks/carousel/edit.tsx b/src/blocks/carousel/edit.tsx
index 3b0d652..cec8986 100644
--- a/src/blocks/carousel/edit.tsx
+++ b/src/blocks/carousel/edit.tsx
@@ -25,6 +25,10 @@ import { createBlock, type BlockConfiguration } from '@wordpress/blocks';
import type { CarouselAttributes } from './types';
import { EditorCarouselContext } from './editor-context';
import type { EmblaCarouselType } from 'embla-carousel';
+import { getSlideTemplates, type SlideTemplate } from './templates';
+import TemplatePicker from './components/TemplatePicker';
+
+type SetupStep = 'slide-count' | 'template';
export default function Edit( {
attributes,
@@ -55,6 +59,8 @@ export default function Edit( {
const [ emblaApi, setEmblaApi ] = useState();
const [ canScrollPrev, setCanScrollPrev ] = useState( false );
const [ canScrollNext, setCanScrollNext ] = useState( false );
+ const [ setupStep, setSetupStep ] = useState( 'slide-count' );
+ const [ pendingSlideCount, setPendingSlideCount ] = useState( 0 );
const { replaceInnerBlocks, insertBlock } = useDispatch( 'core/block-editor' );
@@ -155,16 +161,35 @@ export default function Edit( {
],
);
- const handleSetup = ( slideCount: number ) => {
- const slides = Array.from( { length: slideCount }, () =>
- createBlock( 'carousel-kit/carousel-slide', {}, [
- createBlock( 'core/paragraph', {} ),
- ] ),
- );
+ /**
+ * Handle the initial setup of the carousel block
+ *
+ * @param {number} count - The number of slides selected by the user.
+ */
+ const handleSlideCountPicked = ( count: number ) => {
+ setPendingSlideCount( count );
+ setSetupStep( 'template' );
+ };
+
+ /**
+ * Handle the selection of a slide template during setup.
+ *
+ * @param {SlideTemplate} template - The slide template selected by the user.
+ */
+ const handleTemplateSelected = ( template: SlideTemplate ) => {
+ // Query Loop goes directly inside the viewport; regular templates get slide wrappers.
+ const viewportChildren = template.isQueryLoop
+ ? [ createBlock( 'core/query', {}, [] ) ]
+ : Array.from( { length: Math.max( pendingSlideCount, 1 ) }, () =>
+ createBlock( 'carousel-kit/carousel-slide', {}, template.innerBlocks() ),
+ );
replaceInnerBlocks(
clientId,
- [ createBlock( 'carousel-kit/carousel-viewport', {}, slides ), createNavGroup() ],
+ [
+ createBlock( 'carousel-kit/carousel-viewport', {}, viewportChildren ),
+ createNavGroup(),
+ ],
false,
);
};
@@ -393,30 +418,45 @@ export default function Edit( {
-
- { [ 1, 2, 3, 4 ].map( ( count ) => (
+ { setupStep === 'slide-count' && (
+ <>
+
+ { [ 1, 2, 3, 4 ].map( ( count ) => (
+
+ ) ) }
+
- ) ) }
-
-
+ >
+ ) }
+ { setupStep === 'template' && (
+ setSetupStep( 'slide-count' ) }
+ />
+ ) }
diff --git a/src/blocks/carousel/editor.scss b/src/blocks/carousel/editor.scss
index bcb99fa..4c385f6 100644
--- a/src/blocks/carousel/editor.scss
+++ b/src/blocks/carousel/editor.scss
@@ -23,6 +23,67 @@
}
}
+// ── Template picker ──────────────────────────────────────────────────────────
+.carousel-kit-template-picker {
+ width: 100%;
+
+ .carousel-kit-template-picker__grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ gap: 12px;
+ margin-bottom: 12px;
+ }
+
+ .carousel-kit-template-picker__item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ padding: 16px 12px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ background: #fff;
+ cursor: pointer;
+ text-align: center;
+ transition: border-color 0.15s, box-shadow 0.15s;
+
+ &:hover,
+ &:focus-visible {
+ border-color: var(--wp-admin-theme-color, #3858e9);
+ box-shadow: 0 0 0 1px var(--wp-admin-theme-color, #3858e9);
+ outline: none;
+ }
+ }
+
+ .carousel-kit-template-picker__icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ background: #f0f0f0;
+ color: #1e1e1e;
+ }
+
+ .carousel-kit-template-picker__label {
+ font-weight: 600;
+ font-size: 13px;
+ line-height: 1.3;
+ }
+
+ .carousel-kit-template-picker__description {
+ font-size: 12px;
+ color: #757575;
+ line-height: 1.4;
+ }
+
+ .carousel-kit-template-picker__back {
+ display: block;
+ margin-top: 4px;
+ }
+}
+
// ── Viewport empty state ─────────────────────────────────────────────────────
// Rendered via renderAppender inside .embla__container (a flex row).
// flex: 0 0 100% makes it fill the full container width as a single flex item.
diff --git a/src/blocks/carousel/templates.ts b/src/blocks/carousel/templates.ts
new file mode 100644
index 0000000..505f59f
--- /dev/null
+++ b/src/blocks/carousel/templates.ts
@@ -0,0 +1,151 @@
+/**
+ * Slide template definitions for the Carousel block.
+ *
+ * Developers can register additional templates via the
+ * `rtcamp.carouselKit.slideTemplates` WordPress filter (applied with `applyFilters`).
+ *
+ * @package
+ */
+
+import { createBlock, type BlockInstance } from '@wordpress/blocks';
+import { type IconType } from '@wordpress/components';
+import { applyFilters } from '@wordpress/hooks';
+import { __ } from '@wordpress/i18n';
+import { columns, image, layout, gallery, post } from '@wordpress/icons';
+
+export interface SlideTemplate {
+ /** Unique machine-readable name. */
+ name: string;
+ /** Human-readable title shown in the picker. */
+ label: string;
+ /** Short description shown below the label. */
+ description: string;
+ /** WordPress icon component used in the picker. Accepts any value supported by `` from `@wordpress/components`. */
+ icon: IconType;
+ /**
+ * Whether this template uses a Query Loop instead of individual slides.
+ * When true, `slideCount` is ignored and a `core/query` block is placed
+ * directly inside the carousel viewport.
+ */
+ isQueryLoop?: boolean;
+ /**
+ * Build the inner blocks for a single slide.
+ * Called once per slide (or not at all for Query Loop templates).
+ */
+ innerBlocks: () => BlockInstance[];
+}
+
+// ── Default templates ────────────────────────────────────────────────────────
+
+const blankSlide: SlideTemplate = {
+ name: 'blank',
+ label: __( 'Text Slides', 'carousel-kit' ),
+ description: __( 'Slides starting with a paragraph you can replace or extend.', 'carousel-kit' ),
+ icon: columns,
+ innerBlocks: () => [ createBlock( 'core/paragraph', {} ) ],
+};
+
+const imageSlide: SlideTemplate = {
+ name: 'image',
+ label: __( 'Image Slides', 'carousel-kit' ),
+ description: __( 'Slides prefilled with an image block.', 'carousel-kit' ),
+ icon: image,
+ innerBlocks: () => [ createBlock( 'core/image', {} ) ],
+};
+
+const heroSlide: SlideTemplate = {
+ name: 'hero',
+ label: __( 'Image + Heading + Text + CTA', 'carousel-kit' ),
+ description: __( 'Marketing slider with heading, paragraph, and button.', 'carousel-kit' ),
+ icon: layout,
+ innerBlocks: () => [
+ createBlock( 'core/cover', {}, [
+ createBlock( 'core/heading', {
+ level: 2,
+ placeholder: __( 'Slide Heading', 'carousel-kit' ),
+ } ),
+ createBlock( 'core/paragraph', {
+ placeholder: __( 'Slide description text…', 'carousel-kit' ),
+ } ),
+ createBlock( 'core/buttons', {}, [
+ createBlock( 'core/button', {} ),
+ ] ),
+ ] ),
+ ],
+};
+
+const imageCaptionSlide: SlideTemplate = {
+ name: 'image-caption',
+ label: __( 'Image + Caption', 'carousel-kit' ),
+ description: __( 'Image with supporting text below.', 'carousel-kit' ),
+ icon: gallery,
+ innerBlocks: () => [
+ createBlock( 'core/image', {} ),
+ createBlock( 'core/paragraph', {
+ placeholder: __( 'Caption text…', 'carousel-kit' ),
+ } ),
+ ],
+};
+
+const queryLoopSlide: SlideTemplate = {
+ name: 'query-loop',
+ label: __( 'Query Loop Slides', 'carousel-kit' ),
+ description: __( 'Dynamically generate slides from posts.', 'carousel-kit' ),
+ icon: post,
+ isQueryLoop: true,
+ innerBlocks: () => [], // Not used — Query Loop is handled specially.
+};
+
+const DEFAULT_TEMPLATES: SlideTemplate[] = [
+ blankSlide,
+ imageSlide,
+ heroSlide,
+ imageCaptionSlide,
+ queryLoopSlide,
+];
+
+/**
+ * Retrieve all available slide templates.
+ *
+ * External code can add templates via:
+ *
+ * ```js
+ * import { addFilter } from '@wordpress/hooks';
+ *
+ * addFilter(
+ * 'rtcamp.carouselKit.slideTemplates',
+ * 'my-plugin/custom-templates',
+ * ( templates ) => [
+ * ...templates,
+ * {
+ * name: 'testimonial',
+ * label: 'Testimonial',
+ * description: 'Quote with author name.',
+ * icon: 'format-quote',
+ * innerBlocks: () => [
+ * createBlock( 'core/quote', {} ),
+ * createBlock( 'core/paragraph', { placeholder: '— Author' } ),
+ * ],
+ * },
+ * ],
+ * );
+ * ```
+ */
+export function getSlideTemplates(): SlideTemplate[] {
+ const templates = applyFilters(
+ 'rtcamp.carouselKit.slideTemplates',
+ DEFAULT_TEMPLATES,
+ );
+
+ if ( Array.isArray( templates ) ) {
+ return templates as SlideTemplate[];
+ }
+
+ // eslint-disable-next-line no-console
+ console.warn(
+ 'rtcamp.carouselKit.slideTemplates filter returned a non-array value. Falling back to default slide templates.',
+ templates,
+ );
+
+ return DEFAULT_TEMPLATES;
+}