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
7 changes: 4 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,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",
Expand Down
124 changes: 124 additions & 0 deletions src/blocks/carousel/__tests__/templates.test.ts
Original file line number Diff line number Diff line change
@@ -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' );
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion is brittle: IconType can be a string, function, or element depending on how icons are provided (especially for filtered/custom templates). Consider asserting the icon is defined (or is one of the allowed primitive types) rather than strictly typeof === 'object'.

Suggested change
expect( typeof template.icon ).toBe( 'object' );
expect( template.icon ).toBeDefined();
expect( template.icon ).not.toBeNull();
expect( [ 'string', 'function', 'object' ] ).toContain(
typeof template.icon,
);

Copilot uses AI. Check for mistakes.
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();
} );
} );
} );
} );
53 changes: 53 additions & 0 deletions src/blocks/carousel/components/TemplatePicker.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="carousel-kit-template-picker">
<div className="carousel-kit-template-picker__grid">
{ templates.map( ( template ) => (
<button
key={ template.name }
type="button"
className="carousel-kit-template-picker__item"
onClick={ () => onSelect( template ) }
>
<div className="carousel-kit-template-picker__icon">
<Icon icon={ template.icon } size={ 28 } />
</div>
<div className="carousel-kit-template-picker__label">
{ template.label }
</div>
<div className="carousel-kit-template-picker__description">
{ template.description }
</div>
</button>
) ) }
</div>
<Button
variant="link"
className="carousel-kit-template-picker__back"
onClick={ onBack }
>
{ __( 'Back', 'carousel-kit' ) }
</Button>
</div>
);
}
92 changes: 66 additions & 26 deletions src/blocks/carousel/edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -55,6 +59,8 @@ export default function Edit( {
const [ emblaApi, setEmblaApi ] = useState<EmblaCarouselType | undefined>();
const [ canScrollPrev, setCanScrollPrev ] = useState( false );
const [ canScrollNext, setCanScrollNext ] = useState( false );
const [ setupStep, setSetupStep ] = useState<SetupStep>( 'slide-count' );
const [ pendingSlideCount, setPendingSlideCount ] = useState<number>( 0 );

Comment on lines 59 to 64
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setupStep/pendingSlideCount are not reset after setup completes. If the user later deletes all inner blocks (making showSetup true again), the placeholder can reopen on the template step with a stale slide count and no direct Skip link. Consider resetting setup state when hasInnerBlocks transitions to false (e.g., in an effect keyed on showSetup).

Copilot uses AI. Check for mistakes.
const { replaceInnerBlocks, insertBlock } = useDispatch( 'core/block-editor' );

Expand Down Expand Up @@ -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,
);
};
Expand Down Expand Up @@ -393,30 +418,45 @@ export default function Edit( {
<Placeholder
icon="columns"
label={ __( 'Carousel', 'carousel-kit' ) }
instructions={ __( 'How many slides would you like to start with?', 'carousel-kit' ) }
instructions={
setupStep === 'slide-count'
? __( 'How many slides would you like to start with?', 'carousel-kit' )
: __( 'Choose a slide template:', 'carousel-kit' )
}
className="carousel-kit-setup"
>
<div className="carousel-kit-setup__options">
{ [ 1, 2, 3, 4 ].map( ( count ) => (
{ setupStep === 'slide-count' && (
<>
<div className="carousel-kit-setup__options">
{ [ 1, 2, 3, 4 ].map( ( count ) => (
<Button
key={ count }
variant="secondary"
className="carousel-kit-setup__option"
onClick={ () => handleSlideCountPicked( count ) }
>
{ count === 1
? __( '1 Slide', 'carousel-kit' )
: `${ count } ${ __( 'Slides', 'carousel-kit' ) }` }
</Button>
) ) }
</div>
<Button
key={ count }
variant="secondary"
className="carousel-kit-setup__option"
onClick={ () => handleSetup( count ) }
variant="link"
className="carousel-kit-setup__skip"
onClick={ handleSkip }
>
{ count === 1
? __( '1 Slide', 'carousel-kit' )
: `${ count } ${ __( 'Slides', 'carousel-kit' ) }` }
{ __( 'Skip', 'carousel-kit' ) }
</Button>
) ) }
</div>
<Button
variant="link"
className="carousel-kit-setup__skip"
onClick={ handleSkip }
>
{ __( 'Skip', 'carousel-kit' ) }
</Button>
</>
) }
{ setupStep === 'template' && (
<TemplatePicker
templates={ getSlideTemplates() }
onSelect={ handleTemplateSelected }
onBack={ () => setSetupStep( 'slide-count' ) }
/>
Comment on lines +453 to +458
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getSlideTemplates() runs during render, which will re-run applyFilters on every re-render while the setup UI is shown. Consider memoizing the templates list (e.g., useMemo) or only computing it when entering the template step to avoid unnecessary work.

Copilot uses AI. Check for mistakes.
) }
</Placeholder>
</div>
</EditorCarouselContext.Provider>
Expand Down
Loading
Loading