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; +}