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 @@ -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",
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' );
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 );

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' ) }
/>
) }
</Placeholder>
</div>
</EditorCarouselContext.Provider>
Expand Down
Loading
Loading