Skip to content

Commit dd90468

Browse files
feat: Add slide template picker and templates for carousel block (#83)
* feat: add slide template picker and templates for carousel block * Add fallback for non-array filter results * refactor: update CSS variables and class naming for consistency in carousel styles * fix: keep keyboard focus inside block during carousel setup Auto-focus the first template button when the TemplatePicker mounts and enable block selection after replaceInnerBlocks so focus stays in the editor canvas instead of jumping to the sidebar. * fix: validate slide templates and log warnings for invalid entries * Address copilot feedback * fix: optimize slideTemplates memoization and improve comments for setup flow * fix: update default slide template from 'blank' to 'text' in carousel templates * refactor: scope carousel CSS variables to .rt-carousel Move custom properties off :root so they don't leak into the global scope and rename --carousel-kit-* to --rt-carousel-* for consistency with the block's class prefix. * fix: drop duplicate slide template names from filter callbacks Filter callbacks may register entries that collide with built-in template names, causing React key collisions when rendered. De-dupe by name after validation and warn on dropped entries. * fix: restore focus on template back-nav and guard document access Move keyboard focus to the first slide-count button when the user navigates back from the template picker so keyboard users don't get dropped at the top of the page. Also guard the post-setup focus restoration with a typeof document check so the effect doesn't throw in environments without a document (e.g. SSR or unit tests). --------- Co-authored-by: Masud Rana <mr.masudrana00@gmail.com>
1 parent 4762978 commit dd90468

8 files changed

Lines changed: 1454 additions & 36 deletions

File tree

package-lock.json

Lines changed: 612 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"@wordpress/data": "^10.10.0",
4343
"@wordpress/dom-ready": "^4.37.0",
4444
"@wordpress/element": "6.38.0",
45+
"@wordpress/hooks": "4.41.0",
4546
"@wordpress/i18n": "^6.10.0",
4647
"@wordpress/icons": "11.5.0",
4748
"@wordpress/interactivity": "6.37.0",
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* Unit tests for the carousel editor setup flow.
3+
*
4+
* @package
5+
*/
6+
7+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
8+
import Edit from '../edit';
9+
import type { CarouselAttributes } from '../types';
10+
11+
let mockBlockCount = 0;
12+
13+
jest.mock( '@wordpress/block-editor', () => ( {
14+
useBlockProps: jest.fn( ( props = {} ) => props ),
15+
useInnerBlocksProps: jest.fn( ( props = {} ) => props ),
16+
InspectorControls: jest.fn( ( { children } ) => children ),
17+
InspectorAdvancedControls: jest.fn( ( { children } ) => children ),
18+
BlockControls: jest.fn( ( { children } ) => children ),
19+
} ) );
20+
21+
jest.mock( '@wordpress/components', () => {
22+
const React = jest.requireActual( 'react' );
23+
24+
const Button = ( { children, onClick, className, ...rest }: any ) => (
25+
<button type="button" className={ className } onClick={ onClick } { ...rest }>
26+
{ children }
27+
</button>
28+
);
29+
30+
const Passthrough = ( { children }: any ) => <>{ children }</>;
31+
32+
return {
33+
PanelBody: Passthrough,
34+
ToggleControl: jest.fn( () => null ),
35+
SelectControl: jest.fn( () => null ),
36+
FormTokenField: jest.fn( () => null ),
37+
BaseControl: Passthrough,
38+
TextControl: jest.fn( () => null ),
39+
RangeControl: jest.fn( () => null ),
40+
Placeholder: ( { children, instructions, className }: any ) => (
41+
<div className={ className }>
42+
<p>{ instructions }</p>
43+
{ children }
44+
</div>
45+
),
46+
Button,
47+
ToolbarButton: Button,
48+
};
49+
} );
50+
51+
jest.mock( '@wordpress/data', () => ( {
52+
useDispatch: jest.fn( () => ( {
53+
replaceInnerBlocks: jest.fn(),
54+
insertBlock: jest.fn(),
55+
} ) ),
56+
useSelect: jest.fn( ( selector: any ) =>
57+
selector( ( storeName: string ) => {
58+
if ( storeName === 'core/block-editor' ) {
59+
return {
60+
getBlockCount: () => mockBlockCount,
61+
getBlocks: () => [],
62+
};
63+
}
64+
65+
if ( storeName === 'core/blocks' ) {
66+
return {
67+
getBlockTypes: () => [],
68+
};
69+
}
70+
71+
return {};
72+
} ),
73+
),
74+
} ) );
75+
76+
jest.mock( '@wordpress/icons', () => ( {
77+
plus: 'plus',
78+
columns: { name: 'columns' },
79+
image: { name: 'image' },
80+
layout: { name: 'layout' },
81+
gallery: { name: 'gallery' },
82+
post: { name: 'post' },
83+
} ) );
84+
85+
jest.mock( '@wordpress/blocks', () => ( {
86+
createBlock: jest.fn( ( name: string, attributes = {}, innerBlocks = [] ) => ( {
87+
name,
88+
attributes,
89+
innerBlocks,
90+
} ) ),
91+
} ) );
92+
93+
jest.mock( '../components/TemplatePicker', () => ( {
94+
__esModule: true,
95+
default: ( { onBack }: { onBack: () => void } ) => (
96+
<div>
97+
<button type="button" onClick={ onBack }>
98+
Back
99+
</button>
100+
</div>
101+
),
102+
} ) );
103+
104+
const createAttributes = (): CarouselAttributes => ( {
105+
loop: false,
106+
dragFree: false,
107+
carouselAlign: 'start',
108+
containScroll: 'trimSnaps',
109+
direction: 'ltr',
110+
axis: 'x',
111+
height: '',
112+
allowedSlideBlocks: [],
113+
autoplay: false,
114+
autoplayDelay: 1000,
115+
autoplayStopOnInteraction: true,
116+
autoplayStopOnMouseEnter: false,
117+
ariaLabel: 'Carousel',
118+
slidesToScroll: '1',
119+
slideGap: 0,
120+
} );
121+
122+
describe( 'Carousel Edit setup flow', () => {
123+
beforeEach( () => {
124+
mockBlockCount = 0;
125+
} );
126+
127+
it( 'restores focus to first slide-count button when going back from templates', async () => {
128+
render(
129+
<Edit
130+
attributes={ createAttributes() }
131+
setAttributes={ jest.fn() }
132+
clientId="client-1"
133+
/>,
134+
);
135+
136+
fireEvent.click( screen.getByRole( 'button', { name: '2 Slides' } ) );
137+
const backButton = screen.getByRole( 'button', { name: 'Back' } );
138+
backButton.focus();
139+
fireEvent.click( backButton );
140+
141+
await waitFor( () => {
142+
expect( screen.getByRole( 'button', { name: '1 Slide' } ) ).toHaveFocus();
143+
} );
144+
} );
145+
146+
it( 'does not throw when completing setup in an environment without document', () => {
147+
const originalDocumentDescriptor = Object.getOwnPropertyDescriptor( globalThis, 'document' );
148+
149+
const { rerender } = render(
150+
<Edit
151+
attributes={ createAttributes() }
152+
setAttributes={ jest.fn() }
153+
clientId="client-2"
154+
/>,
155+
);
156+
157+
mockBlockCount = 1;
158+
159+
if ( originalDocumentDescriptor?.configurable ) {
160+
Object.defineProperty( globalThis, 'document', {
161+
value: undefined,
162+
configurable: true,
163+
} );
164+
}
165+
166+
expect( () => {
167+
rerender(
168+
<Edit
169+
attributes={ createAttributes() }
170+
setAttributes={ jest.fn() }
171+
clientId="client-2"
172+
/>,
173+
);
174+
} ).not.toThrow();
175+
176+
if ( originalDocumentDescriptor?.configurable ) {
177+
Object.defineProperty( globalThis, 'document', originalDocumentDescriptor );
178+
}
179+
} );
180+
} );
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/**
2+
* Unit tests for slide template definitions and the template registry.
3+
*
4+
* Verifies:
5+
* - All default templates have the required shape
6+
* - Template inner blocks produce valid BlockInstance arrays
7+
* - Query Loop template is flagged correctly
8+
* - The `rtcamp.carouselKit.slideTemplates` filter hook is applied
9+
*
10+
* @package
11+
*/
12+
13+
/// <reference types="jest" />
14+
15+
import { applyFilters } from '@wordpress/hooks';
16+
import { getSlideTemplates, type SlideTemplate } from '../templates';
17+
18+
/* ── Mocks ────────────────────────────────────────────────────────────────── */
19+
20+
// Provide a minimal createBlock mock that returns a plain object.
21+
jest.mock( '@wordpress/blocks', () => ( {
22+
createBlock: jest.fn( ( name: string, attrs = {}, inner = [] ) => ( {
23+
name,
24+
attributes: attrs,
25+
innerBlocks: inner,
26+
clientId: `mock-${ name }-${ Math.random().toString( 36 ).slice( 2, 8 ) }`,
27+
} ) ),
28+
} ) );
29+
30+
jest.mock( '@wordpress/hooks', () => ( {
31+
applyFilters: jest.fn( ( _hookName: string, value: unknown ) => value ),
32+
} ) );
33+
34+
jest.mock( '@wordpress/i18n', () => ( {
35+
__: jest.fn( ( str: string ) => str ),
36+
} ) );
37+
38+
const mockedApplyFilters = jest.mocked( applyFilters );
39+
let consoleWarnSpy: jest.SpiedFunction< typeof console.warn >;
40+
41+
/* ── Tests ────────────────────────────────────────────────────────────────── */
42+
43+
describe( 'Slide Templates', () => {
44+
beforeEach( () => {
45+
consoleWarnSpy = jest.spyOn( console, 'warn' ).mockImplementation( () => undefined );
46+
mockedApplyFilters.mockClear();
47+
mockedApplyFilters.mockImplementation( ( _hookName: string, value: unknown ) => value );
48+
} );
49+
50+
afterEach( () => {
51+
consoleWarnSpy.mockRestore();
52+
} );
53+
54+
describe( 'getSlideTemplates()', () => {
55+
it( 'returns an array of templates', () => {
56+
const templates = getSlideTemplates();
57+
expect( Array.isArray( templates ) ).toBe( true );
58+
expect( templates.length ).toBeGreaterThanOrEqual( 5 );
59+
} );
60+
61+
it( 'applies the rtcamp.carouselKit.slideTemplates filter', () => {
62+
getSlideTemplates();
63+
expect( mockedApplyFilters ).toHaveBeenCalledWith(
64+
'rtcamp.carouselKit.slideTemplates',
65+
expect.any( Array ),
66+
);
67+
} );
68+
69+
it( 'passes a fresh copy of the default templates to filters', () => {
70+
mockedApplyFilters.mockImplementationOnce( ( _hookName: string, value: unknown ) => {
71+
( value as SlideTemplate[] ).push( {
72+
name: 'testimonial',
73+
label: 'Testimonial',
74+
description: 'Quote with author name.',
75+
icon: 'format-quote',
76+
innerBlocks: () => [],
77+
} );
78+
return value;
79+
} );
80+
81+
const mutatedTemplates = getSlideTemplates();
82+
const freshTemplates = getSlideTemplates();
83+
84+
expect( mutatedTemplates.map( ( template ) => template.name ) ).toContain( 'testimonial' );
85+
expect( freshTemplates.map( ( template ) => template.name ) ).not.toContain( 'testimonial' );
86+
} );
87+
88+
it( 'falls back to defaults when a filter returns a non-array value', () => {
89+
mockedApplyFilters.mockImplementationOnce( () => 'invalid' as never );
90+
91+
const templates = getSlideTemplates();
92+
93+
expect( Array.isArray( templates ) ).toBe( true );
94+
expect( templates.length ).toBeGreaterThanOrEqual( 5 );
95+
expect( templates.map( ( template ) => template.name ) ).toContain( 'text' );
96+
expect( consoleWarnSpy ).toHaveBeenCalledWith(
97+
'rtcamp.carouselKit.slideTemplates filter returned a non-array value. Falling back to default slide templates.',
98+
'invalid',
99+
);
100+
} );
101+
102+
it( 'drops duplicate template names returned by filters', () => {
103+
mockedApplyFilters.mockImplementationOnce( ( _hookName: string, value: unknown ) => [
104+
...( value as SlideTemplate[] ),
105+
{
106+
name: 'text',
107+
label: 'Duplicate Text',
108+
description: 'Duplicate entry',
109+
icon: 'format-quote',
110+
innerBlocks: () => [],
111+
},
112+
] );
113+
114+
const templates = getSlideTemplates();
115+
const textTemplates = templates.filter( ( template ) => template.name === 'text' );
116+
117+
expect( textTemplates ).toHaveLength( 1 );
118+
expect( consoleWarnSpy ).toHaveBeenCalledWith(
119+
'rtcamp.carouselKit.slideTemplates: dropping duplicate template name "text".',
120+
expect.objectContaining( { name: 'text', label: 'Duplicate Text' } ),
121+
);
122+
} );
123+
} );
124+
125+
describe( 'Template Shape', () => {
126+
const templates = getSlideTemplates();
127+
const templateCases: Array<[ string, SlideTemplate ]> = templates.map( ( template ) => [
128+
template.name,
129+
template,
130+
] );
131+
132+
it.each<[ string, SlideTemplate ]>( templateCases )(
133+
'template "%s" has required properties',
134+
( _name, template ) => {
135+
expect( typeof template.name ).toBe( 'string' );
136+
expect( template.name.length ).toBeGreaterThan( 0 );
137+
expect( typeof template.label ).toBe( 'string' );
138+
expect( typeof template.description ).toBe( 'string' );
139+
expect( template.icon ).toBeDefined();
140+
expect( template.icon ).not.toBeNull();
141+
expect( [ 'string', 'function', 'object' ] ).toContain(
142+
typeof template.icon,
143+
);
144+
expect( typeof template.innerBlocks ).toBe( 'function' );
145+
},
146+
);
147+
148+
it( 'each template has a unique name', () => {
149+
const names = templates.map( ( t ) => t.name );
150+
expect( new Set( names ).size ).toBe( names.length );
151+
} );
152+
} );
153+
154+
describe( 'Default Templates', () => {
155+
const templates = getSlideTemplates();
156+
const byName = ( name: string ) =>
157+
templates.find( ( t ) => t.name === name )!;
158+
159+
it( 'text template produces a paragraph block', () => {
160+
const blocks = byName( 'text' ).innerBlocks();
161+
expect( blocks ).toHaveLength( 1 );
162+
expect( blocks[ 0 ]!.name ).toBe( 'core/paragraph' );
163+
} );
164+
165+
it( 'image template produces an image block', () => {
166+
const blocks = byName( 'image' ).innerBlocks();
167+
expect( blocks ).toHaveLength( 1 );
168+
expect( blocks[ 0 ]!.name ).toBe( 'core/image' );
169+
} );
170+
171+
it( 'hero template produces a cover with heading, paragraph, and button', () => {
172+
const blocks = byName( 'hero' ).innerBlocks();
173+
expect( blocks ).toHaveLength( 1 );
174+
expect( blocks[ 0 ]!.name ).toBe( 'core/cover' );
175+
const inner = blocks[ 0 ]!.innerBlocks;
176+
expect( inner ).toHaveLength( 3 );
177+
expect( inner[ 0 ]!.name ).toBe( 'core/heading' );
178+
expect( inner[ 1 ]!.name ).toBe( 'core/paragraph' );
179+
expect( inner[ 2 ]!.name ).toBe( 'core/buttons' );
180+
} );
181+
182+
it( 'image-caption template produces an image and a paragraph', () => {
183+
const blocks = byName( 'image-caption' ).innerBlocks();
184+
expect( blocks ).toHaveLength( 2 );
185+
expect( blocks[ 0 ]!.name ).toBe( 'core/image' );
186+
expect( blocks[ 1 ]!.name ).toBe( 'core/paragraph' );
187+
} );
188+
189+
it( 'query-loop template is flagged as isQueryLoop', () => {
190+
const ql = byName( 'query-loop' );
191+
expect( ql.isQueryLoop ).toBe( true );
192+
} );
193+
194+
it( 'non-query-loop templates are not flagged as isQueryLoop', () => {
195+
templates
196+
.filter( ( t ) => t.name !== 'query-loop' )
197+
.forEach( ( t ) => {
198+
expect( t.isQueryLoop ).toBeFalsy();
199+
} );
200+
} );
201+
} );
202+
} );

0 commit comments

Comments
 (0)