diff --git a/.eslintignore b/.eslintignore index cb94d18..df5d739 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,3 +2,4 @@ **/node_modules/** **/vendor/** **/build/** +**/coverage/** diff --git a/.gitignore b/.gitignore index d546272..703fbed 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ phpstan.neon # Test output /tests/_output/* !/tests/_output/.gitkeep +coverage/ # OS files [Tt]humbs.db diff --git a/.stylelintignore b/.stylelintignore index b10243e..63cd50f 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -1,5 +1,6 @@ ## Ignore build and dependency directories /build +/coverage /node_modules /tests /vendor diff --git a/CHANGELOG.md b/CHANGELOG.md index 869e70d..6c25a38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [2.0.2](https://github.com/rtCamp/rt-carousel/compare/2.0.1...2.0.2) (2026-06-08) + + +### Features + +* add accessible Carousel Counter block ([#139](https://github.com/rtCamp/rt-carousel/pull/139)) ([49fd86b](https://github.com/rtCamp/rt-carousel/commit/49fd86b)) +* add slide template picker and starter templates for carousel block ([#83](https://github.com/rtCamp/rt-carousel/pull/83)) ([dd90468](https://github.com/rtCamp/rt-carousel/commit/dd90468)) +* add Terms Query support and Terms Query carousel pattern ([#131](https://github.com/rtCamp/rt-carousel/pull/131)) ([b0cf807](https://github.com/rtCamp/rt-carousel/commit/b0cf807)) + + +### Maintenance + +* confirm compatibility with WordPress 7.0 +* update axios from 1.15.0 to 1.16.0 ([#136](https://github.com/rtCamp/rt-carousel/pull/136)) ([6073e39](https://github.com/rtCamp/rt-carousel/commit/6073e39)) +* update fast-uri from 3.1.0 to 3.1.2 ([#137](https://github.com/rtCamp/rt-carousel/pull/137)) ([4762978](https://github.com/rtCamp/rt-carousel/commit/4762978)) +* update ip-address from 10.1.0 to 10.2.0 ([#135](https://github.com/rtCamp/rt-carousel/pull/135)) ([98ddd50](https://github.com/rtCamp/rt-carousel/commit/98ddd50)) +* update postcss from 8.5.6 to 8.5.13 ([#132](https://github.com/rtCamp/rt-carousel/pull/132)) ([0093274](https://github.com/rtCamp/rt-carousel/commit/0093274)) + + ## [2.0.1](https://github.com/rtCamp/rt-carousel/compare/v2.0.0...v2.0.1) (2026-05-04) diff --git a/README.md b/README.md index 0c9eaa5..eae712f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,15 @@ # rtCarousel +![rtCarousel banner](wp-assets/banner-1544x500.png) + ![Build Status](https://github.com/rtCamp/rt-carousel/actions/workflows/release.yml/badge.svg?branch=main) ![Latest Release](https://img.shields.io/github/v/release/rtCamp/rt-carousel) +![WordPress Plugin Version](https://img.shields.io/wordpress/plugin/v/rt-carousel) **A modular, high-performance carousel block for WordPress, powered by the Interactivity API and Embla Carousel.** +[View on WordPress.org](https://wordpress.org/plugins/rt-carousel/) + Easily create dynamic, accessible, and customizable carousels for any content type—posts, testimonials, images, and more. Designed for speed, flexibility, and seamless integration with the WordPress block editor. ## Features @@ -12,7 +17,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. @@ -29,7 +34,7 @@ Easily create dynamic, accessible, and customizable carousels for any content ty | Requirement | Minimum | Recommended | | ----------- | ------------ | ----------- | -| WordPress | 6.6+ | 6.9+ | +| WordPress | 6.6+ | 7.0+ | | PHP | 8.2+ | 8.2+ | | Gutenberg | Not required | — | @@ -68,9 +73,15 @@ Yes! rtCarousel is fully compatible with Full Site Editing. You can use the caro 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? +### How do I add content to an empty Carousel Viewport? + +Use **Add Slide** for static/manual slides, **Add Query Loop** for dynamic post slides, or **Add Terms Query** for dynamic taxonomy term slides. + +### Does it support the Query Loop and Terms Query blocks? + +Yes. Add a Query Loop or Terms Query block inside the Carousel Viewport, and each post or term becomes a slide automatically. You can also start from the bundled Query Loop Carousel or Terms Query Carousel patterns. -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. +Do not place Query Loop or Terms Query inside a Carousel Slide block. Their generated posts or terms are used as the carousel slides automatically; Carousel Slide is intended for static or manually created slide content. ### Is it accessible? diff --git a/composer.lock b/composer.lock index ca59430..355efb5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ce07304be6cef4c520c42700f79bcaee", + "content-hash": "59a604a7dae48f9d42e8ddd64a862e7f", "packages": [], "packages-dev": [ { diff --git a/docs/USAGE.md b/docs/USAGE.md index dce47df..7694af3 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -44,6 +44,20 @@ To create a specialized carousel (e.g., testimonials only), set the `allowedSlid --- +## Adding Content to an Empty Viewport + +When the **Carousel Viewport** is empty, rtCarousel shows three insertion actions: + +| Action | Use When | +| :--- | :--- | +| **Add Slide** | You want static or manually created slide content. | +| **Add Query Loop** | You want dynamic post, page, product, or custom post type slides. | +| **Add Terms Query** | You want dynamic category, tag, or custom taxonomy term slides. | + +Use **Add Query Loop** or **Add Terms Query** for dynamic content so the generated posts or terms become the carousel slides automatically. Use **Add Slide** only for static/manual slides. + +--- + ## Using Query Loop with Carousel You can create dynamic post sliders or content carousels using the WordPress Query Loop block. @@ -55,12 +69,45 @@ You can create dynamic post sliders or content carousels using the WordPress Que 4. Configure your query (post type, category, order, etc). **Disable** "Inherit query from template" if using on single posts/pages to avoid loop conflicts. 5. Design your slide inside the Query Loop's **Post Template**. -**Note:** Each post generated by the Query Loop becomes an individual slide. The system automatically detects `.wp-block-post-template` and forces horizontal flex row display. The `slideGap` attribute controls spacing. +**Important:** Do not wrap Query Loop in a Carousel Slide block. Each post generated by the Query Loop becomes an individual carousel slide, so the Query Loop block must be placed directly inside the Carousel Viewport. The Carousel Slide block is for static or manually created slides. + +**Note:** The system automatically detects `.wp-block-post-template` and forces horizontal flex row display. The `slideGap` attribute controls spacing. + +You can also start from the bundled **rtCarousel: Query Loop Carousel** pattern. + +--- + +## Using Terms Query with Carousel + +You can create dynamic taxonomy sliders using the WordPress Terms Query block (`core/terms-query`). This supports categories, tags, and custom taxonomies. + +### Setup Steps +1. Add the **Carousel** block to your page. +2. Select the inner **Carousel Viewport** block. +3. Insert a **Terms Query** block directly inside the Viewport, not inside a Carousel Slide. +4. Configure the taxonomy, order, visibility, and included terms in the Terms Query settings. +5. Design your slide inside the Terms Query's **Term Template**. + +**Important:** Do not wrap Terms Query in a Carousel Slide block. Each term generated by the Terms Query becomes an individual carousel slide, so the Terms Query block must be placed directly inside the Carousel Viewport. The Carousel Slide block is for static or manually created slides. + +**Note:** The system automatically detects `.wp-block-term-template` and applies the same carousel layout behavior used for Query Loop. The `slideGap` attribute controls spacing. + +You can also start from the bundled **rtCarousel: Terms Query Carousel** pattern. ### Block Selection Guide | Use Case | Recommended Block | | :--- | :--- | | Dynamic Content (Posts, Pages, Products, Custom Post Types) | Query Loop (`core/query`) | +| Dynamic Taxonomy Content (Categories, Tags, Custom Taxonomies) | Terms Query (`core/terms-query`) | | Static Content (Hero Slider, Logo Showcase, Manual Testimonials) | Carousel Slide (`rt-carousel/carousel-slide`) | | Mixed Content (Slide 1 is a Video, Slide 2 is Text) | Carousel Slide (`rt-carousel/carousel-slide`) | + +### Terms Query Selection Guide + +| Taxonomy Use Case | Recommended Setup | +| :--- | :--- | +| All categories | 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 static term cards | Carousel Slide (`rt-carousel/carousel-slide`) | diff --git a/examples/patterns/terms-query.php b/examples/patterns/terms-query.php new file mode 100644 index 0000000..c2a3c47 --- /dev/null +++ b/examples/patterns/terms-query.php @@ -0,0 +1,46 @@ + + + + + diff --git a/inc/Plugin.php b/inc/Plugin.php index a296e70..fa8bf5c 100644 --- a/inc/Plugin.php +++ b/inc/Plugin.php @@ -130,6 +130,7 @@ public function register_blocks(): void { $blocks = [ 'carousel', 'carousel/controls', + 'carousel/counter', 'carousel/dots', 'carousel/progress', 'carousel/viewport', diff --git a/languages/rt-carousel.pot b/languages/rt-carousel.pot index 1d69d99..9287dc7 100644 --- a/languages/rt-carousel.pot +++ b/languages/rt-carousel.pot @@ -2,22 +2,22 @@ # This file is distributed under the GPL-2.0-or-later. msgid "" msgstr "" -"Project-Id-Version: rtCarousel 2.0.0\n" +"Project-Id-Version: rtCarousel 2.0.2\n" "Report-Msgid-Bugs-To: https://github.com/rtCamp/rt-carousel/issues\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2026-04-15T21:07:27+00:00\n" +"POT-Creation-Date: 2026-06-08T06:36:07+00:00\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"X-Generator: WP-CLI 2.12.0\n" +"X-Generator: WP-CLI 2.10.0\n" "X-Domain: rt-carousel\n" #. Plugin Name of the plugin #: rt-carousel.php -#: inc/Plugin.php:97 -#: inc/Plugin.php:137 +#: inc/Plugin.php:118 +#: inc/Plugin.php:159 msgid "rtCarousel" msgstr "" @@ -45,11 +45,15 @@ msgstr "" msgid "rtCarousel: The Composer autoloader was not found. If you installed the plugin from the GitHub source code, make sure to run `composer install`." msgstr "" -#: inc/Plugin.php:78 -msgid "The old \"Carousel Kit\" plugin has been deactivated. rtCarousel is its replacement." +#: inc/Plugin.php:99 +msgid "The \"Carousel Kit\" plugin is still active. rtCarousel is its replacement — please deactivate Carousel Kit." msgstr "" -#: inc/Plugin.php:138 +#: inc/Plugin.php:101 +msgid "Deactivate Carousel Kit" +msgstr "" + +#: inc/Plugin.php:160 msgid "Pre-configured carousel patterns for various use cases." msgstr "" @@ -61,207 +65,282 @@ msgstr "" msgid "Next Slide" msgstr "" +#. translators: 1: current slide number, 2: total slide count. +#: build/blocks/carousel/counter/index.js:51 +msgid "Slide %1$d of %2$d" +msgstr "" + +#: build/blocks/carousel/counter/index.js:122 +msgid "Carousel counter" +msgstr "" + #. translators: %d: slide number #: build/blocks/carousel/dots/index.js:2 #: build/blocks/carousel/index.js:3 -#, js-format +#: build/blocks/carousel/index.js:5 msgid "Go to slide %d" msgstr "" #: build/blocks/carousel/index.js:1 -msgid "Carousel Settings" +msgid "Text Slides" msgstr "" #: build/blocks/carousel/index.js:1 -msgid "Loop" +msgid "Slides starting with a paragraph you can replace or extend." msgstr "" #: build/blocks/carousel/index.js:1 -msgid "Enables infinite scrolling of slides." +msgid "Image Slides" msgstr "" #: build/blocks/carousel/index.js:1 -msgid "Free Drag" +msgid "Slides prefilled with an image block." msgstr "" #: build/blocks/carousel/index.js:1 -msgid "Enables momentum scrolling." +msgid "Image + Heading + Text + CTA" msgstr "" #: build/blocks/carousel/index.js:1 -msgid "Alignment" +msgid "Marketing slider with heading, paragraph, and button." msgstr "" #: build/blocks/carousel/index.js:1 -msgid "Start" +msgid "Slide Heading" msgstr "" #: build/blocks/carousel/index.js:1 -msgid "Center" +msgid "Slide description text…" msgstr "" #: build/blocks/carousel/index.js:1 -msgid "End" +msgid "Image + Caption" msgstr "" #: build/blocks/carousel/index.js:1 -msgid "Contain Scroll" +msgid "Image with supporting text below." msgstr "" #: build/blocks/carousel/index.js:1 -msgid "Trim Snaps" +msgid "Caption text…" msgstr "" #: build/blocks/carousel/index.js:1 -msgid "Keep Snaps" +msgid "Query Loop Slides" msgstr "" #: build/blocks/carousel/index.js:1 -msgid "None" +msgid "Dynamically generate slides from posts." msgstr "" #: build/blocks/carousel/index.js:1 +msgid "Back" +msgstr "" + +#: build/blocks/carousel/index.js:3 +msgid "Carousel Settings" +msgstr "" + +#: build/blocks/carousel/index.js:3 +msgid "Loop" +msgstr "" + +#: build/blocks/carousel/index.js:3 +msgid "Enables infinite scrolling of slides." +msgstr "" + +#: build/blocks/carousel/index.js:3 +msgid "Free Drag" +msgstr "" + +#: build/blocks/carousel/index.js:3 +msgid "Enables momentum scrolling." +msgstr "" + +#: build/blocks/carousel/index.js:3 +msgid "Alignment" +msgstr "" + +#: build/blocks/carousel/index.js:3 +msgid "Start" +msgstr "" + +#: build/blocks/carousel/index.js:3 +msgid "Center" +msgstr "" + +#: build/blocks/carousel/index.js:3 +msgid "End" +msgstr "" + +#: build/blocks/carousel/index.js:3 +msgid "Contain Scroll" +msgstr "" + +#: build/blocks/carousel/index.js:3 +msgid "Trim Snaps" +msgstr "" + +#: build/blocks/carousel/index.js:3 +msgid "Keep Snaps" +msgstr "" + +#: build/blocks/carousel/index.js:3 +msgid "None" +msgstr "" + +#: build/blocks/carousel/index.js:3 msgid "Prevents excess scrolling at the beginning or end." msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Scroll Auto" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Scrolls the number of slides currently visible in the viewport." msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Slides to Scroll" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Direction" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Left to Right (LTR)" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Right to Left (RTL)" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Choose content direction. RTL is typically used for Arabic, Hebrew, and other right-to-left languages." msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Orientation" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Horizontal" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Vertical" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Height" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Set a fixed height for vertical carousel (e.g., 400px)." msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Autoplay Options" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Enable Autoplay" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Delay (ms)" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Stop on Interaction" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Stop autoplay when user interacts with carousel." msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Stop on Mouse Enter" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Stop autoplay when mouse hovers over carousel." msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "ARIA Label" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Provide a descriptive label for screen readers (e.g., 'Featured Products')." msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Use this to allow only certain blocks in the slide. If empty, all blocks will be allowed." msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Allowed Slide Blocks" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Layout" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Slide Gap (px)" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Carousel" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "How many slides would you like to start with?" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 +msgid "Choose a slide template:" +msgstr "" + +#: build/blocks/carousel/index.js:3 msgid "1 Slide" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Slides" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 msgid "Skip" msgstr "" -#: build/blocks/carousel/index.js:1 +#: build/blocks/carousel/index.js:3 #: build/blocks/carousel/viewport/index.js:1 msgid "Add Slide" msgstr "" -#: build/blocks/carousel/index.js:3 +#. translators: {{currentSlide}}: current slide number, {{totalSlides}}: total slide count. +#: build/blocks/carousel/index.js:7 +#: build/blocks/carousel/index.js:9 +msgid "Slide {{currentSlide}} of {{totalSlides}}" +msgstr "" + +#: build/blocks/carousel/index.js:9 msgid "Default (100%)" msgstr "" -#: build/blocks/carousel/index.js:3 +#: build/blocks/carousel/index.js:9 msgid "2 Columns (50%)" msgstr "" -#: build/blocks/carousel/index.js:3 +#: build/blocks/carousel/index.js:9 msgid "3 Columns (33%)" msgstr "" -#: build/blocks/carousel/index.js:3 +#: build/blocks/carousel/index.js:9 msgid "4 Columns (25%)" msgstr "" @@ -269,6 +348,14 @@ msgstr "" msgid "Carousel progress" msgstr "" +#: build/blocks/carousel/viewport/index.js:1 +msgid "Add Query Loop" +msgstr "" + +#: build/blocks/carousel/viewport/index.js:1 +msgid "Add Terms Query" +msgstr "" + #: build/blocks/carousel/viewport/index.js:1 msgid "Viewport Actions" msgstr "" @@ -297,6 +384,18 @@ msgctxt "block description" msgid "Navigation buttons for the carousel." msgstr "" +#: build/blocks/carousel/counter/block.json +#: src/blocks/carousel/counter/block.json +msgctxt "block title" +msgid "Carousel Counter" +msgstr "" + +#: build/blocks/carousel/counter/block.json +#: src/blocks/carousel/counter/block.json +msgctxt "block description" +msgid "Current slide counter for the carousel." +msgstr "" + #: build/blocks/carousel/dots/block.json #: src/blocks/carousel/dots/block.json msgctxt "block title" diff --git a/package-lock.json b/package-lock.json index 53e6e50..370b7dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rt-carousel", - "version": "2.0.1", + "version": "2.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rt-carousel", - "version": "2.0.1", + "version": "2.0.2", "dependencies": { "@wordpress/babel-preset-default": "8.38.0", "@wordpress/block-editor": "^15.10.0", @@ -16,6 +16,7 @@ "@wordpress/data": "^10.10.0", "@wordpress/dom-ready": "^4.37.0", "@wordpress/element": "6.38.0", + "@wordpress/hooks": "4.41.0", "@wordpress/i18n": "^6.10.0", "@wordpress/icons": "11.5.0", "@wordpress/interactivity": "6.37.0", @@ -2426,6 +2427,40 @@ "url": "https://github.com/sponsors/JounQin" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "license": "MIT", @@ -2633,6 +2668,24 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { "version": "4.1.1", "dev": true, @@ -2644,6 +2697,19 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/js": { "version": "8.57.1", "dev": true, @@ -2783,6 +2849,37 @@ "node": ">=10.10.0" } }, + "node_modules/@humanwhocodes/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "dev": true, @@ -3782,6 +3879,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "dev": true, @@ -4391,6 +4501,27 @@ "@parcel/watcher-win32-x64": "2.5.4" } }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.4.tgz", + "integrity": "sha512-hoh0vx4v+b3BNI7Cjoy2/B0ARqcwVNrzN/n7DLq9ZB4I3lrsvhrkCViJyfTj/Qi5xM9YFiH4AmHGK6pgH1ss7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@parcel/watcher-darwin-arm64": { "version": "2.5.4", "cpu": [ @@ -4410,6 +4541,237 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.4.tgz", + "integrity": "sha512-UKaQFhCtNJW1A9YyVz3Ju7ydf6QgrpNQfRZ35wNKUhTQ3dxJ/3MULXN5JN/0Z80V/KUBDGa3RZaKq1EQT2a2gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.4.tgz", + "integrity": "sha512-Dib0Wv3Ow/m2/ttvLdeI2DBXloO7t3Z0oCp4bAb2aqyqOjKPPGrg10pMJJAQ7tt8P4V2rwYwywkDhUia/FgS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.4.tgz", + "integrity": "sha512-I5Vb769pdf7Q7Sf4KNy8Pogl/URRCKu9ImMmnVKYayhynuyGYMzuI4UOWnegQNa2sGpsPSbzDsqbHNMyeyPCgw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.4.tgz", + "integrity": "sha512-kGO8RPvVrcAotV4QcWh8kZuHr9bXi9a3bSZw7kFarYR0+fGliU7hd/zevhjw8fnvIKG3J9EO5G6sXNGCSNMYPQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.4.tgz", + "integrity": "sha512-KU75aooXhqGFY2W5/p8DYYHt4hrjHZod8AhcGAmhzPn/etTa+lYCDB2b1sJy3sWJ8ahFVTdy+EbqSBvMx3iFlw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.4.tgz", + "integrity": "sha512-Qx8uNiIekVutnzbVdrgSanM+cbpDD3boB1f8vMtnuG5Zau4/bdDbXyKwIn0ToqFhIuob73bcxV9NwRm04/hzHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.4.tgz", + "integrity": "sha512-UYBQvhYmgAv61LNUn24qGQdjtycFBKSK3EXr72DbJqX9aaLbtCOO8+1SkKhD/GNiJ97ExgcHBrukcYhVjrnogA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.4.tgz", + "integrity": "sha512-YoRWCVgxv8akZrMhdyVi6/TyoeeMkQ0PGGOf2E4omODrvd1wxniXP+DBynKoHryStks7l+fDAMUBRzqNHrVOpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.4.tgz", + "integrity": "sha512-iby+D/YNXWkiQNYcIhg8P5hSjzXEHaQrk2SLrWOUD7VeC4Ohu0WQvmV+HDJokZVJ2UjJ4AGXW3bx7Lls9Ln4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.4.tgz", + "integrity": "sha512-vQN+KIReG0a2ZDpVv8cgddlf67J8hk1WfZMMP7sMeZmJRSmEax5xNDNWKdgqSe2brOKTQQAs3aCCUal2qBHAyg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.4.tgz", + "integrity": "sha512-3A6efb6BOKwyw7yk9ro2vus2YTt2nvcd56AuzxdMiVOxL9umDyN5PKkKfZ/gZ9row41SjVmTVQNWQhaRRGpOKw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@parcel/watcher/node_modules/picomatch": { "version": "4.0.4", "dev": true, @@ -5263,6 +5625,39 @@ "@opentelemetry/semantic-conventions": "^1.34.0" } }, + "node_modules/@sentry/node/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sentry/node/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@sentry/node/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@sentry/opentelemetry": { "version": "9.47.1", "dev": true, @@ -5737,6 +6132,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "dev": true, @@ -6799,59 +7205,314 @@ "eslint": "^7.0.0 || ^8.0.0" } }, - "node_modules/@typescript-eslint/utils/node_modules/semver": { - "version": "7.7.4", + "node_modules/@typescript-eslint/utils/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.15.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.15.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.15.0", + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@typescript-eslint/types": "6.15.0", - "eslint-visitor-keys": "^3.4.1" + "@napi-rs/wasm-runtime": "^0.2.11" }, "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=14.0.0" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "3.4.3", + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "ISC" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "win32" ] }, "node_modules/@use-gesture/core": { @@ -8198,7 +8859,9 @@ } }, "node_modules/@wordpress/hooks": { - "version": "4.38.0", + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.41.0.tgz", + "integrity": "sha512-WDbLcLA3DOcjDGNLcxHZTPyhltWd/75G2hxFphe/hzcJUNmgysDTSSXO/bBrIWf6rwWD6TS3ejCaGC9J6DwYiw==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -9690,11 +10353,12 @@ } }, "node_modules/axios": { - "version": "1.15.0", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "dev": true, - "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } @@ -9881,14 +10545,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/bare-events": { "version": "2.8.2", "dev": true, @@ -10093,17 +10749,6 @@ "dev": true, "license": "ISC" }, - "node_modules/brace-expansion": { - "version": "5.0.5", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/braces": { "version": "3.0.3", "devOptional": true, @@ -10742,6 +11387,13 @@ "node_modules/computed-style": { "version": "0.1.4" }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/configstore": { "version": "7.1.0", "dev": true, @@ -12663,6 +13315,24 @@ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, + "node_modules/eslint-plugin-import/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/eslint-plugin-import/node_modules/debug": { "version": "3.2.7", "dev": true, @@ -12682,6 +13352,19 @@ "node": ">=0.10.0" } }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint-plugin-jest": { "version": "27.9.0", "dev": true, @@ -12884,6 +13567,37 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint-plugin-playwright": { "version": "0.15.3", "dev": true, @@ -12969,6 +13683,24 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" } }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/eslint-plugin-react/node_modules/doctrine": { "version": "2.1.0", "dev": true, @@ -12980,6 +13712,19 @@ "node": ">=0.10.0" } }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint-plugin-react/node_modules/resolve": { "version": "2.0.0-next.6", "dev": true, @@ -13035,6 +13780,24 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/eslint/node_modules/eslint-scope": { "version": "7.2.2", "dev": true, @@ -13101,6 +13864,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint/node_modules/p-locate": { "version": "5.0.0", "dev": true, @@ -13427,7 +14203,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "devOptional": true, "funding": [ { @@ -13438,8 +14216,7 @@ "type": "opencollective", "url": "https://opencollective.com/fastify" } - ], - "license": "BSD-3-Clause" + ] }, "node_modules/fastest-levenshtein": { "version": "1.0.16", @@ -14109,6 +14886,37 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/global-directory": { "version": "4.0.1", "dev": true, @@ -14741,6 +15549,37 @@ "node": ">=10" } }, + "node_modules/ignore-walk/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore-walk/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/ignore-walk/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/image-ssim": { "version": "0.2.0", "dev": true, @@ -14900,9 +15739,10 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "dev": true, - "license": "MIT", "engines": { "node": ">= 12" } @@ -17065,6 +17905,24 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/markdownlint-cli/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/markdownlint-cli/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/markdownlint-cli/node_modules/commander": { "version": "9.0.0", "dev": true, @@ -17092,6 +17950,19 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/markdownlint-cli/node_modules/minimatch": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", + "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/markdownlint-rule-helpers": { "version": "0.16.0", "dev": true, @@ -17342,20 +18213,6 @@ "dev": true, "license": "ISC" }, - "node_modules/minimatch": { - "version": "10.2.4", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/minimist": { "version": "1.2.8", "dev": true, @@ -18417,7 +19274,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", "funding": [ { "type": "opencollective", @@ -18432,7 +19291,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -22163,6 +23021,37 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/text-decoder": { "version": "1.2.3", "dev": true, diff --git a/package.json b/package.json index cf8ce34..88682a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rt-carousel", - "version": "2.0.1", + "version": "2.0.2", "description": "Carousel block using Embla and WordPress Interactivity API", "author": "rtCamp", "private": true, @@ -42,6 +42,7 @@ "@wordpress/data": "^10.10.0", "@wordpress/dom-ready": "^4.37.0", "@wordpress/element": "6.38.0", + "@wordpress/hooks": "4.41.0", "@wordpress/i18n": "^6.10.0", "@wordpress/icons": "11.5.0", "@wordpress/interactivity": "6.37.0", @@ -71,7 +72,6 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "webpack-dev-server": ">=5.2.1", - "minimatch": ">=10.2.1", "serialize-javascript": ">=7.0.3" } } diff --git a/readme.txt b/readme.txt index 78216b9..4f85c6d 100644 --- a/readme.txt +++ b/readme.txt @@ -1,10 +1,10 @@ === rtCarousel === -Contributors: rtcamp, danish17, immasud, gagan0123, up1512001, mi5t4n, aviral89, vishal4669, imrraaj, aishwarryapande +Contributors: rtcamp, iamdanih17, immasud, gagan0123, up1512001, mi5t4n, aviral89, vishal4669, imrraaj, aishwarryapande Tags: carousel, slider, block, interactivity-api, embla Requires at least: 6.6 -Tested up to: 6.9 +Tested up to: 7.0 Requires PHP: 8.2 -Stable tag: 2.0.1 +Stable tag: 2.0.2 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -85,6 +85,14 @@ rtCarousel is the successor to Carousel Kit. Simply install and activate rtCarou == Changelog == += 2.0.2 = +* New: Carousel Counter block with an accessible current/total slide indicator +* New: Slide template picker and starter templates for new carousel blocks +* New: Terms Query support and Terms Query carousel pattern +* Tweak: Confirm compatibility with WordPress 7.0 +* Tweak: Update JavaScript dependencies for upstream security and maintenance fixes + + = 2.0.1 = * New: Add a11y announcements for carousel slide changes * Fix: Carousel dot focus loss with VoiceOver activation diff --git a/rt-carousel.php b/rt-carousel.php index 2f7ae77..cccd476 100644 --- a/rt-carousel.php +++ b/rt-carousel.php @@ -10,7 +10,7 @@ * Domain Path: /languages * License: GPL-2.0-or-later * License URI: https://www.gnu.org/licenses/gpl-2.0.html - * Version: 2.0.1 + * Version: 2.0.2 * Text Domain: rt-carousel * * @package rt-carousel diff --git a/src/blocks/carousel/__tests__/edit.test.tsx b/src/blocks/carousel/__tests__/edit.test.tsx new file mode 100644 index 0000000..62297fe --- /dev/null +++ b/src/blocks/carousel/__tests__/edit.test.tsx @@ -0,0 +1,220 @@ +/** + * Unit tests for the carousel editor setup flow. + * + * @package + */ + +import '@testing-library/jest-dom'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import Edit from '../edit'; +import type { CarouselAttributes } from '../types'; +import type { ReactNode as MockReactNode } from 'react'; + +let mockBlockCount = 0; + +jest.mock( '@wordpress/block-editor', () => ( { + useBlockProps: jest.fn( ( props = {} ) => props ), + useInnerBlocksProps: jest.fn( ( props = {} ) => props ), + InspectorControls: jest.fn( ( { children } ) => children ), + InspectorAdvancedControls: jest.fn( ( { children } ) => children ), + BlockControls: jest.fn( ( { children } ) => children ), +} ) ); + +jest.mock( '@wordpress/components', () => { + const Button = ( { + children, + onClick, + className, + ...rest + }: { + children?: MockReactNode; + className?: string; + onClick?: () => void; + } ) => ( + + ); + + const Passthrough = ( { + children, + }: { + children?: MockReactNode; + } ) => <>{ children }; + + return { + PanelBody: Passthrough, + ToggleControl: jest.fn( () => null ), + SelectControl: jest.fn( () => null ), + FormTokenField: jest.fn( () => null ), + BaseControl: Passthrough, + TextControl: jest.fn( () => null ), + RangeControl: jest.fn( () => null ), + Placeholder: ( { + children, + instructions, + className, + }: { + children?: MockReactNode; + className?: string; + instructions?: MockReactNode; + } ) => ( +
+

{ instructions }

+ { children } +
+ ), + Button, + ToolbarButton: Button, + }; +} ); + +type BlockEditorMockSelectors = { + getBlockCount: () => number; + getBlocks: () => unknown[]; +}; + +type BlocksMockSelectors = { + getBlockTypes: () => unknown[]; +}; + +type MockSelect = { + ( storeName: 'core/block-editor' ): BlockEditorMockSelectors; + ( storeName: 'core/blocks' ): BlocksMockSelectors; + ( storeName: string ): Record; +}; + +type MockUseSelectCallback = ( select: MockSelect ) => unknown; + +jest.mock( '@wordpress/data', () => ( { + useDispatch: jest.fn( () => ( { + replaceInnerBlocks: jest.fn(), + insertBlock: jest.fn(), + } ) ), + useSelect: jest.fn( ( selector: MockUseSelectCallback ) => { + const select = ( ( storeName: string ) => { + if ( storeName === 'core/block-editor' ) { + return { + getBlockCount: () => mockBlockCount, + getBlocks: () => [], + }; + } + + if ( storeName === 'core/blocks' ) { + return { + getBlockTypes: () => [], + }; + } + + return {}; + } ) as MockSelect; + + return selector( select ); + } ), +} ) ); + +jest.mock( '@wordpress/icons', () => ( { + plus: 'plus', + columns: { name: 'columns' }, + image: { name: 'image' }, + layout: { name: 'layout' }, + gallery: { name: 'gallery' }, + post: { name: 'post' }, +} ) ); + +jest.mock( '@wordpress/blocks', () => ( { + createBlock: jest.fn( ( name: string, attributes = {}, innerBlocks = [] ) => ( { + name, + attributes, + innerBlocks, + } ) ), +} ) ); + +jest.mock( '../components/TemplatePicker', () => ( { + __esModule: true, + default: ( { onBack }: { onBack: () => void } ) => ( +
+ +
+ ), +} ) ); + +const createAttributes = (): CarouselAttributes => ( { + loop: false, + dragFree: false, + carouselAlign: 'start', + containScroll: 'trimSnaps', + direction: 'ltr', + axis: 'x', + height: '', + allowedSlideBlocks: [], + autoplay: false, + autoplayDelay: 1000, + autoplayStopOnInteraction: true, + autoplayStopOnMouseEnter: false, + ariaLabel: 'Carousel', + slidesToScroll: '1', + slideGap: 0, +} ); + +describe( 'Carousel Edit setup flow', () => { + beforeEach( () => { + mockBlockCount = 0; + } ); + + it( 'restores focus to first slide-count button when going back from templates', async () => { + render( + , + ); + + fireEvent.click( screen.getByRole( 'button', { name: '2 Slides' } ) ); + const backButton = screen.getByRole( 'button', { name: 'Back' } ); + backButton.focus(); + fireEvent.click( backButton ); + + await waitFor( () => { + expect( screen.getByRole( 'button', { name: '1 Slide' } ) ).toHaveFocus(); + } ); + } ); + + it( 'does not throw when completing setup in an environment without document', () => { + const originalDocumentDescriptor = Object.getOwnPropertyDescriptor( globalThis, 'document' ); + + const { rerender } = render( + , + ); + + mockBlockCount = 1; + + if ( originalDocumentDescriptor?.configurable ) { + Object.defineProperty( globalThis, 'document', { + value: undefined, + configurable: true, + } ); + } + + expect( () => { + rerender( + , + ); + } ).not.toThrow(); + + if ( originalDocumentDescriptor?.configurable ) { + Object.defineProperty( globalThis, 'document', originalDocumentDescriptor ); + } + } ); +} ); diff --git a/src/blocks/carousel/__tests__/templates.test.ts b/src/blocks/carousel/__tests__/templates.test.ts new file mode 100644 index 0000000..bfcd7f0 --- /dev/null +++ b/src/blocks/carousel/__tests__/templates.test.ts @@ -0,0 +1,202 @@ +/** + * Unit tests for slide template definitions and the template registry. + * + * Verifies: + * - All default templates have the required shape + * - Template inner blocks produce valid BlockInstance arrays + * - Query Loop template is flagged correctly + * - The `rtcamp.carouselKit.slideTemplates` filter hook is applied + * + * @package + */ + +/// + +import { applyFilters } from '@wordpress/hooks'; +import { getSlideTemplates, type SlideTemplate } from '../templates'; + +/* ── Mocks ────────────────────────────────────────────────────────────────── */ + +// Provide a minimal createBlock mock that returns a plain object. +jest.mock( '@wordpress/blocks', () => ( { + createBlock: jest.fn( ( name: string, attrs = {}, inner = [] ) => ( { + name, + attributes: attrs, + innerBlocks: inner, + clientId: `mock-${ name }-${ Math.random().toString( 36 ).slice( 2, 8 ) }`, + } ) ), +} ) ); + +jest.mock( '@wordpress/hooks', () => ( { + applyFilters: jest.fn( ( _hookName: string, value: unknown ) => value ), +} ) ); + +jest.mock( '@wordpress/i18n', () => ( { + __: jest.fn( ( str: string ) => str ), +} ) ); + +const mockedApplyFilters = jest.mocked( applyFilters ); +let consoleWarnSpy: jest.SpiedFunction< typeof console.warn >; + +/* ── Tests ────────────────────────────────────────────────────────────────── */ + +describe( 'Slide Templates', () => { + beforeEach( () => { + consoleWarnSpy = jest.spyOn( console, 'warn' ).mockImplementation( () => undefined ); + mockedApplyFilters.mockClear(); + mockedApplyFilters.mockImplementation( ( _hookName: string, value: unknown ) => value ); + } ); + + afterEach( () => { + consoleWarnSpy.mockRestore(); + } ); + + describe( 'getSlideTemplates()', () => { + it( 'returns an array of templates', () => { + const templates = getSlideTemplates(); + expect( Array.isArray( templates ) ).toBe( true ); + expect( templates.length ).toBeGreaterThanOrEqual( 5 ); + } ); + + it( 'applies the rtcamp.carouselKit.slideTemplates filter', () => { + getSlideTemplates(); + expect( mockedApplyFilters ).toHaveBeenCalledWith( + 'rtcamp.carouselKit.slideTemplates', + expect.any( Array ), + ); + } ); + + it( 'passes a fresh copy of the default templates to filters', () => { + mockedApplyFilters.mockImplementationOnce( ( _hookName: string, value: unknown ) => { + ( value as SlideTemplate[] ).push( { + name: 'testimonial', + label: 'Testimonial', + description: 'Quote with author name.', + icon: 'format-quote', + innerBlocks: () => [], + } ); + return value; + } ); + + const mutatedTemplates = getSlideTemplates(); + const freshTemplates = getSlideTemplates(); + + expect( mutatedTemplates.map( ( template ) => template.name ) ).toContain( 'testimonial' ); + expect( freshTemplates.map( ( template ) => template.name ) ).not.toContain( 'testimonial' ); + } ); + + it( 'falls back to defaults when a filter returns a non-array value', () => { + mockedApplyFilters.mockImplementationOnce( () => 'invalid' as never ); + + const templates = getSlideTemplates(); + + expect( Array.isArray( templates ) ).toBe( true ); + expect( templates.length ).toBeGreaterThanOrEqual( 5 ); + expect( templates.map( ( template ) => template.name ) ).toContain( 'text' ); + expect( consoleWarnSpy ).toHaveBeenCalledWith( + 'rtcamp.carouselKit.slideTemplates filter returned a non-array value. Falling back to default slide templates.', + 'invalid', + ); + } ); + + it( 'drops duplicate template names returned by filters', () => { + mockedApplyFilters.mockImplementationOnce( ( _hookName: string, value: unknown ) => [ + ...( value as SlideTemplate[] ), + { + name: 'text', + label: 'Duplicate Text', + description: 'Duplicate entry', + icon: 'format-quote', + innerBlocks: () => [], + }, + ] ); + + const templates = getSlideTemplates(); + const textTemplates = templates.filter( ( template ) => template.name === 'text' ); + + expect( textTemplates ).toHaveLength( 1 ); + expect( consoleWarnSpy ).toHaveBeenCalledWith( + 'rtcamp.carouselKit.slideTemplates: dropping duplicate template name "text".', + expect.objectContaining( { name: 'text', label: 'Duplicate Text' } ), + ); + } ); + } ); + + describe( 'Template Shape', () => { + const templates = getSlideTemplates(); + const templateCases: Array<[ string, SlideTemplate ]> = templates.map( ( template ) => [ + template.name, + template, + ] ); + + it.each<[ string, SlideTemplate ]>( templateCases )( + 'template "%s" has required properties', + ( _name, template ) => { + expect( typeof template.name ).toBe( 'string' ); + expect( template.name.length ).toBeGreaterThan( 0 ); + expect( typeof template.label ).toBe( 'string' ); + expect( typeof template.description ).toBe( 'string' ); + expect( template.icon ).toBeDefined(); + expect( template.icon ).not.toBeNull(); + expect( [ 'string', 'function', 'object' ] ).toContain( + typeof template.icon, + ); + expect( typeof template.innerBlocks ).toBe( 'function' ); + }, + ); + + it( 'each template has a unique name', () => { + const names = templates.map( ( t ) => t.name ); + expect( new Set( names ).size ).toBe( names.length ); + } ); + } ); + + describe( 'Default Templates', () => { + const templates = getSlideTemplates(); + const byName = ( name: string ) => + templates.find( ( t ) => t.name === name )!; + + it( 'text template produces a paragraph block', () => { + const blocks = byName( 'text' ).innerBlocks(); + expect( blocks ).toHaveLength( 1 ); + expect( blocks[ 0 ]!.name ).toBe( 'core/paragraph' ); + } ); + + it( 'image template produces an image block', () => { + const blocks = byName( 'image' ).innerBlocks(); + expect( blocks ).toHaveLength( 1 ); + expect( blocks[ 0 ]!.name ).toBe( 'core/image' ); + } ); + + it( 'hero template produces a cover with heading, paragraph, and button', () => { + const blocks = byName( 'hero' ).innerBlocks(); + expect( blocks ).toHaveLength( 1 ); + expect( blocks[ 0 ]!.name ).toBe( 'core/cover' ); + const inner = blocks[ 0 ]!.innerBlocks; + expect( inner ).toHaveLength( 3 ); + expect( inner[ 0 ]!.name ).toBe( 'core/heading' ); + expect( inner[ 1 ]!.name ).toBe( 'core/paragraph' ); + expect( inner[ 2 ]!.name ).toBe( 'core/buttons' ); + } ); + + it( 'image-caption template produces an image and a paragraph', () => { + const blocks = byName( 'image-caption' ).innerBlocks(); + expect( blocks ).toHaveLength( 2 ); + expect( blocks[ 0 ]!.name ).toBe( 'core/image' ); + expect( blocks[ 1 ]!.name ).toBe( 'core/paragraph' ); + } ); + + it( 'query-loop template is flagged as isQueryLoop', () => { + const ql = byName( 'query-loop' ); + expect( ql.isQueryLoop ).toBe( true ); + } ); + + it( 'non-query-loop templates are not flagged as isQueryLoop', () => { + templates + .filter( ( t ) => t.name !== 'query-loop' ) + .forEach( ( t ) => { + expect( t.isQueryLoop ).toBeFalsy(); + } ); + } ); + } ); +} ); diff --git a/src/blocks/carousel/__tests__/view.test.ts b/src/blocks/carousel/__tests__/view.test.ts index d7aa624..599fca3 100644 --- a/src/blocks/carousel/__tests__/view.test.ts +++ b/src/blocks/carousel/__tests__/view.test.ts @@ -436,6 +436,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, initialized: true } ); + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + ( getElement as jest.Mock ).mockReturnValue( { ref: term2 } ); + + const result = storeConfig.callbacks.isSlideActive(); + + expect( result ).toBe( true ); + } ); } ); describe( 'isDotActive', () => { @@ -586,6 +606,56 @@ describe( 'Carousel View Module', () => { } ); } ); + describe( 'carousel count callbacks', () => { + it( 'should return 1-based current count', () => { + const mockContext = createMockContext( { selectedIndex: 2 } ); + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + + const result = storeConfig.callbacks.getCurrentCount(); + + expect( result ).toBe( '3' ); + } ); + + it( 'should use scroll snap length for total count', () => { + const mockContext = createMockContext( { + scrollSnaps: [ { index: 0 }, { index: 1 } ], + slideCount: 6, + } ); + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + + const result = storeConfig.callbacks.getTotalCount(); + + expect( result ).toBe( '2' ); + } ); + + it( 'should return accessible count label', () => { + const mockContext = createMockContext( { + selectedIndex: 1, + scrollSnaps: [ { index: 0 }, { index: 1 }, { index: 2 } ], + countLabelPattern: 'Slide {{currentSlide}} of {{totalSlides}}', + } ); + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + + const result = storeConfig.callbacks.getCountLabel(); + + expect( result ).toBe( 'Slide 2 of 3' ); + } ); + + it( 'should replace repeated placeholders in count label', () => { + const mockContext = createMockContext( { + selectedIndex: 1, + scrollSnaps: [ { index: 0 }, { index: 1 }, { index: 2 } ], + countLabelPattern: + 'Slide {{currentSlide}} of {{totalSlides}} ({{currentSlide}}/{{totalSlides}})', + } ); + ( getContext as jest.Mock ).mockReturnValue( mockContext ); + + const result = storeConfig.callbacks.getCountLabel(); + + expect( result ).toBe( 'Slide 2 of 3 (2/3)' ); + } ); + } ); + describe( 'getProgressBarStyle', () => { it( 'should return display:none when slideCount is 0', () => { const mockContext = createMockContext( { slideCount: 0 } ); @@ -699,7 +769,8 @@ describe( 'Carousel View Module', () => { it( 'should update announcement after a manual slide change', () => { const mockContext = createMockContext( { - announcementPattern: 'Slide {{currentSlide}} of {{totalSlides}}', + announcementPattern: + 'Slide {{currentSlide}} of {{totalSlides}} ({{currentSlide}}/{{totalSlides}})', selectedIndex: -1, } ); const { wrapper, viewport } = createMockCarouselDOM(); @@ -750,7 +821,7 @@ describe( 'Carousel View Module', () => { mockContext.shouldAnnounce = true; listeners.select?.(); - expect( mockContext.announcement ).toBe( 'Slide 2 of 5' ); + expect( mockContext.announcement ).toBe( 'Slide 2 of 5 (2/5)' ); expect( mockContext.shouldAnnounce ).toBe( false ); } finally { ( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver = diff --git a/src/blocks/carousel/__tests__/viewport-edit.test.tsx b/src/blocks/carousel/__tests__/viewport-edit.test.tsx new file mode 100644 index 0000000..ba51456 --- /dev/null +++ b/src/blocks/carousel/__tests__/viewport-edit.test.tsx @@ -0,0 +1,198 @@ +/** + * Unit tests for the carousel viewport editor appender. + * + * @package + */ + +import '@testing-library/jest-dom'; +import type { ReactNode } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { createBlock } from '@wordpress/blocks'; +import { useDispatch } from '@wordpress/data'; +import Edit from '../viewport/edit'; +import { EditorCarouselContext } from '../editor-context'; + +const mockInsertBlock = jest.fn(); + +jest.mock( '@wordpress/block-editor', () => ( { + useBlockProps: jest.fn( ( props = {} ) => props ), + useInnerBlocksProps: jest.fn( ( props = {}, options = {} ) => ( { + ...props, + children: options.renderAppender ? options.renderAppender() : null, + } ) ), + InspectorControls: jest.fn( ( { children }: { children: ReactNode } ) => children ), + BlockControls: jest.fn( ( { children }: { children: ReactNode } ) => children ), +} ) ); + +jest.mock( '@wordpress/components', () => { + const Button = ( { + children, + className, + onClick, + }: { + children?: ReactNode; + className?: string; + onClick?: () => void; + } ) => ( + + ); + + return { + Button, + PanelBody: ( { children }: { children: ReactNode } ) => children, + ToolbarButton: Button, + }; +} ); + +jest.mock( '@wordpress/compose', () => ( { + useMergeRefs: jest.fn( + ( refs: Array< ( ( node: HTMLDivElement | null ) => void ) | { current: HTMLDivElement | null } | undefined > ) => + ( node: HTMLDivElement | null ) => { + refs.forEach( ( ref ) => { + if ( typeof ref === 'function' ) { + ref( node ); + } else if ( ref ) { + ref.current = node; + } + } ); + }, + ), +} ) ); + +jest.mock( '@wordpress/data', () => ( { + useDispatch: jest.fn( () => ( { insertBlock: mockInsertBlock } ) ), + useSelect: jest.fn( ( selector ) => + selector( () => ( { + getBlocks: () => [], + getSelectedBlockClientId: () => null, + getBlockParents: () => [], + } ) ), + ), +} ) ); + +jest.mock( '@wordpress/blocks', () => ( { + createBlock: jest.fn( ( name: string, attributes = {}, innerBlocks = [] ) => ( { + name, + attributes, + innerBlocks, + } ) ), +} ) ); + +jest.mock( '@wordpress/icons', () => ( { + plus: 'plus', +} ) ); + +jest.mock( '../hooks/useCarouselObservers', () => ( { + useCarouselObservers: jest.fn(), +} ) ); + +const renderViewportEdit = () => + render( + + + , + ); + +describe( 'Carousel Viewport Edit', () => { + beforeEach( () => { + mockInsertBlock.mockClear(); + ( createBlock as jest.Mock ).mockClear(); + ( useDispatch as jest.Mock ).mockReturnValue( { + insertBlock: mockInsertBlock, + } ); + } ); + + it( 'offers manual and dynamic content insertion actions when empty', () => { + renderViewportEdit(); + + expect( screen.getAllByRole( 'button', { name: 'Add Slide' } ).length ).toBeGreaterThan( + 0, + ); + expect( + screen.getByRole( 'button', { name: 'Add Query Loop' } ), + ).toBeInTheDocument(); + expect( + screen.getByRole( 'button', { name: 'Add Terms Query' } ), + ).toBeInTheDocument(); + } ); + + it( 'keeps empty appender actions focusable without moving focus on render', () => { + const { container } = renderViewportEdit(); + const appenderActions = container.querySelectorAll( + '.rt-carousel-viewport-empty__actions button', + ); + const appender = container.querySelector( + '[data-rt-carousel-empty-appender="true"]', + ); + + expect( appender ).not.toBeNull(); + expect( appender?.tabIndex ).toBe( -1 ); + expect( appenderActions ).toHaveLength( 3 ); + appenderActions.forEach( ( action ) => { + expect( action.tabIndex ).not.toBeLessThan( 0 ); + expect( action ).not.toHaveFocus(); + } ); + } ); + + it( 'inserts a Query Loop directly inside the viewport', () => { + renderViewportEdit(); + + fireEvent.click( screen.getByRole( 'button', { name: 'Add Query Loop' } ) ); + + expect( createBlock ).toHaveBeenCalledWith( 'core/query' ); + expect( mockInsertBlock ).toHaveBeenCalledWith( + expect.objectContaining( { name: 'core/query' } ), + undefined, + 'viewport-client-id', + ); + } ); + + it( 'inserts a Terms Query directly inside the viewport', () => { + renderViewportEdit(); + + fireEvent.click( screen.getByRole( 'button', { name: 'Add Terms Query' } ) ); + + expect( createBlock ).toHaveBeenCalledWith( 'core/terms-query', { + termQuery: { + perPage: 10, + taxonomy: 'category', + order: 'asc', + orderBy: 'name', + include: [], + hideEmpty: false, + showNested: false, + inherit: false, + }, + } ); + expect( mockInsertBlock ).toHaveBeenCalledWith( + expect.objectContaining( { name: 'core/terms-query' } ), + undefined, + 'viewport-client-id', + ); + } ); +} ); diff --git a/src/blocks/carousel/components/TemplatePicker.tsx b/src/blocks/carousel/components/TemplatePicker.tsx new file mode 100644 index 0000000..117380d --- /dev/null +++ b/src/blocks/carousel/components/TemplatePicker.tsx @@ -0,0 +1,61 @@ +/** + * TemplatePicker — grid of slide template options shown during block setup. + * + * @package + */ + +import { __ } from '@wordpress/i18n'; +import { Button, Icon } from '@wordpress/components'; +import { useRef, useEffect } from '@wordpress/element'; +import type { SlideTemplate } from '../templates'; + +interface TemplatePickerProps { + templates: SlideTemplate[]; + onSelect: ( template: SlideTemplate ) => void; + onBack: () => void; +} + +export default function TemplatePicker( { + templates, + onSelect, + onBack, +}: TemplatePickerProps ) { + const gridRef = useRef< HTMLDivElement >( null ); + + useEffect( () => { + const firstButton = gridRef.current?.querySelector< HTMLButtonElement >( 'button' ); + firstButton?.focus(); + }, [] ); + + return ( +
+
+ { templates.map( ( template ) => ( + + ) ) } +
+ +
+ ); +} diff --git a/src/blocks/carousel/counter/block.json b/src/blocks/carousel/counter/block.json new file mode 100644 index 0000000..eb83908 --- /dev/null +++ b/src/blocks/carousel/counter/block.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "version": "1.0.0", + "name": "rt-carousel/carousel-counter", + "title": "Carousel Counter", + "category": "rt-carousel", + "icon": "editor-ol", + "ancestor": [ + "rt-carousel/carousel" + ], + "description": "Current slide counter 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/counter/edit.tsx b/src/blocks/carousel/counter/edit.tsx new file mode 100644 index 0000000..5447b7e --- /dev/null +++ b/src/blocks/carousel/counter/edit.tsx @@ -0,0 +1,31 @@ +import { __, sprintf } 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: 'rt-carousel-counter', + } ); + + const { selectedIndex, scrollSnaps, slideCount } = + useContext( EditorCarouselContext ); + const total = Math.max( scrollSnaps.length || slideCount, 1 ); + const current = Math.min( Math.max( selectedIndex + 1, 1 ), total ); + const label = sprintf( + /* translators: 1: current slide number, 2: total slide count. */ + __( 'Slide %1$d of %2$d', 'rt-carousel' ), + current, + total, + ); + + return ( +
+ { current } + + { total } +
+ ); +} diff --git a/src/blocks/carousel/counter/index.ts b/src/blocks/carousel/counter/index.ts new file mode 100644 index 0000000..90d7f18 --- /dev/null +++ b/src/blocks/carousel/counter/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 { CarouselCounterAttributes } from '../types'; +import './style.scss'; + +registerBlockType( metadata as BlockConfiguration, { + edit: Edit, + save: Save, +} ); diff --git a/src/blocks/carousel/counter/save.tsx b/src/blocks/carousel/counter/save.tsx new file mode 100644 index 0000000..9aa22aa --- /dev/null +++ b/src/blocks/carousel/counter/save.tsx @@ -0,0 +1,28 @@ +import { useBlockProps } from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; + +export default function Save() { + return ( +
+ + + +
+ ); +} diff --git a/src/blocks/carousel/counter/style.scss b/src/blocks/carousel/counter/style.scss new file mode 100644 index 0000000..5e6cf05 --- /dev/null +++ b/src/blocks/carousel/counter/style.scss @@ -0,0 +1,24 @@ +.rt-carousel-counter { + display: inline-flex; + align-items: baseline; + gap: var(--rt-carousel-counter-gap, 0); + color: var(--rt-carousel-counter-color, inherit); + font-size: var(--rt-carousel-counter-font-size, 1rem); + font-weight: var(--rt-carousel-counter-font-weight, 500); + line-height: 1; + white-space: nowrap; + + &__current { + color: var(--rt-carousel-counter-current-color, currentcolor); + } + + &__separator { + color: var(--rt-carousel-counter-separator-color, currentcolor); + opacity: var(--rt-carousel-counter-muted-opacity, 0.72); + } + + &__total { + color: var(--rt-carousel-counter-total-color, currentcolor); + opacity: var(--rt-carousel-counter-muted-opacity, 0.72); + } +} diff --git a/src/blocks/carousel/dynamic-list-selectors.ts b/src/blocks/carousel/dynamic-list-selectors.ts new file mode 100644 index 0000000..e870d9a --- /dev/null +++ b/src/blocks/carousel/dynamic-list-selectors.ts @@ -0,0 +1,8 @@ +export const DYNAMIC_LIST_CONTAINER_SELECTOR = + '.wp-block-post-template, .wp-block-term-template'; + +export const DYNAMIC_LIST_SLIDE_SELECTOR = '.wp-block-post, .wp-block-term'; + +export const CAROUSEL_CONTAINER_SELECTOR = `.embla__container, ${ DYNAMIC_LIST_CONTAINER_SELECTOR }`; + +export const CAROUSEL_SLIDE_SELECTOR = `.embla__slide, ${ DYNAMIC_LIST_SLIDE_SELECTOR }`; diff --git a/src/blocks/carousel/edit.tsx b/src/blocks/carousel/edit.tsx index 9edf641..f4f4be8 100644 --- a/src/blocks/carousel/edit.tsx +++ b/src/blocks/carousel/edit.tsx @@ -20,11 +20,15 @@ import { } from '@wordpress/components'; import { plus } from '@wordpress/icons'; import { useSelect, useDispatch } from '@wordpress/data'; -import { useState, useMemo, useCallback, useEffect } from '@wordpress/element'; +import { useState, useMemo, useCallback, useEffect, useRef } from '@wordpress/element'; import { createBlock, type BlockConfiguration } from '@wordpress/blocks'; import type { CarouselAttributes } from './types'; import { EditorCarouselContext } from './editor-context'; import type { EmblaCarouselType } from 'embla-carousel'; +import { getSlideTemplates, type SlideTemplate } from './templates'; +import TemplatePicker from './components/TemplatePicker'; + +type SetupStep = 'slide-count' | 'template'; export default function Edit( { attributes, @@ -55,10 +59,15 @@ export default function Edit( { const [ emblaApi, setEmblaApi ] = useState(); const [ canScrollPrev, setCanScrollPrev ] = useState( false ); const [ canScrollNext, setCanScrollNext ] = useState( false ); + const [ setupStep, setSetupStep ] = useState( 'slide-count' ); + const [ pendingSlideCount, setPendingSlideCount ] = useState( 0 ); const [ scrollProgress, setScrollProgress ] = useState( 0 ); const [ selectedIndex, setSelectedIndex ] = useState( 0 ); + const [ scrollSnaps, setScrollSnaps ] = useState( [] ); const [ slideCount, setSlideCount ] = useState( 0 ); + const slideTemplates = useMemo( getSlideTemplates, [ getSlideTemplates ] ); + const { replaceInnerBlocks, insertBlock } = useDispatch( 'core/block-editor' ); const hasInnerBlocks = useSelect( @@ -85,6 +94,58 @@ export default function Edit( { }, [ insertBlock, viewportClientId ] ); const showSetup = ! hasInnerBlocks; + const prevShowSetup = useRef( showSetup ); + const slideCountOptionsRef = useRef< HTMLDivElement >( null ); + const shouldRestoreSlideCountFocus = useRef( false ); + const shouldFocusEmptyViewport = useRef( false ); + + // Reset the setup flow when the placeholder reopens after all inner blocks are removed. + // When setup completes, focus the carousel block so focus stays in the canvas. + // Supports both iframed and non-iframed editors. + useEffect( () => { + if ( ! prevShowSetup.current && showSetup ) { + setSetupStep( 'slide-count' ); + setPendingSlideCount( 0 ); + } + + if ( prevShowSetup.current && ! showSetup ) { + if ( typeof document !== 'undefined' ) { + const iframe = document.querySelector< HTMLIFrameElement >( 'iframe[name="editor-canvas"]' ); + const blockNode = + iframe?.contentDocument?.getElementById( `block-${ clientId }` ) ?? + document.getElementById( `block-${ clientId }` ); + + if ( shouldFocusEmptyViewport.current ) { + blockNode + ?.querySelector< HTMLElement >( + '[data-rt-carousel-empty-appender="true"]', + ) + ?.focus(); + shouldFocusEmptyViewport.current = false; + } else { + blockNode?.focus(); + } + } + } + prevShowSetup.current = showSetup; + }, [ showSetup, clientId ] ); + + // After navigating back from template step, restore focus to first slide-count button. + useEffect( () => { + if ( ! showSetup || setupStep !== 'slide-count' || ! shouldRestoreSlideCountFocus.current ) { + return; + } + + const rafId = requestAnimationFrame( () => { + const firstBtn = slideCountOptionsRef.current?.querySelector< HTMLButtonElement >( + 'button', + ); + firstBtn?.focus(); + shouldRestoreSlideCountFocus.current = false; + } ); + + return () => cancelAnimationFrame( rafId ); + }, [ showSetup, setupStep ] ); // Fetch registered block types for the allowed-blocks token field const blockTypes = useSelect( ( select ) => { @@ -132,6 +193,7 @@ export default function Edit( { scrollProgress, setScrollProgress, selectedIndex, + scrollSnaps, slideCount, carouselOptions, } ), @@ -141,6 +203,7 @@ export default function Edit( { canScrollNext, scrollProgress, selectedIndex, + scrollSnaps, slideCount, carouselOptions, setEmblaApi, @@ -162,6 +225,7 @@ export default function Edit( { const updateState = () => { setSelectedIndex( emblaApi.selectedScrollSnap() ); + setScrollSnaps( emblaApi.scrollSnapList() ); setSlideCount( emblaApi.slideNodes().length ); updateScrollProgress(); }; @@ -193,20 +257,40 @@ export default function Edit( { }, [ createBlock( 'rt-carousel/carousel-controls', {} ), + createBlock( 'rt-carousel/carousel-counter', {} ), createBlock( 'rt-carousel/carousel-dots', {} ), ], ); - const handleSetup = ( slideCount: number ) => { - const slides = Array.from( { length: slideCount }, () => - createBlock( 'rt-carousel/carousel-slide', {}, [ - createBlock( 'core/paragraph', {} ), - ] ), - ); + /** + * Handle the initial setup of the carousel block + * + * @param {number} count - The number of slides selected by the user. + */ + const handleSlideCountPicked = ( count: number ) => { + setPendingSlideCount( count ); + setSetupStep( 'template' ); + }; + + /** + * Handle the selection of a slide template during setup. + * + * @param {SlideTemplate} template - The slide template selected by the user. + */ + const handleTemplateSelected = ( template: SlideTemplate ) => { + // Query Loop goes directly inside the viewport; regular templates get slide wrappers. + const viewportChildren = template.isQueryLoop + ? [ createBlock( 'core/query', {}, [] ) ] + : Array.from( { length: Math.max( pendingSlideCount, 1 ) }, () => + createBlock( 'rt-carousel/carousel-slide', {}, template.innerBlocks() ), + ); replaceInnerBlocks( clientId, - [ createBlock( 'rt-carousel/carousel-viewport', {}, slides ), createNavGroup() ], + [ + createBlock( 'rt-carousel/carousel-viewport', {}, viewportChildren ), + createNavGroup(), + ], false, ); }; @@ -215,6 +299,7 @@ export default function Edit( { * Skip — still creates the correct structure, just without slides. */ const handleSkip = () => { + shouldFocusEmptyViewport.current = true; replaceInnerBlocks( clientId, [ createBlock( 'rt-carousel/carousel-viewport', {} ), createNavGroup() ], @@ -435,30 +520,51 @@ export default function Edit( { -
- { [ 1, 2, 3, 4 ].map( ( count ) => ( + { setupStep === 'slide-count' && ( + <> +
+ { [ 1, 2, 3, 4 ].map( ( count ) => ( + + ) ) } +
- ) ) } -
- + + ) } + { setupStep === 'template' && ( + { + shouldRestoreSlideCountFocus.current = true; + setSetupStep( 'slide-count' ); + } } + /> + ) }
diff --git a/src/blocks/carousel/editor-context.ts b/src/blocks/carousel/editor-context.ts index 2daf3fe..6e705ed 100644 --- a/src/blocks/carousel/editor-context.ts +++ b/src/blocks/carousel/editor-context.ts @@ -12,6 +12,7 @@ export type EditorCarouselContextType = { scrollProgress: number; setScrollProgress: ( value: number ) => void; selectedIndex: number; + scrollSnaps: number[]; slideCount: number; carouselOptions: Omit, 'slidesToScroll'> & { slidesToScroll?: number | string; @@ -29,6 +30,7 @@ const defaultValue: EditorCarouselContextType = { scrollProgress: 0, setScrollProgress: () => {}, selectedIndex: 0, + scrollSnaps: [], slideCount: 0, carouselOptions: {}, }; diff --git a/src/blocks/carousel/editor.scss b/src/blocks/carousel/editor.scss index 17dbac6..8d08f8c 100644 --- a/src/blocks/carousel/editor.scss +++ b/src/blocks/carousel/editor.scss @@ -2,22 +2,122 @@ * Editor-only styles for Carousel */ +.rt-carousel { + // Border colors + --rt-carousel-border-default: #ddd; + --rt-carousel-border-dashed: #ccc; + + // Background colors + --rt-carousel-bg-white: #fff; + --rt-carousel-bg-icon: #f0f0f0; + + // Text colors + --rt-carousel-text-primary: #1e1e1e; + --rt-carousel-text-muted: #757575; + + // 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 ──────────────────────────────────────────────────────────── .rt-carousel-setup { - .rt-carousel-setup__options { + &__options { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; } - .rt-carousel-setup__option { + &__option { min-width: 80px; justify-content: center; } - .rt-carousel-setup__skip { + &__skip { + display: block; + margin-top: 4px; + } +} + +// ── Template picker ────────────────────────────────────────────────────────── +.rt-carousel-template-picker { + width: 100%; + + &__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; + margin-bottom: 12px; + } + + &__item { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 16px 12px; + border: 1px solid var(--rt-carousel-border-default); + border-radius: 4px; + background: var(--rt-carousel-bg-white); + cursor: pointer; + text-align: center; + transition: + border-color 0.15s, + box-shadow 0.15s; + + &:hover, + &:focus-visible { + border-color: var(--rt-carousel-accent); + box-shadow: 0 0 0 1px var(--rt-carousel-accent); + outline: none; + } + } + + &__icon { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--rt-carousel-bg-icon); + color: var(--rt-carousel-text-primary); + } + + &__label { + font-weight: 600; + font-size: 13px; + line-height: 1.3; + } + + &__description { + font-size: 12px; + color: var(--rt-carousel-text-muted); + line-height: 1.4; + } + + &__back { display: block; margin-top: 4px; } @@ -32,9 +132,16 @@ justify-content: center; align-items: center; padding: 2rem; - border: 1px dashed #ccc; + border: 1px dashed var(--rt-carousel-border-dashed); border-radius: 2px; box-sizing: border-box; + + &__actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; + } } // Give slides a minimum visual height in the editor so they are @@ -42,26 +149,3 @@ .rt-carousel .embla__slide { min-height: 200px; } - -.rt-carousel { - - /* Ensure selectable area */ - padding: 0.625rem; - border: 1px dashed #ccc; - box-sizing: border-box; - - &.is-selected { - border-color: var(--wp-admin-theme-color); - } - - /* Add dashed border in editor to make it visible if empty */ - &.is-empty { - border: 1px dashed #ccc; - min-height: 50px; - } - - // Vertical Axis adjustments in editor - &[data-axis="y"] .embla__container { - height: 100% !important; - } -} diff --git a/src/blocks/carousel/embla-options.ts b/src/blocks/carousel/embla-options.ts new file mode 100644 index 0000000..f9a6133 --- /dev/null +++ b/src/blocks/carousel/embla-options.ts @@ -0,0 +1,15 @@ +import type { EmblaOptionsType } from 'embla-carousel'; + +export const normalizeContainScroll = ( + value: unknown, +): NonNullable => { + if ( value === 'trimSnaps' || value === 'keepSnaps' ) { + return value; + } + + if ( value === '' ) { + return false; + } + + return 'trimSnaps'; +}; diff --git a/src/blocks/carousel/hooks/useCarouselObservers.ts b/src/blocks/carousel/hooks/useCarouselObservers.ts index 7cfef8d..fb85076 100644 --- a/src/blocks/carousel/hooks/useCarouselObservers.ts +++ b/src/blocks/carousel/hooks/useCarouselObservers.ts @@ -1,20 +1,25 @@ import { useEffect } from '@wordpress/element'; import type { EmblaCarouselType } from 'embla-carousel'; +import { + CAROUSEL_CONTAINER_SELECTOR, + CAROUSEL_SLIDE_SELECTOR, + DYNAMIC_LIST_CONTAINER_SELECTOR, +} from '../dynamic-list-selectors'; const RESIZE_DEBOUNCE_MS = 200; const MUTATION_DEBOUNCE_MS = 150; /** - * Unified observer hook that handles both resize detection and Query Loop + * Unified observer hook that handles both resize detection and dynamic list * 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. + * **Dynamic list detection** (slide count changes): + * Uses full destroy/recreate via `initEmblaRef` because Query Loop and Terms + * Query changes can replace the template element or swap out its children. * Embla caches references to container and slide elements, so when those DOM * nodes are replaced, a fresh instance is required. * @@ -78,8 +83,9 @@ 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( CAROUSEL_CONTAINER_SELECTOR ); + const firstSlide = + container?.querySelector( CAROUSEL_SLIDE_SELECTOR ) ?? null; if ( firstSlide === observedSlide ) { return; @@ -97,9 +103,13 @@ export function useCarouselObservers( } }; - const checkQueryLoopChanges = (): boolean => { - const postTemplate = viewportEl.querySelector( '.wp-block-post-template' ); - const currentCount = postTemplate ? postTemplate.children.length : 0; + const checkDynamicListChanges = (): boolean => { + const dynamicListTemplate = viewportEl.querySelector( + DYNAMIC_LIST_CONTAINER_SELECTOR, + ); + const currentCount = dynamicListTemplate + ? dynamicListTemplate.children.length + : 0; const changed = currentCount !== lastSlideCount; lastSlideCount = currentCount; @@ -115,7 +125,7 @@ export function useCarouselObservers( }; const processMutations = () => { - const needsFullInit = checkQueryLoopChanges(); + const needsFullInit = checkDynamicListChanges(); if ( needsFullInit ) { clearTimeout( resizeTimer ); @@ -142,7 +152,9 @@ 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( + DYNAMIC_LIST_CONTAINER_SELECTOR, + ); lastSlideCount = initialTemplate ? initialTemplate.children.length : 0; updateSlideObservation(); diff --git a/src/blocks/carousel/index.ts b/src/blocks/carousel/index.ts index 5a9db43..4e72b51 100644 --- a/src/blocks/carousel/index.ts +++ b/src/blocks/carousel/index.ts @@ -15,7 +15,9 @@ import { __ } from '@wordpress/i18n'; registerBlockType( metadata as BlockConfiguration, { edit: Edit, save: Save, - deprecated, + deprecated: deprecated as unknown as NonNullable< + BlockConfiguration[ 'deprecated' ] + >, } ); const styles = [ diff --git a/src/blocks/carousel/save.tsx b/src/blocks/carousel/save.tsx index 907cc4d..73ef6ca 100644 --- a/src/blocks/carousel/save.tsx +++ b/src/blocks/carousel/save.tsx @@ -52,6 +52,11 @@ export default function Save( { slideCount: 0, /* translators: %d: slide number */ ariaLabelPattern: __( 'Go to slide %d', 'rt-carousel' ), + /* translators: {{currentSlide}}: current slide number, {{totalSlides}}: total slide count. */ + countLabelPattern: __( + 'Slide {{currentSlide}} of {{totalSlides}}', + 'rt-carousel', + ), announcement: '', shouldAnnounce: false, /* translators: {{currentSlide}}: current slide number, {{totalSlides}}: total slide count. */ diff --git a/src/blocks/carousel/styles/_core.scss b/src/blocks/carousel/styles/_core.scss index 159ffbb..1305655 100644 --- a/src/blocks/carousel/styles/_core.scss +++ b/src/blocks/carousel/styles/_core.scss @@ -13,9 +13,10 @@ overflow: hidden; } -/* Ensure the default container and Query Loop list are flex rows */ +/* Ensure the default container and dynamic query lists are flex rows */ :where(.rt-carousel) .embla__container, -:where(.rt-carousel) .embla .wp-block-post-template { +:where(.rt-carousel) .embla .wp-block-post-template, +:where(.rt-carousel) .embla .wp-block-term-template { display: flex; flex-wrap: nowrap; width: 100%; @@ -25,15 +26,17 @@ gap: var(--rt-carousel-gap, 0); } -/* Ensure intermediate wrappers (like wp-block-query) don't shrink */ -:where(.rt-carousel) .embla .wp-block-query { +/* Ensure intermediate wrappers don't shrink */ +:where(.rt-carousel) .embla .wp-block-query, +:where(.rt-carousel) .embla .wp-block-terms-query { width: 100%; min-width: 100%; } -/* Force slides (including posts) to respect a configurable width variable */ +/* Force slides to respect a configurable width variable */ :where(.rt-carousel) .embla__slide, -:where(.rt-carousel) .embla .wp-block-post-template li { +:where(.rt-carousel) .embla .wp-block-post-template li, +:where(.rt-carousel) .embla .wp-block-term-template li { flex: 0 0 var(--rt-carousel-slide-width, 100%); max-width: var(--rt-carousel-slide-width, 100%); min-width: 0; @@ -72,12 +75,14 @@ * We switch to margin for consistent spacing in loop mode. */ :where(.rt-carousel[data-loop="true"]) .embla__container, -:where(.rt-carousel[data-loop="true"]) .embla .wp-block-post-template { +:where(.rt-carousel[data-loop="true"]) .embla .wp-block-post-template, +:where(.rt-carousel[data-loop="true"]) .embla .wp-block-term-template { gap: 0; } :where(.rt-carousel[data-loop="true"]) .embla__slide, -:where(.rt-carousel[data-loop="true"]) .embla .wp-block-post-template li { +:where(.rt-carousel[data-loop="true"]) .embla .wp-block-post-template li, +:where(.rt-carousel[data-loop="true"]) .embla .wp-block-term-template li { margin-inline-end: var(--rt-carousel-gap, 0); } @@ -86,25 +91,29 @@ height: var(--rt-carousel-height); } -:where(.rt-carousel[data-axis="y"]) .embla .wp-block-query { +:where(.rt-carousel[data-axis="y"]) .embla .wp-block-query, +:where(.rt-carousel[data-axis="y"]) .embla .wp-block-terms-query { height: 100%; } :where(.rt-carousel[data-axis="y"]) .embla__container, -:where(.rt-carousel[data-axis="y"]) .embla .wp-block-post-template { +:where(.rt-carousel[data-axis="y"]) .embla .wp-block-post-template, +:where(.rt-carousel[data-axis="y"]) .embla .wp-block-term-template { flex-direction: column; height: 100%; min-height: 100%; } :where(.rt-carousel[data-axis="y"]) .embla__slide, -:where(.rt-carousel[data-axis="y"]) .embla .wp-block-post-template li { +:where(.rt-carousel[data-axis="y"]) .embla .wp-block-post-template li, +:where(.rt-carousel[data-axis="y"]) .embla .wp-block-term-template li { margin-inline-end: 0; } /* Vertical + Loop specific */ :where(.rt-carousel[data-axis="y"][data-loop="true"]) .embla__slide, -:where(.rt-carousel[data-axis="y"][data-loop="true"]) .embla .wp-block-post-template li { +:where(.rt-carousel[data-axis="y"][data-loop="true"]) .embla .wp-block-post-template li, +:where(.rt-carousel[data-axis="y"][data-loop="true"]) .embla .wp-block-term-template li { max-height: var(--rt-carousel-slide-width, 100%); margin-block-end: var(--rt-carousel-gap, 0); } diff --git a/src/blocks/carousel/styles/_variants.scss b/src/blocks/carousel/styles/_variants.scss index ed1ef25..b57da16 100644 --- a/src/blocks/carousel/styles/_variants.scss +++ b/src/blocks/carousel/styles/_variants.scss @@ -4,18 +4,21 @@ /* 2 Columns */ :where(.rt-carousel).is-style-columns-2, -:where(.rt-carousel) .embla .wp-block-post-template.columns-2 { +:where(.rt-carousel) .embla .wp-block-post-template.columns-2, +:where(.rt-carousel) .embla .wp-block-term-template.columns-2 { --rt-carousel-slide-width: 50%; } /* 3 Columns */ :where(.rt-carousel).is-style-columns-3, -:where(.rt-carousel) .embla .wp-block-post-template.columns-3 { +:where(.rt-carousel) .embla .wp-block-post-template.columns-3, +:where(.rt-carousel) .embla .wp-block-term-template.columns-3 { --rt-carousel-slide-width: 33.333%; } /* 4 Columns */ :where(.rt-carousel).is-style-columns-4, -:where(.rt-carousel) .embla .wp-block-post-template.columns-4 { +:where(.rt-carousel) .embla .wp-block-post-template.columns-4, +:where(.rt-carousel) .embla .wp-block-term-template.columns-4 { --rt-carousel-slide-width: 25%; } diff --git a/src/blocks/carousel/templates.ts b/src/blocks/carousel/templates.ts new file mode 100644 index 0000000..e7c75a4 --- /dev/null +++ b/src/blocks/carousel/templates.ts @@ -0,0 +1,195 @@ +/** + * Slide template definitions for the Carousel block. + * + * Developers can register additional templates via the + * `rtcamp.carouselKit.slideTemplates` WordPress filter (applied with `applyFilters`). + * + * @package + */ + +import { createBlock, type BlockInstance } from '@wordpress/blocks'; +import { type IconType } from '@wordpress/components'; +import { applyFilters } from '@wordpress/hooks'; +import { __ } from '@wordpress/i18n'; +import { columns, image, layout, gallery, post } from '@wordpress/icons'; + +export interface SlideTemplate { + /** Unique machine-readable name. */ + name: string; + /** Human-readable title shown in the picker. */ + label: string; + /** Short description shown below the label. */ + description: string; + /** WordPress icon component used in the picker. Accepts any value supported by `` from `@wordpress/components`. */ + icon: IconType; + /** + * Whether this template uses a Query Loop instead of individual slides. + * When true, `slideCount` is ignored and a `core/query` block is placed + * directly inside the carousel viewport. + */ + isQueryLoop?: boolean; + /** + * Build the inner blocks for a single slide. + * Called once per slide (or not at all for Query Loop templates). + */ + innerBlocks: () => BlockInstance[]; +} + +// ── Default templates ──────────────────────────────────────────────────────── + +const textSlide: SlideTemplate = { + name: 'text', + label: __( 'Text Slides', 'rt-carousel' ), + description: __( 'Slides starting with a paragraph you can replace or extend.', 'rt-carousel' ), + icon: columns, + innerBlocks: () => [ createBlock( 'core/paragraph', {} ) ], +}; + +const imageSlide: SlideTemplate = { + name: 'image', + label: __( 'Image Slides', 'rt-carousel' ), + description: __( 'Slides prefilled with an image block.', 'rt-carousel' ), + icon: image, + innerBlocks: () => [ createBlock( 'core/image', {} ) ], +}; + +const heroSlide: SlideTemplate = { + name: 'hero', + label: __( 'Image + Heading + Text + CTA', 'rt-carousel' ), + description: __( 'Marketing slider with heading, paragraph, and button.', 'rt-carousel' ), + icon: layout, + innerBlocks: () => [ + createBlock( 'core/cover', {}, [ + createBlock( 'core/heading', { + level: 2, + placeholder: __( 'Slide Heading', 'rt-carousel' ), + } ), + createBlock( 'core/paragraph', { + placeholder: __( 'Slide description text…', 'rt-carousel' ), + } ), + createBlock( 'core/buttons', {}, [ + createBlock( 'core/button', {} ), + ] ), + ] ), + ], +}; + +const imageCaptionSlide: SlideTemplate = { + name: 'image-caption', + label: __( 'Image + Caption', 'rt-carousel' ), + description: __( 'Image with supporting text below.', 'rt-carousel' ), + icon: gallery, + innerBlocks: () => [ + createBlock( 'core/image', {} ), + createBlock( 'core/paragraph', { + placeholder: __( 'Caption text…', 'rt-carousel' ), + } ), + ], +}; + +const queryLoopSlide: SlideTemplate = { + name: 'query-loop', + label: __( 'Query Loop Slides', 'rt-carousel' ), + description: __( 'Dynamically generate slides from posts.', 'rt-carousel' ), + icon: post, + isQueryLoop: true, + innerBlocks: () => [], // Not used — Query Loop is handled specially. +}; + +const DEFAULT_TEMPLATES: SlideTemplate[] = [ + textSlide, + imageSlide, + heroSlide, + imageCaptionSlide, + queryLoopSlide, +]; + +function getDefaultTemplates(): SlideTemplate[] { + return DEFAULT_TEMPLATES.map( ( template ) => ( { + ...template, + } ) ); +} + +/** + * Retrieve all available slide templates. + * + * External code can add templates via: + * + * ```js + * import { addFilter } from '@wordpress/hooks'; + * + * addFilter( + * 'rtcamp.carouselKit.slideTemplates', + * 'my-plugin/custom-templates', + * ( templates ) => [ + * ...templates, + * { + * name: 'testimonial', + * label: 'Testimonial', + * description: 'Quote with author name.', + * icon: 'format-quote', + * innerBlocks: () => [ + * createBlock( 'core/quote', {} ), + * createBlock( 'core/paragraph', { placeholder: '— Author' } ), + * ], + * }, + * ], + * ); + * ``` + */ +export function getSlideTemplates(): SlideTemplate[] { + const defaultTemplates = getDefaultTemplates(); + const templates = applyFilters( + 'rtcamp.carouselKit.slideTemplates', + defaultTemplates, + ); + + if ( Array.isArray( templates ) ) { + const valid = ( templates as unknown[] ).filter( ( t ): t is SlideTemplate => { + if ( + t !== null && + t !== undefined && + typeof t === 'object' && + typeof ( t as SlideTemplate ).name === 'string' && + typeof ( t as SlideTemplate ).label === 'string' && + typeof ( t as SlideTemplate ).description === 'string' && + ( t as SlideTemplate ).icon !== undefined && + ( t as SlideTemplate ).icon !== null && + typeof ( t as SlideTemplate ).innerBlocks === 'function' + ) { + return true; + } + // eslint-disable-next-line no-console + console.warn( + 'rtcamp.carouselKit.slideTemplates: dropping invalid template entry.', + t, + ); + return false; + } ); + + // De-duplicate by name to prevent React key collisions from filter callbacks. + const seenNames = new Set< string >(); + const deduped = valid.filter( ( template ) => { + if ( seenNames.has( template.name ) ) { + // eslint-disable-next-line no-console + console.warn( + `rtcamp.carouselKit.slideTemplates: dropping duplicate template name "${ template.name }".`, + template, + ); + return false; + } + seenNames.add( template.name ); + return true; + } ); + + return deduped; + } + + // eslint-disable-next-line no-console + console.warn( + 'rtcamp.carouselKit.slideTemplates filter returned a non-array value. Falling back to default slide templates.', + templates, + ); + + return defaultTemplates; +} diff --git a/src/blocks/carousel/types.ts b/src/blocks/carousel/types.ts index d140a73..e05351b 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 CarouselCounterAttributes = Record; /** * Typed subset of the block editor store selectors used in this plugin. @@ -57,6 +58,7 @@ export type CarouselContext = { canScrollNext: boolean; scrollProgress: number; ariaLabelPattern: string; + countLabelPattern?: string; announcement?: string; announcementPattern?: string; shouldAnnounce?: boolean; diff --git a/src/blocks/carousel/view.ts b/src/blocks/carousel/view.ts index 25f80c9..c2ae8e4 100644 --- a/src/blocks/carousel/view.ts +++ b/src/blocks/carousel/view.ts @@ -5,6 +5,11 @@ import EmblaCarousel, { } from 'embla-carousel'; import Autoplay, { type AutoplayOptionsType } from 'embla-carousel-autoplay'; import type { CarouselContext } from './types'; +import { + DYNAMIC_LIST_CONTAINER_SELECTOR, + CAROUSEL_SLIDE_SELECTOR, +} from './dynamic-list-selectors'; +import { normalizeContainScroll } from './embla-options'; type ElementWithRef = { ref?: HTMLElement | null; @@ -56,6 +61,29 @@ const getProgress = (): number => { return Math.max( 0, Math.min( 1, scrollProgress || 0 ) ); }; +const getSnapCount = ( context: CarouselContext ): number => { + return Math.max( context.scrollSnaps?.length || context.slideCount || 0, 1 ); +}; + +const getCurrentSnap = ( context: CarouselContext ): number => { + return Math.min( + Math.max( ( context.selectedIndex || 0 ) + 1, 1 ), + getSnapCount( context ), + ); +}; + +const interpolateSlidePattern = ( + pattern: string, + currentSlide: string, + totalSlides: string, +): string => { + return pattern + .split( '{{currentSlide}}' ) + .join( currentSlide ) + .split( '{{totalSlides}}' ) + .join( totalSlides ); +}; + const getSlideAnnouncement = ( context: CarouselContext, selectedIndex: number, @@ -64,9 +92,11 @@ const getSlideAnnouncement = ( if ( ! slideCount || slideCount <= 1 || ! context.announcementPattern ) { return ''; } - return context.announcementPattern - .replace( '{{currentSlide}}', ( selectedIndex + 1 ).toString() ) - .replace( '{{totalSlides}}', slideCount.toString() ); + return interpolateSlidePattern( + context.announcementPattern, + ( selectedIndex + 1 ).toString(), + slideCount.toString(), + ); }; const updateSlideAnnouncement = ( @@ -157,9 +187,9 @@ store( 'rt-carousel/carousel', { return false; } - // Check for either standard slide or Query Loop post + // Check for either standard slide or dynamic Query item. const slide = getElementRef( getElement() )?.closest?.( - '.embla__slide, .wp-block-post', + CAROUSEL_SLIDE_SELECTOR, ); if ( ! slide || ! slide.parentElement ) { @@ -167,9 +197,7 @@ store( 'rt-carousel/carousel', { } const slides = Array.from( slide.parentElement.children ).filter( - ( child: Element ) => - child.classList?.contains( 'embla__slide' ) || - child.classList?.contains( 'wp-block-post' ), + ( child: Element ) => child.matches( CAROUSEL_SLIDE_SELECTOR ), ); const index = slides.indexOf( slide ); @@ -193,6 +221,22 @@ store( 'rt-carousel/carousel', { const index = ( snap?.index || 0 ) + 1; return context.ariaLabelPattern.replace( '%d', index.toString() ); }, + getCurrentCount: () => { + return getCurrentSnap( getContext() ).toString(); + }, + getTotalCount: () => { + return getSnapCount( getContext() ).toString(); + }, + getCountLabel: () => { + const context = getContext(); + const current = getCurrentSnap( context ).toString(); + const total = getSnapCount( context ).toString(); + return interpolateSlidePattern( + context.countLabelPattern || 'Slide {{currentSlide}} of {{totalSlides}}', + current, + total, + ); + }, getProgressBarNow: () => { return Math.round( getProgress() * 100 ); }, @@ -214,7 +258,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,8 +266,8 @@ store( 'rt-carousel/carousel', { return; } - const queryLoopContainer = viewport.querySelector( - '.wp-block-post-template', + const dynamicListContainer = viewport.querySelector( + DYNAMIC_LIST_CONTAINER_SELECTOR, ); const startEmbla = () => { @@ -235,12 +279,6 @@ 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 direction = [ 'ltr', 'rtl' ].includes( rawOptions.direction as string, ) @@ -260,10 +298,10 @@ store( 'rt-carousel/carousel', { const options: EmblaOptionsType = { ...rawOptions, align, - containScroll, + containScroll: normalizeContainScroll( rawOptions.containScroll ), direction, slidesToScroll, - container: queryLoopContainer || null, + container: dynamicListContainer || null, }; const plugins = []; @@ -272,11 +310,7 @@ store( 'rt-carousel/carousel', { plugins.push( Autoplay( context.autoplay as AutoplayOptionsType ) ); } - const embla = EmblaCarousel( - viewport as HTMLElement, - options, - plugins, - ); + const embla = EmblaCarousel( viewport, options, plugins ); emblaInstances.set( viewport, embla ); viewport[ EMBLA_KEY ] = embla; @@ -347,7 +381,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; diff --git a/src/blocks/carousel/viewport/edit.tsx b/src/blocks/carousel/viewport/edit.tsx index 2b4a132..1e1f3be 100644 --- a/src/blocks/carousel/viewport/edit.tsx +++ b/src/blocks/carousel/viewport/edit.tsx @@ -15,6 +15,8 @@ import { useMergeRefs } from '@wordpress/compose'; import { EditorCarouselContext } from '../editor-context'; import EmblaCarousel, { type EmblaCarouselType } from 'embla-carousel'; import { useCarouselObservers } from '../hooks/useCarouselObservers'; +import { DYNAMIC_LIST_CONTAINER_SELECTOR } from '../dynamic-list-selectors'; +import { normalizeContainScroll } from '../embla-options'; const EMBLA_KEY = Symbol.for( 'carousel-system.carousel' ); @@ -28,13 +30,6 @@ export default function Edit( { EditorCarouselContext, ); - const blockProps = useBlockProps( { - className: 'embla', - style: { - height: carouselOptions?.axis === 'y' ? carouselOptions?.height : undefined, - }, - } ); - /** * Single store subscription for slide count, IDs, and which slide (if any) * is currently selected — including nested child-block selection. @@ -66,6 +61,13 @@ export default function Edit( { const hasSlides = slideCount > 0; + const blockProps = useBlockProps( { + className: 'embla', + style: { + height: carouselOptions?.axis === 'y' ? carouselOptions?.height : undefined, + }, + } ); + const emblaRef = useRef( null ); const emblaApiRef = useRef(); const initEmblaRef = useRef<() => void>(); @@ -93,15 +95,48 @@ export default function Edit( { insertBlock( block, undefined, clientId ); }, [ insertBlock, clientId ] ); + const addQueryLoop = useCallback( () => { + const block = createBlock( 'core/query' ); + insertBlock( block, undefined, clientId ); + }, [ insertBlock, clientId ] ); + + const addTermsQuery = useCallback( () => { + const block = createBlock( 'core/terms-query', { + termQuery: { + perPage: 10, + taxonomy: 'category', + order: 'asc', + orderBy: 'name', + include: [], + hideEmpty: false, + showNested: false, + inherit: false, + }, + } ); + insertBlock( block, undefined, clientId ); + }, [ insertBlock, clientId ] ); + const EmptyAppender = useCallback( () => ( -
- +
+
+ + + +
), - [ addSlide ], + [ addSlide, addQueryLoop, addTermsQuery ], ); const innerBlocksProps = useInnerBlocksProps( @@ -115,7 +150,7 @@ export default function Edit( { }, { orientation: carouselOptions?.axis === 'y' ? 'vertical' : 'horizontal', - allowedBlocks: [ 'rt-carousel/carousel-slide', 'core/query' ], + allowedBlocks: [ 'rt-carousel/carousel-slide', 'core/query', 'core/terms-query' ], renderAppender: ! hasSlides ? EmptyAppender : undefined, }, ); @@ -169,8 +204,8 @@ export default function Edit( { embla.destroy(); } - const queryLoopContainer = viewport.querySelector( - '.wp-block-post-template', + const dynamicListContainer = viewport.querySelector( + DYNAMIC_LIST_CONTAINER_SELECTOR, ) as HTMLElement; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -179,12 +214,12 @@ export default function Edit( { embla = EmblaCarousel( viewport, { loop: options?.loop ?? false, dragFree: options?.dragFree ?? false, - containScroll: options?.containScroll || 'trimSnaps', + containScroll: normalizeContainScroll( options?.containScroll ), axis: options?.axis || 'x', align: options?.align || 'start', direction: options?.direction || 'ltr', slidesToScroll: options?.slidesToScroll || 1, - container: queryLoopContainer || undefined, + container: dynamicListContainer || undefined, watchDrag: false, // Clicks in slide gaps must not trigger Embla scroll in the editor. watchSlides: false, // Gutenberg injects block UI nodes into .embla__container; Embla's built-in MutationObserver would call reInit() on those, corrupting slide order and transforms. watchResize: false, // Replaced by a manual debounced ResizeObserver in useCarouselObservers. diff --git a/tests/php/Unit/PluginTest.php b/tests/php/Unit/PluginTest.php index 2477243..f299dd7 100644 --- a/tests/php/Unit/PluginTest.php +++ b/tests/php/Unit/PluginTest.php @@ -4,7 +4,7 @@ * * Tests cover: * - Block category registration - * - Block registration (all 5 carousel blocks) + * - Block registration (all carousel blocks) * - Pattern category registration * - Block pattern registration and caching * - Error handling and edge cases @@ -34,6 +34,7 @@ class PluginTest extends UnitTestCase { private const EXPECTED_BLOCKS = [ 'carousel', 'carousel/controls', + 'carousel/counter', 'carousel/dots', 'carousel/progress', 'carousel/viewport', @@ -150,7 +151,7 @@ function ( string $path ) use ( &$registered_blocks ): void { $instance = $this->getPluginInstance(); $this->invokeMethod( $instance, 'register_blocks' ); - $this->assertCount( 6, $registered_blocks ); + $this->assertCount( 7, $registered_blocks ); // Verify each expected block is registered foreach ( self::EXPECTED_BLOCKS as $block ) { @@ -173,7 +174,7 @@ function ( string $path ) use ( &$registered_blocks ): void { public function test_register_blocks_handles_missing_build_path(): void { // The actual behavior check: register_block_type should be called // for each block when the constant is defined (as it is in our tests). - Functions\expect( 'register_block_type' )->times( 6 ); + Functions\expect( 'register_block_type' )->times( 7 ); $instance = $this->getPluginInstance(); $this->invokeMethod( $instance, 'register_blocks' );