Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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?

Expand Down
26 changes: 25 additions & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
49 changes: 49 additions & 0 deletions examples/patterns/terms-query.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php
/**
* Title: Carousel Kit: Terms Query Carousel
* Slug: carousel-kit/terms-query-carousel
* Categories: carousel-kit
* Description: A carousel block containing a Terms Query displaying taxonomy terms (categories, tags, custom taxonomies) as slides.
*/

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
?>

<!-- wp:carousel-kit/carousel {"ariaLabel":"Carousel Kit: Terms Query Carousel","slideGap":16,"metadata":{"name":"Carousel Kit: Terms Query Carousel","categories":["carousel-kit"],"patternName":"carousel-kit/terms-query-carousel"},"align":"wide","className":"wp-block-carousel-carousel is-style-default"} -->
<div class="wp-block-carousel-kit-carousel alignwide carousel-kit wp-block-carousel-carousel is-style-default" role="region" aria-roledescription="carousel" aria-label="Carousel Kit: Terms Query Carousel" dir="ltr" data-axis="x" data-wp-interactive="carousel-kit/carousel" data-wp-context="{&quot;options&quot;:{&quot;loop&quot;:false,&quot;dragFree&quot;:false,&quot;align&quot;:&quot;start&quot;,&quot;containScroll&quot;:&quot;trimSnaps&quot;,&quot;direction&quot;:&quot;ltr&quot;,&quot;axis&quot;:&quot;x&quot;,&quot;slidesToScroll&quot;:1},&quot;autoplay&quot;:false,&quot;isPlaying&quot;:false,&quot;timerIterationId&quot;:0,&quot;selectedIndex&quot;:-1,&quot;scrollSnaps&quot;:[],&quot;canScrollPrev&quot;:false,&quot;canScrollNext&quot;:false,&quot;ariaLabelPattern&quot;:&quot;Go to slide %d&quot;}" data-wp-init="callbacks.initCarousel" style="--carousel-kit-gap:16px"><!-- wp:carousel-kit/carousel-viewport {"className":"wp-block-carousel-carousel-viewport"} -->
<div class="wp-block-carousel-kit-carousel-viewport embla wp-block-carousel-carousel-viewport">
<div class="embla__container"><!-- wp:carousel-kit/carousel-slide {"className":"wp-block-carousel-carousel-slide"} -->
<div class="wp-block-carousel-kit-carousel-slide embla__slide wp-block-carousel-carousel-slide" role="group" aria-roledescription="slide" data-wp-interactive="carousel-kit/carousel" data-wp-class--is-active="callbacks.isSlideActive" data-wp-bind--aria-current="callbacks.isSlideActive"><!-- wp:terms-query {"termQuery":{"perPage":10,"taxonomy":"category","order":"asc","orderBy":"name","include":[],"hideEmpty":false,"showNested":false,"inherit":false}} -->
<div class="wp-block-terms-query"><!-- wp:term-template {"layout":{"type":"grid","columnCount":3}} -->
<!-- wp:group {"className":"is-style-section-1","style":{"spacing":{"padding":{"top":"30px","right":"30px","bottom":"30px","left":"30px"}},"border":{"radius":{"topLeft":"10px","topRight":"10px","bottomLeft":"10px","bottomRight":"10px"}},"color":{"background":"#f6f6f6"}},"layout":{"inherit":false}} -->
<div class="wp-block-group is-style-section-1 has-background" style="border-top-left-radius:10px;border-top-right-radius:10px;border-bottom-left-radius:10px;border-bottom-right-radius:10px;background-color:#f6f6f6;padding-top:30px;padding-right:30px;padding-bottom:30px;padding-left:30px"><!-- wp:term-name {"isLink":true,"fontSize":"x-large"} /-->

<!-- wp:term-description /-->

<!-- wp:term-count /-->
</div>
<!-- /wp:group -->
<!-- /wp:term-template -->
</div>
<!-- /wp:terms-query -->
</div>
<!-- /wp:carousel-kit/carousel-slide -->
</div>
</div>
<!-- /wp:carousel-kit/carousel-viewport -->

<!-- wp:group {"style":{"spacing":{"margin":{"top":"var:preset|spacing|30","bottom":"0"}}},"layout":{"type":"flex","flexWrap":"nowrap"}} -->
<div class="wp-block-group" style="margin-top:var(--wp--preset--spacing--30);margin-bottom:0"><!-- wp:carousel-kit/carousel-controls {"className":"wp-block-carousel-carousel-controls"} -->
<div class="wp-block-carousel-kit-carousel-controls carousel-kit-controls wp-block-carousel-carousel-controls"><button type="button" class="carousel-kit-controls__btn carousel-kit-controls__btn--prev" data-wp-on--click="actions.scrollPrev" data-wp-bind--disabled="!state.canScrollPrev" aria-label="Previous Slide"><svg class="carousel-kit-controls__icon" width="13" height="8" viewBox="0 0 13 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 3.55371L3.55371 7.10742V4.26562H12.7861V2.84375H3.55371V0L0 3.55371Z" fill="#1C1C1C"></path>
</svg></button><button type="button" class="carousel-kit-controls__btn carousel-kit-controls__btn--next" data-wp-on--click="actions.scrollNext" data-wp-bind--disabled="!state.canScrollNext" aria-label="Next Slide"><svg class="carousel-kit-controls__icon" width="13" height="8" viewBox="0 0 13 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.7861 3.55371L9.23242 7.10742V4.26562H0V2.84375H9.23242V0L12.7861 3.55371Z" fill="#1C1C1C"></path>
</svg></button></div>
<!-- /wp:carousel-kit/carousel-controls -->
</div>
<!-- /wp:group -->
</div>
<!-- /wp:carousel-kit/carousel -->
31 changes: 31 additions & 0 deletions inc/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}

/**
Expand Down Expand Up @@ -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.
*
Expand Down
20 changes: 20 additions & 0 deletions src/blocks/carousel/__tests__/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
19 changes: 10 additions & 9 deletions src/blocks/carousel/hooks/useCarouselObservers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
36 changes: 26 additions & 10 deletions src/blocks/carousel/styles/_core.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}

Expand All @@ -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);
}
9 changes: 6 additions & 3 deletions src/blocks/carousel/styles/_variants.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
}
2 changes: 1 addition & 1 deletion src/blocks/carousel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export type CarouselDotsAttributes = Record<string, never>;
* 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<string, unknown>; innerBlocks: ReturnType<BlockEditorSelectors['getBlocks']> }>;
getSelectedBlockClientId: () => string | null;
getBlockParents: ( clientId: string ) => string[];
}
Expand Down
11 changes: 6 additions & 5 deletions src/blocks/carousel/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand All @@ -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 );
Expand Down Expand Up @@ -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 = () => {
Expand Down
Loading