diff --git a/inc/Plugin.php b/inc/Plugin.php index a296e70..3a66ac3 100644 --- a/inc/Plugin.php +++ b/inc/Plugin.php @@ -132,6 +132,7 @@ public function register_blocks(): void { 'carousel/controls', 'carousel/dots', 'carousel/progress', + 'carousel/thumbnails', 'carousel/viewport', 'carousel/slide', ]; diff --git a/src/blocks/carousel/__tests__/edit.test.tsx b/src/blocks/carousel/__tests__/edit.test.tsx index 876982c..6f5bb14 100644 --- a/src/blocks/carousel/__tests__/edit.test.tsx +++ b/src/blocks/carousel/__tests__/edit.test.tsx @@ -5,8 +5,10 @@ */ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; import Edit from '../edit'; import type { CarouselAttributes } from '../types'; +import type { ButtonHTMLAttributes, ReactNode } from 'react'; let mockBlockCount = 0; @@ -19,15 +21,24 @@ jest.mock( '@wordpress/block-editor', () => ( { } ) ); jest.mock( '@wordpress/components', () => { - const React = jest.requireActual( 'react' ); + type MockButtonProps = ButtonHTMLAttributes & { + children?: ReactNode; + }; + type MockChildrenProps = { + children?: ReactNode; + }; + type MockPlaceholderProps = MockChildrenProps & { + instructions?: ReactNode; + className?: string; + }; - const Button = ( { children, onClick, className, ...rest }: any ) => ( + const Button = ( { children, onClick, className, ...rest }: MockButtonProps ) => ( ); - const Passthrough = ( { children }: any ) => <>{ children }; + const Passthrough = ( { children }: MockChildrenProps ) => <>{ children }; return { PanelBody: Passthrough, @@ -37,7 +48,7 @@ jest.mock( '@wordpress/components', () => { BaseControl: Passthrough, TextControl: jest.fn( () => null ), RangeControl: jest.fn( () => null ), - Placeholder: ( { children, instructions, className }: any ) => ( + Placeholder: ( { children, instructions, className }: MockPlaceholderProps ) => (

{ instructions }

{ children } @@ -53,7 +64,7 @@ jest.mock( '@wordpress/data', () => ( { replaceInnerBlocks: jest.fn(), insertBlock: jest.fn(), } ) ), - useSelect: jest.fn( ( selector: any ) => + useSelect: jest.fn( ( selector: ( select: ( storeName: string ) => unknown ) => unknown ) => selector( ( storeName: string ) => { if ( storeName === 'core/block-editor' ) { return { diff --git a/src/blocks/carousel/__tests__/thumbnails-edit.test.tsx b/src/blocks/carousel/__tests__/thumbnails-edit.test.tsx new file mode 100644 index 0000000..4da5563 --- /dev/null +++ b/src/blocks/carousel/__tests__/thumbnails-edit.test.tsx @@ -0,0 +1,93 @@ +/** + * Unit tests for the carousel thumbnails editor block. + * + * @package + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import type { EmblaCarouselType } from 'embla-carousel'; +import Edit from '../thumbnails/edit'; +import { EditorCarouselContext } from '../editor-context'; + +jest.mock( '@wordpress/block-editor', () => ( { + useBlockProps: jest.fn( ( props = {} ) => props ), +} ) ); + +jest.mock( '@wordpress/i18n', () => ( { + __: jest.fn( ( text: string ) => text ), + sprintf: jest.fn( ( format: string, value: number ) => + format.replace( '%d', value.toString() ), + ), +} ) ); + +const createMockEmbla = (): EmblaCarouselType => + ( { + scrollTo: jest.fn(), + scrollSnapList: jest.fn( () => [] ), + slideNodes: jest.fn( () => [] ), + on: jest.fn(), + off: jest.fn(), + } as unknown as EmblaCarouselType ); + +const createMockEmblaWithImage = (): EmblaCarouselType => { + const slide = document.createElement( 'div' ); + slide.innerHTML = '
Editor image
'; + + return { + ...createMockEmbla(), + scrollSnapList: jest.fn( () => [ 0 ] ), + slideNodes: jest.fn( () => [ slide ] ), + } as unknown as EmblaCarouselType; +}; + +const renderWithContext = ( emblaApi: EmblaCarouselType | undefined ) => + render( + + + , + ); + +describe( 'Carousel Thumbnails Edit', () => { + it( 'renders fallback thumbnails when no Embla slides are available', () => { + renderWithContext( undefined ); + + expect( screen.getAllByRole( 'button' ) ).toHaveLength( 3 ); + expect( screen.getByRole( 'button', { name: 'Go to slide 2' } ) ).toHaveClass( + 'is-active', + ); + } ); + + it( 'scrolls the editor carousel when a thumbnail is clicked', () => { + const emblaApi = createMockEmbla(); + + renderWithContext( emblaApi ); + fireEvent.click( screen.getByRole( 'button', { name: 'Go to slide 3' } ) ); + + expect( emblaApi.scrollTo ).toHaveBeenCalledWith( 2 ); + } ); + + it( 'renders slide images in editor thumbnails when available', () => { + const { container } = renderWithContext( createMockEmblaWithImage() ); + const thumbnailImage = container.querySelector( '.rt-carousel-thumbnail__image' ); + + expect( thumbnailImage ).toHaveAttribute( + 'src', + 'https://example.com/editor-image.jpg', + ); + } ); +} ); diff --git a/src/blocks/carousel/__tests__/view.test.ts b/src/blocks/carousel/__tests__/view.test.ts index d7aa624..dda958e 100644 --- a/src/blocks/carousel/__tests__/view.test.ts +++ b/src/blocks/carousel/__tests__/view.test.ts @@ -13,6 +13,7 @@ */ import { store, getContext, getElement } from '@wordpress/interactivity'; +import '@testing-library/jest-dom'; import EmblaCarousel, { type EmblaCarouselType } from 'embla-carousel'; @@ -90,6 +91,24 @@ const createMockCarouselDOM = () => { return { wrapper, viewport, button }; }; +const createMockThumbnailsDOM = () => { + const { wrapper, viewport } = createMockCarouselDOM(); + const thumbnails = document.createElement( 'div' ); + thumbnails.className = 'rt-carousel-thumbnails'; + + const thumbnailsViewport = document.createElement( 'div' ); + thumbnailsViewport.className = 'rt-carousel-thumbnails__viewport'; + + const thumbnailsContainer = document.createElement( 'div' ); + thumbnailsContainer.className = 'rt-carousel-thumbnails__container'; + + thumbnailsViewport.appendChild( thumbnailsContainer ); + thumbnails.appendChild( thumbnailsViewport ); + wrapper.appendChild( thumbnails ); + + return { wrapper, viewport, thumbnails, thumbnailsContainer }; +}; + /** * Helper to create mock Embla instance with all required methods. * @@ -103,10 +122,13 @@ const createMockEmblaInstance = ( overrides = {} ) => ( { on: jest.fn(), off: jest.fn(), destroy: jest.fn(), + reInit: jest.fn(), canScrollPrev: jest.fn( () => true ), canScrollNext: jest.fn( () => true ), selectedScrollSnap: jest.fn( () => 0 ), scrollSnapList: jest.fn( () => [ 0, 0.5, 1 ] ), + slideNodes: jest.fn( () => [] ), + scrollProgress: jest.fn( () => 0 ), ...overrides, } ); @@ -758,6 +780,294 @@ describe( 'Carousel View Module', () => { } } ); } ); + + describe( 'initThumbnails', () => { + it( 'should be defined as a function', () => { + expect( storeConfig?.callbacks?.initThumbnails ).toBeDefined(); + expect( typeof storeConfig?.callbacks?.initThumbnails ).toBe( 'function' ); + } ); + + it( 'should wait for the main carousel ready event before building thumbnails', () => { + const { wrapper, viewport, thumbnails, thumbnailsContainer } = + createMockThumbnailsDOM(); + const slide = document.createElement( 'div' ); + slide.className = 'embla__slide'; + slide.textContent = 'Slide content'; + const mainEmbla = createMockEmblaInstance( { + scrollSnapList: jest.fn( () => [ 0 ] ), + slideNodes: jest.fn( () => [ slide ] ), + } ); + const thumbEmbla = createMockEmblaInstance(); + + ( getContext as jest.Mock ).mockReturnValue( createMockContext() ); + ( getElement as jest.Mock ).mockReturnValue( { ref: thumbnails } ); + ( EmblaCarousel as unknown as jest.Mock ).mockReturnValue( thumbEmbla ); + + const cleanup = storeConfig.callbacks.initThumbnails(); + + expect( thumbnailsContainer.children ).toHaveLength( 0 ); + + setEmblaOnViewport( viewport, mainEmbla ); + wrapper.dispatchEvent( + new CustomEvent( 'rt-carousel:init', { detail: { embla: mainEmbla } } ), + ); + + expect( thumbnailsContainer.querySelectorAll( 'button' ) ).toHaveLength( 1 ); + + cleanup?.(); + } ); + + it( 'should generate thumbnail buttons from slide nodes', () => { + const { viewport, thumbnails, thumbnailsContainer } = + createMockThumbnailsDOM(); + const slides = [ 1, 2, 3 ].map( ( index ) => { + const slide = document.createElement( 'div' ); + slide.className = 'embla__slide'; + slide.innerHTML = `

Slide ${ index }

`; + return slide; + } ); + const mainEmbla = createMockEmblaInstance( { + scrollSnapList: jest.fn( () => [ 0, 0.5, 1 ] ), + slideNodes: jest.fn( () => slides ), + } ); + const thumbEmbla = createMockEmblaInstance(); + + setEmblaOnViewport( viewport, mainEmbla ); + ( getContext as jest.Mock ).mockReturnValue( createMockContext() ); + ( getElement as jest.Mock ).mockReturnValue( { ref: thumbnails } ); + ( EmblaCarousel as unknown as jest.Mock ).mockReturnValue( thumbEmbla ); + + const cleanup = storeConfig.callbacks.initThumbnails(); + const buttons = thumbnailsContainer.querySelectorAll( 'button' ); + + expect( buttons ).toHaveLength( 3 ); + expect( thumbnailsContainer.querySelector( '[data-wp-text]' ) ).toBeNull(); + expect( thumbnailsContainer.querySelector( '#slide-1' ) ).toBeNull(); + + cleanup?.(); + } ); + + it( 'should render slide images as full thumbnail images', () => { + const { viewport, thumbnails, thumbnailsContainer } = + createMockThumbnailsDOM(); + const slide = document.createElement( 'div' ); + slide.className = 'embla__slide'; + slide.innerHTML = '
Money
'; + const mainEmbla = createMockEmblaInstance( { + scrollSnapList: jest.fn( () => [ 0 ] ), + slideNodes: jest.fn( () => [ slide ] ), + } ); + const thumbEmbla = createMockEmblaInstance(); + + setEmblaOnViewport( viewport, mainEmbla ); + ( getContext as jest.Mock ).mockReturnValue( createMockContext() ); + ( getElement as jest.Mock ).mockReturnValue( { ref: thumbnails } ); + ( EmblaCarousel as unknown as jest.Mock ).mockReturnValue( thumbEmbla ); + + const cleanup = storeConfig.callbacks.initThumbnails(); + const thumbnailImage = thumbnailsContainer.querySelector( + '.rt-carousel-thumbnail__image', + ); + + expect( thumbnailImage ).toBeInstanceOf( HTMLImageElement ); + expect( thumbnailImage ).toHaveAttribute( + 'src', + 'https://example.com/image.jpg', + ); + expect( thumbnailImage ).toHaveAttribute( 'alt', '' ); + + cleanup?.(); + } ); + + it( 'should use translated carousel label pattern for thumbnail labels', () => { + const { viewport, thumbnails, thumbnailsContainer } = + createMockThumbnailsDOM(); + const mainEmbla = createMockEmblaInstance( { + scrollSnapList: jest.fn( () => [ 0 ] ), + slideNodes: jest.fn( () => [ document.createElement( 'div' ) ] ), + } ); + const thumbEmbla = createMockEmblaInstance(); + + setEmblaOnViewport( viewport, mainEmbla ); + ( getContext as jest.Mock ).mockReturnValue( + createMockContext( { ariaLabelPattern: 'View item %d' } ), + ); + ( getElement as jest.Mock ).mockReturnValue( { ref: thumbnails } ); + ( EmblaCarousel as unknown as jest.Mock ).mockReturnValue( thumbEmbla ); + + const cleanup = storeConfig.callbacks.initThumbnails(); + + expect( thumbnailsContainer.querySelector( 'button' ) ).toHaveAttribute( + 'aria-label', + 'View item 1', + ); + + cleanup?.(); + } ); + + it( 'should preview the first slide for each grouped scroll snap', () => { + const { viewport, thumbnails, thumbnailsContainer } = + createMockThumbnailsDOM(); + const slides = [ 1, 2, 3, 4 ].map( ( index ) => { + const slide = document.createElement( 'div' ); + slide.innerHTML = ``; + return slide; + } ); + const mainEmbla = createMockEmblaInstance( { + scrollSnapList: jest.fn( () => [ 0, 1 ] ), + slideNodes: jest.fn( () => slides ), + } ); + const thumbEmbla = createMockEmblaInstance(); + + setEmblaOnViewport( viewport, mainEmbla ); + ( getContext as jest.Mock ).mockReturnValue( createMockContext() ); + ( getElement as jest.Mock ).mockReturnValue( { ref: thumbnails } ); + ( EmblaCarousel as unknown as jest.Mock ).mockReturnValue( thumbEmbla ); + + const cleanup = storeConfig.callbacks.initThumbnails(); + const images = thumbnailsContainer.querySelectorAll( + '.rt-carousel-thumbnail__image', + ); + + expect( images.item( 0 ) ).toHaveAttribute( + 'src', + 'https://example.com/slide-1.jpg', + ); + expect( images.item( 1 ) ).toHaveAttribute( + 'src', + 'https://example.com/slide-3.jpg', + ); + + cleanup?.(); + } ); + + it( 'should scroll the main carousel when a thumbnail is clicked', () => { + const { viewport, thumbnails, thumbnailsContainer } = + createMockThumbnailsDOM(); + const slides = [ 1, 2 ].map( () => document.createElement( 'div' ) ); + const mainEmbla = createMockEmblaInstance( { + selectedScrollSnap: jest.fn( () => 0 ), + scrollSnapList: jest.fn( () => [ 0, 1 ] ), + slideNodes: jest.fn( () => slides ), + } ); + const thumbEmbla = createMockEmblaInstance(); + + setEmblaOnViewport( viewport, mainEmbla ); + ( getContext as jest.Mock ).mockReturnValue( createMockContext() ); + ( getElement as jest.Mock ).mockReturnValue( { ref: thumbnails } ); + ( EmblaCarousel as unknown as jest.Mock ).mockReturnValue( thumbEmbla ); + + const cleanup = storeConfig.callbacks.initThumbnails(); + const secondButton = thumbnailsContainer.querySelectorAll( 'button' ).item( 1 ); + + secondButton.dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) ); + + expect( mainEmbla.scrollTo ).toHaveBeenCalledWith( 1 ); + + cleanup?.(); + } ); + + it( 'should still scroll when announcement context is unavailable during thumbnail click', () => { + const { viewport, thumbnails, thumbnailsContainer } = + createMockThumbnailsDOM(); + const slides = [ 1, 2 ].map( () => document.createElement( 'div' ) ); + const mainEmbla = createMockEmblaInstance( { + selectedScrollSnap: jest.fn( () => 0 ), + scrollSnapList: jest.fn( () => [ 0, 1 ] ), + slideNodes: jest.fn( () => slides ), + } ); + const thumbEmbla = createMockEmblaInstance(); + + setEmblaOnViewport( viewport, mainEmbla ); + ( getContext as jest.Mock ).mockReturnValue( createMockContext() ); + ( getElement as jest.Mock ).mockReturnValue( { ref: thumbnails } ); + ( EmblaCarousel as unknown as jest.Mock ).mockReturnValue( thumbEmbla ); + + const cleanup = storeConfig.callbacks.initThumbnails(); + ( getContext as jest.Mock ).mockImplementation( () => { + throw new Error( 'No interactivity scope' ); + } ); + + thumbnailsContainer + .querySelectorAll( 'button' ) + .item( 1 ) + .dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) ); + + expect( mainEmbla.scrollTo ).toHaveBeenCalledWith( 1 ); + + cleanup?.(); + } ); + + it( 'should update active thumbnail state on select', () => { + const { viewport, thumbnails, thumbnailsContainer } = + createMockThumbnailsDOM(); + const listeners: { select?: () => void } = {}; + const selectedScrollSnap = jest + .fn() + .mockReturnValueOnce( 0 ) + .mockReturnValueOnce( 0 ) + .mockReturnValue( 1 ); + const mainEmbla = createMockEmblaInstance( { + selectedScrollSnap, + scrollSnapList: jest.fn( () => [ 0, 1 ] ), + slideNodes: jest.fn( () => [ + document.createElement( 'div' ), + document.createElement( 'div' ), + ] ), + } ); + mainEmbla.on = jest.fn( ( eventName: string, callback: () => void ) => { + if ( eventName === 'select' ) { + listeners.select = callback; + } + return mainEmbla; + } ); + const thumbEmbla = createMockEmblaInstance(); + + setEmblaOnViewport( viewport, mainEmbla ); + ( getContext as jest.Mock ).mockReturnValue( createMockContext() ); + ( getElement as jest.Mock ).mockReturnValue( { ref: thumbnails } ); + ( EmblaCarousel as unknown as jest.Mock ).mockReturnValue( thumbEmbla ); + + const cleanup = storeConfig.callbacks.initThumbnails(); + listeners.select?.(); + + const buttons = thumbnailsContainer.querySelectorAll( 'button' ); + const firstButton = buttons.item( 0 ); + const secondButton = buttons.item( 1 ); + expect( firstButton ).not.toHaveClass( 'is-active' ); + expect( firstButton ).not.toHaveAttribute( 'aria-current' ); + expect( secondButton ).toHaveClass( 'is-active' ); + expect( secondButton ).toHaveAttribute( 'aria-current', 'true' ); + expect( thumbEmbla.scrollTo ).toHaveBeenCalledWith( 1 ); + + cleanup?.(); + } ); + + it( 'should destroy thumbnail Embla and remove listeners on cleanup', () => { + const { viewport, thumbnails, thumbnailsContainer } = + createMockThumbnailsDOM(); + const mainEmbla = createMockEmblaInstance( { + scrollSnapList: jest.fn( () => [ 0 ] ), + slideNodes: jest.fn( () => [ document.createElement( 'div' ) ] ), + } ); + const thumbEmbla = createMockEmblaInstance(); + + setEmblaOnViewport( viewport, mainEmbla ); + ( getContext as jest.Mock ).mockReturnValue( createMockContext() ); + ( getElement as jest.Mock ).mockReturnValue( { ref: thumbnails } ); + ( EmblaCarousel as unknown as jest.Mock ).mockReturnValue( thumbEmbla ); + + const cleanup = storeConfig.callbacks.initThumbnails(); + expect( thumbnailsContainer.children ).toHaveLength( 1 ); + + cleanup?.(); + + expect( mainEmbla.off ).toHaveBeenCalledWith( 'select', expect.any( Function ) ); + expect( mainEmbla.off ).toHaveBeenCalledWith( 'reInit', expect.any( Function ) ); + expect( thumbEmbla.destroy ).toHaveBeenCalledTimes( 1 ); + expect( thumbnailsContainer.children ).toHaveLength( 0 ); + } ); + } ); } ); } ); diff --git a/src/blocks/carousel/deprecated.tsx b/src/blocks/carousel/deprecated.tsx index 2107d9a..23c6f1d 100644 --- a/src/blocks/carousel/deprecated.tsx +++ b/src/blocks/carousel/deprecated.tsx @@ -12,23 +12,23 @@ import type { CarouselAttributes } from './types'; function SaveV200( { attributes, }: { - attributes: CarouselAttributes; + attributes: Partial; } ) { const { - loop, - dragFree, - carouselAlign, - containScroll, - direction, - autoplay, - autoplayDelay, - autoplayStopOnInteraction, - autoplayStopOnMouseEnter, - ariaLabel, - slideGap, - axis, - height, - slidesToScroll, + loop = false, + dragFree = false, + carouselAlign = 'start', + containScroll = 'trimSnaps', + direction = 'ltr', + autoplay = false, + autoplayDelay = 4000, + autoplayStopOnInteraction = true, + autoplayStopOnMouseEnter = false, + ariaLabel = 'Carousel', + slideGap = 0, + axis = 'x', + height = '300px', + slidesToScroll = '1', } = attributes; const context = { @@ -107,7 +107,7 @@ const deprecated = [ }, supports: { interactivity: true, - align: [ 'wide', 'full' ], + align: [ 'wide', 'full' ] as const, html: false, color: { text: false, diff --git a/src/blocks/carousel/editor.scss b/src/blocks/carousel/editor.scss index a6e2a12..a940cdc 100644 --- a/src/blocks/carousel/editor.scss +++ b/src/blocks/carousel/editor.scss @@ -17,6 +17,26 @@ // Accent / interactive (falls back to WP admin theme color) --rt-carousel-accent: var(--wp-admin-theme-color, #3858e9); + + /* Ensure selectable area */ + padding: 0.625rem; + border: 1px dashed var(--rt-carousel-border-dashed); + box-sizing: border-box; + + &.is-selected { + border-color: var(--rt-carousel-accent); + } + + /* Add dashed border in editor to make it visible if empty */ + &.is-empty { + border: 1px dashed var(--rt-carousel-border-dashed); + min-height: 50px; + } + + // Vertical Axis adjustments in editor + &[data-axis="y"] .embla__container { + height: 100% !important; + } } // ── Setup chooser ──────────────────────────────────────────────────────────── @@ -122,26 +142,3 @@ .rt-carousel .embla__slide { min-height: 200px; } - -.rt-carousel { - - /* Ensure selectable area */ - padding: 0.625rem; - border: 1px dashed var(--rt-carousel-border-dashed); - box-sizing: border-box; - - &.is-selected { - border-color: var(--rt-carousel-accent); - } - - /* Add dashed border in editor to make it visible if empty */ - &.is-empty { - border: 1px dashed var(--rt-carousel-border-dashed); - min-height: 50px; - } - - // Vertical Axis adjustments in editor - &[data-axis="y"] .embla__container { - height: 100% !important; - } -} diff --git a/src/blocks/carousel/thumbnails/block.json b/src/blocks/carousel/thumbnails/block.json new file mode 100644 index 0000000..55db437 --- /dev/null +++ b/src/blocks/carousel/thumbnails/block.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "version": "1.0.0", + "name": "rt-carousel/carousel-thumbnails", + "title": "Carousel Thumbnails", + "category": "rt-carousel", + "icon": "format-gallery", + "ancestor": [ + "rt-carousel/carousel" + ], + "description": "Thumbnail navigation for the carousel.", + "textdomain": "rt-carousel", + "attributes": {}, + "supports": { + "interactivity": true + }, + "editorScript": "file:./index.js", + "style": "file:./style-index.css" +} diff --git a/src/blocks/carousel/thumbnails/edit.tsx b/src/blocks/carousel/thumbnails/edit.tsx new file mode 100644 index 0000000..6252d55 --- /dev/null +++ b/src/blocks/carousel/thumbnails/edit.tsx @@ -0,0 +1,123 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { useBlockProps } from '@wordpress/block-editor'; +import { useContext, useEffect, useMemo, useState } from '@wordpress/element'; +import { EditorCarouselContext } from '../editor-context'; + +const getPreviewText = ( slide: HTMLElement | undefined, index: number ) => { + const text = slide?.textContent?.replace( /\s+/g, ' ' ).trim(); + /* translators: %d: slide number */ + return text || sprintf( __( 'Slide %d', 'rt-carousel' ), index + 1 ); +}; + +const getPreviewImage = ( slide: HTMLElement | undefined ) => { + const image = slide?.querySelector( 'img' ); + if ( ! image ) { + return undefined; + } + + return { + src: image.currentSrc || image.src, + srcset: image.getAttribute( 'srcset' ) || undefined, + sizes: image.getAttribute( 'sizes' ) || undefined, + }; +}; + +const getSlideForSnap = ( + slides: HTMLElement[], + snapIndex: number, + snapCount: number, +) => { + if ( slides.length === 0 || snapCount <= 0 ) { + return undefined; + } + + const estimatedGroupSize = Math.max( 1, Math.ceil( slides.length / snapCount ) ); + return slides[ Math.min( snapIndex * estimatedGroupSize, slides.length - 1 ) ]; +}; + +export default function Edit() { + const blockProps = useBlockProps( { + className: 'rt-carousel-thumbnails', + } ); + + const { emblaApi, selectedIndex } = useContext( EditorCarouselContext ); + const [ slideNodes, setSlideNodes ] = useState( [] ); + const [ snapCount, setSnapCount ] = useState( 0 ); + + useEffect( () => { + if ( ! emblaApi ) { + setSlideNodes( [] ); + setSnapCount( 0 ); + return; + } + + const update = () => { + setSlideNodes( emblaApi.slideNodes() ); + setSnapCount( emblaApi.scrollSnapList().length ); + }; + + emblaApi.on( 'reInit', update ); + emblaApi.on( 'select', update ); + update(); + + return () => { + emblaApi.off( 'reInit', update ); + emblaApi.off( 'select', update ); + }; + }, [ emblaApi ] ); + + const thumbnails = useMemo( () => { + const count = snapCount || slideNodes.length || 3; + return Array.from( { length: count }, ( _, index ) => { + const slide = getSlideForSnap( slideNodes, index, count ); + return { + index, + label: getPreviewText( slide, index ), + image: getPreviewImage( slide ), + }; + } ); + }, [ slideNodes, snapCount ] ); + + return ( +
+
+
+ { thumbnails.map( ( thumbnail ) => { + const isActive = thumbnail.index === selectedIndex; + return ( + + ); + } ) } +
+
+
+ ); +} diff --git a/src/blocks/carousel/thumbnails/index.ts b/src/blocks/carousel/thumbnails/index.ts new file mode 100644 index 0000000..9aabed9 --- /dev/null +++ b/src/blocks/carousel/thumbnails/index.ts @@ -0,0 +1,11 @@ +import { registerBlockType, type BlockConfiguration } from '@wordpress/blocks'; +import Edit from './edit'; +import Save from './save'; +import metadata from './block.json'; +import type { CarouselThumbnailsAttributes } from '../types'; +import './style.scss'; + +registerBlockType( metadata as BlockConfiguration, { + edit: Edit, + save: Save, +} ); diff --git a/src/blocks/carousel/thumbnails/save.tsx b/src/blocks/carousel/thumbnails/save.tsx new file mode 100644 index 0000000..8deb06a --- /dev/null +++ b/src/blocks/carousel/thumbnails/save.tsx @@ -0,0 +1,17 @@ +import { useBlockProps } from '@wordpress/block-editor'; + +export default function Save() { + const blockProps = useBlockProps.save( { + className: 'rt-carousel-thumbnails', + 'data-wp-interactive': 'rt-carousel/carousel', + 'data-wp-init': 'callbacks.initThumbnails', + } ); + + return ( +
+
+
+
+
+ ); +} diff --git a/src/blocks/carousel/thumbnails/style.scss b/src/blocks/carousel/thumbnails/style.scss new file mode 100644 index 0000000..f419ba0 --- /dev/null +++ b/src/blocks/carousel/thumbnails/style.scss @@ -0,0 +1,89 @@ +.rt-carousel-thumbnails { + --rt-carousel-thumbnail-width: 96px; + --rt-carousel-thumbnail-height: 64px; + --rt-carousel-thumbnail-gap: 0.5rem; + --rt-carousel-thumbnail-radius: 2px; + --rt-carousel-thumbnail-border-color: rgb(221, 221, 221); + --rt-carousel-thumbnail-active-border-color: rgba(28, 28, 28, 1); + --rt-carousel-thumbnail-opacity: 0.65; + --rt-carousel-thumbnail-active-opacity: 1; + + margin-top: 0.75rem; +} + +.rt-carousel-thumbnails__viewport { + overflow: hidden; + width: 100%; +} + +.rt-carousel-thumbnails__container { + display: flex; + gap: var(--rt-carousel-thumbnail-gap); +} + +.rt-carousel-thumbnail { + flex: 0 0 var(--rt-carousel-thumbnail-width); + width: var(--rt-carousel-thumbnail-width); + height: var(--rt-carousel-thumbnail-height); + padding: 0; + border: 2px solid var(--rt-carousel-thumbnail-border-color); + border-radius: var(--rt-carousel-thumbnail-radius); + background: transparent; + cursor: pointer; + overflow: hidden; + opacity: var(--rt-carousel-thumbnail-opacity); + transition: border-color 0.2s ease, opacity 0.2s ease; +} + +.rt-carousel-thumbnail.is-active { + border-color: var(--rt-carousel-thumbnail-active-border-color); + opacity: var(--rt-carousel-thumbnail-active-opacity); +} + +.rt-carousel-thumbnail__preview { + display: block; + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + pointer-events: none; + background: rgb(246, 247, 247); + color: rgb(28, 28, 28); +} + +.rt-carousel-thumbnail__image { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; +} + +.rt-carousel-thumbnail__preview > *:not(.rt-carousel-thumbnail__image):not(.rt-carousel-thumbnail__fallback):not(.rt-carousel-thumbnail__editor-preview) { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + transform: scale(0.2); + transform-origin: top left; + inline-size: 500%; + block-size: 500%; + max-width: none; + pointer-events: none; +} + +.rt-carousel-thumbnail__fallback, +.rt-carousel-thumbnail__editor-preview { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + padding: 0.25rem; + box-sizing: border-box; + font-size: 0.75rem; + line-height: 1.2; + text-align: center; + overflow: hidden; +} diff --git a/src/blocks/carousel/types.ts b/src/blocks/carousel/types.ts index d140a73..edd01ab 100644 --- a/src/blocks/carousel/types.ts +++ b/src/blocks/carousel/types.ts @@ -27,6 +27,7 @@ export type CarouselSlideAttributes = { export type CarouselControlsAttributes = Record; export type CarouselDotsAttributes = Record; export type CarouselProgressAttributes = Record; +export type CarouselThumbnailsAttributes = Record; /** * Typed subset of the block editor store selectors used in this plugin. diff --git a/src/blocks/carousel/view.ts b/src/blocks/carousel/view.ts index 25f80c9..5b75a2a 100644 --- a/src/blocks/carousel/view.ts +++ b/src/blocks/carousel/view.ts @@ -18,6 +18,8 @@ type EmblaViewportElement = HTMLElement & { export const emblaInstances = new WeakMap(); +const CAROUSEL_READY_EVENT = 'rt-carousel:init'; + const getElementRef = ( rawElement: unknown ): HTMLElement | null => { if ( rawElement instanceof HTMLElement ) { return rawElement; @@ -45,6 +47,38 @@ const getEmblaFromElement = ( return emblaInstances.get( viewport ) || viewport[ EMBLA_KEY ] || null; }; +const getCarouselRoot = ( element: HTMLElement | null ): HTMLElement | null => { + return element?.closest( '.rt-carousel' ) ?? null; +}; + +const getMainViewport = ( + wrapper: HTMLElement | null, +): EmblaViewportElement | null => { + return wrapper?.querySelector( '.embla' ) as EmblaViewportElement | null; +}; + +const getMainEmblaFromRoot = ( + wrapper: HTMLElement | null, +): EmblaCarouselType | null => { + const viewport = getMainViewport( wrapper ); + if ( ! viewport ) { + return null; + } + return emblaInstances.get( viewport ) || viewport[ EMBLA_KEY ] || null; +}; + +const dispatchCarouselReady = ( + wrapper: HTMLElement, + embla: EmblaCarouselType, +): void => { + wrapper.dispatchEvent( + new CustomEvent( CAROUSEL_READY_EVENT, { + bubbles: true, + detail: { embla }, + } ), + ); +}; + const getProgress = (): number => { const { scrollProgress, slideCount, selectedIndex, options } = getContext(); if ( ! slideCount || slideCount <= 1 ) { @@ -92,6 +126,155 @@ const markForAnnouncement = (): void => { getContext().shouldAnnounce = true; }; +const safelyMarkForAnnouncement = (): void => { + try { + markForAnnouncement(); + } catch { + // Native event listeners do not always run inside an Interactivity API + // scope. Navigation should still happen if announcement state is unavailable. + } +}; + +const stripInteractiveAttributes = ( element: Element ): void => { + Array.from( element.attributes ).forEach( ( attribute ) => { + if ( + attribute.name === 'id' || + attribute.name === 'aria-current' || + attribute.name.startsWith( 'data-wp-' ) || + attribute.name.startsWith( 'on' ) + ) { + element.removeAttribute( attribute.name ); + } + } ); + + if ( element instanceof HTMLElement ) { + element.removeAttribute( 'tabindex' ); + element.removeAttribute( 'contenteditable' ); + element.setAttribute( 'aria-hidden', 'true' ); + } + + if ( + element instanceof HTMLButtonElement || + element instanceof HTMLInputElement || + element instanceof HTMLSelectElement || + element instanceof HTMLTextAreaElement + ) { + element.disabled = true; + } +}; + +const createThumbnailPreview = ( slide: HTMLElement ): HTMLElement => { + const preview = document.createElement( 'span' ); + preview.className = 'rt-carousel-thumbnail__preview'; + preview.setAttribute( 'aria-hidden', 'true' ); + + const sourceImage = slide.querySelector( 'img' ); + if ( sourceImage ) { + const image = document.createElement( 'img' ); + image.className = 'rt-carousel-thumbnail__image'; + image.src = sourceImage.currentSrc || sourceImage.src; + image.alt = ''; + image.decoding = 'async'; + image.loading = 'lazy'; + + const srcset = sourceImage.getAttribute( 'srcset' ); + const sizes = sourceImage.getAttribute( 'sizes' ); + if ( srcset ) { + image.setAttribute( 'srcset', srcset ); + } + if ( sizes ) { + image.setAttribute( 'sizes', sizes ); + } + + preview.appendChild( image ); + return preview; + } + + const clone = slide.cloneNode( true ) as HTMLElement; + [ clone, ...Array.from( clone.querySelectorAll( '*' ) ) ].forEach( + stripInteractiveAttributes, + ); + clone.classList.remove( 'is-active' ); + preview.appendChild( clone ); + + return preview; +}; + +const getSlideForSnap = ( + slides: HTMLElement[], + snapIndex: number, + snapCount: number, +): HTMLElement | undefined => { + if ( slides.length === 0 || snapCount <= 0 ) { + return undefined; + } + + const estimatedGroupSize = Math.max( 1, Math.ceil( slides.length / snapCount ) ); + const slideIndex = Math.min( + snapIndex * estimatedGroupSize, + slides.length - 1, + ); + return slides[ slideIndex ]; +}; + +const buildThumbnailButtons = ( + mainEmbla: EmblaCarouselType, + container: HTMLElement, + selectedIndex: number, + ariaLabelPattern: string, + onClick: ( index: number ) => void, +): HTMLButtonElement[] => { + const slides = mainEmbla.slideNodes(); + const snaps = mainEmbla.scrollSnapList(); + + container.replaceChildren(); + + return snaps.map( ( _snap, index ) => { + const slide = getSlideForSnap( slides, index, snaps.length ); + const button = document.createElement( 'button' ); + button.className = 'rt-carousel-thumbnail'; + button.type = 'button'; + button.setAttribute( + 'aria-label', + ariaLabelPattern.replace( '%d', ( index + 1 ).toString() ), + ); + button.addEventListener( 'click', () => onClick( index ) ); + + if ( slide ) { + button.appendChild( createThumbnailPreview( slide ) ); + } else { + const fallback = document.createElement( 'span' ); + fallback.className = 'rt-carousel-thumbnail__fallback'; + fallback.setAttribute( 'aria-hidden', 'true' ); + fallback.textContent = ( index + 1 ).toString(); + button.appendChild( fallback ); + } + + if ( index === selectedIndex ) { + button.classList.add( 'is-active' ); + button.setAttribute( 'aria-current', 'true' ); + } + + container.appendChild( button ); + return button; + } ); +}; + +const updateThumbnailButtons = ( + buttons: HTMLButtonElement[], + selectedIndex: number, +): void => { + buttons.forEach( ( button, index ) => { + const isActive = index === selectedIndex; + button.classList.toggle( 'is-active', isActive ); + if ( isActive ) { + button.setAttribute( 'aria-current', 'true' ); + } else { + button.removeAttribute( 'aria-current' ); + } + } ); +}; + store( 'rt-carousel/carousel', { state: { get canScrollPrev() { @@ -203,6 +386,110 @@ store( 'rt-carousel/carousel', { } return `transform:translate3d(${ getProgress() * 100 }%, 0px, 0px)`; }, + initThumbnails: () => { + try { + const context = getContext(); + const element = getElementRef( getElement() ); + + if ( ! element || typeof element.querySelector !== 'function' ) { + // eslint-disable-next-line no-console + console.warn( 'Carousel: Invalid thumbnails element', element ); + return; + } + + const wrapper = getCarouselRoot( element ); + const viewport = element.querySelector( + '.rt-carousel-thumbnails__viewport', + ); + const container = element.querySelector( + '.rt-carousel-thumbnails__container', + ); + + if ( ! wrapper || ! viewport || ! container ) { + // eslint-disable-next-line no-console + console.warn( 'Carousel: Thumbnail elements not found' ); + return; + } + + let cleanupThumbnails: ( () => void ) | undefined; + + const setup = ( mainEmbla: EmblaCarouselType ) => { + cleanupThumbnails?.(); + + let thumbnailButtons: HTMLButtonElement[] = []; + const thumbEmbla = EmblaCarousel( viewport, { + align: 'start', + containScroll: 'keepSnaps', + dragFree: true, + axis: 'x', + } ); + + const scrollTo = ( index: number ) => { + if ( index !== mainEmbla.selectedScrollSnap() ) { + safelyMarkForAnnouncement(); + } + mainEmbla.scrollTo( index ); + }; + + const syncSelected = () => { + const selectedIndex = mainEmbla.selectedScrollSnap(); + updateThumbnailButtons( thumbnailButtons, selectedIndex ); + thumbEmbla.scrollTo( selectedIndex ); + }; + + const rebuild = () => { + thumbnailButtons = buildThumbnailButtons( + mainEmbla, + container, + mainEmbla.selectedScrollSnap(), + context.ariaLabelPattern, + scrollTo, + ); + thumbEmbla.reInit(); + syncSelected(); + }; + + mainEmbla.on( 'select', syncSelected ); + mainEmbla.on( 'reInit', rebuild ); + + rebuild(); + + cleanupThumbnails = () => { + mainEmbla.off( 'select', syncSelected ); + mainEmbla.off( 'reInit', rebuild ); + thumbEmbla.destroy(); + container.replaceChildren(); + }; + + return cleanupThumbnails; + }; + + const existingEmbla = getMainEmblaFromRoot( wrapper ); + if ( existingEmbla ) { + setup( existingEmbla ); + } + + const onCarouselReady = ( event: Event ) => { + const customEvent = event as CustomEvent<{ embla?: EmblaCarouselType }>; + const mainEmbla = customEvent.detail?.embla || getMainEmblaFromRoot( wrapper ); + if ( mainEmbla ) { + setup( mainEmbla ); + } + }; + + wrapper.addEventListener( CAROUSEL_READY_EVENT, onCarouselReady ); + + return () => { + wrapper.removeEventListener( CAROUSEL_READY_EVENT, onCarouselReady ); + cleanupThumbnails?.(); + }; + } catch ( e ) { + // eslint-disable-next-line no-console + console.error( 'Carousel: Error in initThumbnails', e ); + + return null; + } + }, initCarousel: () => { try { const context = getContext(); @@ -214,7 +501,7 @@ store( 'rt-carousel/carousel', { return; } - const viewport = element.querySelector( '.embla' ); + const viewport = element.querySelector( '.embla' ); if ( ! viewport ) { // eslint-disable-next-line no-console @@ -222,7 +509,7 @@ store( 'rt-carousel/carousel', { return; } - const queryLoopContainer = viewport.querySelector( + const queryLoopContainer = viewport.querySelector( '.wp-block-post-template', ); @@ -235,11 +522,19 @@ store( 'rt-carousel/carousel', { ? ( rawOptions.align as 'start' | 'center' | 'end' ) : 'start'; - const containScroll = [ 'trimSnaps', 'keepSnaps', '' ].includes( - rawOptions.containScroll as string, - ) - ? ( rawOptions.containScroll as 'trimSnaps' | 'keepSnaps' | '' ) - : 'trimSnaps'; + const rawContainScroll = rawOptions.containScroll as + | string + | boolean + | undefined; + let containScroll: EmblaOptionsType['containScroll'] = 'trimSnaps'; + if ( + rawContainScroll === 'trimSnaps' || + rawContainScroll === 'keepSnaps' + ) { + containScroll = rawContainScroll; + } else if ( rawContainScroll === '' ) { + containScroll = false; + } const direction = [ 'ltr', 'rtl' ].includes( rawOptions.direction as string, @@ -314,6 +609,7 @@ store( 'rt-carousel/carousel', { } ); updateState(); + dispatchCarouselReady( element, embla ); return () => { embla.destroy(); @@ -347,7 +643,7 @@ store( 'rt-carousel/carousel', { if ( 'IntersectionObserver' in window ) { intersectionObserver = new IntersectionObserver( ( entries ) => { - if ( entries[ 0 ].isIntersecting ) { + if ( entries[ 0 ]?.isIntersecting ) { init(); intersectionObserver?.disconnect(); intersectionObserver = undefined;