diff --git a/README.md b/README.md index 3e54097..5e0d433 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Easily create dynamic, accessible, and customizable carousels for any content ty - **Flexible Compound Block Architecture**: Mix and match any blocks inside the carousel. - **High Performance**: Viewport & Slide Engine powered by Embla Carousel. - **Interactivity API**: Reactive state management with `data-wp-interactive`. -- **Dynamic Content**: Full support for WordPress **Query Loop** block. +- **Dynamic Content**: Full support for WordPress **Query Loop** and **Terms Query** blocks. - **Accessibility**: W3C-compliant roles, labels, and keyboard navigation. - **RTL Support**: Built-in support for Right-to-Left languages. @@ -68,9 +68,9 @@ Yes! Carousel Kit is fully compatible with Full Site Editing. You can use the ca Absolutely. Each slide is a container that accepts any WordPress block—images, paragraphs, groups, columns, and even other third-party blocks. -### Does it support the Query Loop block? +### Does it support the Query Loop and Terms Query blocks? -Yes. Simply add a Query Loop block inside the Carousel Viewport, and each post in the loop becomes a slide automatically. No special configuration needed. +Yes. Simply add a Query Loop or Terms Query block inside the Carousel Viewport, and each post or term in the loop becomes a slide automatically. No special configuration needed. ### Is it accessible? diff --git a/docs/USAGE.md b/docs/USAGE.md index 8918d62..fe4744f 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -61,6 +61,30 @@ You can create dynamic post sliders or content carousels using the WordPress Que | Use Case | Recommended Block | | :--- | :--- | -| Dynamic Content (Posts, Pages, Products, Custom Post Types) | Query Loop (`core/query`) | +| Dynamic Content (Posts, Pages, Products, Custom Post Types, Categories, Tags, Custom Taxonomies) | Query Loop (`core/query`) and Terms Query (`core/terms-query`) | | Static Content (Hero Slider, Logo Showcase, Manual Testimonials) | Carousel Slide (`carousel-kit/carousel-slide`) | | Mixed Content (Slide 1 is a Video, Slide 2 is Text) | Carousel Slide (`carousel-kit/carousel-slide`) | + +--- + +## Using Terms Query with Carousel + +You can display taxonomy terms (categories, tags, or custom taxonomies) as carousel slides using the WordPress Terms Query block (`core/terms-query`). This works exactly like the Query Loop integration. + +### Setup Steps +1. Add the **Carousel** block to your page. +2. Select the inner **Carousel Viewport** block. +3. Insert a **Terms Query** block inside the Viewport (instead of a Carousel Slide). +4. Configure the taxonomy, order, and filters in the Terms Query settings panel. +5. Design your slide inside the Terms Query's **Term Template**. + +**Note:** Each term generated by the Terms Query becomes an individual slide. The system automatically detects `.wp-block-term-template` and applies the same horizontal flex row display as for Query Loop. The `slideGap` attribute controls spacing between terms. + +### Block Selection Guide for Terms + +| Taxonomy Use Case | Recommended Setup | +| :--- | :--- | +| All categories of a post type | Terms Query (`core/terms-query`) with `taxonomy: category` | +| All tags | Terms Query (`core/terms-query`) with `taxonomy: post_tag` | +| Custom taxonomy terms | Terms Query (`core/terms-query`) with your taxonomy slug | +| Hand-picked terms with custom markup | Carousel Slide (`carousel-kit/carousel-slide`) | diff --git a/examples/patterns/terms-query.php b/examples/patterns/terms-query.php new file mode 100644 index 0000000..390d804 --- /dev/null +++ b/examples/patterns/terms-query.php @@ -0,0 +1,49 @@ + + + + + diff --git a/inc/Plugin.php b/inc/Plugin.php index 25f975a..ac74edf 100644 --- a/inc/Plugin.php +++ b/inc/Plugin.php @@ -39,6 +39,7 @@ protected function setup_hooks(): void { add_filter( 'block_categories_all', [ $this, 'register_block_category' ] ); add_action( 'init', [ $this, 'register_pattern_category' ] ); add_action( 'init', [ $this, 'register_block_patterns' ] ); + add_filter( 'render_block_core/term-template', [ $this, 'add_term_template_columns_class' ], 10, 3 ); } /** @@ -130,6 +131,36 @@ public function register_block_patterns(): void { } } + /** + * Add columns-{N} class to term-template output. + * + * Gutenberg's post-template block natively adds a columns-{N} class based + * on its grid layout columnCount, but term-template does not. This filter + * bridges the gap so the carousel can detect the column count identically. + * + * @param string $block_content Rendered block content. + * @param array $parsed_block Parsed block data. + * @param \WP_Block $block Block instance. + * + * @return string + */ + public function add_term_template_columns_class( string $block_content, array $parsed_block, \WP_Block $block ): string { + $column_count = $block->attributes['layout']['columnCount'] ?? null; + + if ( empty( $column_count ) ) { + return $block_content; + } + + $processor = new \WP_HTML_Tag_Processor( $block_content ); + + if ( $processor->next_tag( 'ul' ) ) { + $processor->add_class( sanitize_title( 'columns-' . $column_count ) ); + return $processor->get_updated_html(); + } + + return $block_content; + } + /** * Load patterns from the filesystem. * diff --git a/src/blocks/carousel/__tests__/view.test.ts b/src/blocks/carousel/__tests__/view.test.ts index 0fca857..779a66b 100644 --- a/src/blocks/carousel/__tests__/view.test.ts +++ b/src/blocks/carousel/__tests__/view.test.ts @@ -432,6 +432,26 @@ describe( 'Carousel View Module', () => { expect( result ).toBe( true ); } ); + + it( 'should work with Terms Query terms (.wp-block-term)', () => { + const container = document.createElement( 'div' ); + + const term1 = document.createElement( 'li' ); + term1.className = 'wp-block-term'; + const term2 = document.createElement( 'li' ); + term2.className = 'wp-block-term'; + + container.appendChild( term1 ); + container.appendChild( term2 ); + + const mockContext = createMockContext( { selectedIndex: 1 } ); + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + ( getElement as jest.Mock ).mockReturnValue( { ref: term2 } ); + + const result = storeConfig.callbacks.isSlideActive(); + + expect( result ).toBe( true ); + } ); } ); describe( 'isDotActive', () => { diff --git a/src/blocks/carousel/hooks/useCarouselObservers.ts b/src/blocks/carousel/hooks/useCarouselObservers.ts index 7cfef8d..d88b5da 100644 --- a/src/blocks/carousel/hooks/useCarouselObservers.ts +++ b/src/blocks/carousel/hooks/useCarouselObservers.ts @@ -5,16 +5,17 @@ const RESIZE_DEBOUNCE_MS = 200; const MUTATION_DEBOUNCE_MS = 150; /** - * Unified observer hook that handles both resize detection and Query Loop - * DOM mutations through a single coordinated MutationObserver. + * Unified observer hook that handles both resize detection and Query Loop / + * Terms Query DOM mutations through a single coordinated MutationObserver. * * **Resize detection** (viewport + first slide width changes): * Uses `reInit()` because resize only affects measurements — the DOM structure * (container + slides) remains unchanged, so Embla's cached references stay valid. * - * **Query Loop detection** (slide count changes): - * Uses full destroy/recreate via `initEmblaRef` because Query Loop changes can - * replace the `.wp-block-post-template` element or swap out its children entirely. + * **Query Loop / Terms Query detection** (slide count changes): + * Uses full destroy/recreate via `initEmblaRef` because Query Loop / Terms Query + * changes can replace the `.wp-block-post-template` or `.wp-block-term-template` + * element or swap out its children entirely. * Embla caches references to container and slide elements, so when those DOM * nodes are replaced, a fresh instance is required. * @@ -78,8 +79,8 @@ export function useCarouselObservers( resizeObserver.observe( viewportEl ); const updateSlideObservation = () => { - const container = viewportEl.querySelector( '.embla__container, .wp-block-post-template' ); - const firstSlide = container?.querySelector( '.embla__slide, .wp-block-post' ) ?? null; + const container = viewportEl.querySelector( '.embla__container, .wp-block-post-template, .wp-block-term-template' ); + const firstSlide = container?.querySelector( '.embla__slide, .wp-block-post, .wp-block-term' ) ?? null; if ( firstSlide === observedSlide ) { return; @@ -98,7 +99,7 @@ export function useCarouselObservers( }; const checkQueryLoopChanges = (): boolean => { - const postTemplate = viewportEl.querySelector( '.wp-block-post-template' ); + const postTemplate = viewportEl.querySelector( '.wp-block-post-template, .wp-block-term-template' ); const currentCount = postTemplate ? postTemplate.children.length : 0; const changed = currentCount !== lastSlideCount; @@ -142,7 +143,7 @@ export function useCarouselObservers( mutationObserver.observe( viewportEl, { childList: true, subtree: true } ); // Seed the initial slide count so the first mutation doesn't trigger a spurious init. - const initialTemplate = viewportEl.querySelector( '.wp-block-post-template' ); + const initialTemplate = viewportEl.querySelector( '.wp-block-post-template, .wp-block-term-template' ); lastSlideCount = initialTemplate ? initialTemplate.children.length : 0; updateSlideObservation(); diff --git a/src/blocks/carousel/styles/_core.scss b/src/blocks/carousel/styles/_core.scss index d35f485..a09a13b 100644 --- a/src/blocks/carousel/styles/_core.scss +++ b/src/blocks/carousel/styles/_core.scss @@ -13,9 +13,13 @@ overflow: hidden; } -/* Ensure the default container and Query Loop list are flex rows */ +/* + * Ensure the default container, Query Loop list and Terms Query list + * are flex rows + */ :where(.carousel-kit) .embla__container, -:where(.carousel-kit) .embla .wp-block-post-template { +:where(.carousel-kit) .embla .wp-block-post-template, +:where(.carousel-kit) .embla .wp-block-term-template { display: flex; flex-wrap: nowrap; width: 100%; @@ -25,15 +29,23 @@ gap: var(--carousel-kit-gap, 0); } -/* Ensure intermediate wrappers (like wp-block-query) don't shrink */ -:where(.carousel-kit) .embla .wp-block-query { +/* + * Ensure intermediate wrappers (like wp-block-query / + * wp-block-terms-query) don't shrink + */ +:where(.carousel-kit) .embla .wp-block-query, +:where(.carousel-kit) .embla .wp-block-terms-query { width: 100%; min-width: 100%; } -/* Force slides (including posts) to respect a configurable width variable */ +/* + * Force slides (including posts and terms) to respect a configurable + * width variable + */ :where(.carousel-kit) .embla__slide, -:where(.carousel-kit) .embla .wp-block-post-template li { +:where(.carousel-kit) .embla .wp-block-post-template li, +:where(.carousel-kit) .embla .wp-block-term-template li { flex: 0 0 var(--carousel-kit-slide-width, 100%); max-width: var(--carousel-kit-slide-width, 100%); min-width: 0; @@ -72,12 +84,14 @@ * We switch to margin for consistent spacing in loop mode. */ :where(.carousel-kit[data-loop="true"]) .embla__container, -:where(.carousel-kit[data-loop="true"]) .embla .wp-block-post-template { +:where(.carousel-kit[data-loop="true"]) .embla .wp-block-post-template, +:where(.carousel-kit[data-loop="true"]) .embla .wp-block-term-template { gap: 0; } :where(.carousel-kit[data-loop="true"]) .embla__slide, -:where(.carousel-kit[data-loop="true"]) .embla .wp-block-post-template li { +:where(.carousel-kit[data-loop="true"]) .embla .wp-block-post-template li, +:where(.carousel-kit[data-loop="true"]) .embla .wp-block-term-template li { margin-inline-end: var(--carousel-kit-gap, 0); } @@ -93,12 +107,14 @@ } :where(.carousel-kit[data-axis="y"]) .embla__slide, -:where(.carousel-kit[data-axis="y"]) .embla .wp-block-post-template li { +:where(.carousel-kit[data-axis="y"]) .embla .wp-block-post-template li, +:where(.carousel-kit[data-axis="y"]) .embla .wp-block-term-template li { margin-inline-end: 0; } /* Vertical + Loop specific */ :where(.carousel-kit[data-axis="y"][data-loop="true"]) .embla__slide, -:where(.carousel-kit[data-axis="y"][data-loop="true"]) .embla .wp-block-post-template li { +:where(.carousel-kit[data-axis="y"][data-loop="true"]) .embla .wp-block-post-template li, +:where(.carousel-kit[data-axis="y"][data-loop="true"]) .embla .wp-block-term-template li { margin-block-end: var(--carousel-kit-gap, 0); } diff --git a/src/blocks/carousel/styles/_variants.scss b/src/blocks/carousel/styles/_variants.scss index 87eb4fc..52d3655 100644 --- a/src/blocks/carousel/styles/_variants.scss +++ b/src/blocks/carousel/styles/_variants.scss @@ -4,18 +4,21 @@ /* 2 Columns */ :where(.carousel-kit).is-style-columns-2, -:where(.carousel-kit) .embla .wp-block-post-template.columns-2 { +:where(.carousel-kit) .embla .wp-block-post-template.columns-2, +:where(.carousel-kit) .embla .wp-block-term-template.columns-2 { --carousel-kit-slide-width: 50%; } /* 3 Columns */ :where(.carousel-kit).is-style-columns-3, -:where(.carousel-kit) .embla .wp-block-post-template.columns-3 { +:where(.carousel-kit) .embla .wp-block-post-template.columns-3, +:where(.carousel-kit) .embla .wp-block-term-template.columns-3 { --carousel-kit-slide-width: 33.333%; } /* 4 Columns */ :where(.carousel-kit).is-style-columns-4, -:where(.carousel-kit) .embla .wp-block-post-template.columns-4 { +:where(.carousel-kit) .embla .wp-block-post-template.columns-4, +:where(.carousel-kit) .embla .wp-block-term-template.columns-4 { --carousel-kit-slide-width: 25%; } diff --git a/src/blocks/carousel/types.ts b/src/blocks/carousel/types.ts index 2c4ac8b..1f7678e 100644 --- a/src/blocks/carousel/types.ts +++ b/src/blocks/carousel/types.ts @@ -32,7 +32,7 @@ export type CarouselDotsAttributes = Record; * This avoids `as any` casts while keeping dot-notation and type safety. */ export interface BlockEditorSelectors { - getBlocks: ( clientId: string ) => Array<{ clientId: string }>; + getBlocks: ( clientId: string ) => Array<{ clientId: string; name: string; attributes: Record; innerBlocks: ReturnType }>; getSelectedBlockClientId: () => string | null; getBlockParents: ( clientId: string ) => string[]; } diff --git a/src/blocks/carousel/view.ts b/src/blocks/carousel/view.ts index acce223..b11a8a2 100644 --- a/src/blocks/carousel/view.ts +++ b/src/blocks/carousel/view.ts @@ -102,9 +102,9 @@ store( 'carousel-kit/carousel', { return false; } - // Check for either standard slide or Query Loop post + // Check for either standard slide, Query Loop post, or Terms Query term const slide = getElementRef( getElement() )?.closest?.( - '.embla__slide, .wp-block-post', + '.embla__slide, .wp-block-post, .wp-block-term', ); if ( ! slide || ! slide.parentElement ) { @@ -115,7 +115,8 @@ store( 'carousel-kit/carousel', { const slides = Array.from( slide.parentElement.children ).filter( ( child: Element ) => child.classList?.contains( 'embla__slide' ) || - child.classList?.contains( 'wp-block-post' ), + child.classList?.contains( 'wp-block-post' ) || + child.classList?.contains( 'wp-block-term' ), ); const index = slides.indexOf( slide ); @@ -158,9 +159,9 @@ store( 'carousel-kit/carousel', { return; } - // Check for Query Loop container + // Check for Query Loop or Terms Query container const queryLoopContainer = viewport.querySelector( - '.wp-block-post-template', + '.wp-block-post-template, .wp-block-term-template', ); const startEmbla = () => { diff --git a/src/blocks/carousel/viewport/edit.tsx b/src/blocks/carousel/viewport/edit.tsx index f584c06..2c2b855 100644 --- a/src/blocks/carousel/viewport/edit.tsx +++ b/src/blocks/carousel/viewport/edit.tsx @@ -88,6 +88,67 @@ export default function Edit( { useCarouselObservers( viewportEl, emblaApiRef, initEmblaRef ); + /** + * Gutenberg's term-template block does not add a columns-{N} class to its + * wrapper in the editor (unlike post-template). Mirror the class here so + * the carousel CSS can detect the column count identically. + */ + const termTemplateColumnCount = useSelect( + ( select ) => { + const store = select( 'core/block-editor' ) as BlockEditorSelectors; + const find = ( + blocks: ReturnType, + ): number | null => { + for ( const block of blocks ) { + if ( block.name === 'core/term-template' ) { + // eslint-disable-next-line dot-notation + const layout = block.attributes?.[ 'layout' ] as { columnCount?: number } | undefined; + return layout?.columnCount ?? null; + } + const found = find( block.innerBlocks ?? [] ); + if ( found ) { + return found; + } + } + return null; + }; + return find( store.getBlocks( clientId ) ); + }, + [ clientId ], + ); + + useEffect( () => { + if ( ! emblaRef.current || ! termTemplateColumnCount ) { + return; + } + + const viewport = emblaRef.current; + const expectedClass = `columns-${ termTemplateColumnCount }`; + + const applyClass = () => { + const el = viewport.querySelector( '.wp-block-term-template' ); + if ( ! el || el.classList.contains( expectedClass ) ) { + return; + } + el.classList.forEach( ( cls ) => { + if ( cls.startsWith( 'columns-' ) ) { + el.classList.remove( cls ); + } + } ); + el.classList.add( expectedClass ); + }; + + applyClass(); + + // Gutenberg replaces the DOM element when toggling between edit and + // preview (select / deselect). A MutationObserver ensures the class + // is re-applied on the new element. + const observer = new MutationObserver( applyClass ); + observer.observe( viewport, { childList: true, subtree: true } ); + + return () => observer.disconnect(); + }, [ termTemplateColumnCount ] ); + const addSlide = useCallback( () => { const block = createBlock( 'carousel-kit/carousel-slide' ); insertBlock( block, undefined, clientId ); @@ -115,7 +176,7 @@ export default function Edit( { }, { orientation: carouselOptions?.axis === 'y' ? 'vertical' : 'horizontal', - allowedBlocks: [ 'carousel-kit/carousel-slide', 'core/query' ], + allowedBlocks: [ 'carousel-kit/carousel-slide', 'core/query', 'core/terms-query' ], renderAppender: ! hasSlides ? EmptyAppender : undefined, }, ); @@ -170,7 +231,7 @@ export default function Edit( { } const queryLoopContainer = viewport.querySelector( - '.wp-block-post-template', + '.wp-block-post-template, .wp-block-term-template', ) as HTMLElement; // eslint-disable-next-line @typescript-eslint/no-explicit-any