From 6e7034d57f9dbe78daa26dad40e46c1e3362af9c Mon Sep 17 00:00:00 2001 From: Danish Shakeel Date: Sat, 18 Jul 2026 02:22:57 +0200 Subject: [PATCH 01/14] docs: add carousel-as-tabs design spec --- .../2026-07-18-carousel-as-tabs-design.md | 278 ++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-18-carousel-as-tabs-design.md diff --git a/docs/superpowers/specs/2026-07-18-carousel-as-tabs-design.md b/docs/superpowers/specs/2026-07-18-carousel-as-tabs-design.md new file mode 100644 index 0000000..2454442 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-carousel-as-tabs-design.md @@ -0,0 +1,278 @@ +# Carousel as Tabs — Design Spec + +**Date:** 2026-07-18 +**Branch:** `feat/carousel-as-tabs` +**Status:** Approved + +--- + +## Overview + +Add a "Use as Tabs" mode to the existing `rt-carousel/carousel` block. When enabled, the carousel becomes a semantically correct tab widget (WAI-ARIA Tabs pattern, option b — Embla engine retained, panels shown/hidden rather than scrolled). A new `rt-carousel/carousel-tab-list` block provides the tab navigation UI, ported from `vertex-blocks/key-features-tabs-dots` with customisable active-tab colours. + +--- + +## 1. Data Model + +### 1.1 New carousel attribute + +Add to `src/blocks/carousel/block.json`: + +```json +"useTabs": { "type": "boolean", "default": false } +``` + +Add to `providesContext`: + +```json +"rt-carousel/carousel/useTabs": "useTabs" +``` + +### 1.2 Attributes disabled when `useTabs = true` + +These are hidden in the inspector and reset to defaults on toggle-ON: + +| Attribute | Reset value | +|-----------|-------------| +| `loop` | `false` | +| `dragFree` | `false` | +| `carouselAlign` | `"start"` | +| `containScroll` | `"trimSnaps"` | +| `slidesToScroll` | `"1"` | +| `autoplay` | `false` | +| `axis` | `"x"` | + +`slideGap`, `direction`, `ariaLabel` remain available. + +When toggling OFF, only `useTabs` is set back to `false`. Disabled attributes stay at defaults (acceptable — values were reset on toggle-ON). + +### 1.3 New block: `rt-carousel/carousel-tab-list` + +```json +{ + "name": "rt-carousel/carousel-tab-list", + "title": "Carousel Tab List", + "ancestor": ["rt-carousel/carousel"], + "attributes": { + "labels": { "type": "array", "items": { "type": "string" }, "default": [] }, + "orientation": { "type": "string", "enum": ["horizontal","vertical"], "default": "horizontal" }, + "activeTabBackgroundColor": { "type": "string", "default": "" }, + "activeTabTextColor": { "type": "string", "default": "" } + }, + "usesContext": ["rt-carousel/carousel/useTabs"], + "supports": { "interactivity": true } +} +``` + +Colour attributes output as CSS custom properties `--rt-tab-active-bg` / `--rt-tab-active-color` on the block wrapper, consumed by SCSS. + +--- + +## 2. Rendered Markup & ARIA + +### 2.1 Frontend output when `useTabs = true` + +```html + + +``` + +### 2.2 Frontend output when `useTabs = false` + +Identical to current behaviour. Zero change. + +### 2.3 ARIA rules + +- `role="tablist"` on the tab-list block container +- `role="tab"` on each button; `aria-selected` replaces `aria-current` +- `role="tabpanel"` on each slide; `aria-labelledby` points to matching tab `id` +- `aria-controls` on each tab points to matching panel `id` +- `hidden` attribute on non-active panels (AT skips them; Embla still renders them in DOM) +- `aria-roledescription="carousel"` removed from outer block when `useTabs = true` +- `aria-roledescription="slide"` removed from slides when `useTabs = true` + +### 2.4 ID generation + +`carouselId` is set during `callbacks.initCarousel` — a short counter stored in `context.carouselId`. Tab and panel IDs derived as `rt-carousel-tab-{carouselId}-{snapIndex}` / `rt-carousel-panel-{carouselId}-{snapIndex}`. + +### 2.5 Embla in tabs mode + +`duration: 0` added to `carouselOptions` when `useTabs = true` (both editor and frontend) to suppress scroll animation. The overflow clip + `hidden` attribute handle the visual switch. + +--- + +## 3. Editor Behaviour + +### 3.1 Toggle control + +Placed at the top of the "Carousel Settings" `PanelBody`: + +``` +[ ] Use as Tabs +``` + +Toggle-ON side effects (single `setAttributes` call + one `insertBlock`): +1. `setAttributes({ useTabs: true, loop: false, dragFree: false, carouselAlign: 'start', containScroll: 'trimSnaps', slidesToScroll: '1', autoplay: false, axis: 'x' })` +2. `insertBlock(createBlock('rt-carousel/carousel-tab-list', {}), 0, clientId)` — inserts tab-list as first child of carousel (before viewport) + +Toggle-OFF side effects: +1. `setAttributes({ useTabs: false })` only — tab-list block stays (user may repurpose as carousel nav dots) + +### 3.2 Hidden inspector controls when `useTabs = true` + +Hidden via conditional render in `edit.tsx`: +- Loop, Free Drag, Alignment, Contain Scroll, Scroll Auto, Slides to Scroll, Orientation (axis) +- Entire "Autoplay Options" `PanelBody` + +Visible in both modes: +- Slide Gap, Direction, ARIA Label + +### 3.3 Inspector panel title + +```tsx + +``` + +### 3.4 WYSIWYG in editor + +- Carousel wrapper gets `data-is-tabs` attribute when `useTabs = true` +- Slide `edit.tsx` reads its own index (`getBlockIndex(clientId)` relative to viewport siblings) + `selectedIndex` from `EditorCarouselContext` → applies `is-active` CSS class when indices match +- Editor stylesheet: + +```css +.rt-carousel[data-is-tabs] .embla__slide:not(.is-active) { + display: none; +} +``` + +- Clicking a tab label in editor → Embla `select` event → `selectedIndex` updates in context → slides re-render → correct slide visible +- Editor is visually identical to frontend + +### 3.5 Known limitation + +Block breadcrumb/list-view label for `rt-carousel/carousel-slide` stays "Carousel Slide" regardless of mode. Changing it would require a block variation or dynamic title API not currently available without extra complexity. + +--- + +## 4. New Block: `rt-carousel/carousel-tab-list` + +### 4.1 Source + +Direct port of `vertex-blocks/key-features-tabs-dots`. Key differences: + +- Lives in `rt-carousel` plugin, not `vertex-blocks` +- `ancestor` is `rt-carousel/carousel` +- Adds `activeTabBackgroundColor` / `activeTabTextColor` attributes +- Uses `role="tablist"` on container, `role="tab"` on buttons +- Uses `aria-selected` instead of `aria-current` +- Extends `rt-carousel/carousel` Interactivity store with new callbacks + +### 4.2 New store callbacks added to `view.ts` + +```ts +// Added to rt-carousel/carousel store +callbacks: { + getKeyFeatureDotText: () => { /* label from dotLabels[snap.index] or index+1 */ }, + getTabAriaControls: () => `rt-carousel-panel-${context.carouselId}-${snap.index}`, + getTabId: () => `rt-carousel-tab-${context.carouselId}-${snap.index}`, + // On slide elements (resolved via slide's position in container): + getSlideTabPanelId: () => `rt-carousel-panel-${context.carouselId}-${slideIndex}`, + getSlideTabLabelledBy: () => `rt-carousel-tab-${context.carouselId}-${slideIndex}`, + isSlideHiddenForTabs: () => context.useTabs && !callbacks.isSlideActive(), +} +``` + +`slideIndex` resolved the same way as existing `isSlideActive` (element position in `.embla__container`). + +### 4.3 Colour customisation + +```scss +.wp-block-rt-carousel-carousel-tab-list { + --rt-tab-active-bg: #000; // fallback + --rt-tab-active-color: #fff; // fallback + + &__tab { + &.is-active, + &[aria-selected="true"] { + background-color: var(--rt-tab-active-bg); + color: var(--rt-tab-active-color); + } + } +} +``` + +Colour pickers in `edit.tsx` via two `ColorPalette` controls in an `InspectorControls` panel. + +### 4.4 `edit.tsx` behaviour + +- Reads `slideCount`, `selectedIndex`, `scrollSnaps` from `EditorCarouselContext` +- Renders inline-editable `RichText` labels per tab (one per snap) +- Active tab highlighted matching `selectedIndex` + +--- + +## 5. File Changes Summary + +| File | Change | +|------|--------| +| `src/blocks/carousel/block.json` | Add `useTabs` attribute + `providesContext` entry | +| `src/blocks/carousel/types.ts` | Add `useTabs` to `CarouselAttributes` and `CarouselContext` | +| `src/blocks/carousel/edit.tsx` | Toggle control, auto-insert, hide controls, `data-is-tabs`, WYSIWYG slide class logic | +| `src/blocks/carousel/save.tsx` | Conditional `aria-roledescription`, `duration:0` in options, `context.useTabs` | +| `src/blocks/carousel/editor.scss` | WYSIWYG hide rule for tabs mode | +| `src/blocks/carousel/view.ts` | New callbacks: `isSlideHiddenForTabs`, `getSlideTabPanelId`, `getSlideTabLabelledBy`; generate + store `carouselId` in context during `initCarousel` | +| `src/blocks/carousel/slide/block.json` | Add `usesContext: ["rt-carousel/carousel/useTabs"]` | +| `src/blocks/carousel/slide/save.tsx` | Conditional `role`, `hidden` binding, `id`/`aria-labelledby` bindings | +| `src/blocks/carousel/slide/edit.tsx` | Apply `is-active` class based on index match | +| `src/blocks/carousel/carousel-tab-list/` | **New block** — all files | +| `src/blocks/carousel/index.ts` | Register new block | + +--- + +## 6. Out of Scope + +- Slide breadcrumb label rename ("Carousel Slide" → "Tab Panel") — not feasible without block variation +- Keyboard arrow-key navigation between tabs (WAI-ARIA full keyboard pattern) — deferred +- Animating panel transitions in tabs mode — deferred From 892c876949a50eafa839e6e5ab34109bef00a0f8 Mon Sep 17 00:00:00 2001 From: Danish Shakeel Date: Sat, 18 Jul 2026 02:31:04 +0200 Subject: [PATCH 02/14] docs: add carousel-as-tabs implementation plan --- .../plans/2026-07-18-carousel-as-tabs.md | 1785 +++++++++++++++++ 1 file changed, 1785 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-18-carousel-as-tabs.md diff --git a/docs/superpowers/plans/2026-07-18-carousel-as-tabs.md b/docs/superpowers/plans/2026-07-18-carousel-as-tabs.md new file mode 100644 index 0000000..ba67337 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-carousel-as-tabs.md @@ -0,0 +1,1785 @@ +# Carousel as Tabs Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a "Use as Tabs" toggle to `rt-carousel/carousel` that converts the carousel into a WAI-ARIA-compliant tab widget, keeping Embla as the engine, with a new `rt-carousel/carousel-tab-list` block for labeled tab navigation. + +**Architecture:** A new boolean attribute `useTabs` on the carousel block gates all tab-specific behaviour. A new sibling block `rt-carousel/carousel-tab-list` (ported from vertex-blocks) provides labeled, clickable tabs that extend the existing `rt-carousel/carousel` Interactivity store. Slides gain `role="tabpanel"` and `hidden` in tabs mode; the outer block drops `aria-roledescription="carousel"`. The editor is WYSIWYG: non-active slides are hidden via CSS when `data-is-tabs` is set on the wrapper. + +**Tech Stack:** TypeScript/TSX, WordPress Interactivity API, Embla Carousel, `@wordpress/block-editor`, `@wordpress/components`, Jest + `@testing-library/react`. + +## Global Constraints + +- All user-facing strings wrapped in `__('...', 'rt-carousel')` or `_x`/`sprintf` equivalents. +- No new npm dependencies — use only what is already installed. +- All new block files live under `src/blocks/carousel/carousel-tab-list/`. +- PHP block registration goes in `inc/Plugin.php` alongside existing blocks. +- Test command: `npm run test:js` (runs `wp-scripts test-unit-js`). +- Build command: `npm run build`. +- Existing test coverage thresholds: 40% branches/functions/lines/statements — do not drop below. +- Never add AI authorship to commits. + +--- + +## File Map + +| File | Action | Purpose | +|------|--------|---------| +| `src/blocks/carousel/block.json` | Modify | Add `useTabs` attribute + `providesContext` entry | +| `src/blocks/carousel/types.ts` | Modify | Add `useTabs` to `CarouselAttributes` and `useTabs`/`carouselId` to `CarouselContext` | +| `src/blocks/carousel/editor-context.ts` | Modify | Add `useTabs` to `EditorCarouselContextType`; add `duration?:number` to `carouselOptions` type | +| `src/blocks/carousel/slide/block.json` | Modify | Add `rt-carousel/carousel/useTabs` to `usesContext` | +| `src/blocks/carousel/carousel-tab-list/block.json` | Create | New block metadata | +| `src/blocks/carousel/carousel-tab-list/types.ts` | Create | `TabListAttributes` and `TabContext` types | +| `src/blocks/carousel/carousel-tab-list/index.ts` | Create | Block registration | +| `src/blocks/carousel/carousel-tab-list/edit.tsx` | Create | Editor UI: inline label editing + color pickers | +| `src/blocks/carousel/carousel-tab-list/save.tsx` | Create | Static HTML with Interactivity API directives | +| `src/blocks/carousel/carousel-tab-list/view.ts` | Create | Extend `rt-carousel/carousel` store with tab callbacks | +| `src/blocks/carousel/carousel-tab-list/style.scss` | Create | Tab list styles with CSS custom property theming | +| `inc/Plugin.php` | Modify | Register new block | +| `src/blocks/carousel/save.tsx` | Modify | Conditional `aria-roledescription`; `useTabs`/`carouselId`/`duration` in context | +| `src/blocks/carousel/slide/save.tsx` | Modify | Conditional `role`/`aria-roledescription`/bindings when `useTabs` | +| `src/blocks/carousel/view.ts` | Modify | `carouselId` generation in `initCarousel`; new tab callbacks | +| `src/blocks/carousel/edit.tsx` | Modify | `useTabs` toggle, auto-insert, hide controls, `data-is-tabs`, `duration` in `carouselOptions` | +| `src/blocks/carousel/viewport/edit.tsx` | Modify | Forward `duration` from `carouselOptions` to Embla | +| `src/blocks/carousel/slide/edit.tsx` | Modify | Apply `is-active` class based on `selectedIndex` when `useTabs` | +| `src/blocks/carousel/editor.scss` | Modify | Hide non-active slides when `data-is-tabs` is set | +| `src/blocks/carousel/__tests__/view.test.ts` | Modify | Tests for new view callbacks | +| `src/blocks/carousel/__tests__/edit.test.tsx` | Modify | Tests for `useTabs` toggle behaviour | + +--- + +## Task 1: Data model — attributes, types, context + +**Files:** +- Modify: `src/blocks/carousel/block.json` +- Modify: `src/blocks/carousel/types.ts` +- Modify: `src/blocks/carousel/editor-context.ts` +- Modify: `src/blocks/carousel/slide/block.json` + +**Interfaces:** +- Produces: `CarouselAttributes.useTabs: boolean`, `CarouselContext.useTabs: boolean`, `CarouselContext.carouselId: string`, `EditorCarouselContextType.useTabs: boolean`, `EditorCarouselContextType.carouselOptions.duration?: number` +- All later tasks depend on these types. + +- [ ] **Step 1: Add `useTabs` attribute and context to carousel `block.json`** + +In `src/blocks/carousel/block.json`, add the following to `"attributes"` (after `"slidesToScroll"`): + +```json +"useTabs": { + "type": "boolean", + "default": false +} +``` + +And update `"providesContext"` (it currently doesn't exist — add it alongside the existing `"providesContext"` for `allowedSlideBlocks`): + +```json +"providesContext": { + "rt-carousel/carousel/allowedSlideBlocks": "allowedSlideBlocks", + "rt-carousel/carousel/useTabs": "useTabs" +} +``` + +- [ ] **Step 2: Add `useTabs` to `CarouselAttributes` and update `CarouselContext` in `types.ts`** + +Open `src/blocks/carousel/types.ts`. Add `useTabs: boolean;` to `CarouselAttributes`: + +```ts +export type CarouselAttributes = { + loop: boolean; + dragFree: boolean; + carouselAlign: 'start' | 'center' | 'end'; + align?: 'start' | 'center' | 'end'; + containScroll: 'trimSnaps' | 'keepSnaps'; + direction: 'ltr' | 'rtl'; + axis: 'x' | 'y'; + height: string; + allowedSlideBlocks: string[]; + autoplay: boolean; + autoplayDelay: number; + autoplayStopOnInteraction: boolean; + autoplayStopOnMouseEnter: boolean; + ariaLabel: string; + slideGap: number; + slidesToScroll: string; + useTabs: boolean; +}; +``` + +Add `useTabs` and `carouselId` to `CarouselContext`: + +```ts +export type CarouselContext = { + options: EmblaOptionsType & { + slidesToScroll?: number | 'auto'; + }; + autoplay: + | boolean + | { + delay: number; + stopOnInteraction: boolean; + stopOnMouseEnter: boolean; + }; + isPlaying: boolean; + timerIterationId: number; + selectedIndex: number; + scrollSnaps: { index: number }[]; + canScrollPrev: boolean; + canScrollNext: boolean; + scrollProgress: number; + ariaLabelPattern: string; + countLabelPattern?: string; + announcement?: string; + announcementPattern?: string; + shouldAnnounce?: boolean; + ref?: HTMLElement | null; + slideCount: number; + initialized?: boolean; + useTabs: boolean; + carouselId: string; +}; +``` + +- [ ] **Step 3: Update `EditorCarouselContextType` in `editor-context.ts`** + +Open `src/blocks/carousel/editor-context.ts`. Update the type and default value: + +```ts +import { createContext } from '@wordpress/element'; +import type { EmblaCarouselType } from 'embla-carousel'; +import type { CarouselAttributes } from './types'; + +export type EditorCarouselContextType = { + emblaApi: EmblaCarouselType | undefined; + setEmblaApi: ( api: EmblaCarouselType ) => void; + canScrollPrev: boolean; + canScrollNext: boolean; + setCanScrollPrev: ( value: boolean ) => void; + setCanScrollNext: ( value: boolean ) => void; + scrollProgress: number; + setScrollProgress: ( value: number ) => void; + selectedIndex: number; + scrollSnaps: number[]; + slideCount: number; + useTabs: boolean; + carouselOptions: Omit, 'slidesToScroll'> & { + slidesToScroll?: number | string; + duration?: number; + }; +}; + +const defaultValue: EditorCarouselContextType = { + emblaApi: undefined, + setEmblaApi: () => {}, + canScrollPrev: false, + canScrollNext: false, + setCanScrollPrev: () => {}, + setCanScrollNext: () => {}, + scrollProgress: 0, + setScrollProgress: () => {}, + selectedIndex: 0, + scrollSnaps: [], + slideCount: 0, + useTabs: false, + carouselOptions: {}, +}; + +let context = window.__RT_CAROUSEL_CONTEXT__; +if ( ! context ) { + context = createContext( defaultValue ); + window.__RT_CAROUSEL_CONTEXT__ = context; +} + +export const EditorCarouselContext = context; +``` + +Also update the global `Window` type declaration (it's currently in `vertex-blocks`' types; ensure it exists here too). The file already has `window.__RT_CAROUSEL_CONTEXT__` usage — TypeScript needs the declaration. Add at the bottom of `editor-context.ts`: + +```ts +declare global { + interface Window { + __RT_CAROUSEL_CONTEXT__?: typeof context; + } +} +``` + +- [ ] **Step 4: Add `useTabs` to `usesContext` in `slide/block.json`** + +Open `src/blocks/carousel/slide/block.json`. Update `"usesContext"`: + +```json +"usesContext": [ + "rt-carousel/carousel/allowedSlideBlocks", + "rt-carousel/carousel/useTabs" +] +``` + +- [ ] **Step 5: Verify TypeScript compiles** + +```bash +cd /Users/danishshakeel/rtCamp/rt-carousel +npm run lint:js:types 2>&1 | head -40 +``` + +Expected: zero errors related to `useTabs` or `carouselId`. Fix any type errors before proceeding. + +- [ ] **Step 6: Commit** + +```bash +git add src/blocks/carousel/block.json src/blocks/carousel/types.ts src/blocks/carousel/editor-context.ts src/blocks/carousel/slide/block.json +git commit -m "feat: add useTabs attribute to carousel data model" +``` + +--- + +## Task 2: New `rt-carousel/carousel-tab-list` block + +**Files:** +- Create: `src/blocks/carousel/carousel-tab-list/block.json` +- Create: `src/blocks/carousel/carousel-tab-list/types.ts` +- Create: `src/blocks/carousel/carousel-tab-list/index.ts` +- Create: `src/blocks/carousel/carousel-tab-list/edit.tsx` +- Create: `src/blocks/carousel/carousel-tab-list/save.tsx` +- Create: `src/blocks/carousel/carousel-tab-list/view.ts` +- Create: `src/blocks/carousel/carousel-tab-list/style.scss` +- Modify: `inc/Plugin.php` + +**Interfaces:** +- Consumes: `CarouselContext` from `../types`, `EditorCarouselContext` from `../editor-context` +- Produces: Block `rt-carousel/carousel-tab-list` registered and renderable; extends `rt-carousel/carousel` store with `getKeyFeatureDotText`, `getTabAriaControls`, `getTabId`, `getTabLabel` + +- [ ] **Step 1: Create `block.json`** + +Create `src/blocks/carousel/carousel-tab-list/block.json`: + +```json +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "version": "1.0.0", + "name": "rt-carousel/carousel-tab-list", + "title": "Carousel Tab List", + "category": "rt-carousel", + "icon": "list-view", + "description": "Labeled tab navigation for a carousel in tabs mode.", + "textdomain": "rt-carousel", + "ancestor": [ + "rt-carousel/carousel" + ], + "attributes": { + "labels": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "orientation": { + "type": "string", + "enum": [ "horizontal", "vertical" ], + "default": "horizontal" + }, + "activeTabBackgroundColor": { + "type": "string", + "default": "" + }, + "activeTabTextColor": { + "type": "string", + "default": "" + } + }, + "supports": { + "interactivity": true, + "html": false + }, + "editorScript": "file:./index.ts", + "style": "file:./style-index.css", + "viewScriptModule": "file:./view.ts" +} +``` + +- [ ] **Step 2: Create `types.ts`** + +Create `src/blocks/carousel/carousel-tab-list/types.ts`: + +```ts +export type TabListAttributes = { + labels: string[]; + orientation: 'horizontal' | 'vertical'; + activeTabBackgroundColor: string; + activeTabTextColor: string; +}; + +export type TabContext = { + snap?: { index?: number }; + dotLabels?: string[]; + carouselId?: string; + ariaLabelPattern?: string; + selectedIndex?: number; +}; +``` + +- [ ] **Step 3: Create `save.tsx`** + +Create `src/blocks/carousel/carousel-tab-list/save.tsx`: + +```tsx +import { useBlockProps } from '@wordpress/block-editor'; +import type { TabListAttributes } from './types'; + +export default function Save( { attributes }: { attributes: TabListAttributes } ) { + const { orientation, labels, activeTabBackgroundColor, activeTabTextColor } = attributes; + + const style: React.CSSProperties = {}; + if ( activeTabBackgroundColor ) { + ( style as Record )[ '--rt-tab-active-bg' ] = activeTabBackgroundColor; + } + if ( activeTabTextColor ) { + ( style as Record )[ '--rt-tab-active-color' ] = activeTabTextColor; + } + + const blockProps = useBlockProps.save( { + className: orientation === 'vertical' ? 'is-vertical' : undefined, + role: 'tablist', + style, + 'data-wp-interactive': 'rt-carousel/carousel', + 'data-wp-context': JSON.stringify( { dotLabels: labels ?? [] } ), + } ); + + return ( +
+ +
+ ); +} +``` + +- [ ] **Step 4: Create `edit.tsx`** + +Create `src/blocks/carousel/carousel-tab-list/edit.tsx`: + +```tsx +import { __, sprintf } from '@wordpress/i18n'; +import { + InspectorControls, + RichText, + useBlockProps, + // ponytail: ColorPalette is already in @wordpress/block-editor +} from '@wordpress/block-editor'; +import { PanelBody, SelectControl, BaseControl, ColorPalette } from '@wordpress/components'; +import { useContext } from '@wordpress/element'; +import { EditorCarouselContext } from '../editor-context'; +import type { TabListAttributes } from './types'; + +export default function Edit( { + attributes, + setAttributes, +}: { + attributes: TabListAttributes; + setAttributes: ( attrs: Partial ) => void; +} ) { + const { labels, orientation, activeTabBackgroundColor, activeTabTextColor } = attributes; + const carousel = useContext( EditorCarouselContext ); + const dotCount = Math.max( carousel.scrollSnaps.length, 1 ); + + const style: React.CSSProperties = {}; + if ( activeTabBackgroundColor ) { + ( style as Record )[ '--rt-tab-active-bg' ] = activeTabBackgroundColor; + } + if ( activeTabTextColor ) { + ( style as Record )[ '--rt-tab-active-color' ] = activeTabTextColor; + } + + const blockProps = useBlockProps( { + className: orientation === 'vertical' ? 'is-vertical' : undefined, + style, + } ); + + const setLabelAt = ( index: number, value: string ) => { + const next = [ ...labels ]; + while ( next.length <= index ) { + next.push( '' ); + } + next[ index ] = value; + setAttributes( { labels: next } ); + }; + + return ( + <> + + + + setAttributes( { + orientation: value as TabListAttributes[ 'orientation' ], + } ) + } + /> + + + + + setAttributes( { activeTabBackgroundColor: color ?? '' } ) + } + /> + + + + setAttributes( { activeTabTextColor: color ?? '' } ) + } + /> + + + +
+ { Array.from( { length: dotCount } ).map( ( _, index ) => ( + setLabelAt( index, value ) } + /* translators: %d: tab number */ + placeholder={ sprintf( __( 'Tab %d', 'rt-carousel' ), index + 1 ) } + allowedFormats={ [] } + /> + ) ) } +
+ + ); +} +``` + +- [ ] **Step 5: Create `view.ts`** + +Create `src/blocks/carousel/carousel-tab-list/view.ts`: + +```ts +import { getContext, store } from '@wordpress/interactivity'; +import type { TabContext } from './types'; + +// Extends rt-carousel/carousel store — reuses onDotClick/isDotActive/scrollSnaps as-is. +store( 'rt-carousel/carousel', { + callbacks: { + getKeyFeatureDotText: (): string => { + const context = getContext(); + const index = context.snap?.index ?? 0; + const label = context.dotLabels?.[ index ]; + const trimmed = label?.trim(); + return trimmed ? trimmed : String( index + 1 ); + }, + getTabAriaControls: (): string => { + const context = getContext(); + const index = context.snap?.index ?? 0; + return `rt-carousel-panel-${ context.carouselId }-${ index }`; + }, + getTabId: (): string => { + const context = getContext(); + const index = context.snap?.index ?? 0; + return `rt-carousel-tab-${ context.carouselId }-${ index }`; + }, + getTabLabel: (): string => { + const context = getContext(); + const index = ( context.snap?.index ?? 0 ) + 1; + return ( context.ariaLabelPattern ?? 'Go to tab %d' ).replace( + '%d', + index.toString(), + ); + }, + }, +} ); +``` + +- [ ] **Step 6: Create `style.scss`** + +Create `src/blocks/carousel/carousel-tab-list/style.scss`: + +```scss +.wp-block-rt-carousel-carousel-tab-list { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin: 0; + padding: 0; + + &.is-vertical { + flex-direction: column; + } + + &__tab { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.5rem 1rem; + border: 1px solid currentColor; + background: transparent; + cursor: pointer; + font: inherit; + border-radius: 0.25rem; + transition: + background-color 0.2s ease, + color 0.2s ease; + + --rt-tab-active-bg: #000; + --rt-tab-active-color: #fff; + + &:focus-visible { + outline: 2px solid; + outline-offset: 2px; + } + + &.is-active, + &[aria-selected="true"] { + background-color: var(--rt-tab-active-bg); + color: var(--rt-tab-active-color); + } + } +} + +@media (prefers-reduced-motion: reduce) { + .wp-block-rt-carousel-carousel-tab-list__tab { + transition: none; + } +} +``` + +- [ ] **Step 7: Create `index.ts`** + +Create `src/blocks/carousel/carousel-tab-list/index.ts`: + +```ts +import { registerBlockType, type BlockConfiguration } from '@wordpress/blocks'; +import Edit from './edit'; +import Save from './save'; +import metadata from './block.json'; +import type { TabListAttributes } from './types'; +import './style.scss'; + +registerBlockType( metadata as BlockConfiguration, { + edit: Edit, + save: Save, +} ); +``` + +- [ ] **Step 8: Register block in `inc/Plugin.php`** + +Open `inc/Plugin.php`. Find the `$blocks` array in `register_blocks()` and add the new entry: + +```php +$blocks = [ + 'carousel', + 'carousel/carousel-tab-list', + 'carousel/controls', + 'carousel/counter', + 'carousel/dots', + 'carousel/progress', + 'carousel/viewport', + 'carousel/slide', +]; +``` + +- [ ] **Step 9: Build and verify new block appears** + +```bash +cd /Users/danishshakeel/rtCamp/rt-carousel +npm run build 2>&1 | tail -20 +``` + +Expected: build succeeds. `build/blocks/carousel/carousel-tab-list/` directory exists with `index.js`, `style-index.css`, `view.js`, `block.json`. + +- [ ] **Step 10: Commit** + +```bash +git add src/blocks/carousel/carousel-tab-list/ inc/Plugin.php +git commit -m "feat: add rt-carousel/carousel-tab-list block" +``` + +--- + +## Task 3: Carousel `save.tsx` — tabs-mode ARIA + context + +**Files:** +- Modify: `src/blocks/carousel/save.tsx` + +**Interfaces:** +- Consumes: `CarouselAttributes.useTabs` (Task 1), `CarouselContext.useTabs`/`carouselId` (Task 1) +- Produces: Frontend HTML omits `aria-roledescription="carousel"` when `useTabs`; `context.useTabs` and `context.carouselId` serialised into `data-wp-context`; `duration: 0` added to `context.options` when `useTabs` + +- [ ] **Step 1: Write the failing test** + +In `src/blocks/carousel/__tests__/edit.test.tsx`, confirm that the `useTabs` attribute is present in block output (we'll add a proper save test when the block has it). For now, add a type-level assertion in a new file to verify the attribute is wired. Skip a formal save.tsx test here — the Interactivity API context is covered by view.test.ts in Task 5. + +Instead, write a focused smoke test in `src/blocks/carousel/__tests__/view.test.ts` — ensure `context.useTabs` exists on the context type (this compiles or it doesn't). This is a compile-time check. Proceed to implementation. + +- [ ] **Step 2: Update `save.tsx`** + +Open `src/blocks/carousel/save.tsx`. Destructure `useTabs` from `attributes`: + +```tsx +const { + loop, + dragFree, + carouselAlign, + containScroll, + direction, + autoplay, + autoplayDelay, + autoplayStopOnInteraction, + autoplayStopOnMouseEnter, + ariaLabel, + slideGap, + axis, + height, + slidesToScroll, + useTabs, +} = attributes; +``` + +Update the `context` object to add `useTabs`, `carouselId`, and `duration`: + +```tsx +const context: CarouselContext = { + options: { + loop, + dragFree, + align: carouselAlign, + containScroll, + direction, + axis, + slidesToScroll: slidesToScroll === 'auto' ? 'auto' : parseInt( slidesToScroll, 10 ), + // Instant switch in tabs mode — no scroll animation + ...( useTabs ? { duration: 0 } : {} ), + }, + autoplay: autoplay + ? { + delay: autoplayDelay, + stopOnInteraction: autoplayStopOnInteraction, + stopOnMouseEnter: autoplayStopOnMouseEnter, + } + : false, + isPlaying: !! autoplay, + timerIterationId: 0, + selectedIndex: -1, + scrollSnaps: [], + canScrollPrev: false, + canScrollNext: false, + scrollProgress: 0, + 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. */ + announcementPattern: __( + 'Slide {{currentSlide}} of {{totalSlides}}', + 'rt-carousel', + ), + useTabs: useTabs ?? false, + carouselId: '', // Set at runtime by initCarousel in view.ts +}; +``` + +Update `blockProps` to conditionally omit `aria-roledescription`: + +```tsx +const blockProps = useBlockProps.save( { + className: 'rt-carousel', + role: 'region', + ...( ! useTabs ? { 'aria-roledescription': 'carousel' } : {} ), + 'aria-label': ariaLabel, + dir: direction, + 'data-axis': axis, + 'data-loop': loop ? 'true' : undefined, + 'data-wp-interactive': 'rt-carousel/carousel', + 'data-wp-context': JSON.stringify( context ), + 'data-wp-init': 'callbacks.initCarousel', + style: { + '--rt-carousel-gap': `${ slideGap }px`, + '--rt-carousel-height': axis === 'y' ? height : undefined, + } as React.CSSProperties, +} ); +``` + +- [ ] **Step 3: TypeScript check** + +```bash +npm run lint:js:types 2>&1 | grep -E "error|save\.tsx" | head -20 +``` + +Expected: zero errors in `save.tsx`. + +- [ ] **Step 4: Commit** + +```bash +git add src/blocks/carousel/save.tsx +git commit -m "feat: add useTabs/carouselId context and conditional aria-roledescription to carousel save" +``` + +--- + +## Task 4: Slide `save.tsx` — conditional `role="tabpanel"` and ARIA bindings + +**Files:** +- Modify: `src/blocks/carousel/slide/save.tsx` + +**Interfaces:** +- Consumes: `rt-carousel/carousel/useTabs` context (Task 1 — `usesContext` in slide/block.json) +- Produces: When `useTabs`, slide renders `role="tabpanel"` + `hidden` binding + `id`/`aria-labelledby` bindings; when not, identical to current output + +- [ ] **Step 1: Update `slide/save.tsx`** + +Open `src/blocks/carousel/slide/save.tsx`. Add `context` prop and conditional attributes: + +```tsx +import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor'; +import type { CarouselSlideAttributes } from '../types'; + +export default function Save( { + attributes, + context, +}: { + attributes: CarouselSlideAttributes; + context?: { 'rt-carousel/carousel/useTabs'?: boolean }; +} ) { + const { verticalAlignment } = attributes; + const useTabs = context?.[ 'rt-carousel/carousel/useTabs' ] ?? false; + + const blockProps = useBlockProps.save( { + className: `embla__slide${ + verticalAlignment ? ` is-vertically-aligned-${ verticalAlignment }` : '' + }`, + role: useTabs ? 'tabpanel' : 'group', + ...( ! useTabs ? { 'aria-roledescription': 'slide' } : {} ), + 'data-wp-interactive': 'rt-carousel/carousel', + ...( useTabs + ? { + 'data-wp-bind--id': 'callbacks.getSlideTabPanelId', + 'data-wp-bind--aria-labelledby': 'callbacks.getSlideTabLabelledBy', + 'data-wp-bind--hidden': 'callbacks.isSlideHiddenForTabs', + } + : { + 'data-wp-class--is-active': 'callbacks.isSlideActive', + 'data-wp-bind--aria-current': 'callbacks.isSlideActive', + } ), + } ); + + const innerBlocksProps = useInnerBlocksProps.save( blockProps ); + + return
; +} +``` + +- [ ] **Step 2: TypeScript check** + +```bash +npm run lint:js:types 2>&1 | grep -E "error|slide/save" | head -20 +``` + +Expected: zero errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/blocks/carousel/slide/save.tsx +git commit -m "feat: add tabpanel role and ARIA bindings to slide save in tabs mode" +``` + +--- + +## Task 5: `view.ts` — `carouselId` generation and tab callbacks + +**Files:** +- Modify: `src/blocks/carousel/view.ts` +- Modify: `src/blocks/carousel/__tests__/view.test.ts` + +**Interfaces:** +- Consumes: `CarouselContext.useTabs`, `CarouselContext.carouselId`, `CAROUSEL_SLIDE_SELECTOR` +- Produces: `context.carouselId` set in `initCarousel`; new callbacks: `isSlideHiddenForTabs`, `getSlideTabPanelId`, `getSlideTabLabelledBy` + +- [ ] **Step 1: Write failing tests for new callbacks** + +Open `src/blocks/carousel/__tests__/view.test.ts`. At the bottom of the file (after existing tests), add: + +```ts +describe( 'isSlideHiddenForTabs', () => { + const isSlideHiddenForTabs = storeConfig?.callbacks?.isSlideHiddenForTabs; + + it( 'returns false when useTabs is false', () => { + const { wrapper } = createMockCarouselDOM(); + const slide = document.createElement( 'div' ); + slide.className = 'embla__slide'; + wrapper.querySelector( '.embla' )?.appendChild( slide ); + + ( getContext as jest.Mock ).mockReturnValue( + createMockContext( { useTabs: false, carouselId: '1', selectedIndex: 0, initialized: true } ), + ); + ( getElement as jest.Mock ).mockReturnValue( slide ); + + expect( isSlideHiddenForTabs() ).toBe( false ); + } ); + + it( 'returns false when useTabs is true and slide is active', () => { + const container = document.createElement( 'div' ); + container.className = 'embla__container'; + const slide = document.createElement( 'div' ); + slide.className = 'embla__slide'; + container.appendChild( slide ); + + const viewport = document.createElement( 'div' ); + viewport.className = 'embla'; + viewport.appendChild( container ); + + const wrapper = document.createElement( 'div' ); + wrapper.className = 'rt-carousel'; + wrapper.appendChild( viewport ); + + ( getContext as jest.Mock ).mockReturnValue( + createMockContext( { useTabs: true, carouselId: '1', selectedIndex: 0, initialized: true } ), + ); + ( getElement as jest.Mock ).mockReturnValue( slide ); + + expect( isSlideHiddenForTabs() ).toBe( false ); + } ); + + it( 'returns true when useTabs is true and slide is not active', () => { + const container = document.createElement( 'div' ); + container.className = 'embla__container'; + const slide0 = document.createElement( 'div' ); + slide0.className = 'embla__slide'; + const slide1 = document.createElement( 'div' ); + slide1.className = 'embla__slide'; + container.appendChild( slide0 ); + container.appendChild( slide1 ); + + const viewport = document.createElement( 'div' ); + viewport.className = 'embla'; + viewport.appendChild( container ); + + const wrapper = document.createElement( 'div' ); + wrapper.className = 'rt-carousel'; + wrapper.appendChild( viewport ); + + ( getContext as jest.Mock ).mockReturnValue( + createMockContext( { useTabs: true, carouselId: '1', selectedIndex: 0, initialized: true } ), + ); + // slide1 is index 1, selectedIndex is 0 → should be hidden + ( getElement as jest.Mock ).mockReturnValue( slide1 ); + + expect( isSlideHiddenForTabs() ).toBe( true ); + } ); +} ); + +describe( 'getSlideTabPanelId', () => { + const getSlideTabPanelId = storeConfig?.callbacks?.getSlideTabPanelId; + + it( 'returns correct id for slide at index 1', () => { + const container = document.createElement( 'div' ); + container.className = 'embla__container'; + const slide0 = document.createElement( 'div' ); + slide0.className = 'embla__slide'; + const slide1 = document.createElement( 'div' ); + slide1.className = 'embla__slide'; + container.appendChild( slide0 ); + container.appendChild( slide1 ); + + const viewport = document.createElement( 'div' ); + viewport.className = 'embla'; + viewport.appendChild( container ); + const wrapper = document.createElement( 'div' ); + wrapper.className = 'rt-carousel'; + wrapper.appendChild( viewport ); + + ( getContext as jest.Mock ).mockReturnValue( + createMockContext( { carouselId: 'abc', useTabs: true } ), + ); + ( getElement as jest.Mock ).mockReturnValue( slide1 ); + + expect( getSlideTabPanelId() ).toBe( 'rt-carousel-panel-abc-1' ); + } ); +} ); + +describe( 'getSlideTabLabelledBy', () => { + const getSlideTabLabelledBy = storeConfig?.callbacks?.getSlideTabLabelledBy; + + it( 'returns correct labelledby id for slide at index 0', () => { + const container = document.createElement( 'div' ); + container.className = 'embla__container'; + const slide0 = document.createElement( 'div' ); + slide0.className = 'embla__slide'; + container.appendChild( slide0 ); + + const viewport = document.createElement( 'div' ); + viewport.className = 'embla'; + viewport.appendChild( container ); + const wrapper = document.createElement( 'div' ); + wrapper.className = 'rt-carousel'; + wrapper.appendChild( viewport ); + + ( getContext as jest.Mock ).mockReturnValue( + createMockContext( { carouselId: 'abc', useTabs: true } ), + ); + ( getElement as jest.Mock ).mockReturnValue( slide0 ); + + expect( getSlideTabLabelledBy() ).toBe( 'rt-carousel-tab-abc-0' ); + } ); +} ); +``` + +- [ ] **Step 2: Run tests — expect failures** + +```bash +npm run test:js -- --testPathPattern="view.test" 2>&1 | tail -30 +``` + +Expected: tests for `isSlideHiddenForTabs`, `getSlideTabPanelId`, `getSlideTabLabelledBy` fail with "is not a function" because the callbacks don't exist yet. + +- [ ] **Step 3: Add `carouselId` generation to `initCarousel` in `view.ts`** + +Open `src/blocks/carousel/view.ts`. Add a module-level counter before the `store()` call: + +```ts +// Incrementing counter for unique carousel IDs on the same page +let carouselIdCounter = 0; +``` + +Inside `initCarousel`, after `const context = getContext();`, add: + +```ts +// Assign a unique ID for tab panel/tab linkage +if ( ! context.carouselId ) { + context.carouselId = String( ++carouselIdCounter ); +} +``` + +- [ ] **Step 4: Add new callbacks to the `store()` call in `view.ts`** + +Inside the `callbacks` object in `store('rt-carousel/carousel', { ... })`, add after `initCarousel`: + +```ts +isSlideHiddenForTabs: () => { + const context = getContext(); + if ( ! context.useTabs ) { + return false; + } + if ( ! context.initialized ) { + return true; + } + + const slide = getElementRef( getElement() )?.closest?.( + CAROUSEL_SLIDE_SELECTOR, + ); + + if ( ! slide || ! slide.parentElement ) { + return false; + } + + const slides = Array.from( slide.parentElement.children ).filter( + ( child: Element ) => child.matches( CAROUSEL_SLIDE_SELECTOR ), + ); + + const index = slides.indexOf( slide ); + if ( index === -1 ) { + return false; + } + return context.selectedIndex !== index; +}, +getSlideTabPanelId: () => { + const context = getContext(); + const slide = getElementRef( getElement() )?.closest?.( + CAROUSEL_SLIDE_SELECTOR, + ); + + if ( ! slide || ! slide.parentElement ) { + return ''; + } + + const slides = Array.from( slide.parentElement.children ).filter( + ( child: Element ) => child.matches( CAROUSEL_SLIDE_SELECTOR ), + ); + + const index = slides.indexOf( slide ); + return `rt-carousel-panel-${ context.carouselId }-${ index }`; +}, +getSlideTabLabelledBy: () => { + const context = getContext(); + const slide = getElementRef( getElement() )?.closest?.( + CAROUSEL_SLIDE_SELECTOR, + ); + + if ( ! slide || ! slide.parentElement ) { + return ''; + } + + const slides = Array.from( slide.parentElement.children ).filter( + ( child: Element ) => child.matches( CAROUSEL_SLIDE_SELECTOR ), + ); + + const index = slides.indexOf( slide ); + return `rt-carousel-tab-${ context.carouselId }-${ index }`; +}, +``` + +- [ ] **Step 5: Also update `createMockContext` helper in `view.test.ts` to include `useTabs` and `carouselId`** + +Find the `createMockContext` helper in the test file. Add `useTabs: false` and `carouselId: ''` to the default return value: + +```ts +const createMockContext = ( + overrides: Partial = {}, +): CarouselContext => ( { + options: { loop: true }, + autoplay: false, + isPlaying: false, + timerIterationId: 0, + selectedIndex: 0, + scrollSnaps: [ { index: 0 }, { index: 1 }, { index: 2 } ], + canScrollPrev: true, + canScrollNext: true, + scrollProgress: 0, + slideCount: 3, + ariaLabelPattern: 'Go to slide %d', + useTabs: false, + carouselId: '', + ...overrides, +} ); +``` + +- [ ] **Step 6: Run tests — expect pass** + +```bash +npm run test:js -- --testPathPattern="view.test" 2>&1 | tail -30 +``` + +Expected: all tests pass, including new `isSlideHiddenForTabs`, `getSlideTabPanelId`, `getSlideTabLabelledBy` suites. + +- [ ] **Step 7: Run full test suite to confirm no regressions** + +```bash +npm run test:js 2>&1 | tail -30 +``` + +Expected: all tests pass. + +- [ ] **Step 8: TypeScript check** + +```bash +npm run lint:js:types 2>&1 | grep error | head -20 +``` + +Expected: zero errors. + +- [ ] **Step 9: Commit** + +```bash +git add src/blocks/carousel/view.ts src/blocks/carousel/__tests__/view.test.ts +git commit -m "feat: add carouselId and tab panel/labelledby callbacks to view store" +``` + +--- + +## Task 6: Carousel `edit.tsx` — toggle, auto-insert, hide controls, `duration` in options + +**Files:** +- Modify: `src/blocks/carousel/edit.tsx` +- Modify: `src/blocks/carousel/viewport/edit.tsx` + +**Interfaces:** +- Consumes: `useTabs: boolean` from `attributes` (Task 1); `insertBlock`, `createBlock` (already imported); `EditorCarouselContextType.useTabs`/`carouselOptions.duration` (Task 1) +- Produces: `data-is-tabs` on carousel wrapper; `useTabs` in context value; `duration: 0` in `carouselOptions` when `useTabs`; "Use as Tabs" toggle at top of inspector panel; auto-inserts `rt-carousel/carousel-tab-list` on toggle-ON; hides carousel-only controls when tabs active + +- [ ] **Step 1: Write a failing test for the toggle behaviour** + +Open `src/blocks/carousel/__tests__/edit.test.tsx`. Find the existing describe block. Add a new test (the mock for `ToggleControl` currently returns `null` — update it to call onChange so we can test): + +First, update the `ToggleControl` mock to be testable. Find the `ToggleControl: jest.fn( () => null )` mock and replace with: + +```ts +ToggleControl: jest.fn( ( { onChange, checked, label } ) => ( + onChange( e.target.checked ) } + readOnly={ ! onChange } + /> +) ), +``` + +Then add a test: + +```ts +describe( 'useTabs toggle', () => { + it( 'renders Use as Tabs toggle', async () => { + const setAttributes = jest.fn(); + const mockAttributes: CarouselAttributes = { + loop: false, + dragFree: false, + carouselAlign: 'start', + containScroll: 'trimSnaps', + direction: 'ltr', + axis: 'x', + height: '300px', + allowedSlideBlocks: [], + autoplay: false, + autoplayDelay: 4000, + autoplayStopOnInteraction: true, + autoplayStopOnMouseEnter: false, + ariaLabel: 'Carousel', + slideGap: 0, + slidesToScroll: '1', + useTabs: false, + }; + + // Need inner blocks to avoid setup screen + mockBlockCount = 2; + + render( + , + ); + + const toggle = screen.getByRole( 'checkbox', { name: /use as tabs/i } ); + expect( toggle ).toBeInTheDocument(); + } ); +} ); +``` + +- [ ] **Step 2: Run test — expect failure** + +```bash +npm run test:js -- --testPathPattern="edit.test" 2>&1 | tail -30 +``` + +Expected: fails because "Use as Tabs" toggle doesn't exist yet. + +- [ ] **Step 3: Update `carousel/edit.tsx` — destructure `useTabs`, update inspector** + +Open `src/blocks/carousel/edit.tsx`. + +**3a. Destructure `useTabs`** from `attributes` (in the existing destructuring block): + +```ts +const { + loop, + dragFree, + carouselAlign, + containScroll, + direction, + axis, + height, + allowedSlideBlocks, + autoplay, + autoplayDelay, + autoplayStopOnInteraction, + autoplayStopOnMouseEnter, + ariaLabel, + slidesToScroll = '1', + useTabs = false, +} = attributes; +``` + +**3b. Update `blockProps`** — add `data-is-tabs` and `data-axis` stays: + +```tsx +const blockProps = useBlockProps( { + className: 'rt-carousel', + dir: direction, + 'data-axis': axis, + 'data-loop': loop ? 'true' : undefined, + 'data-is-tabs': useTabs ? 'true' : undefined, + style: { + '--rt-carousel-gap': `${ attributes.slideGap }px`, + '--rt-carousel-height': axis === 'y' ? height : undefined, + } as React.CSSProperties, +} ); +``` + +**3c. Update `carouselOptions` memo** — add `useTabs` to deps and `duration`: + +```ts +const carouselOptions = useMemo( + () => ( { + loop, + dragFree, + align: carouselAlign, + containScroll, + direction, + axis, + height, + slidesToScroll: slidesToScroll === 'auto' ? 'auto' : parseInt( slidesToScroll, 10 ), + duration: useTabs ? 0 : undefined, + } ), + [ loop, dragFree, carouselAlign, containScroll, direction, axis, height, slidesToScroll, useTabs ], +); +``` + +**3d. Update `contextValue` memo** — add `useTabs`: + +```ts +const contextValue = useMemo( + () => ( { + emblaApi, + setEmblaApi, + canScrollPrev, + setCanScrollPrev, + canScrollNext, + setCanScrollNext, + scrollProgress, + setScrollProgress, + selectedIndex, + scrollSnaps, + slideCount, + carouselOptions, + useTabs, + } ), + [ + emblaApi, + canScrollPrev, + canScrollNext, + scrollProgress, + selectedIndex, + scrollSnaps, + slideCount, + carouselOptions, + useTabs, + setEmblaApi, + setCanScrollPrev, + setCanScrollNext, + setScrollProgress, + ], +); +``` + +**3e. Add the toggle handler function** (before `inspectorControls`): + +```ts +const handleUseTabsChange = ( value: boolean ) => { + if ( value ) { + setAttributes( { + useTabs: true, + loop: false, + dragFree: false, + carouselAlign: 'start', + containScroll: 'trimSnaps', + slidesToScroll: '1', + autoplay: false, + axis: 'x', + } ); + // Auto-insert tab list as first child (before viewport) + insertBlock( + createBlock( 'rt-carousel/carousel-tab-list', {} ), + 0, + clientId, + ); + } else { + setAttributes( { useTabs: false } ); + } +}; +``` + +**3f. Update `inspectorControls`** — add toggle at top; conditionally hide carousel-only controls: + +Replace the opening of the first `PanelBody` in `inspectorControls`: + +```tsx + + + + { ! useTabs && ( + <> + setAttributes( { loop: value } ) } + help={ __( + 'Enables infinite scrolling of slides.', + 'rt-carousel', + ) } + /> + setAttributes( { dragFree: value } ) } + help={ __( 'Enables momentum scrolling.', 'rt-carousel' ) } + /> + + setAttributes( { carouselAlign: value as CarouselAttributes[ 'carouselAlign' ] } ) + } + /> + + setAttributes( { containScroll: value as CarouselAttributes[ 'containScroll' ] } ) + } + help={ __( + 'Prevents excess scrolling at the beginning or end.', + 'rt-carousel', + ) } + /> + + setAttributes( { slidesToScroll: isAuto ? 'auto' : '1' } ) + } + help={ __( + 'Scrolls the number of slides currently visible in the viewport.', + 'rt-carousel', + ) } + /> + { slidesToScroll !== 'auto' && ( + + setAttributes( { slidesToScroll: ( value || 1 ).toString() } ) + } + min={ 1 } + max={ 10 } + /> + ) } + + setAttributes( { axis: value as CarouselAttributes[ 'axis' ] } ) + } + /> + { axis === 'y' && ( + setAttributes( { height: value } ) } + help={ __( + 'Set a fixed height for vertical carousel (e.g., 400px).', + 'rt-carousel', + ) } + /> + ) } + + ) } + + setAttributes( { direction: value as CarouselAttributes[ 'direction' ] } ) + } + help={ __( + 'Choose content direction. RTL is typically used for Arabic, Hebrew, and other right-to-left languages.', + 'rt-carousel', + ) } + /> + + { ! useTabs && ( + + setAttributes( { autoplay: value } ) } + /> + { autoplay && ( + <> + + setAttributes( { autoplayDelay: value ?? 1000 } ) + } + min={ 1000 } + max={ 10000 } + step={ 100 } + /> + + setAttributes( { autoplayStopOnInteraction: value } ) + } + help={ __( + 'Stop autoplay when user interacts with carousel.', + 'rt-carousel', + ) } + /> + + setAttributes( { autoplayStopOnMouseEnter: value } ) + } + help={ __( + 'Stop autoplay when mouse hovers over carousel.', + 'rt-carousel', + ) } + /> + + ) } + + ) } + +``` + +Keep the remaining `InspectorControls` panels (styles panel with Slide Gap, `InspectorAdvancedControls`) unchanged. + +- [ ] **Step 4: Update `viewport/edit.tsx` — forward `duration` to Embla** + +Open `src/blocks/carousel/viewport/edit.tsx`. Find the Embla initialisation inside the `useEffect`. Currently: + +```ts +embla = EmblaCarousel( viewport, { + loop: options?.loop ?? false, + dragFree: options?.dragFree ?? false, + containScroll: normalizeContainScroll( options?.containScroll ), + axis: options?.axis || 'x', + align: options?.align || 'start', + direction: options?.direction || 'ltr', + slidesToScroll: options?.slidesToScroll || 1, + container: dynamicListContainer || undefined, + watchDrag: false, + watchSlides: false, + watchResize: false, +} ); +``` + +Add `duration` forwarding: + +```ts +embla = EmblaCarousel( viewport, { + loop: options?.loop ?? false, + dragFree: options?.dragFree ?? false, + containScroll: normalizeContainScroll( options?.containScroll ), + axis: options?.axis || 'x', + align: options?.align || 'start', + direction: options?.direction || 'ltr', + slidesToScroll: options?.slidesToScroll || 1, + duration: options?.duration, + container: dynamicListContainer || undefined, + watchDrag: false, + watchSlides: false, + watchResize: false, +} ); +``` + +- [ ] **Step 5: Run test — expect pass** + +```bash +npm run test:js -- --testPathPattern="edit.test" 2>&1 | tail -30 +``` + +Expected: "Use as Tabs toggle" test passes. + +- [ ] **Step 6: TypeScript check** + +```bash +npm run lint:js:types 2>&1 | grep error | head -20 +``` + +Expected: zero errors. + +- [ ] **Step 7: Commit** + +```bash +git add src/blocks/carousel/edit.tsx src/blocks/carousel/viewport/edit.tsx +git commit -m "feat: add Use as Tabs toggle, auto-insert tab list, hide carousel-only controls" +``` + +--- + +## Task 7: Slide `edit.tsx` WYSIWYG + `editor.scss` hide rule + +**Files:** +- Modify: `src/blocks/carousel/slide/edit.tsx` +- Modify: `src/blocks/carousel/editor.scss` + +**Interfaces:** +- Consumes: `EditorCarouselContext.selectedIndex`, `EditorCarouselContext.useTabs`, `rt-carousel/carousel/useTabs` context prop from slide's `usesContext` +- Produces: In the editor, active slide gets `is-active` CSS class when `useTabs` is on; non-active slides are hidden via `.rt-carousel[data-is-tabs] .embla__slide:not(.is-active) { display: none }` + +- [ ] **Step 1: Write failing test** + +Add to `src/blocks/carousel/__tests__/edit.test.tsx` (within a new describe block): + +```ts +describe( 'slide edit WYSIWYG', () => { + it( 'renders — slide edit imports compile without error', () => { + // Smoke test: the slide Edit component can be imported + // Full WYSIWYG behaviour is verified manually in the editor + const SlideEdit = require( '../slide/edit' ).default; + expect( typeof SlideEdit ).toBe( 'function' ); + } ); +} ); +``` + +- [ ] **Step 2: Run test — expect pass (already passes, just confirms module loads)** + +```bash +npm run test:js -- --testPathPattern="edit.test" 2>&1 | tail -15 +``` + +Expected: passes (the slide/edit module already exists). + +- [ ] **Step 3: Update `slide/edit.tsx`** + +Open `src/blocks/carousel/slide/edit.tsx`. Replace the entire file content: + +```tsx +import { + useBlockProps, + useInnerBlocksProps, + BlockControls, + BlockVerticalAlignmentToolbar, +} from '@wordpress/block-editor'; +import { useSelect } from '@wordpress/data'; +import { useContext } from '@wordpress/element'; +import type { CarouselSlideAttributes, BlockEditorSelectors } from '../types'; +import { EditorCarouselContext } from '../editor-context'; + +export default function Edit( { + attributes, + setAttributes, + clientId, + context, +}: { + attributes: CarouselSlideAttributes; + setAttributes: ( attributes: Partial ) => void; + clientId: string; + context: { 'rt-carousel/carousel/allowedSlideBlocks'?: string[]; 'rt-carousel/carousel/useTabs'?: boolean }; +} ) { + const allowedBlocks = context[ 'rt-carousel/carousel/allowedSlideBlocks' ]; + const useTabs = context[ 'rt-carousel/carousel/useTabs' ] ?? false; + const { verticalAlignment } = attributes; + const { selectedIndex } = useContext( EditorCarouselContext ); + + // Find this slide's position among its siblings to determine if it's active + const slideIndex = useSelect( + ( select ) => { + const blockEditor = select( 'core/block-editor' ) as BlockEditorSelectors; + const parentId = ( blockEditor as unknown as { getBlockRootClientId: ( id: string ) => string } ).getBlockRootClientId( clientId ); + if ( ! parentId ) { + return -1; + } + const siblings = blockEditor.getBlocks( parentId ) as Array<{ clientId: string }>; + return siblings.findIndex( ( b ) => b.clientId === clientId ); + }, + [ clientId ], + ); + + const isActive = useTabs && slideIndex === selectedIndex; + + const blockProps = useBlockProps( { + className: [ + 'embla__slide', + verticalAlignment ? `is-vertically-aligned-${ verticalAlignment }` : '', + isActive ? 'is-active' : '', + ] + .filter( Boolean ) + .join( ' ' ), + } ); + + const innerBlocksProps = useInnerBlocksProps( blockProps, { + allowedBlocks: + allowedBlocks && allowedBlocks.length > 0 ? allowedBlocks : undefined, + templateLock: false, + } ); + + return ( + <> + + + setAttributes( { verticalAlignment: value } ) + } + /> + +
+ + ); +} +``` + +Note: `BlockEditorSelectors` currently defines `getBlocks` and `getSelectedBlockClientId` and `getBlockParents`. We need `getBlockRootClientId` too. Update `types.ts`: + +In `src/blocks/carousel/types.ts`, update `BlockEditorSelectors`: + +```ts +export interface BlockEditorSelectors { + getBlocks: ( clientId: string ) => Array<{ clientId: string }>; + getSelectedBlockClientId: () => string | null; + getBlockParents: ( clientId: string ) => string[]; + getBlockRootClientId: ( clientId: string ) => string | null; +} +``` + +- [ ] **Step 4: Add hide rule to `editor.scss`** + +Open `src/blocks/carousel/editor.scss`. Append at the end: + +```scss +// Tabs mode: hide non-active slides for WYSIWYG tab behaviour +.rt-carousel[data-is-tabs] { + .embla__slide:not(.is-active) { + display: none; + } +} +``` + +- [ ] **Step 5: TypeScript check** + +```bash +npm run lint:js:types 2>&1 | grep error | head -20 +``` + +Expected: zero errors. + +- [ ] **Step 6: Run full test suite** + +```bash +npm run test:js 2>&1 | tail -20 +``` + +Expected: all tests pass. + +- [ ] **Step 7: Build** + +```bash +npm run build 2>&1 | tail -20 +``` + +Expected: build succeeds. + +- [ ] **Step 8: Commit** + +```bash +git add src/blocks/carousel/slide/edit.tsx src/blocks/carousel/editor.scss src/blocks/carousel/types.ts +git commit -m "feat: WYSIWYG tabs mode — is-active class on active slide and hide non-active" +``` + +--- + +## Task 8: Final verification and spec check + +- [ ] **Step 1: Run full test suite** + +```bash +npm run test:js 2>&1 | tail -30 +``` + +Expected: all tests pass, coverage thresholds met (≥40% branches/functions/lines/statements). + +- [ ] **Step 2: TypeScript clean** + +```bash +npm run lint:js:types 2>&1 +``` + +Expected: zero errors. + +- [ ] **Step 3: Build clean** + +```bash +npm run build 2>&1 | tail -20 +``` + +Expected: build succeeds, `build/blocks/carousel/carousel-tab-list/` exists with `index.js`, `view.js`, `style-index.css`, `block.json`. + +- [ ] **Step 4: Spec coverage check** + +Verify each spec requirement has a corresponding implementation: + +| Spec requirement | Implemented in | +|-----------------|---------------| +| `useTabs` attribute on carousel | Task 1: block.json | +| Disabled attrs reset on toggle-ON | Task 6: handleUseTabsChange | +| Tab list auto-inserted | Task 6: insertBlock in handleUseTabsChange | +| Inspector controls hidden when useTabs | Task 6: conditional render | +| Panel title "Tab Settings" | Task 6: PanelBody title | +| WYSIWYG: non-active slides hidden | Task 7: editor.scss + is-active class | +| `aria-roledescription` removed when useTabs | Task 3: save.tsx | +| `role="tabpanel"` on slides | Task 4: slide/save.tsx | +| `hidden` binding on slides | Task 4: slide/save.tsx | +| `id`/`aria-labelledby` bindings on slides | Task 4 + Task 5 callbacks | +| `role="tablist"` on tab list | Task 2: save.tsx | +| `role="tab"` + `aria-selected` on buttons | Task 2: save.tsx | +| `aria-controls`/`id` on tab buttons | Task 2: save.tsx + Task 2: view.ts | +| Labeled tabs via RichText | Task 2: edit.tsx | +| Active tab colour customisation | Task 2: edit.tsx + style.scss | +| `duration: 0` in tabs mode | Task 3: save.tsx + Task 6: edit.tsx + viewport/edit.tsx | +| `carouselId` for ID linkage | Task 5: view.ts initCarousel | +| Tab list stays when useTabs toggled OFF | Task 6: handleUseTabsChange (only sets useTabs:false) | +| PHP block registration | Task 2: Plugin.php | + +- [ ] **Step 5: Final commit** + +```bash +git add -A +git commit -m "feat: carousel-as-tabs complete implementation" +``` From 236dc2acd5752243d7541b26f9bdebb297f9ab59 Mon Sep 17 00:00:00 2001 From: Danish Shakeel Date: Sun, 19 Jul 2026 14:18:03 +0200 Subject: [PATCH 03/14] feat: add useTabs attribute to carousel data model --- src/blocks/carousel/__tests__/edit.test.tsx | 1 + src/blocks/carousel/__tests__/types.test.ts | 2 ++ src/blocks/carousel/block.json | 7 ++++++- src/blocks/carousel/editor-context.ts | 3 +++ src/blocks/carousel/slide/block.json | 3 ++- src/blocks/carousel/types.ts | 4 ++++ 6 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/blocks/carousel/__tests__/edit.test.tsx b/src/blocks/carousel/__tests__/edit.test.tsx index 62297fe..d6d55c6 100644 --- a/src/blocks/carousel/__tests__/edit.test.tsx +++ b/src/blocks/carousel/__tests__/edit.test.tsx @@ -157,6 +157,7 @@ const createAttributes = (): CarouselAttributes => ( { ariaLabel: 'Carousel', slidesToScroll: '1', slideGap: 0, + useTabs: false, } ); describe( 'Carousel Edit setup flow', () => { diff --git a/src/blocks/carousel/__tests__/types.test.ts b/src/blocks/carousel/__tests__/types.test.ts index aed1c05..67774de 100644 --- a/src/blocks/carousel/__tests__/types.test.ts +++ b/src/blocks/carousel/__tests__/types.test.ts @@ -33,6 +33,7 @@ describe( 'CarouselAttributes Type', () => { ariaLabel: 'Image carousel', slideGap: 16, slidesToScroll: '1', + useTabs: false, }; expect( attributes ).toBeDefined(); @@ -58,6 +59,7 @@ describe( 'CarouselAttributes Type', () => { ariaLabel: '', slideGap: 0, slidesToScroll: 'auto', + useTabs: false, }; // Verify all keys exist diff --git a/src/blocks/carousel/block.json b/src/blocks/carousel/block.json index e0df7ca..83da8b3 100644 --- a/src/blocks/carousel/block.json +++ b/src/blocks/carousel/block.json @@ -9,7 +9,8 @@ "description": "Carousel container using Embla and Interactivity API.", "textdomain": "rt-carousel", "providesContext": { - "rt-carousel/carousel/allowedSlideBlocks": "allowedSlideBlocks" + "rt-carousel/carousel/allowedSlideBlocks": "allowedSlideBlocks", + "rt-carousel/carousel/useTabs": "useTabs" }, "supports": { "interactivity": true, @@ -85,6 +86,10 @@ "slidesToScroll": { "type": "string", "default": "1" + }, + "useTabs": { + "type": "boolean", + "default": false } }, "editorScript": "file:./index.js", diff --git a/src/blocks/carousel/editor-context.ts b/src/blocks/carousel/editor-context.ts index 6e705ed..2611822 100644 --- a/src/blocks/carousel/editor-context.ts +++ b/src/blocks/carousel/editor-context.ts @@ -14,8 +14,10 @@ export type EditorCarouselContextType = { selectedIndex: number; scrollSnaps: number[]; slideCount: number; + useTabs?: boolean; carouselOptions: Omit, 'slidesToScroll'> & { slidesToScroll?: number | string; + duration?: number; }; }; @@ -32,6 +34,7 @@ const defaultValue: EditorCarouselContextType = { selectedIndex: 0, scrollSnaps: [], slideCount: 0, + useTabs: false, carouselOptions: {}, }; diff --git a/src/blocks/carousel/slide/block.json b/src/blocks/carousel/slide/block.json index fd07d6a..2cd2e9f 100644 --- a/src/blocks/carousel/slide/block.json +++ b/src/blocks/carousel/slide/block.json @@ -17,7 +17,8 @@ } }, "usesContext": [ - "rt-carousel/carousel/allowedSlideBlocks" + "rt-carousel/carousel/allowedSlideBlocks", + "rt-carousel/carousel/useTabs" ], "editorScript": "file:./index.js" } \ No newline at end of file diff --git a/src/blocks/carousel/types.ts b/src/blocks/carousel/types.ts index e05351b..8a19be2 100644 --- a/src/blocks/carousel/types.ts +++ b/src/blocks/carousel/types.ts @@ -18,6 +18,7 @@ export type CarouselAttributes = { ariaLabel: string; slideGap: number; slidesToScroll: string; + useTabs: boolean; }; export type CarouselViewportAttributes = Record; @@ -37,6 +38,7 @@ export interface BlockEditorSelectors { getBlocks: ( clientId: string ) => Array<{ clientId: string }>; getSelectedBlockClientId: () => string | null; getBlockParents: ( clientId: string ) => string[]; + getBlockRootClientId: ( clientId: string ) => string | null; } export type CarouselContext = { @@ -65,4 +67,6 @@ export type CarouselContext = { ref?: HTMLElement | null; slideCount: number; initialized?: boolean; + useTabs?: boolean; + carouselId?: string; }; From 37c85822ae3c3ce7002059076c79f9aaa88a214a Mon Sep 17 00:00:00 2001 From: Danish Shakeel Date: Sun, 19 Jul 2026 14:19:41 +0200 Subject: [PATCH 04/14] feat: add rt-carousel/carousel-tab-list block --- inc/Plugin.php | 1 + .../carousel/carousel-tab-list/block.json | 43 ++++++++ .../carousel/carousel-tab-list/edit.tsx | 102 ++++++++++++++++++ .../carousel/carousel-tab-list/index.ts | 11 ++ .../carousel/carousel-tab-list/save.tsx | 42 ++++++++ .../carousel/carousel-tab-list/style.scss | 46 ++++++++ .../carousel/carousel-tab-list/types.ts | 15 +++ src/blocks/carousel/carousel-tab-list/view.ts | 33 ++++++ 8 files changed, 293 insertions(+) create mode 100644 src/blocks/carousel/carousel-tab-list/block.json create mode 100644 src/blocks/carousel/carousel-tab-list/edit.tsx create mode 100644 src/blocks/carousel/carousel-tab-list/index.ts create mode 100644 src/blocks/carousel/carousel-tab-list/save.tsx create mode 100644 src/blocks/carousel/carousel-tab-list/style.scss create mode 100644 src/blocks/carousel/carousel-tab-list/types.ts create mode 100644 src/blocks/carousel/carousel-tab-list/view.ts diff --git a/inc/Plugin.php b/inc/Plugin.php index fa8bf5c..8e45d4d 100644 --- a/inc/Plugin.php +++ b/inc/Plugin.php @@ -129,6 +129,7 @@ public function register_block_category( array $categories ): array { public function register_blocks(): void { $blocks = [ 'carousel', + 'carousel/carousel-tab-list', 'carousel/controls', 'carousel/counter', 'carousel/dots', diff --git a/src/blocks/carousel/carousel-tab-list/block.json b/src/blocks/carousel/carousel-tab-list/block.json new file mode 100644 index 0000000..bb5c6c9 --- /dev/null +++ b/src/blocks/carousel/carousel-tab-list/block.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "version": "1.0.0", + "name": "rt-carousel/carousel-tab-list", + "title": "Carousel Tab List", + "category": "rt-carousel", + "icon": "list-view", + "description": "Labeled tab navigation for a carousel in tabs mode.", + "textdomain": "rt-carousel", + "ancestor": [ + "rt-carousel/carousel" + ], + "attributes": { + "labels": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "orientation": { + "type": "string", + "enum": [ "horizontal", "vertical" ], + "default": "horizontal" + }, + "activeTabBackgroundColor": { + "type": "string", + "default": "" + }, + "activeTabTextColor": { + "type": "string", + "default": "" + } + }, + "supports": { + "interactivity": true, + "html": false + }, + "editorScript": "file:./index.ts", + "style": "file:./style-index.css", + "viewScriptModule": "file:./view.ts" +} diff --git a/src/blocks/carousel/carousel-tab-list/edit.tsx b/src/blocks/carousel/carousel-tab-list/edit.tsx new file mode 100644 index 0000000..cd8e050 --- /dev/null +++ b/src/blocks/carousel/carousel-tab-list/edit.tsx @@ -0,0 +1,102 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { + InspectorControls, + RichText, + useBlockProps, + ColorPalette, +} from '@wordpress/block-editor'; +import { PanelBody, SelectControl, BaseControl } from '@wordpress/components'; +import { useContext } from '@wordpress/element'; +import { EditorCarouselContext } from '../editor-context'; +import type { TabListAttributes } from './types'; + +export default function Edit( { + attributes, + setAttributes, +}: { + attributes: TabListAttributes; + setAttributes: ( attrs: Partial ) => void; +} ) { + const { labels, orientation, activeTabBackgroundColor, activeTabTextColor } = attributes; + const carousel = useContext( EditorCarouselContext ); + const dotCount = Math.max( carousel.scrollSnaps.length, 1 ); + + const style: React.CSSProperties = {}; + if ( activeTabBackgroundColor ) { + ( style as Record )[ '--rt-tab-active-bg' ] = activeTabBackgroundColor; + } + if ( activeTabTextColor ) { + ( style as Record )[ '--rt-tab-active-color' ] = activeTabTextColor; + } + + const blockProps = useBlockProps( { + className: orientation === 'vertical' ? 'is-vertical' : undefined, + style, + } ); + + const setLabelAt = ( index: number, value: string ) => { + const next = [ ...labels ]; + while ( next.length <= index ) { + next.push( '' ); + } + next[ index ] = value; + setAttributes( { labels: next } ); + }; + + return ( + <> + + + + setAttributes( { + orientation: value as TabListAttributes[ 'orientation' ], + } ) + } + /> + + + + + setAttributes( { activeTabBackgroundColor: color ?? '' } ) + } + /> + + + + setAttributes( { activeTabTextColor: color ?? '' } ) + } + /> + + + +
+ { Array.from( { length: dotCount } ).map( ( _, index ) => ( + setLabelAt( index, value ) } + /* translators: %d: tab number */ + placeholder={ sprintf( __( 'Tab %d', 'rt-carousel' ), index + 1 ) } + allowedFormats={ [] } + /> + ) ) } +
+ + ); +} diff --git a/src/blocks/carousel/carousel-tab-list/index.ts b/src/blocks/carousel/carousel-tab-list/index.ts new file mode 100644 index 0000000..5684d48 --- /dev/null +++ b/src/blocks/carousel/carousel-tab-list/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 { TabListAttributes } from './types'; +import './style.scss'; + +registerBlockType( metadata as BlockConfiguration, { + edit: Edit, + save: Save, +} ); diff --git a/src/blocks/carousel/carousel-tab-list/save.tsx b/src/blocks/carousel/carousel-tab-list/save.tsx new file mode 100644 index 0000000..7771109 --- /dev/null +++ b/src/blocks/carousel/carousel-tab-list/save.tsx @@ -0,0 +1,42 @@ +import { useBlockProps } from '@wordpress/block-editor'; +import type { TabListAttributes } from './types'; + +export default function Save( { attributes }: { attributes: TabListAttributes } ) { + const { orientation, labels, activeTabBackgroundColor, activeTabTextColor } = attributes; + + const style: React.CSSProperties = {}; + if ( activeTabBackgroundColor ) { + ( style as Record )[ '--rt-tab-active-bg' ] = activeTabBackgroundColor; + } + if ( activeTabTextColor ) { + ( style as Record )[ '--rt-tab-active-color' ] = activeTabTextColor; + } + + const blockProps = useBlockProps.save( { + className: orientation === 'vertical' ? 'is-vertical' : undefined, + role: 'tablist', + style, + 'data-wp-interactive': 'rt-carousel/carousel', + 'data-wp-context': JSON.stringify( { dotLabels: labels ?? [] } ), + } ); + + return ( +
+ +
+ ); +} diff --git a/src/blocks/carousel/carousel-tab-list/style.scss b/src/blocks/carousel/carousel-tab-list/style.scss new file mode 100644 index 0000000..014bb58 --- /dev/null +++ b/src/blocks/carousel/carousel-tab-list/style.scss @@ -0,0 +1,46 @@ +.wp-block-rt-carousel-carousel-tab-list { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin: 0; + padding: 0; + + &.is-vertical { + flex-direction: column; + } + + &__tab { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.5rem 1rem; + border: 1px solid currentColor; + background: transparent; + cursor: pointer; + font: inherit; + border-radius: 0.25rem; + transition: + background-color 0.2s ease, + color 0.2s ease; + + --rt-tab-active-bg: #000; + --rt-tab-active-color: #fff; + + &:focus-visible { + outline: 2px solid; + outline-offset: 2px; + } + + &.is-active, + &[aria-selected="true"] { + background-color: var(--rt-tab-active-bg); + color: var(--rt-tab-active-color); + } + } +} + +@media (prefers-reduced-motion: reduce) { + .wp-block-rt-carousel-carousel-tab-list__tab { + transition: none; + } +} diff --git a/src/blocks/carousel/carousel-tab-list/types.ts b/src/blocks/carousel/carousel-tab-list/types.ts new file mode 100644 index 0000000..c7d4366 --- /dev/null +++ b/src/blocks/carousel/carousel-tab-list/types.ts @@ -0,0 +1,15 @@ +export type TabListAttributes = { + labels: string[]; + orientation: 'horizontal' | 'vertical'; + activeTabBackgroundColor: string; + activeTabTextColor: string; +}; + +export type TabContext = { + snap?: { index?: number }; + dotLabels?: string[]; + carouselId?: string; + ariaLabelPattern?: string; + selectedIndex?: number; + scrollSnaps?: { index: number }[]; +}; diff --git a/src/blocks/carousel/carousel-tab-list/view.ts b/src/blocks/carousel/carousel-tab-list/view.ts new file mode 100644 index 0000000..75882ad --- /dev/null +++ b/src/blocks/carousel/carousel-tab-list/view.ts @@ -0,0 +1,33 @@ +import { getContext, store } from '@wordpress/interactivity'; +import type { TabContext } from './types'; + +// Extends rt-carousel/carousel store — reuses onDotClick/isDotActive/scrollSnaps as-is. +store( 'rt-carousel/carousel', { + callbacks: { + getKeyFeatureDotText: (): string => { + const context = getContext(); + const index = context.snap?.index ?? 0; + const label = context.dotLabels?.[ index ]; + const trimmed = label?.trim(); + return trimmed ? trimmed : String( index + 1 ); + }, + getTabAriaControls: (): string => { + const context = getContext(); + const index = context.snap?.index ?? 0; + return `rt-carousel-panel-${ context.carouselId }-${ index }`; + }, + getTabId: (): string => { + const context = getContext(); + const index = context.snap?.index ?? 0; + return `rt-carousel-tab-${ context.carouselId }-${ index }`; + }, + getTabLabel: (): string => { + const context = getContext(); + const index = ( context.snap?.index ?? 0 ) + 1; + return ( context.ariaLabelPattern ?? 'Go to tab %d' ).replace( + '%d', + index.toString(), + ); + }, + }, +} ); From a25a06aff148d5298827d2adecda8f8ed3e145a8 Mon Sep 17 00:00:00 2001 From: Danish Shakeel Date: Sun, 19 Jul 2026 14:20:20 +0200 Subject: [PATCH 05/14] feat: add useTabs/carouselId context and conditional aria-roledescription to carousel save --- src/blocks/carousel/save.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/blocks/carousel/save.tsx b/src/blocks/carousel/save.tsx index 73ef6ca..c920a9b 100644 --- a/src/blocks/carousel/save.tsx +++ b/src/blocks/carousel/save.tsx @@ -22,6 +22,7 @@ export default function Save( { axis, height, slidesToScroll, + useTabs = false, } = attributes; // Pass configuration to the frontend via data-wp-context @@ -34,6 +35,8 @@ export default function Save( { direction, axis, slidesToScroll: slidesToScroll === 'auto' ? 'auto' : parseInt( slidesToScroll, 10 ), + // Instant switch in tabs mode — no scroll animation + ...( useTabs ? { duration: 0 } : {} ), }, autoplay: autoplay ? { @@ -64,12 +67,14 @@ export default function Save( { 'Slide {{currentSlide}} of {{totalSlides}}', 'rt-carousel', ), + useTabs, + carouselId: '', // Set at runtime by initCarousel in view.ts }; const blockProps = useBlockProps.save( { className: 'rt-carousel', role: 'region', - 'aria-roledescription': 'carousel', + ...( ! useTabs ? { 'aria-roledescription': 'carousel' } : {} ), 'aria-label': ariaLabel, dir: direction, 'data-axis': axis, From 39fef4b23c45d031889be83efe49b4386c3c7ed6 Mon Sep 17 00:00:00 2001 From: Danish Shakeel Date: Sun, 19 Jul 2026 14:20:46 +0200 Subject: [PATCH 06/14] feat: add tabpanel role and ARIA bindings to slide save in tabs mode --- src/blocks/carousel/slide/save.tsx | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/blocks/carousel/slide/save.tsx b/src/blocks/carousel/slide/save.tsx index f73a867..9518982 100644 --- a/src/blocks/carousel/slide/save.tsx +++ b/src/blocks/carousel/slide/save.tsx @@ -1,17 +1,33 @@ import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor'; import type { CarouselSlideAttributes } from '../types'; -export default function Save( { attributes }: { attributes: CarouselSlideAttributes } ) { +export default function Save( { + attributes, + context, +}: { + attributes: CarouselSlideAttributes; + context?: { 'rt-carousel/carousel/useTabs'?: boolean }; +} ) { const { verticalAlignment } = attributes; + const useTabs = context?.[ 'rt-carousel/carousel/useTabs' ] ?? false; + const blockProps = useBlockProps.save( { className: `embla__slide${ verticalAlignment ? ` is-vertically-aligned-${ verticalAlignment }` : '' }`, - role: 'group', - 'aria-roledescription': 'slide', + role: useTabs ? 'tabpanel' : 'group', + ...( ! useTabs ? { 'aria-roledescription': 'slide' } : {} ), 'data-wp-interactive': 'rt-carousel/carousel', - 'data-wp-class--is-active': 'callbacks.isSlideActive', - 'data-wp-bind--aria-current': 'callbacks.isSlideActive', + ...( useTabs + ? { + 'data-wp-bind--id': 'callbacks.getSlideTabPanelId', + 'data-wp-bind--aria-labelledby': 'callbacks.getSlideTabLabelledBy', + 'data-wp-bind--hidden': 'callbacks.isSlideHiddenForTabs', + } + : { + 'data-wp-class--is-active': 'callbacks.isSlideActive', + 'data-wp-bind--aria-current': 'callbacks.isSlideActive', + } ), } ); const innerBlocksProps = useInnerBlocksProps.save( blockProps ); From 30bdfe15af859d7b714618ddb0bb578691072fab Mon Sep 17 00:00:00 2001 From: Danish Shakeel Date: Sun, 19 Jul 2026 14:22:28 +0200 Subject: [PATCH 07/14] feat: add carouselId and tab panel/labelledby callbacks to view store --- src/blocks/carousel/__tests__/view.test.ts | 111 +++++++++++++++++++++ src/blocks/carousel/view.ts | 69 +++++++++++++ 2 files changed, 180 insertions(+) diff --git a/src/blocks/carousel/__tests__/view.test.ts b/src/blocks/carousel/__tests__/view.test.ts index 599fca3..596257c 100644 --- a/src/blocks/carousel/__tests__/view.test.ts +++ b/src/blocks/carousel/__tests__/view.test.ts @@ -1011,3 +1011,114 @@ describe( 'Edge Cases and Error Handling', () => { expect( mockContext.selectedIndex ).toBe( 50 ); } ); } ); + +describe( 'isSlideHiddenForTabs', () => { + const isSlideHiddenForTabs = storeConfig?.callbacks?.isSlideHiddenForTabs; + + it( 'returns false when useTabs is false', () => { + ( getContext as jest.Mock ).mockReturnValue( + createMockContext( { useTabs: false, carouselId: '1', selectedIndex: 0, initialized: true } ), + ); + ( getElement as jest.Mock ).mockReturnValue( document.createElement( 'div' ) ); + + expect( isSlideHiddenForTabs() ).toBe( false ); + } ); + + it( 'returns false when useTabs is true and slide is active (index 0)', () => { + const container = document.createElement( 'div' ); + container.className = 'embla__container'; + const slide = document.createElement( 'div' ); + slide.className = 'embla__slide'; + container.appendChild( slide ); + const viewport = document.createElement( 'div' ); + viewport.className = 'embla'; + viewport.appendChild( container ); + const wrapper = document.createElement( 'div' ); + wrapper.className = 'rt-carousel'; + wrapper.appendChild( viewport ); + + ( getContext as jest.Mock ).mockReturnValue( + createMockContext( { useTabs: true, carouselId: '1', selectedIndex: 0, initialized: true } ), + ); + ( getElement as jest.Mock ).mockReturnValue( slide ); + + expect( isSlideHiddenForTabs() ).toBe( false ); + } ); + + it( 'returns true when useTabs is true and slide is not active', () => { + const container = document.createElement( 'div' ); + container.className = 'embla__container'; + const slide0 = document.createElement( 'div' ); + slide0.className = 'embla__slide'; + const slide1 = document.createElement( 'div' ); + slide1.className = 'embla__slide'; + container.appendChild( slide0 ); + container.appendChild( slide1 ); + const viewport = document.createElement( 'div' ); + viewport.className = 'embla'; + viewport.appendChild( container ); + const wrapper = document.createElement( 'div' ); + wrapper.className = 'rt-carousel'; + wrapper.appendChild( viewport ); + + ( getContext as jest.Mock ).mockReturnValue( + createMockContext( { useTabs: true, carouselId: '1', selectedIndex: 0, initialized: true } ), + ); + ( getElement as jest.Mock ).mockReturnValue( slide1 ); + + expect( isSlideHiddenForTabs() ).toBe( true ); + } ); +} ); + +describe( 'getSlideTabPanelId', () => { + const getSlideTabPanelId = storeConfig?.callbacks?.getSlideTabPanelId; + + it( 'returns correct id for slide at index 1', () => { + const container = document.createElement( 'div' ); + container.className = 'embla__container'; + const slide0 = document.createElement( 'div' ); + slide0.className = 'embla__slide'; + const slide1 = document.createElement( 'div' ); + slide1.className = 'embla__slide'; + container.appendChild( slide0 ); + container.appendChild( slide1 ); + const viewport = document.createElement( 'div' ); + viewport.className = 'embla'; + viewport.appendChild( container ); + const wrapper = document.createElement( 'div' ); + wrapper.className = 'rt-carousel'; + wrapper.appendChild( viewport ); + + ( getContext as jest.Mock ).mockReturnValue( + createMockContext( { carouselId: 'abc', useTabs: true } ), + ); + ( getElement as jest.Mock ).mockReturnValue( slide1 ); + + expect( getSlideTabPanelId() ).toBe( 'rt-carousel-panel-abc-1' ); + } ); +} ); + +describe( 'getSlideTabLabelledBy', () => { + const getSlideTabLabelledBy = storeConfig?.callbacks?.getSlideTabLabelledBy; + + it( 'returns correct labelledby id for slide at index 0', () => { + const container = document.createElement( 'div' ); + container.className = 'embla__container'; + const slide0 = document.createElement( 'div' ); + slide0.className = 'embla__slide'; + container.appendChild( slide0 ); + const viewport = document.createElement( 'div' ); + viewport.className = 'embla'; + viewport.appendChild( container ); + const wrapper = document.createElement( 'div' ); + wrapper.className = 'rt-carousel'; + wrapper.appendChild( viewport ); + + ( getContext as jest.Mock ).mockReturnValue( + createMockContext( { carouselId: 'abc', useTabs: true } ), + ); + ( getElement as jest.Mock ).mockReturnValue( slide0 ); + + expect( getSlideTabLabelledBy() ).toBe( 'rt-carousel-tab-abc-0' ); + } ); +} ); diff --git a/src/blocks/carousel/view.ts b/src/blocks/carousel/view.ts index c2ae8e4..6fde9e6 100644 --- a/src/blocks/carousel/view.ts +++ b/src/blocks/carousel/view.ts @@ -122,6 +122,9 @@ const markForAnnouncement = (): void => { getContext().shouldAnnounce = true; }; +// Incrementing counter for unique carousel IDs on the same page +let carouselIdCounter = 0; + store( 'rt-carousel/carousel', { state: { get canScrollPrev() { @@ -247,11 +250,77 @@ store( 'rt-carousel/carousel', { } return `transform:translate3d(${ getProgress() * 100 }%, 0px, 0px)`; }, + isSlideHiddenForTabs: () => { + const context = getContext(); + if ( ! context.useTabs ) { + return false; + } + if ( ! context.initialized ) { + return true; + } + + const slide = getElementRef( getElement() )?.closest?.( + CAROUSEL_SLIDE_SELECTOR, + ); + + if ( ! slide || ! slide.parentElement ) { + return false; + } + + const slides = Array.from( slide.parentElement.children ).filter( + ( child: Element ) => child.matches( CAROUSEL_SLIDE_SELECTOR ), + ); + + const index = slides.indexOf( slide ); + if ( index === -1 ) { + return false; + } + return context.selectedIndex !== index; + }, + getSlideTabPanelId: () => { + const context = getContext(); + const slide = getElementRef( getElement() )?.closest?.( + CAROUSEL_SLIDE_SELECTOR, + ); + + if ( ! slide || ! slide.parentElement ) { + return ''; + } + + const slides = Array.from( slide.parentElement.children ).filter( + ( child: Element ) => child.matches( CAROUSEL_SLIDE_SELECTOR ), + ); + + const index = slides.indexOf( slide ); + return `rt-carousel-panel-${ context.carouselId }-${ index }`; + }, + getSlideTabLabelledBy: () => { + const context = getContext(); + const slide = getElementRef( getElement() )?.closest?.( + CAROUSEL_SLIDE_SELECTOR, + ); + + if ( ! slide || ! slide.parentElement ) { + return ''; + } + + const slides = Array.from( slide.parentElement.children ).filter( + ( child: Element ) => child.matches( CAROUSEL_SLIDE_SELECTOR ), + ); + + const index = slides.indexOf( slide ); + return `rt-carousel-tab-${ context.carouselId }-${ index }`; + }, initCarousel: () => { try { const context = getContext(); const element = getElementRef( getElement() ); + // Assign a unique ID for tab panel/tab linkage + if ( ! context.carouselId ) { + context.carouselId = String( ++carouselIdCounter ); + } + if ( ! element || typeof element.querySelector !== 'function' ) { // eslint-disable-next-line no-console console.warn( 'Carousel: Invalid root element', element ); From 2fcc302b3f70f8b5bef767c521b4c606221ce21e Mon Sep 17 00:00:00 2001 From: Danish Shakeel Date: Sun, 19 Jul 2026 14:28:34 +0200 Subject: [PATCH 08/14] feat: add Use as Tabs toggle to carousel edit controls - Toggle in inspector disables carousel-specific controls when enabled - Auto-inserts carousel-tab-list block above viewport on toggle ON - Passes duration:0 via carouselOptions for instant tab switching - Passes useTabs to EditorCarouselContext for downstream consumers - Adds data-is-tabs attribute to blockProps for WYSIWYG CSS hook - viewport/edit.tsx: passes duration option through to Embla init --- src/blocks/carousel/__tests__/edit.test.tsx | 28 +- src/blocks/carousel/edit.tsx | 297 +++++++++++--------- src/blocks/carousel/viewport/edit.tsx | 1 + 3 files changed, 198 insertions(+), 128 deletions(-) diff --git a/src/blocks/carousel/__tests__/edit.test.tsx b/src/blocks/carousel/__tests__/edit.test.tsx index d6d55c6..f3f75e6 100644 --- a/src/blocks/carousel/__tests__/edit.test.tsx +++ b/src/blocks/carousel/__tests__/edit.test.tsx @@ -44,7 +44,15 @@ jest.mock( '@wordpress/components', () => { return { PanelBody: Passthrough, - ToggleControl: jest.fn( () => null ), + ToggleControl: jest.fn( ( { onChange, checked, label } ) => ( + onChange?.( e.target.checked ) } + readOnly={ ! onChange } + /> + ) ), SelectControl: jest.fn( () => null ), FormTokenField: jest.fn( () => null ), BaseControl: Passthrough, @@ -219,3 +227,21 @@ describe( 'Carousel Edit setup flow', () => { } } ); } ); + +describe( 'useTabs toggle', () => { + it( 'renders Use as Tabs toggle when inner blocks exist', () => { + mockBlockCount = 2; + + render( + , + ); + + const toggle = screen.getByRole( 'checkbox', { name: /use as tabs/i } ); + expect( toggle ).toBeInTheDocument(); + expect( toggle ).not.toBeChecked(); + } ); +} ); diff --git a/src/blocks/carousel/edit.tsx b/src/blocks/carousel/edit.tsx index f4f4be8..b72f875 100644 --- a/src/blocks/carousel/edit.tsx +++ b/src/blocks/carousel/edit.tsx @@ -54,6 +54,7 @@ export default function Edit( { autoplayStopOnMouseEnter, ariaLabel, slidesToScroll = '1', + useTabs = false, } = attributes; const [ emblaApi, setEmblaApi ] = useState(); @@ -160,6 +161,7 @@ export default function Edit( { dir: direction, 'data-axis': axis, 'data-loop': loop ? 'true' : undefined, + 'data-is-tabs': useTabs ? 'true' : undefined, style: { '--rt-carousel-gap': `${ attributes.slideGap }px`, '--rt-carousel-height': axis === 'y' ? height : undefined, @@ -178,8 +180,9 @@ export default function Edit( { axis, height, slidesToScroll: slidesToScroll === 'auto' ? 'auto' : parseInt( slidesToScroll, 10 ), + ...( useTabs ? { duration: 0 } : {} ), } ), - [ loop, dragFree, carouselAlign, containScroll, direction, axis, height, slidesToScroll ], + [ loop, dragFree, carouselAlign, containScroll, direction, axis, height, slidesToScroll, useTabs ], ); const contextValue = useMemo( @@ -196,6 +199,7 @@ export default function Edit( { scrollSnaps, slideCount, carouselOptions, + useTabs, } ), [ emblaApi, @@ -206,6 +210,7 @@ export default function Edit( { scrollSnaps, slideCount, carouselOptions, + useTabs, setEmblaApi, setCanScrollPrev, setCanScrollNext, @@ -307,159 +312,197 @@ export default function Edit( { ); }; + const handleUseTabsChange = ( value: boolean ) => { + if ( value ) { + setAttributes( { + useTabs: true, + loop: false, + dragFree: false, + carouselAlign: 'start', + containScroll: 'trimSnaps', + slidesToScroll: '1', + autoplay: false, + axis: 'x', + } ); + // Auto-insert tab list as first child (before viewport) + insertBlock( + createBlock( 'rt-carousel/carousel-tab-list', {} ), + 0, + clientId, + ); + } else { + setAttributes( { useTabs: false } ); + } + }; + const inspectorControls = ( <> - - setAttributes( { loop: value } ) } - help={ __( - 'Enables infinite scrolling of slides.', - 'rt-carousel', - ) } - /> - setAttributes( { dragFree: value } ) } - help={ __( 'Enables momentum scrolling.', 'rt-carousel' ) } - /> - - setAttributes( { carouselAlign: value as CarouselAttributes[ 'carouselAlign' ] } ) - } - /> - - setAttributes( { containScroll: value as CarouselAttributes[ 'containScroll' ] } ) - } - help={ __( - 'Prevents excess scrolling at the beginning or end.', - 'rt-carousel', - ) } - /> + - setAttributes( { slidesToScroll: isAuto ? 'auto' : '1' } ) - } + label={ __( 'Use as Tabs', 'rt-carousel' ) } + checked={ useTabs } + onChange={ handleUseTabsChange } help={ __( - 'Scrolls the number of slides currently visible in the viewport.', + 'Converts the carousel into an accessible tab widget.', 'rt-carousel', ) } /> - { slidesToScroll !== 'auto' && ( - - setAttributes( { slidesToScroll: ( value || 1 ).toString() } ) - } - min={ 1 } - max={ 10 } - /> - ) } - - setAttributes( { direction: value as CarouselAttributes[ 'direction' ] } ) - } - help={ __( - 'Choose content direction. RTL is typically used for Arabic, Hebrew, and other right-to-left languages.', - 'rt-carousel', - ) } - /> - - setAttributes( { axis: value as CarouselAttributes[ 'axis' ] } ) - } - /> - { axis === 'y' && ( - setAttributes( { height: value } ) } - help={ __( - 'Set a fixed height for vertical carousel (e.g., 400px).', - 'rt-carousel', - ) } - /> - ) } - - - setAttributes( { autoplay: value } ) } - /> - { autoplay && ( + { ! useTabs && ( <> - setAttributes( { loop: value } ) } + help={ __( + 'Enables infinite scrolling of slides.', + 'rt-carousel', + ) } + /> + setAttributes( { dragFree: value } ) } + help={ __( 'Enables momentum scrolling.', 'rt-carousel' ) } + /> + - setAttributes( { autoplayDelay: value ?? 1000 } ) + setAttributes( { carouselAlign: value as CarouselAttributes[ 'carouselAlign' ] } ) } - min={ 1000 } - max={ 10000 } - step={ 100 } /> - - setAttributes( { autoplayStopOnInteraction: value } ) + setAttributes( { containScroll: value as CarouselAttributes[ 'containScroll' ] } ) } help={ __( - 'Stop autoplay when user interacts with carousel.', + 'Prevents excess scrolling at the beginning or end.', 'rt-carousel', ) } /> - setAttributes( { autoplayStopOnMouseEnter: value } ) + label={ __( 'Scroll Auto', 'rt-carousel' ) } + checked={ slidesToScroll === 'auto' } + onChange={ ( isAuto ) => + setAttributes( { slidesToScroll: isAuto ? 'auto' : '1' } ) } help={ __( - 'Stop autoplay when mouse hovers over carousel.', + 'Scrolls the number of slides currently visible in the viewport.', 'rt-carousel', ) } /> + { slidesToScroll !== 'auto' && ( + + setAttributes( { slidesToScroll: ( value || 1 ).toString() } ) + } + min={ 1 } + max={ 10 } + /> + ) } + + setAttributes( { axis: value as CarouselAttributes[ 'axis' ] } ) + } + /> + { axis === 'y' && ( + setAttributes( { height: value } ) } + help={ __( + 'Set a fixed height for vertical carousel (e.g., 400px).', + 'rt-carousel', + ) } + /> + ) } ) } + + setAttributes( { direction: value as CarouselAttributes[ 'direction' ] } ) + } + help={ __( + 'Choose content direction. RTL is typically used for Arabic, Hebrew, and other right-to-left languages.', + 'rt-carousel', + ) } + /> + { ! useTabs && ( + + setAttributes( { autoplay: value } ) } + /> + { autoplay && ( + <> + + setAttributes( { autoplayDelay: value ?? 1000 } ) + } + min={ 1000 } + max={ 10000 } + step={ 100 } + /> + + setAttributes( { autoplayStopOnInteraction: value } ) + } + help={ __( + 'Stop autoplay when user interacts with carousel.', + 'rt-carousel', + ) } + /> + + setAttributes( { autoplayStopOnMouseEnter: value } ) + } + help={ __( + 'Stop autoplay when mouse hovers over carousel.', + 'rt-carousel', + ) } + /> + + ) } + + ) } Date: Sun, 19 Jul 2026 14:29:52 +0200 Subject: [PATCH 09/14] feat: WYSIWYG tabs mode in slide editor - slide/edit.tsx: derive slideIndex from block tree, apply is-active class when useTabs is true and this slide is selectedIndex - editor.scss: hide non-.is-active slides under [data-is-tabs="true"] so editor matches frontend tabs behaviour --- src/blocks/carousel/editor.scss | 5 +++++ src/blocks/carousel/slide/edit.tsx | 26 +++++++++++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/blocks/carousel/editor.scss b/src/blocks/carousel/editor.scss index 8d08f8c..eb3e9cf 100644 --- a/src/blocks/carousel/editor.scss +++ b/src/blocks/carousel/editor.scss @@ -149,3 +149,8 @@ .rt-carousel .embla__slide { min-height: 200px; } + +// WYSIWYG tabs mode: hide non-active slides in the editor +.rt-carousel[data-is-tabs="true"] .embla__slide:not(.is-active) { + display: none; +} diff --git a/src/blocks/carousel/slide/edit.tsx b/src/blocks/carousel/slide/edit.tsx index dd1a0dc..48acdad 100644 --- a/src/blocks/carousel/slide/edit.tsx +++ b/src/blocks/carousel/slide/edit.tsx @@ -4,25 +4,45 @@ import { BlockControls, BlockVerticalAlignmentToolbar, } from '@wordpress/block-editor'; -import type { CarouselSlideAttributes } from '../types'; +import { useSelect } from '@wordpress/data'; +import { useContext } from '@wordpress/element'; +import type { CarouselSlideAttributes, BlockEditorSelectors } from '../types'; +import { EditorCarouselContext } from '../editor-context'; export default function Edit( { attributes, setAttributes, + clientId, context, }: { attributes: CarouselSlideAttributes; setAttributes: ( attributes: Partial ) => void; - context: { 'rt-carousel/carousel/allowedSlideBlocks'?: string[] }; + clientId: string; + context: { 'rt-carousel/carousel/allowedSlideBlocks'?: string[]; 'rt-carousel/carousel/useTabs'?: boolean }; } ) { const allowedBlocks = context[ 'rt-carousel/carousel/allowedSlideBlocks' ]; + const { useTabs, selectedIndex } = useContext( EditorCarouselContext ); + + const slideIndex = useSelect( + ( select ) => { + const blockEditor = select( 'core/block-editor' ) as BlockEditorSelectors; + const parentId = blockEditor.getBlockRootClientId( clientId ); + if ( ! parentId ) return -1; + const siblings = blockEditor.getBlocks( parentId ); + return siblings.findIndex( ( b ) => b.clientId === clientId ); + }, + [ clientId ], + ); + + const isActive = ! useTabs || slideIndex < 0 || slideIndex === selectedIndex; + const { verticalAlignment } = attributes; const blockProps = useBlockProps( { className: `embla__slide${ verticalAlignment ? ` is-vertically-aligned-${ verticalAlignment }` : '' - }`, + }${ useTabs && isActive ? ' is-active' : '' }`, } ); const innerBlocksProps = useInnerBlocksProps( blockProps, { From d7f1d57800a35725538b5490be43f070e4b845b6 Mon Sep 17 00:00:00 2001 From: Danish Shakeel Date: Sun, 19 Jul 2026 14:31:57 +0200 Subject: [PATCH 10/14] fix: resolve lint errors from tabs feature - slide/edit.tsx: add curly braces to if block (curly rule) - view.ts: move context read after early-return guards to satisfy @wordpress/no-unused-vars-before-return in getSlideTabPanelId and getSlideTabLabelledBy - carousel-tab-list/edit.tsx: add id props to BaseControl components to satisfy @wordpress/no-base-control-with-label-without-id --- src/blocks/carousel/carousel-tab-list/edit.tsx | 4 ++-- src/blocks/carousel/slide/edit.tsx | 4 +++- src/blocks/carousel/view.ts | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/blocks/carousel/carousel-tab-list/edit.tsx b/src/blocks/carousel/carousel-tab-list/edit.tsx index cd8e050..1fd6b88 100644 --- a/src/blocks/carousel/carousel-tab-list/edit.tsx +++ b/src/blocks/carousel/carousel-tab-list/edit.tsx @@ -65,7 +65,7 @@ export default function Edit( { title={ __( 'Active Tab Colours', 'rt-carousel' ) } initialOpen={ false } > - + @@ -73,7 +73,7 @@ export default function Edit( { } /> - + diff --git a/src/blocks/carousel/slide/edit.tsx b/src/blocks/carousel/slide/edit.tsx index 48acdad..e24730b 100644 --- a/src/blocks/carousel/slide/edit.tsx +++ b/src/blocks/carousel/slide/edit.tsx @@ -28,7 +28,9 @@ export default function Edit( { ( select ) => { const blockEditor = select( 'core/block-editor' ) as BlockEditorSelectors; const parentId = blockEditor.getBlockRootClientId( clientId ); - if ( ! parentId ) return -1; + if ( ! parentId ) { + return -1; + } const siblings = blockEditor.getBlocks( parentId ); return siblings.findIndex( ( b ) => b.clientId === clientId ); }, diff --git a/src/blocks/carousel/view.ts b/src/blocks/carousel/view.ts index 6fde9e6..b5a6311 100644 --- a/src/blocks/carousel/view.ts +++ b/src/blocks/carousel/view.ts @@ -278,7 +278,6 @@ store( 'rt-carousel/carousel', { return context.selectedIndex !== index; }, getSlideTabPanelId: () => { - const context = getContext(); const slide = getElementRef( getElement() )?.closest?.( CAROUSEL_SLIDE_SELECTOR, ); @@ -287,6 +286,7 @@ store( 'rt-carousel/carousel', { return ''; } + const context = getContext(); const slides = Array.from( slide.parentElement.children ).filter( ( child: Element ) => child.matches( CAROUSEL_SLIDE_SELECTOR ), ); @@ -295,7 +295,6 @@ store( 'rt-carousel/carousel', { return `rt-carousel-panel-${ context.carouselId }-${ index }`; }, getSlideTabLabelledBy: () => { - const context = getContext(); const slide = getElementRef( getElement() )?.closest?.( CAROUSEL_SLIDE_SELECTOR, ); @@ -304,6 +303,7 @@ store( 'rt-carousel/carousel', { return ''; } + const context = getContext(); const slides = Array.from( slide.parentElement.children ).filter( ( child: Element ) => child.matches( CAROUSEL_SLIDE_SELECTOR ), ); From 4268d6a78de2688d2eb41753540cb70ded8363de Mon Sep 17 00:00:00 2001 From: Danish Shakeel Date: Mon, 20 Jul 2026 21:53:07 +0200 Subject: [PATCH 11/14] refactor: add WPBlock type, findBlockDeep helper, encodeContext util WPBlock recursive type replaces inline casts in carousel and tab-list edit.tsx. findBlockDeep is a depth-first search used by editor code to locate nested viewport/carousel ancestors when blocks are placed inside layout containers. encodeContext escapes JSON output for safe embedding in data-wp-context HTML attributes. Shared between carousel and carousel-tab-list save. --- src/blocks/carousel/encode-context.ts | 23 ++++++++++++++++ src/blocks/carousel/types.ts | 39 ++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 src/blocks/carousel/encode-context.ts diff --git a/src/blocks/carousel/encode-context.ts b/src/blocks/carousel/encode-context.ts new file mode 100644 index 0000000..6ac1818 --- /dev/null +++ b/src/blocks/carousel/encode-context.ts @@ -0,0 +1,23 @@ +/** + * Escape a JSON-stringified context for safe use inside a `data-wp-context` + * HTML attribute. + * + * WP's Interactivity API parses `data-wp-context` as JSON. Raw `<`, `>`, `&`, + * `'`, `"` in any string field would break HTML parsing before JSON.parse runs. + * The browser decodes HTML entities first, then the API runs JSON.parse on the + * result — so this encoding round-trips correctly. + * + * Order matters: `&` must be escaped first to avoid double-encoding the + * entities emitted for the other characters. + * + * @param {string} value - Raw JSON.stringify output. + * @return {string} HTML-attribute-safe string. + */ +export function encodeContext( value: string ): string { + return value + .replace( /&/g, '&' ) + .replace( //g, '>' ) + .replace( /"/g, '"' ) + .replace( /'/g, ''' ); +} diff --git a/src/blocks/carousel/types.ts b/src/blocks/carousel/types.ts index 8a19be2..26f0b32 100644 --- a/src/blocks/carousel/types.ts +++ b/src/blocks/carousel/types.ts @@ -30,17 +30,54 @@ export type CarouselDotsAttributes = Record; export type CarouselProgressAttributes = Record; export type CarouselCounterAttributes = Record; +/** + * Recursive block-editor block shape. Covers the fields this plugin reads. + */ +export interface WPBlock { + clientId: string; + name: string; + innerBlocks?: WPBlock[]; +} + /** * Typed subset of the block editor store selectors used in this plugin. * This avoids `as any` casts while keeping dot-notation and type safety. */ export interface BlockEditorSelectors { - getBlocks: ( clientId: string ) => Array<{ clientId: string }>; + getBlocks: ( clientId: string ) => WPBlock[]; getSelectedBlockClientId: () => string | null; getBlockParents: ( clientId: string ) => string[]; + /** Returns ancestor clientIds filtered by block name (closest last). */ + getBlockParentsByBlockName: ( clientId: string, name: string ) => string[]; getBlockRootClientId: ( clientId: string ) => string | null; } +/** + * Depth-first search for the first block of `name` anywhere in the tree. + * + * @template {Object} T - Block-like value with a `name` and optional `innerBlocks`. + * @param {T[]} blocks - Block tree to search (must include innerBlocks). + * @param {string} name - Block name to match. + * @return {T | undefined} First matching block, if any. + */ +export function findBlockDeep( + blocks: T[], + name: string, +): T | undefined { + for ( const block of blocks ) { + if ( block.name === name ) { + return block; + } + if ( block.innerBlocks?.length ) { + const found = findBlockDeep( block.innerBlocks, name ); + if ( found ) { + return found; + } + } + } + return undefined; +} + export type CarouselContext = { options: EmblaOptionsType & { slidesToScroll?: number | 'auto'; From 74e8b53926fca7b48f82a7114a41cfbe0a5a8ac4 Mon Sep 17 00:00:00 2001 From: Danish Shakeel Date: Mon, 20 Jul 2026 21:53:14 +0200 Subject: [PATCH 12/14] feat: allow carousel-tab-list and viewport inside layout blocks Expand parent arrays to include core/column and core/group so users can build side-by-side vertical-tabs layouts. Drops the dead 'deprecated' block from carousel-tab-list/block.json (invalid 'saves' key, never matched) and core/columns plural (direct parent of these blocks is core/column, not the plural columns container). --- .../carousel/carousel-tab-list/block.json | 52 ++++++++++++++----- src/blocks/carousel/viewport/block.json | 4 +- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/blocks/carousel/carousel-tab-list/block.json b/src/blocks/carousel/carousel-tab-list/block.json index bb5c6c9..9af5dd1 100644 --- a/src/blocks/carousel/carousel-tab-list/block.json +++ b/src/blocks/carousel/carousel-tab-list/block.json @@ -3,27 +3,22 @@ "apiVersion": 3, "version": "1.0.0", "name": "rt-carousel/carousel-tab-list", - "title": "Carousel Tab List", + "title": "Tab List", "category": "rt-carousel", "icon": "list-view", - "description": "Labeled tab navigation for a carousel in tabs mode.", + "description": "Tab navigation for a carousel in tabs mode.", "textdomain": "rt-carousel", - "ancestor": [ - "rt-carousel/carousel" + "parent": [ + "rt-carousel/carousel", + "core/column", + "core/group" ], "attributes": { "labels": { "type": "array", - "items": { - "type": "string" - }, + "items": { "type": "string" }, "default": [] }, - "orientation": { - "type": "string", - "enum": [ "horizontal", "vertical" ], - "default": "horizontal" - }, "activeTabBackgroundColor": { "type": "string", "default": "" @@ -31,11 +26,42 @@ "activeTabTextColor": { "type": "string", "default": "" + }, + "inactiveTabBackgroundColor": { + "type": "string", + "default": "" + }, + "inactiveTabTextColor": { + "type": "string", + "default": "" } }, "supports": { "interactivity": true, - "html": false + "html": false, + "layout": { + "allowSwitching": false, + "allowInheriting": false, + "allowVerticalAlignment": false, + "default": { + "type": "flex", + "flexWrap": "wrap" + } + }, + "spacing": { + "padding": true, + "margin": true, + "blockGap": true + }, + "typography": { + "fontSize": true + }, + "border": { + "color": true, + "width": true, + "style": true, + "radius": true + } }, "editorScript": "file:./index.ts", "style": "file:./style-index.css", diff --git a/src/blocks/carousel/viewport/block.json b/src/blocks/carousel/viewport/block.json index 279ea1e..4d8da21 100644 --- a/src/blocks/carousel/viewport/block.json +++ b/src/blocks/carousel/viewport/block.json @@ -7,7 +7,9 @@ "category": "rt-carousel", "icon": "cover-image", "parent": [ - "rt-carousel/carousel" + "rt-carousel/carousel", + "core/column", + "core/group" ], "description": "Viewport container for carousel slides.", "textdomain": "rt-carousel", From 1a0f2bf6331e47997e74546d7ab66d60942b7c99 Mon Sep 17 00:00:00 2001 From: Danish Shakeel Date: Mon, 20 Jul 2026 21:53:24 +0200 Subject: [PATCH 13/14] feat: carousel-tab-list WYSIWYG editor, save markup, store, tests Editor renders tabs as diff --git a/src/blocks/carousel/carousel-tab-list/style.scss b/src/blocks/carousel/carousel-tab-list/style.scss index 014bb58..1753b0a 100644 --- a/src/blocks/carousel/carousel-tab-list/style.scss +++ b/src/blocks/carousel/carousel-tab-list/style.scss @@ -1,33 +1,55 @@ .wp-block-rt-carousel-carousel-tab-list { display: flex; flex-wrap: wrap; - gap: 0.25rem; - margin: 0; - padding: 0; - &.is-vertical { - flex-direction: column; + /* Colour defaults — overridden by inline style when user picks colours */ + --rt-tab-active-bg: #000; + --rt-tab-active-color: #fff; + --rt-tab-inactive-bg: transparent; + --rt-tab-inactive-color: currentcolor; + + /* Border defaults — overridden by inline style via supports.border */ + --rt-tab-border-color: transparent; + --rt-tab-border-width: 1px; + --rt-tab-border-style: solid; + --rt-tab-border-radius: 0.25em; + + /* Border is redirected from the container to individual tab buttons via + * the --rt-tab-* CSS vars. Without this, supports.border would paint a + * border around the tablist container (visually wrong — we want it on + * each tab). */ + /* stylelint-disable-next-line declaration-no-important */ + border: none !important; + + /* Hide the empty tablist before the Interactivity API hydrates