diff --git a/inc/Plugin.php b/inc/Plugin.php index 25f975a..67e4ddc 100644 --- a/inc/Plugin.php +++ b/inc/Plugin.php @@ -70,6 +70,7 @@ public function register_blocks(): void { 'carousel', 'carousel/controls', 'carousel/dots', + 'carousel/progress', 'carousel/viewport', 'carousel/slide', ]; diff --git a/src/blocks/carousel/__tests__/types.test.ts b/src/blocks/carousel/__tests__/types.test.ts index efccbb9..aed1c05 100644 --- a/src/blocks/carousel/__tests__/types.test.ts +++ b/src/blocks/carousel/__tests__/types.test.ts @@ -251,6 +251,8 @@ describe( 'CarouselContext Type', () => { scrollSnaps: [ { index: 0 }, { index: 1 } ], canScrollPrev: false, canScrollNext: true, + scrollProgress: 0, + slideCount: 2, ariaLabelPattern: 'Go to slide %d', }; @@ -275,6 +277,8 @@ describe( 'CarouselContext Type', () => { scrollSnaps: [ { index: 0 }, { index: 1 }, { index: 2 } ], canScrollPrev: true, canScrollNext: true, + scrollProgress: 0.5, + slideCount: 3, ariaLabelPattern: 'Slide %d of 3', }; @@ -300,6 +304,8 @@ describe( 'CarouselContext Type', () => { scrollSnaps: [ { index: 0 }, { index: 1 }, { index: 2 } ], canScrollPrev: false, canScrollNext: true, + scrollProgress: 0, + slideCount: 3, ariaLabelPattern: 'Slide %d', }; @@ -318,6 +324,8 @@ describe( 'CarouselContext Type', () => { scrollSnaps: [ { index: 0 }, { index: 1 }, { index: 2 } ], canScrollPrev: true, canScrollNext: true, + scrollProgress: 0.5, + slideCount: 3, ariaLabelPattern: 'Slide %d', }; @@ -336,6 +344,8 @@ describe( 'CarouselContext Type', () => { scrollSnaps: [ { index: 0 }, { index: 1 }, { index: 2 } ], canScrollPrev: true, canScrollNext: false, + scrollProgress: 1, + slideCount: 3, ariaLabelPattern: 'Slide %d', }; @@ -354,6 +364,8 @@ describe( 'CarouselContext Type', () => { scrollSnaps: [ { index: 0 } ], canScrollPrev: false, canScrollNext: false, + scrollProgress: 0, + slideCount: 1, ariaLabelPattern: 'Slide %d', }; @@ -374,6 +386,8 @@ describe( 'CarouselContext Type', () => { scrollSnaps: [ { index: 0 } ], canScrollPrev: false, canScrollNext: false, + scrollProgress: 0, + slideCount: 1, ariaLabelPattern: 'Slide %d', }; @@ -394,6 +408,8 @@ describe( 'CarouselContext Type', () => { scrollSnaps: [], canScrollPrev: false, canScrollNext: false, + scrollProgress: 0, + slideCount: 0, ariaLabelPattern: 'Slide %d', }; @@ -414,6 +430,8 @@ describe( 'CarouselContext Type', () => { scrollSnaps: [], canScrollPrev: false, canScrollNext: false, + scrollProgress: 0, + slideCount: 0, ariaLabelPattern: 'Slide %d', ref: element, }; @@ -432,6 +450,8 @@ describe( 'CarouselContext Type', () => { scrollSnaps: [], canScrollPrev: false, canScrollNext: false, + scrollProgress: 0, + slideCount: 0, ariaLabelPattern: 'Slide %d', ref: null, }; @@ -449,6 +469,8 @@ describe( 'CarouselContext Type', () => { scrollSnaps: [], canScrollPrev: false, canScrollNext: false, + scrollProgress: 0, + slideCount: 0, ariaLabelPattern: 'Slide %d', }; @@ -470,6 +492,8 @@ describe( 'CarouselContext Type', () => { scrollSnaps: [], canScrollPrev: false, canScrollNext: false, + scrollProgress: 0, + slideCount: 0, ariaLabelPattern: 'Slide %d', }; @@ -489,6 +513,8 @@ describe( 'CarouselContext Type', () => { scrollSnaps: [], canScrollPrev: false, canScrollNext: false, + scrollProgress: 0, + slideCount: 0, ariaLabelPattern: 'Slide %d', }; @@ -515,6 +541,8 @@ describe( 'CarouselContext Type', () => { scrollSnaps: [], canScrollPrev: false, canScrollNext: false, + scrollProgress: 0, + slideCount: 0, ariaLabelPattern: pattern, }; @@ -534,6 +562,8 @@ describe( 'CarouselContext Type', () => { scrollSnaps: [], canScrollPrev: false, canScrollNext: false, + scrollProgress: 0, + slideCount: 0, ariaLabelPattern: 'Slide %d', }; @@ -558,6 +588,8 @@ describe( 'CarouselContext Type', () => { scrollSnaps, canScrollPrev: false, canScrollNext: false, + scrollProgress: 0, + slideCount: 5, ariaLabelPattern: 'Slide %d', }; diff --git a/src/blocks/carousel/__tests__/view.test.ts b/src/blocks/carousel/__tests__/view.test.ts index 0fca857..6faf5db 100644 --- a/src/blocks/carousel/__tests__/view.test.ts +++ b/src/blocks/carousel/__tests__/view.test.ts @@ -65,6 +65,8 @@ const createMockContext = ( scrollSnaps: [ { index: 0 }, { index: 1 }, { index: 2 } ], canScrollPrev: true, canScrollNext: true, + scrollProgress: 0, + slideCount: 3, ariaLabelPattern: 'Go to slide %d', ...overrides, } ); @@ -516,6 +518,114 @@ describe( 'Carousel View Module', () => { } ); } ); + describe( 'getProgressBarNow', () => { + it( 'should return 0 when slideCount is 0', () => { + const mockContext = createMockContext( { slideCount: 0 } ); + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + + const result = storeConfig.callbacks.getProgressBarNow(); + expect( result ).toBe( 0 ); + } ); + + it( 'should return 0 when slideCount is 1', () => { + const mockContext = createMockContext( { slideCount: 1 } ); + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + + const result = storeConfig.callbacks.getProgressBarNow(); + expect( result ).toBe( 0 ); + } ); + + it( 'should use index-based progress in loop mode', () => { + const mockContext = createMockContext( { + options: { loop: true }, + selectedIndex: 1, + slideCount: 3, + } ); + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + + const result = storeConfig.callbacks.getProgressBarNow(); + expect( result ).toBe( 50 ); + } ); + + it( 'should return 100 at last slide in loop mode', () => { + const mockContext = createMockContext( { + options: { loop: true }, + selectedIndex: 2, + slideCount: 3, + } ); + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + + const result = storeConfig.callbacks.getProgressBarNow(); + expect( result ).toBe( 100 ); + } ); + + it( 'should use scrollProgress in non-loop mode', () => { + const mockContext = createMockContext( { + options: { loop: false }, + scrollProgress: 0.75, + slideCount: 4, + } ); + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + + const result = storeConfig.callbacks.getProgressBarNow(); + expect( result ).toBe( 75 ); + } ); + + it( 'should clamp scrollProgress to [0, 1] in non-loop mode', () => { + const mockContext = createMockContext( { + options: { loop: false }, + scrollProgress: 1.5, + slideCount: 3, + } ); + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + + const result = storeConfig.callbacks.getProgressBarNow(); + expect( result ).toBe( 100 ); + } ); + } ); + + describe( 'getProgressBarStyle', () => { + it( 'should return display:none when slideCount is 0', () => { + const mockContext = createMockContext( { slideCount: 0 } ); + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + + const result = storeConfig.callbacks.getProgressBarStyle(); + expect( result ).toBe( 'display:none' ); + } ); + + it( 'should return display:none when slideCount is 1', () => { + const mockContext = createMockContext( { slideCount: 1 } ); + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + + const result = storeConfig.callbacks.getProgressBarStyle(); + expect( result ).toBe( 'display:none' ); + } ); + + it( 'should return transform style with index-based progress in loop mode', () => { + const mockContext = createMockContext( { + options: { loop: true }, + selectedIndex: 1, + slideCount: 3, + } ); + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + + const result = storeConfig.callbacks.getProgressBarStyle(); + expect( result ).toBe( 'transform:translate3d(50%, 0px, 0px)' ); + } ); + + it( 'should return transform style with scrollProgress in non-loop mode', () => { + const mockContext = createMockContext( { + options: { loop: false }, + scrollProgress: 0.5, + slideCount: 3, + } ); + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + + const result = storeConfig.callbacks.getProgressBarStyle(); + expect( result ).toBe( 'transform:translate3d(50%, 0px, 0px)' ); + } ); + } ); + describe( 'initCarousel', () => { it( 'should be defined as a function', () => { expect( storeConfig?.callbacks?.initCarousel ).toBeDefined(); diff --git a/src/blocks/carousel/edit.tsx b/src/blocks/carousel/edit.tsx index 3b0d652..1f43b4e 100644 --- a/src/blocks/carousel/edit.tsx +++ b/src/blocks/carousel/edit.tsx @@ -20,7 +20,7 @@ import { } from '@wordpress/components'; import { plus } from '@wordpress/icons'; import { useSelect, useDispatch } from '@wordpress/data'; -import { useState, useMemo, useCallback } from '@wordpress/element'; +import { useState, useMemo, useCallback, useEffect } from '@wordpress/element'; import { createBlock, type BlockConfiguration } from '@wordpress/blocks'; import type { CarouselAttributes } from './types'; import { EditorCarouselContext } from './editor-context'; @@ -55,6 +55,9 @@ export default function Edit( { const [ emblaApi, setEmblaApi ] = useState(); const [ canScrollPrev, setCanScrollPrev ] = useState( false ); const [ canScrollNext, setCanScrollNext ] = useState( false ); + const [ scrollProgress, setScrollProgress ] = useState( 0 ); + const [ selectedIndex, setSelectedIndex ] = useState( 0 ); + const [ slideCount, setSlideCount ] = useState( 0 ); const { replaceInnerBlocks, insertBlock } = useDispatch( 'core/block-editor' ); @@ -126,19 +129,58 @@ export default function Edit( { setCanScrollPrev, canScrollNext, setCanScrollNext, + scrollProgress, + setScrollProgress, + selectedIndex, + slideCount, carouselOptions, } ), [ emblaApi, canScrollPrev, canScrollNext, + scrollProgress, + selectedIndex, + slideCount, carouselOptions, setEmblaApi, setCanScrollPrev, setCanScrollNext, + setScrollProgress, ], ); + // Subscribe to Embla events to update scrollProgress, selectedIndex, and slideCount + useEffect( () => { + if ( ! emblaApi ) { + return; + } + + const updateScrollProgress = () => { + setScrollProgress( emblaApi.scrollProgress() ); + }; + + const updateState = () => { + setSelectedIndex( emblaApi.selectedScrollSnap() ); + setSlideCount( emblaApi.slideNodes().length ); + updateScrollProgress(); + }; + + emblaApi + .on( 'scroll', updateScrollProgress ) + .on( 'select', updateState ) + .on( 'reInit', updateState ); + + updateState(); + + return () => { + emblaApi + .off( 'scroll', updateScrollProgress ) + .off( 'select', updateState ) + .off( 'reInit', updateState ); + }; + }, [ emblaApi ] ); + const createNavGroup = () => createBlock( 'core/group', @@ -155,8 +197,8 @@ export default function Edit( { ], ); - const handleSetup = ( slideCount: number ) => { - const slides = Array.from( { length: slideCount }, () => + const handleSetup = ( count: number ) => { + const slides = Array.from( { length: count }, () => createBlock( 'carousel-kit/carousel-slide', {}, [ createBlock( 'core/paragraph', {} ), ] ), diff --git a/src/blocks/carousel/editor-context.ts b/src/blocks/carousel/editor-context.ts index 9937ba0..b28016f 100644 --- a/src/blocks/carousel/editor-context.ts +++ b/src/blocks/carousel/editor-context.ts @@ -9,6 +9,10 @@ export type EditorCarouselContextType = { canScrollNext: boolean; setCanScrollPrev: ( value: boolean ) => void; setCanScrollNext: ( value: boolean ) => void; + scrollProgress: number; + setScrollProgress: ( value: number ) => void; + selectedIndex: number; + slideCount: number; carouselOptions: Omit, 'slidesToScroll'> & { slidesToScroll?: number | string; }; @@ -22,6 +26,10 @@ const defaultValue: EditorCarouselContextType = { canScrollNext: false, setCanScrollPrev: () => {}, setCanScrollNext: () => {}, + scrollProgress: 0, + setScrollProgress: () => {}, + selectedIndex: 0, + slideCount: 0, carouselOptions: {}, }; diff --git a/src/blocks/carousel/progress/block.json b/src/blocks/carousel/progress/block.json new file mode 100644 index 0000000..7b99a38 --- /dev/null +++ b/src/blocks/carousel/progress/block.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "version": "1.0.0", + "name": "carousel-kit/carousel-progress", + "title": "Carousel Progress", + "category": "carousel-kit", + "icon": "minus", + "ancestor": [ + "carousel-kit/carousel" + ], + "description": "Progress bar for the carousel.", + "textdomain": "carousel-kit", + "attributes": {}, + "supports": { + "interactivity": true + }, + "editorScript": "file:./index.js", + "style": "file:./style-index.css" +} diff --git a/src/blocks/carousel/progress/edit.tsx b/src/blocks/carousel/progress/edit.tsx new file mode 100644 index 0000000..9fab486 --- /dev/null +++ b/src/blocks/carousel/progress/edit.tsx @@ -0,0 +1,37 @@ +import { __ } from '@wordpress/i18n'; +import { useBlockProps } from '@wordpress/block-editor'; +import { useContext } from '@wordpress/element'; +import { EditorCarouselContext } from '../editor-context'; + +export default function Edit() { + const blockProps = useBlockProps( { + className: 'carousel-kit-progress', + } ); + + const { scrollProgress, selectedIndex, slideCount, carouselOptions } = + useContext( EditorCarouselContext ); + + if ( slideCount <= 1 ) { + return null; + } + + const progress = carouselOptions?.loop + ? selectedIndex / ( slideCount - 1 ) + : Math.max( 0, Math.min( 1, scrollProgress || 0 ) ); + + return ( +
+
+
+ ); +} diff --git a/src/blocks/carousel/progress/index.ts b/src/blocks/carousel/progress/index.ts new file mode 100644 index 0000000..104f097 --- /dev/null +++ b/src/blocks/carousel/progress/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 { CarouselProgressAttributes } from '../types'; +import './style.scss'; + +registerBlockType( metadata as BlockConfiguration, { + edit: Edit, + save: Save, +} ); diff --git a/src/blocks/carousel/progress/save.tsx b/src/blocks/carousel/progress/save.tsx new file mode 100644 index 0000000..31f57c0 --- /dev/null +++ b/src/blocks/carousel/progress/save.tsx @@ -0,0 +1,23 @@ +import { useBlockProps } from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; + +export default function Save() { + return ( +
+
+
+ ); +} diff --git a/src/blocks/carousel/progress/style.scss b/src/blocks/carousel/progress/style.scss new file mode 100644 index 0000000..77c55ec --- /dev/null +++ b/src/blocks/carousel/progress/style.scss @@ -0,0 +1,18 @@ +.carousel-kit-progress { + width: 100%; + height: 4px; + background-color: var(--carousel-kit-progress-bg-color, rgb(221, 221, 221)); + overflow: hidden; + position: relative; + margin-top: 10px; + + &__bar { + position: absolute; + top: 0; + left: -100%; + height: 100%; + width: 100%; + background-color: var(--carousel-kit-progress-color, rgba(28, 28, 28, 1)); + transition: transform 0.4s ease-out; + } +} diff --git a/src/blocks/carousel/save.tsx b/src/blocks/carousel/save.tsx index 45ba944..f6c4cdd 100644 --- a/src/blocks/carousel/save.tsx +++ b/src/blocks/carousel/save.tsx @@ -48,6 +48,8 @@ export default function Save( { scrollSnaps: [], canScrollPrev: false, canScrollNext: false, + scrollProgress: 0, + slideCount: 0, /* translators: %d: slide number */ ariaLabelPattern: __( 'Go to slide %d', 'carousel-kit' ), }; diff --git a/src/blocks/carousel/types.ts b/src/blocks/carousel/types.ts index 2c4ac8b..7d1bf14 100644 --- a/src/blocks/carousel/types.ts +++ b/src/blocks/carousel/types.ts @@ -26,6 +26,7 @@ export type CarouselSlideAttributes = { }; export type CarouselControlsAttributes = Record; export type CarouselDotsAttributes = Record; +export type CarouselProgressAttributes = Record; /** * Typed subset of the block editor store selectors used in this plugin. @@ -54,7 +55,9 @@ export type CarouselContext = { scrollSnaps: { index: number }[]; canScrollPrev: boolean; canScrollNext: boolean; + scrollProgress: number; ariaLabelPattern: string; ref?: HTMLElement | null; + slideCount: number; initialized?: boolean; // Internal state to track if the carousel has been initialized. See: https://github.com/rtCamp/carousel-kit/issues/78 }; diff --git a/src/blocks/carousel/view.ts b/src/blocks/carousel/view.ts index acce223..aa7bec4 100644 --- a/src/blocks/carousel/view.ts +++ b/src/blocks/carousel/view.ts @@ -42,10 +42,20 @@ const getEmblaFromElement = ( if ( ! viewport ) { return null; } - // EMBLA_KEY is optional, so check if it exists return emblaInstances.get( viewport ) || viewport[ EMBLA_KEY ] || null; }; +const getProgress = (): number => { + const { scrollProgress, slideCount, selectedIndex, options } = getContext(); + if ( ! slideCount || slideCount <= 1 ) { + return 0; + } + if ( options?.loop ) { + return selectedIndex / ( slideCount - 1 ); + } + return Math.max( 0, Math.min( 1, scrollProgress || 0 ) ); +}; + store( 'carousel-kit/carousel', { state: { get canScrollPrev() { @@ -82,7 +92,7 @@ store( 'carousel-kit/carousel', { const context = getContext(); const { snap } = context as CarouselContext & { snap?: { index?: number }; - }; // snap is the iterated item + }; if ( snap && typeof snap.index === 'number' ) { const element = getElementRef( getElement() ); @@ -111,7 +121,6 @@ store( 'carousel-kit/carousel', { return false; } - // Filter siblings to find index among valid slides const slides = Array.from( slide.parentElement.children ).filter( ( child: Element ) => child.classList?.contains( 'embla__slide' ) || @@ -139,6 +148,16 @@ store( 'carousel-kit/carousel', { const index = ( snap?.index || 0 ) + 1; return context.ariaLabelPattern.replace( '%d', index.toString() ); }, + getProgressBarNow: () => { + return Math.round( getProgress() * 100 ); + }, + getProgressBarStyle: () => { + const { slideCount } = getContext(); + if ( ! slideCount || slideCount <= 1 ) { + return 'display:none'; + } + return `transform:translate3d(${ getProgress() * 100 }%, 0px, 0px)`; + }, initCarousel: () => { try { const context = getContext(); @@ -158,7 +177,6 @@ store( 'carousel-kit/carousel', { return; } - // Check for Query Loop container const queryLoopContainer = viewport.querySelector( '.wp-block-post-template', ); @@ -166,7 +184,6 @@ store( 'carousel-kit/carousel', { const startEmbla = () => { const rawOptions: EmblaOptionsType = context.options || {}; - // Sanitize options to prevent Embla crashes const align = [ 'start', 'center', 'end' ].includes( rawOptions.align as string, ) @@ -227,12 +244,16 @@ store( 'carousel-kit/carousel', { context.scrollSnaps = embla .scrollSnapList() .map( ( _, index ) => ( { index } ) ); + context.scrollProgress = embla.scrollProgress(); + context.slideCount = embla.slideNodes().length; }; embla.on( 'select', updateState ); embla.on( 'reInit', updateState ); + embla.on( 'scroll', () => { + context.scrollProgress = embla.scrollProgress(); + } ); - // Autoplay API Integration embla.on( 'autoplay:timerset', () => { context.isPlaying = true; context.timerIterationId = ( context.timerIterationId || 0 ) + 1;