diff --git a/src/blocks/carousel/__tests__/viewport-edit.test.tsx b/src/blocks/carousel/__tests__/viewport-edit.test.tsx index cc3605c..687f1d2 100644 --- a/src/blocks/carousel/__tests__/viewport-edit.test.tsx +++ b/src/blocks/carousel/__tests__/viewport-edit.test.tsx @@ -6,13 +6,23 @@ import '@testing-library/jest-dom'; import type { ReactNode } from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { createBlock } from '@wordpress/blocks'; import { useDispatch } from '@wordpress/data'; import Edit from '../viewport/edit'; import { EditorCarouselContext } from '../editor-context'; const mockInsertBlock = jest.fn(); +const mockBlockEditorState: { + slideClientIds: string[]; + selectedBlockClientId: string | null; +} = { + slideClientIds: [], + selectedBlockClientId: null, +}; +const mockScrollTo = jest.fn(); +const mockSelectedScrollSnap = jest.fn( () => 0 ); +const mockSlideRegistry: number[][] = []; jest.mock( '@wordpress/block-editor', () => ( { useBlockProps: jest.fn( ( props = {} ) => props ), @@ -65,13 +75,29 @@ jest.mock( '@wordpress/data', () => ( { useDispatch: jest.fn( () => ( { insertBlock: mockInsertBlock } ) ), useSelect: jest.fn( ( selector ) => selector( () => ( { - getBlocks: () => [], - getSelectedBlockClientId: () => null, + getBlocks: () => + mockBlockEditorState.slideClientIds.map( ( clientId ) => ( { clientId } ) ), + getSelectedBlockClientId: () => mockBlockEditorState.selectedBlockClientId, getBlockParents: () => [], } ) ), ), } ) ); +jest.mock( 'embla-carousel', () => ( { + __esModule: true, + default: jest.fn( () => ( { + canScrollPrev: jest.fn( () => false ), + canScrollNext: jest.fn( () => false ), + scrollTo: mockScrollTo, + selectedScrollSnap: mockSelectedScrollSnap, + internalEngine: () => ( { slideRegistry: mockSlideRegistry } ), + on: jest.fn(), + off: jest.fn(), + destroy: jest.fn(), + reInit: jest.fn(), + } ) ), +} ) ); + jest.mock( '@wordpress/blocks', () => ( { createBlock: jest.fn( ( name: string, attributes = {}, innerBlocks = [] ) => ( { name, @@ -88,7 +114,10 @@ jest.mock( '../hooks/useCarouselObservers', () => ( { useCarouselObservers: jest.fn(), } ) ); -const renderViewportEdit = () => +const renderViewportEdit = ( { + slideCount = 0, + slidesToScroll = 1, +}: { slideCount?: number; slidesToScroll?: number } = {} ) => render( selectedIndex: 0, setSelectedIndex: jest.fn(), scrollSnaps: [], - slideCount: 0, + slideCount, carouselOptions: { loop: false, dragFree: false, @@ -111,7 +140,7 @@ const renderViewportEdit = () => axis: 'x', align: 'start', direction: 'ltr', - slidesToScroll: 1, + slidesToScroll, }, } } > @@ -197,3 +226,81 @@ describe( 'Carousel Viewport Edit', () => { ); } ); } ); + +describe( 'Carousel Viewport Edit - selection scroll mapping', () => { + // `requestAnimationFrame` is faked manually (not via jest fake timers) so the + // selection-scroll effect's callback runs on a deterministic, single flush. + let rafCallbacks: FrameRequestCallback[]; + let rafSpy: jest.SpyInstance; + let cafSpy: jest.SpyInstance; + + beforeEach( () => { + mockBlockEditorState.slideClientIds = []; + mockBlockEditorState.selectedBlockClientId = null; + mockSlideRegistry.length = 0; + mockScrollTo.mockClear(); + mockSelectedScrollSnap.mockReset(); + mockSelectedScrollSnap.mockReturnValue( 0 ); + + rafCallbacks = []; + rafSpy = jest + .spyOn( window, 'requestAnimationFrame' ) + .mockImplementation( ( callback ) => { + rafCallbacks.push( callback ); + return rafCallbacks.length; + } ); + cafSpy = jest.spyOn( window, 'cancelAnimationFrame' ).mockImplementation( () => {} ); + } ); + + afterEach( () => { + rafSpy.mockRestore(); + cafSpy.mockRestore(); + } ); + + const flushRaf = () => { + const callbacks = rafCallbacks; + rafCallbacks = []; + act( () => { + callbacks.forEach( ( callback ) => callback( 0 ) ); + } ); + }; + + it( 'does not scroll away when the selected slide is already in the current snap', () => { + // slidesToScroll: 2, slides 0/1 and 2/3 each share a snap. + mockBlockEditorState.slideClientIds = [ 'slide-0', 'slide-1', 'slide-2', 'slide-3' ]; + mockBlockEditorState.selectedBlockClientId = 'slide-1'; + mockSlideRegistry.push( [ 0, 1 ], [ 2, 3 ] ); + mockSelectedScrollSnap.mockReturnValue( 0 ); // Already showing the [0, 1] snap. + + renderViewportEdit( { slideCount: 4, slidesToScroll: 2 } ); + flushRaf(); + + expect( mockScrollTo ).not.toHaveBeenCalled(); + } ); + + it( 'scrolls to the snap containing the selected slide, not the raw slide index', () => { + mockBlockEditorState.slideClientIds = [ 'slide-0', 'slide-1', 'slide-2', 'slide-3' ]; + mockBlockEditorState.selectedBlockClientId = 'slide-3'; + mockSlideRegistry.push( [ 0, 1 ], [ 2, 3 ] ); + mockSelectedScrollSnap.mockReturnValue( 0 ); // Currently on the first snap. + + renderViewportEdit( { slideCount: 4, slidesToScroll: 2 } ); + flushRaf(); + + // Slide index 3 must resolve to snap 1 ([2, 3]), never scroll to raw index 3. + expect( mockScrollTo ).toHaveBeenCalledWith( 1 ); + expect( mockScrollTo ).not.toHaveBeenCalledWith( 3 ); + } ); + + it( 'still scrolls by slide index when each snap holds a single slide (slidesToScroll: 1)', () => { + mockBlockEditorState.slideClientIds = [ 'slide-0', 'slide-1', 'slide-2' ]; + mockBlockEditorState.selectedBlockClientId = 'slide-2'; + mockSlideRegistry.push( [ 0 ], [ 1 ], [ 2 ] ); + mockSelectedScrollSnap.mockReturnValue( 0 ); + + renderViewportEdit( { slideCount: 3, slidesToScroll: 1 } ); + flushRaf(); + + expect( mockScrollTo ).toHaveBeenCalledWith( 2 ); + } ); +} ); diff --git a/src/blocks/carousel/viewport/edit.tsx b/src/blocks/carousel/viewport/edit.tsx index ea55c80..4c9d2e1 100644 --- a/src/blocks/carousel/viewport/edit.tsx +++ b/src/blocks/carousel/viewport/edit.tsx @@ -188,8 +188,16 @@ export default function Edit( { } const id = requestAnimationFrame( () => { const api = emblaApiRef.current; - if ( api && api.selectedScrollSnap() !== selectedSlideIndex ) { - api.scrollTo( selectedSlideIndex ); + if ( ! api ) { + return; + } + // scrollTo()/selectedScrollSnap() use snap indexes; with slidesToScroll > 1 + // one snap holds several slides, so map the slide to its snap first. + const snapIndex = api + .internalEngine() + .slideRegistry.findIndex( ( slides ) => slides.includes( selectedSlideIndex ) ); + if ( snapIndex > -1 && api.selectedScrollSnap() !== snapIndex ) { + api.scrollTo( snapIndex ); } } ); return () => cancelAnimationFrame( id );