diff --git a/src/blockparty-tabs-panels/block.json b/src/blockparty-tabs-panels/block.json
index 8f2abaf..67a6854 100644
--- a/src/blockparty-tabs-panels/block.json
+++ b/src/blockparty-tabs-panels/block.json
@@ -2,13 +2,11 @@
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "blockparty/tabs-panels",
- "version": "1.1.5",
+ "version": "2.0.0",
"title": "Panels",
"category": "widgets",
- "description": "Accessible tabs block item",
- "parent": [
- "blockparty/tabs"
- ],
+ "description": "Group the content panels of a tabbed interface.",
+ "parent": [ "blockparty/tabs" ],
"supports": {
"html": false,
"anchor": false,
@@ -21,10 +19,7 @@
"type": "string"
}
},
- "usesContext": [
- "blockparty/Tabs",
- "blockparty/TabsActive"
- ],
+ "usesContext": [ "blockparty/Tabs", "blockparty/TabsActive" ],
"textdomain": "blockparty-tabs",
"editorScript": "file:./index.js"
}
diff --git a/src/blockparty-tabs-panels/edit.js b/src/blockparty-tabs-panels/edit.js
index fcd8b60..daa3739 100644
--- a/src/blockparty-tabs-panels/edit.js
+++ b/src/blockparty-tabs-panels/edit.js
@@ -1,14 +1,15 @@
import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';
+import { TabsAddRemoveBlockControls } from '../blockparty-tabs/TabsAddRemoveControls';
const BLOCKS_CHILD = 'blockparty/tabs-panel-item';
const ALLOWED_BLOCKS = [ BLOCKS_CHILD ];
const LOCK_TEMPLATE = { lock: { move: true, remove: true } };
-export default function Edit( {} ) {
+export default function Edit( { clientId } ) {
const blockProps = useBlockProps();
const innerBlocksProps = useInnerBlocksProps( blockProps, {
allowedBlocks: ALLOWED_BLOCKS,
- __experimentalDirectInsert: false,
+ directInsert: false,
templateLock: false,
template: [
[ BLOCKS_CHILD, LOCK_TEMPLATE ],
@@ -19,5 +20,10 @@ export default function Edit( {} ) {
renderAppender: false,
} );
- return
;
+ return (
+ <>
+
+
+ >
+ );
}
diff --git a/src/blockparty-tabs/GetSynchedID.js b/src/blockparty-tabs/GetSynchedID.js
index f0173c2..cf0c5a4 100644
--- a/src/blockparty-tabs/GetSynchedID.js
+++ b/src/blockparty-tabs/GetSynchedID.js
@@ -1,13 +1,48 @@
-import { select } from '@wordpress/data';
-
-const GetSynchedID = ( clientId, context, setAttributes ) => {
- const InstanceId = context[ 'blockparty/Tabs' ];
- const currentIndex =
- select( 'core/block-editor' ).getBlockIndex( clientId );
- const synkedId = InstanceId + '-' + currentIndex;
- setAttributes( { linkId: 'block-tab-' + synkedId } );
- setAttributes( { panelId: 'block-panel-' + synkedId } );
- setAttributes( { index: currentIndex } );
+import { useEffect } from '@wordpress/element';
+import { useSelect } from '@wordpress/data';
+
+/**
+ * Keeps nav/panel item linkId, panelId, and index in sync with the parent tabs instance.
+ * Must run as a hook (not during render) to avoid updating BlockTools while editing.
+ *
+ * @param {string} clientId Block client ID.
+ * @param {Object} context Block context (expects blockparty/Tabs).
+ * @param {Object} attributes Current block attributes.
+ * @param {Function} setAttributes Block setAttributes callback.
+ */
+const useSynchedID = ( clientId, context, attributes, setAttributes ) => {
+ const instanceId = context?.[ 'blockparty/Tabs' ];
+ const currentIndex = useSelect(
+ ( select ) => select( 'core/block-editor' ).getBlockIndex( clientId ),
+ [ clientId ]
+ );
+
+ useEffect( () => {
+ if ( typeof instanceId === 'undefined' ) {
+ return;
+ }
+
+ const synkedId = instanceId + '-' + currentIndex;
+ const linkId = 'block-tab-' + synkedId;
+ const panelId = 'block-panel-' + synkedId;
+
+ if (
+ attributes.linkId === linkId &&
+ attributes.panelId === panelId &&
+ attributes.index === currentIndex
+ ) {
+ return;
+ }
+
+ setAttributes( { linkId, panelId, index: currentIndex } );
+ }, [
+ instanceId,
+ currentIndex,
+ attributes.linkId,
+ attributes.panelId,
+ attributes.index,
+ setAttributes,
+ ] );
};
-export default GetSynchedID;
+export default useSynchedID;
diff --git a/src/blockparty-tabs/TabsAddRemoveControls.js b/src/blockparty-tabs/TabsAddRemoveControls.js
new file mode 100644
index 0000000..238cdc1
--- /dev/null
+++ b/src/blockparty-tabs/TabsAddRemoveControls.js
@@ -0,0 +1,253 @@
+/**
+ * Shared Add / Remove tab toolbar controls for the tabs block tree.
+ */
+
+import { __ } from '@wordpress/i18n';
+import { BlockControls } from '@wordpress/block-editor';
+import { ToolbarGroup, ToolbarButton, Button } from '@wordpress/components';
+import { useSelect, useDispatch } from '@wordpress/data';
+import { createBlock } from '@wordpress/blocks';
+import { plus } from '@wordpress/icons';
+
+const TABS_BLOCK_NAME = 'blockparty/tabs';
+const LOCK_TEMPLATE = { lock: { move: true, remove: true } };
+
+/**
+ * Resolves the owning blockparty/tabs clientId from any block in the tree.
+ * Uses the closest ancestor so nested tabs stay scoped to their own root.
+ *
+ * @param {Function} selectStore Bound select from useSelect.
+ * @param {string} clientId Current block clientId.
+ * @return {string|null} Tabs root clientId, or null.
+ */
+function getTabsRootClientId( selectStore, clientId ) {
+ const { getBlockName, getBlockParents } =
+ selectStore( 'core/block-editor' );
+
+ if ( getBlockName( clientId ) === TABS_BLOCK_NAME ) {
+ return clientId;
+ }
+
+ const parents = getBlockParents( clientId, true );
+ for ( let i = 0; i < parents.length; i += 1 ) {
+ if ( getBlockName( parents[ i ] ) === TABS_BLOCK_NAME ) {
+ return parents[ i ];
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Computes tabsActive after removing the tab at removedIndex.
+ *
+ * @param {number} tabsActive Current active index on the tabs block.
+ * @param {number} removedIndex Index being removed.
+ * @param {number} count Tab count before removal.
+ * @return {number} Active index after removal.
+ */
+function getTabsActiveAfterRemove( tabsActive, removedIndex, count ) {
+ const newCount = count - 1;
+ if ( newCount <= 0 ) {
+ return 0;
+ }
+
+ let next = tabsActive;
+ if ( tabsActive > removedIndex ) {
+ next = tabsActive - 1;
+ } else if ( tabsActive === removedIndex ) {
+ next = Math.max( 0, removedIndex - 1 );
+ }
+
+ return Math.min( next, newCount - 1 );
+}
+
+/**
+ * Hook exposing insert / remove / append helpers for a tabs tree.
+ *
+ * @param {string} clientId Block clientId (any block in the tabs tree).
+ * @param {number} [index] Explicit tab index; falls back to tabsActive.
+ * @return {Object} Tabs mutation helpers and resolved state.
+ */
+export const useTabsMutations = ( clientId, index ) => {
+ const {
+ navId,
+ panelId,
+ nav,
+ panels,
+ count,
+ resolvedIndex,
+ tabsRootId,
+ tabsActive,
+ } = useSelect(
+ ( selectStore ) => {
+ const { getBlockOrder, getBlockAttributes } =
+ selectStore( 'core/block-editor' );
+ const rootClientId = getTabsRootClientId( selectStore, clientId );
+
+ if ( ! rootClientId ) {
+ return {
+ navId: null,
+ panelId: null,
+ nav: [],
+ panels: [],
+ count: 0,
+ resolvedIndex: 0,
+ tabsRootId: null,
+ tabsActive: 0,
+ };
+ }
+
+ const wrappers = getBlockOrder( rootClientId );
+ const navBlocks = getBlockOrder( wrappers[ 0 ] );
+ const storedTabsActive =
+ getBlockAttributes( rootClientId )?.tabsActive ?? 0;
+ const tabCount = navBlocks.length;
+ const maxIndex = Math.max( 0, tabCount - 1 );
+ const nextResolvedIndex =
+ typeof index === 'number'
+ ? index
+ : Math.min( Math.max( storedTabsActive, 0 ), maxIndex );
+
+ return {
+ navId: wrappers[ 0 ],
+ panelId: wrappers[ 1 ],
+ nav: navBlocks,
+ panels: getBlockOrder( wrappers[ 1 ] ),
+ count: tabCount,
+ resolvedIndex: nextResolvedIndex,
+ tabsRootId: rootClientId,
+ tabsActive: storedTabsActive,
+ };
+ },
+ [ clientId, index ]
+ );
+
+ const { removeBlock, insertBlock, updateBlockAttributes } =
+ useDispatch( 'core/block-editor' );
+
+ const insertTab = ( insertAt ) => {
+ if ( ! navId || ! panelId ) {
+ return;
+ }
+
+ const newNavItem = createBlock(
+ 'blockparty/tabs-nav-item',
+ LOCK_TEMPLATE
+ );
+ const newPanelItem = createBlock(
+ 'blockparty/tabs-panel-item',
+ LOCK_TEMPLATE
+ );
+ insertBlock( newPanelItem, insertAt, panelId, false );
+ insertBlock( newNavItem, insertAt, navId, true );
+ };
+
+ const insertTabAfterActive = () => {
+ insertTab( resolvedIndex + 1 );
+ };
+
+ const appendTab = () => {
+ insertTab( count );
+ };
+
+ const removeTab = () => {
+ if ( ! navId || ! panelId || ! tabsRootId || 1 >= count ) {
+ return;
+ }
+
+ if ( 0 > resolvedIndex || resolvedIndex >= count ) {
+ return;
+ }
+
+ const navClientId = nav[ resolvedIndex ];
+ const panelClientId = panels[ resolvedIndex ];
+ if ( ! navClientId || ! panelClientId ) {
+ return;
+ }
+
+ const nextTabsActive = getTabsActiveAfterRemove(
+ tabsActive,
+ resolvedIndex,
+ count
+ );
+
+ updateBlockAttributes( [ navClientId, panelClientId ], {
+ lock: { move: true, remove: false },
+ } );
+ removeBlock( panelClientId );
+ removeBlock( navClientId );
+ updateBlockAttributes( tabsRootId, {
+ tabsActive: nextTabsActive,
+ } );
+ };
+
+ return {
+ count,
+ resolvedIndex,
+ canRemove: 1 < count,
+ insertTabAfterActive,
+ appendTab,
+ removeTab,
+ };
+};
+
+/**
+ * Toolbar group with Add tab / Remove tab buttons.
+ *
+ * @param {Object} props Component props.
+ * @param {string} props.clientId Block clientId (any block in the tabs tree).
+ * @param {number} [props.index] Explicit tab index; falls back to tabsActive.
+ * @return {JSX.Element} Toolbar group.
+ */
+export const TabsAddRemoveToolbar = ( { clientId, index } ) => {
+ const { canRemove, insertTabAfterActive, removeTab } = useTabsMutations(
+ clientId,
+ index
+ );
+
+ return (
+
+
+ { __( 'Add tab', 'blockparty-tabs' ) }
+
+
+ { __( 'Remove tab', 'blockparty-tabs' ) }
+
+
+ );
+};
+
+/**
+ * Button-style appender that inserts a synced nav item + panel item.
+ *
+ * @param {Object} props Component props.
+ * @param {string} props.clientId tabs-nav block clientId.
+ * @return {JSX.Element} Appender button.
+ */
+export const TabsNavAppender = ( { clientId } ) => {
+ const { appendTab } = useTabsMutations( clientId );
+
+ return (
+
+ );
+};
+
+/**
+ * BlockControls wrapper for Add / Remove tab actions.
+ *
+ * @param {Object} props Component props.
+ * @param {string} props.clientId Block clientId (any block in the tabs tree).
+ * @param {number} [props.index] Explicit tab index; falls back to tabsActive.
+ * @return {JSX.Element} Block controls with shared toolbar.
+ */
+export const TabsAddRemoveBlockControls = ( { clientId, index } ) => (
+
+
+
+);
diff --git a/src/blockparty-tabs/block.json b/src/blockparty-tabs/block.json
index 4b8f37e..d694539 100644
--- a/src/blockparty-tabs/block.json
+++ b/src/blockparty-tabs/block.json
@@ -2,27 +2,52 @@
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "blockparty/tabs",
- "version": "1.1.5",
+ "version": "2.0.0",
"title": "Tabs",
"category": "widgets",
- "description": "Accessible tabs block",
+ "description": "Organize content into tabs so visitors can browse sections without scrolling endlessly.",
"supports": {
- "html": false,
+ "align": [ "full", "wide" ],
"anchor": true,
- "align": [
- "wide",
- "full"
- ]
+ "background": {
+ "backgroundImage": true,
+ "backgroundSize": true,
+ "gradient": true
+ },
+ "color": {
+ "text": true,
+ "background": true,
+ "heading": true,
+ "button": true,
+ "link": true
+ },
+ "__experimentalBorder": {
+ "color": true,
+ "radius": true,
+ "style": true,
+ "width": true
+ },
+ "layout": {
+ "allowEditing": false
+ },
+ "html": false,
+ "interactivity": true,
+ "spacing": {
+ "blockGap": true,
+ "margin": true,
+ "padding": true
+ },
+ "shadow": true,
+ "typography": {
+ "fontSize": true,
+ "lineHeight": true
+ }
},
"attributes": {
"tabsIndex": {
"type": "integer",
"default": 0
},
- "title": {
- "type": "string",
- "default": ""
- },
"tabsActive": {
"type": "integer",
"default": 0
diff --git a/src/blockparty-tabs/deprecated.js b/src/blockparty-tabs/deprecated.js
new file mode 100644
index 0000000..a42ac76
--- /dev/null
+++ b/src/blockparty-tabs/deprecated.js
@@ -0,0 +1,137 @@
+/**
+ * Deprecated versions of the tabs block.
+ */
+
+import classnames from 'classnames';
+import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';
+
+/**
+ * Moves a legacy parent label onto the tabs-nav inner block as ariaLabel.
+ *
+ * @param {Object} attributes Parent block attributes.
+ * @param {Array} innerBlocks Parent inner blocks.
+ * @param {string} label Label to apply on the nav block.
+ * @return {Array} Migrated [ attributes, innerBlocks ].
+ */
+function migrateLabelToNav( attributes, innerBlocks, label ) {
+ const { title, ariaLabel, ...rest } = attributes;
+ const value = label || ariaLabel || title || '';
+
+ if ( ! value || ! Array.isArray( innerBlocks ) ) {
+ return [ rest, innerBlocks ];
+ }
+
+ const nextInnerBlocks = innerBlocks.map( ( block ) => {
+ if ( block.name !== 'blockparty/tabs-nav' ) {
+ return block;
+ }
+
+ return {
+ ...block,
+ attributes: {
+ ...block.attributes,
+ ariaLabel: value,
+ },
+ };
+ } );
+
+ return [ rest, nextInnerBlocks ];
+}
+
+/**
+ * Parent wrapper with role="tablist" and supports.ariaLabel (pre-nav move).
+ */
+const v2 = {
+ attributes: {
+ tabsIndex: {
+ type: 'integer',
+ default: 0,
+ },
+ tabsActive: {
+ type: 'integer',
+ default: 0,
+ },
+ mode: {
+ type: 'string',
+ default: 'top-left',
+ },
+ ariaLabel: {
+ type: 'string',
+ source: 'attribute',
+ attribute: 'aria-label',
+ selector: '*',
+ },
+ },
+ supports: {
+ html: false,
+ anchor: true,
+ align: [ 'wide', 'full' ],
+ ariaLabel: true,
+ },
+ migrate( attributes, innerBlocks ) {
+ return migrateLabelToNav( attributes, innerBlocks );
+ },
+ save( { attributes } ) {
+ const { mode } = attributes;
+
+ const innerBlocksProps = useInnerBlocksProps.save(
+ useBlockProps.save( {
+ className: classnames( {
+ [ `has-align-${ mode }` ]: mode,
+ } ),
+ role: 'tablist',
+ } )
+ );
+
+ return
;
+ },
+};
+
+/**
+ * Original parent wrapper using a custom title attribute as aria-label.
+ */
+const v1 = {
+ attributes: {
+ tabsIndex: {
+ type: 'integer',
+ default: 0,
+ },
+ title: {
+ type: 'string',
+ default: '',
+ },
+ tabsActive: {
+ type: 'integer',
+ default: 0,
+ },
+ mode: {
+ type: 'string',
+ default: 'top-left',
+ },
+ },
+ supports: {
+ html: false,
+ anchor: true,
+ align: [ 'wide', 'full' ],
+ },
+ migrate( attributes, innerBlocks ) {
+ return migrateLabelToNav( attributes, innerBlocks, attributes.title );
+ },
+ save( { attributes } ) {
+ const { title, mode } = attributes;
+
+ const innerBlocksProps = useInnerBlocksProps.save(
+ useBlockProps.save( {
+ className: classnames( {
+ [ `has-align-${ mode }` ]: mode,
+ } ),
+ } )
+ );
+
+ return (
+
+ );
+ },
+};
+
+export default [ v2, v1 ];
diff --git a/src/blockparty-tabs/edit.js b/src/blockparty-tabs/edit.js
index 0da9a0e..94c18f3 100644
--- a/src/blockparty-tabs/edit.js
+++ b/src/blockparty-tabs/edit.js
@@ -3,14 +3,12 @@ import classnames from 'classnames';
import {
useBlockProps,
useInnerBlocksProps,
- InspectorControls,
BlockControls,
AlignmentControl,
} from '@wordpress/block-editor';
-import { PanelBody, PanelRow, TextControl } from '@wordpress/components';
-import { select } from '@wordpress/data';
+import { useEffect } from '@wordpress/element';
+import { select, useSelect } from '@wordpress/data';
import {
- heading,
justifyRight,
justifyCenter,
justifyLeft,
@@ -20,6 +18,7 @@ import {
} from '@wordpress/icons';
import './editor.scss';
import { useSyncTabsActiveForTabsBlock } from './SyncTabsActive';
+import { TabsAddRemoveToolbar } from './TabsAddRemoveControls';
const DEFAULT_TABS_POSITIONS = [
{
@@ -54,16 +53,23 @@ const DEFAULT_TABS_POSITIONS = [
},
];
-const setTabsIndex = ( setAttributes, clientId ) => {
- const currentIndex =
- select( 'core/block-editor' ).getBlockIndex( clientId );
- setAttributes( { tabsIndex: currentIndex } );
-};
-
export default function Edit( { attributes, setAttributes, clientId } ) {
useSyncTabsActiveForTabsBlock( clientId );
- setTabsIndex( setAttributes, clientId );
- const { title, mode } = attributes;
+
+ const currentIndex = useSelect(
+ ( selectStore ) =>
+ selectStore( 'core/block-editor' ).getBlockIndex( clientId ),
+ [ clientId ]
+ );
+
+ useEffect( () => {
+ if ( attributes.tabsIndex === currentIndex ) {
+ return;
+ }
+ setAttributes( { tabsIndex: currentIndex } );
+ }, [ attributes.tabsIndex, currentIndex, setAttributes ] );
+
+ const { mode } = attributes;
const blockProps = useBlockProps( {
className: classnames( {
[ `has-align-${ mode }` ]: mode,
@@ -86,7 +92,7 @@ export default function Edit( { attributes, setAttributes, clientId } ) {
}
const innerBlocksProps = useInnerBlocksProps( blockProps, {
- __experimentalDirectInsert: false,
+ directInsert: false,
templateLock: 'all',
template: [ [ 'blockparty/tabs-nav' ], [ 'blockparty/tabs-panels' ] ],
templateInsertUpdatesSelection: true,
@@ -102,24 +108,8 @@ export default function Edit( { attributes, setAttributes, clientId } ) {
setAttributes( { mode: newAlign } )
}
/>
+
-
-
-
- {
- setAttributes( { title: content } );
- } }
- />
-
-
-
>
);
diff --git a/src/blockparty-tabs/editor.scss b/src/blockparty-tabs/editor.scss
index 5e868b1..e3fc6dd 100644
--- a/src/blockparty-tabs/editor.scss
+++ b/src/blockparty-tabs/editor.scss
@@ -35,6 +35,31 @@
white-space: nowrap;
}
}
+
+ // Override Gutenberg's absolute appender so it sits after the last tab.
+ > .block-list-appender {
+ position: static;
+ right: auto;
+ bottom: auto;
+ display: none;
+ flex: 0 0 auto;
+ align-self: center;
+ width: auto;
+ margin: 0;
+ list-style: none;
+
+ .block-list-appender__toggle {
+ display: flex;
+ }
+ }
+
+ &.is-selected,
+ &.has-child-selected {
+ > .block-list-appender {
+ display: inline-flex;
+ align-items: center;
+ }
+ }
}
&-panels {
@@ -65,6 +90,13 @@
&.has-align-sidebar-right,
&.has-align-sidebar-left {
+ > #{$block}-nav {
+ > .block-list-appender {
+ justify-content: center;
+ width: 100%;
+ }
+ }
+
> #{$block}-panels {
> #{$block}-panel-item {
&.is-active {
diff --git a/src/blockparty-tabs/index.js b/src/blockparty-tabs/index.js
index 5fea10b..c307ad9 100644
--- a/src/blockparty-tabs/index.js
+++ b/src/blockparty-tabs/index.js
@@ -2,6 +2,7 @@ import { registerBlockType } from '@wordpress/blocks';
import './style.scss';
import Edit from './edit';
import save from './save';
+import deprecated from './deprecated';
import metadata from './block.json';
import { tabs } from '@wordpress/icons';
@@ -9,4 +10,5 @@ registerBlockType( metadata.name, {
icon: tabs,
edit: Edit,
save,
+ deprecated,
} );
diff --git a/src/blockparty-tabs/save.js b/src/blockparty-tabs/save.js
index 20e348d..f24c6fd 100644
--- a/src/blockparty-tabs/save.js
+++ b/src/blockparty-tabs/save.js
@@ -2,7 +2,7 @@ import classnames from 'classnames';
import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';
export default function save( { attributes } ) {
- const { title, mode } = attributes;
+ const { mode } = attributes;
const innerBlocksProps = useInnerBlocksProps.save(
useBlockProps.save( {
@@ -11,5 +11,6 @@ export default function save( { attributes } ) {
} ),
} )
);
- return
;
+
+ return
;
}
diff --git a/src/blockparty-tabs/script.js b/src/blockparty-tabs/script.js
index 5e55a5a..0890658 100644
--- a/src/blockparty-tabs/script.js
+++ b/src/blockparty-tabs/script.js
@@ -1,153 +1,7 @@
-/*
- * This content is licensed according to the W3C Software License at
- * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
- *
- * File: tabs-automatic.js
- *
- * Desc: Tablist widget that implements ARIA Authoring Practices
- */
-
-'use strict';
-
-class TabsAutomatic {
- constructor( groupNode ) {
- this.tablistNode = groupNode;
-
- this.tabs = [];
-
- this.firstTab = null;
- this.lastTab = null;
-
- this.tabnav = this.tablistNode.querySelector(
- '.wp-block-blockparty-tabs-nav'
- );
- this.tabs = Array.from( this.tabnav.querySelectorAll( 'a[role=tab]' ) );
- this.tabpanelsNode = this.tabnav.nextElementSibling;
-
- this.tabpanels = [];
-
- for ( let i = 0; i < this.tabs.length; i += 1 ) {
- const tab = this.tabs[ i ];
- const tabpanel = this.tabpanelsNode.children[ i ];
-
- tab.tabIndex = -1;
- tab.setAttribute( 'aria-selected', 'false' );
- this.tabpanels.push( tabpanel );
-
- tab.addEventListener( 'keydown', this.onKeydown.bind( this ) );
- tab.addEventListener( 'click', this.onClick.bind( this ) );
-
- if ( ! this.firstTab ) {
- this.firstTab = tab;
- }
- this.lastTab = tab;
- }
-
- this.setSelectedTab( this.firstTab, false );
- }
-
- setSelectedTab( currentTab, setFocus ) {
- if ( typeof setFocus !== 'boolean' ) {
- setFocus = true;
- }
- for ( let i = 0; i < this.tabs.length; i += 1 ) {
- const tab = this.tabs[ i ];
- if ( currentTab === tab ) {
- tab.parentNode.classList.add( 'is-active' );
- tab.setAttribute( 'aria-selected', 'true' );
- tab.removeAttribute( 'tabindex' );
- this.tabpanels[ i ].classList.remove( 'is-hidden' );
- this.tabpanels[ i ].classList.add( 'is-active' );
- if ( setFocus ) {
- tab.focus();
- }
- } else {
- tab.parentNode.classList.remove( 'is-active' );
- tab.setAttribute( 'aria-selected', 'false' );
- tab.tabIndex = -1;
- this.tabpanels[ i ].classList.add( 'is-hidden' );
- this.tabpanels[ i ].classList.remove( 'is-active' );
- }
- }
- }
-
- setSelectedToPreviousTab( currentTab ) {
- let index;
-
- if ( currentTab === this.firstTab ) {
- this.setSelectedTab( this.lastTab );
- } else {
- index = this.tabs.indexOf( currentTab );
- this.setSelectedTab( this.tabs[ index - 1 ] );
- }
- }
-
- setSelectedToNextTab( currentTab ) {
- let index;
-
- if ( currentTab === this.lastTab ) {
- this.setSelectedTab( this.firstTab );
- } else {
- index = this.tabs.indexOf( currentTab );
- this.setSelectedTab( this.tabs[ index + 1 ] );
- }
- }
-
- /* EVENT HANDLERS */
-
- onKeydown( event ) {
- const tgt = event.currentTarget;
- let flag = false;
-
- switch ( event.key ) {
- case 'ArrowLeft':
- this.setSelectedToPreviousTab( tgt );
- flag = true;
- break;
-
- case 'ArrowRight':
- this.setSelectedToNextTab( tgt );
- flag = true;
- break;
-
- case 'Home':
- this.setSelectedTab( this.firstTab );
- flag = true;
- break;
-
- case 'End':
- this.setSelectedTab( this.lastTab );
- flag = true;
- break;
-
- default:
- break;
- }
-
- if ( flag ) {
- event.stopPropagation();
- event.preventDefault();
- }
- }
-
- onClick( event ) {
- event.preventDefault();
- this.setSelectedTab( event.currentTarget );
- }
-}
-
/**
- * Initialize all tablist widgets on the page.
- *
- * @since 1.0.0
- * @return {void}
+ * Frontend entry: boot accessible tabs widgets.
*/
-function initTabs() {
- const tablists = document.querySelectorAll( '.wp-block-blockparty-tabs' );
- for ( let i = 0; i < tablists.length; i++ ) {
- new TabsAutomatic( tablists[ i ] );
- }
-}
+import { initTabs } from './tabs-automatic';
// Deferred scripts run after HTML is parsed; init immediately when ready.
if ( document.readyState === 'loading' ) {
diff --git a/src/blockparty-tabs/style.scss b/src/blockparty-tabs/style.scss
index 4e71c61..17d9781 100644
--- a/src/blockparty-tabs/style.scss
+++ b/src/blockparty-tabs/style.scss
@@ -7,6 +7,7 @@
--wp--blockparty--tabs--nav-border-hover: 4px solid rgba(0, 0, 0, .25);
--wp--blockparty--tabs--nav-border-active: 4px solid #000;
--wp--blockparty--tabs--icon-size: 24px;
+ --wp--blockparty--tabs--icon-gap: 8px;
--wp--blockparty--tabs--active-color: #efefef;
--wp--blockparty--tabs--sidebar-nav-width: 33.33333%;
--wp--blockparty--tabs--sidebar-panel-width: 66.66666%;
@@ -65,9 +66,10 @@
border-bottom: var(--wp--blockparty--tabs--nav-border-active);
}
+ .wp-block-icon,
.wp-block-blockparty-icon,
.wp-block-blockparty-icon-block {
- margin-right: 8px;
+ margin-right: var(--wp--blockparty--tabs--icon-gap);
}
svg {
diff --git a/src/blockparty-tabs/tabs-automatic.js b/src/blockparty-tabs/tabs-automatic.js
new file mode 100644
index 0000000..a259a5e
--- /dev/null
+++ b/src/blockparty-tabs/tabs-automatic.js
@@ -0,0 +1,175 @@
+/*
+ * This content is licensed according to the W3C Software License at
+ * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
+ *
+ * File: tabs-automatic.js
+ *
+ * Desc: Tablist widget that implements ARIA Authoring Practices
+ */
+
+/**
+ * Tablist widget that implements ARIA Authoring Practices (automatic activation).
+ */
+export class TabsAutomatic {
+ /**
+ * @param {HTMLElement} groupNode Tabs root element.
+ */
+ constructor( groupNode ) {
+ this.tablistNode = groupNode;
+
+ this.tabs = [];
+
+ this.firstTab = null;
+ this.lastTab = null;
+
+ this.tabnav = this.tablistNode.querySelector(
+ '.wp-block-blockparty-tabs-nav'
+ );
+ this.tabs = Array.from( this.tabnav.querySelectorAll( 'a[role=tab]' ) );
+ this.tabpanelsNode = this.tabnav.nextElementSibling;
+
+ this.tabpanels = [];
+
+ for ( let i = 0; i < this.tabs.length; i += 1 ) {
+ const tab = this.tabs[ i ];
+ const tabpanel = this.tabpanelsNode.children[ i ];
+
+ tab.tabIndex = -1;
+ tab.setAttribute( 'aria-selected', 'false' );
+ this.tabpanels.push( tabpanel );
+
+ tab.addEventListener( 'keydown', this.onKeydown.bind( this ) );
+ tab.addEventListener( 'click', this.onClick.bind( this ) );
+
+ if ( ! this.firstTab ) {
+ this.firstTab = tab;
+ }
+ this.lastTab = tab;
+ }
+
+ this.setSelectedTab( this.firstTab, false );
+ }
+
+ /**
+ * @param {HTMLElement} currentTab Tab to select.
+ * @param {boolean} setFocus Whether to focus the selected tab.
+ * @return {void}
+ */
+ setSelectedTab( currentTab, setFocus ) {
+ if ( typeof setFocus !== 'boolean' ) {
+ setFocus = true;
+ }
+ for ( let i = 0; i < this.tabs.length; i += 1 ) {
+ const tab = this.tabs[ i ];
+ if ( currentTab === tab ) {
+ tab.parentNode.classList.add( 'is-active' );
+ tab.setAttribute( 'aria-selected', 'true' );
+ tab.removeAttribute( 'tabindex' );
+ this.tabpanels[ i ].classList.remove( 'is-hidden' );
+ this.tabpanels[ i ].classList.add( 'is-active' );
+ if ( setFocus ) {
+ tab.focus();
+ }
+ } else {
+ tab.parentNode.classList.remove( 'is-active' );
+ tab.setAttribute( 'aria-selected', 'false' );
+ tab.tabIndex = -1;
+ this.tabpanels[ i ].classList.add( 'is-hidden' );
+ this.tabpanels[ i ].classList.remove( 'is-active' );
+ }
+ }
+ }
+
+ /**
+ * @param {HTMLElement} currentTab Currently selected tab.
+ * @return {void}
+ */
+ setSelectedToPreviousTab( currentTab ) {
+ let index;
+
+ if ( currentTab === this.firstTab ) {
+ this.setSelectedTab( this.lastTab );
+ } else {
+ index = this.tabs.indexOf( currentTab );
+ this.setSelectedTab( this.tabs[ index - 1 ] );
+ }
+ }
+
+ /**
+ * @param {HTMLElement} currentTab Currently selected tab.
+ * @return {void}
+ */
+ setSelectedToNextTab( currentTab ) {
+ let index;
+
+ if ( currentTab === this.lastTab ) {
+ this.setSelectedTab( this.firstTab );
+ } else {
+ index = this.tabs.indexOf( currentTab );
+ this.setSelectedTab( this.tabs[ index + 1 ] );
+ }
+ }
+
+ /* EVENT HANDLERS */
+
+ /**
+ * @param {KeyboardEvent} event Keydown event.
+ * @return {void}
+ */
+ onKeydown( event ) {
+ const tgt = event.currentTarget;
+ let flag = false;
+
+ switch ( event.key ) {
+ case 'ArrowLeft':
+ this.setSelectedToPreviousTab( tgt );
+ flag = true;
+ break;
+
+ case 'ArrowRight':
+ this.setSelectedToNextTab( tgt );
+ flag = true;
+ break;
+
+ case 'Home':
+ this.setSelectedTab( this.firstTab );
+ flag = true;
+ break;
+
+ case 'End':
+ this.setSelectedTab( this.lastTab );
+ flag = true;
+ break;
+
+ default:
+ break;
+ }
+
+ if ( flag ) {
+ event.stopPropagation();
+ event.preventDefault();
+ }
+ }
+
+ /**
+ * @param {MouseEvent} event Click event.
+ * @return {void}
+ */
+ onClick( event ) {
+ event.preventDefault();
+ this.setSelectedTab( event.currentTarget );
+ }
+}
+
+/**
+ * Initialize all tablist widgets on the page.
+ *
+ * @since 1.0.0
+ * @return {void}
+ */
+export function initTabs() {
+ const tablists = document.querySelectorAll( '.wp-block-blockparty-tabs' );
+ for ( let i = 0; i < tablists.length; i++ ) {
+ new TabsAutomatic( tablists[ i ] );
+ }
+}
diff --git a/bin/bump.sh b/tests/bin/bump.sh
similarity index 95%
rename from bin/bump.sh
rename to tests/bin/bump.sh
index a26218d..d88c1f3 100644
--- a/bin/bump.sh
+++ b/tests/bin/bump.sh
@@ -3,10 +3,10 @@
# Bump the plugin version across project metadata, blocks, and docs.
#
# Usage (no chmod required):
-# bash bin/bump.sh patch
-# bash bin/bump.sh minor
-# bash bin/bump.sh major
-# bash bin/bump.sh 1.2.0
+# bash tests/bin/bump.sh patch
+# bash tests/bin/bump.sh minor
+# bash tests/bin/bump.sh major
+# bash tests/bin/bump.sh 1.2.0
# npm run bump -- patch
# npm run bump -- 1.2.0
#
@@ -19,7 +19,7 @@ set -euo pipefail
# Assets — add or remove paths here
# ---------------------------------------------------------------------------
-ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
# JSON files that expose a top-level "version" field
JSON_VERSION_FILES=(
@@ -67,12 +67,12 @@ replace_json_version() {
usage() {
local code="${1:-0}"
- echo "Usage: bash bin/bump.sh
"
+ echo "Usage: bash tests/bin/bump.sh "
echo ""
echo "Examples:"
- echo " bash bin/bump.sh patch"
- echo " bash bin/bump.sh minor"
- echo " bash bin/bump.sh 1.2.0"
+ echo " bash tests/bin/bump.sh patch"
+ echo " bash tests/bin/bump.sh minor"
+ echo " bash tests/bin/bump.sh 1.2.0"
echo " npm run bump -- patch"
exit "${code}"
}
diff --git a/tests/bin/check-release-version.sh b/tests/bin/check-release-version.sh
new file mode 100755
index 0000000..f535c60
--- /dev/null
+++ b/tests/bin/check-release-version.sh
@@ -0,0 +1,146 @@
+#!/usr/bin/env bash
+#
+# When .plugin-data version changes between base and head commits, verify
+# the release tag is unused and that every versioned file is updated consistently.
+#
+# Usage (from repository root):
+# ./tests/bin/check-release-version.sh
+# (default: merge-base of main|origin/main|master with HEAD → HEAD, for local runs)
+# BASE_SHA= HEAD_SHA= ./tests/bin/check-release-version.sh
+# ./tests/bin/check-release-version.sh
+#
+# GitHub Actions sets BASE_SHA and HEAD_SHA on the pull request; arguments override env.
+#
+
+set -euo pipefail
+
+SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+ROOT=$(cd "$SCRIPT_DIR/../.." && pwd)
+cd "$ROOT"
+
+if [ $# -ge 2 ]; then
+ BASE_SHA=$1
+ HEAD_SHA=$2
+fi
+
+if [ -z "${BASE_SHA:-}" ] || [ -z "${HEAD_SHA:-}" ]; then
+ if [ $# -eq 0 ] && git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+ HEAD_SHA=$(git -C "$ROOT" rev-parse HEAD)
+ base_ref=
+ for r in main origin/main master origin/master; do
+ if git -C "$ROOT" rev-parse -q --verify "$r" >/dev/null 2>&1; then
+ base_ref=$r
+ break
+ fi
+ done
+ if [ -n "$base_ref" ] && MB=$(git -C "$ROOT" merge-base "$base_ref" HEAD 2>/dev/null); then
+ BASE_SHA=$MB
+ echo "Using ${base_ref}..HEAD: merge-base ${BASE_SHA:0:7} → head ${HEAD_SHA:0:7}" >&2
+ fi
+ fi
+fi
+
+if [ -z "${BASE_SHA:-}" ] || [ -z "${HEAD_SHA:-}" ]; then
+ echo "Usage: BASE_SHA= HEAD_SHA= $0" >&2
+ echo " or: $0 " >&2
+ echo " or: $0 (in a git repo, compares merge-base of main|origin/main|... with HEAD)" >&2
+ exit 1
+fi
+
+NEW_VER=$(jq -r .version .plugin-data)
+
+if ! git cat-file -e "${BASE_SHA}:.plugin-data" 2>/dev/null; then
+ echo "::error::Could not read .plugin-data at merge base. Ensure the base branch has .plugin-data."
+ exit 1
+fi
+
+OLD_VER=$(git show "${BASE_SHA}:.plugin-data" | jq -r .version)
+
+if [ "$OLD_VER" = "$NEW_VER" ]; then
+ echo "No version change in .plugin-data (${NEW_VER}). Skipping release and tag checks."
+ exit 0
+fi
+
+echo "Version bump detected: ${OLD_VER} -> ${NEW_VER}"
+
+if git show-ref --verify --quiet "refs/tags/${NEW_VER}"; then
+ echo "::error::Tag '${NEW_VER}' already exists. Bump .plugin-data to a version that is not released yet."
+ exit 1
+fi
+
+have_diff() {
+ [ -n "$(git diff --name-only "${BASE_SHA}" "${HEAD_SHA}" -- "$1")" ]
+}
+
+missing=()
+for f in \
+ .plugin-data \
+ package.json \
+ blockparty-tabs.php \
+ readme.txt \
+ .wordpress-org/blueprints/blueprint.json \
+ CHANGELOG.md
+do
+ if ! have_diff "$f"; then
+ missing+=("$f")
+ fi
+done
+
+while IFS= read -r -d '' f; do
+ if ! have_diff "$f"; then
+ missing+=("$f (block.json)")
+ fi
+done < <(find ./src -name block.json -print0 2>/dev/null)
+
+if [ ${#missing[@]} -ne 0 ]; then
+ echo "::error::This PR bumps the plugin version. The following required paths must be modified (diff vs base branch):"
+ printf ' - %s\n' "${missing[@]}"
+ exit 1
+fi
+
+# Consistency: declared version must match .plugin-data everywhere
+fail=0
+if [ "$(jq -r .version package.json)" != "$NEW_VER" ]; then
+ echo "::error::package.json version must equal ${NEW_VER} (from .plugin-data)"
+ fail=1
+fi
+
+while IFS= read -r -d '' f; do
+ if [ "$(jq -r .version "$f")" != "$NEW_VER" ]; then
+ echo "::error::${f} version must equal ${NEW_VER} (from .plugin-data)"
+ fail=1
+ fi
+done < <(find ./src -name block.json -print0 2>/dev/null)
+
+BP_REF=$(jq -r '.steps[] | select(.pluginData) | .pluginData.ref' .wordpress-org/blueprints/blueprint.json | head -1)
+if [ "$BP_REF" != "$NEW_VER" ]; then
+ echo "::error::.wordpress-org/blueprints/blueprint.json pluginData.ref must be '${NEW_VER}' (got '${BP_REF}')"
+ fail=1
+fi
+
+if ! grep -F "* Version:" blockparty-tabs.php | head -1 | grep -qF "$NEW_VER"; then
+ echo "::error::blockparty-tabs.php plugin header Version must be ${NEW_VER}"
+ fail=1
+fi
+
+if ! grep -qF "define( 'BLOCKPARTY_TABS_VERSION', '$NEW_VER' );" blockparty-tabs.php; then
+ echo "::error::blockparty-tabs.php define BLOCKPARTY_TABS_VERSION must be '${NEW_VER}'"
+ fail=1
+fi
+
+if ! grep -E "^Stable tag:" readme.txt | grep -qF "$NEW_VER"; then
+ echo "::error::readme.txt Stable tag must be ${NEW_VER}"
+ fail=1
+fi
+
+if ! grep -qF "= ${NEW_VER} =" readme.txt; then
+ echo "::error::readme.txt Changelog must include '= ${NEW_VER} =' section"
+ fail=1
+fi
+
+if ! grep -qF "$NEW_VER" CHANGELOG.md; then
+ echo "::error::CHANGELOG.md must mention the release version ${NEW_VER} (e.g. a [${NEW_VER}] section header)"
+ fail=1
+fi
+
+exit "$fail"
diff --git a/tests/bin/initialize.sh b/tests/bin/initialize.sh
new file mode 100755
index 0000000..c563929
--- /dev/null
+++ b/tests/bin/initialize.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Scalar, not an array: bash 3.2 (macOS) treats an empty array expansion as an
+# unbound variable under `set -u`.
+config_arg=""
+for argument in "$@"; do
+ case "$argument" in
+ --config=*) config_arg="$argument" ;;
+ esac
+done
+
+run_cli() {
+ npx wp-env run cli ${config_arg:+"$config_arg"} -- "$@"
+}
+
+# Prefer a recent default theme shipped with WordPress core images.
+if run_cli wp theme is-installed twentytwentyfour; then
+ run_cli wp theme activate twentytwentyfour
+elif run_cli wp theme is-installed twentytwentyfive; then
+ run_cli wp theme activate twentytwentyfive
+elif run_cli wp theme is-installed twentytwentythree; then
+ run_cli wp theme activate twentytwentythree
+fi
+
+run_cli wp plugin activate blockparty-tabs
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..def9b33
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,46 @@
+ ( {
+ getBlockType: jest.fn(),
+} ) );
+
+import { getBlockType } from '@wordpress/blocks';
+
+describe( 'getIconTemplateAttributes', () => {
+ it( 'returns core/icon dimensions style attributes', () => {
+ expect( getIconTemplateAttributes( 'core/icon' ) ).toEqual( {
+ style: {
+ dimensions: {
+ width: '24px',
+ },
+ },
+ } );
+ } );
+
+ it( 'returns legacy width/maxIcons attributes for other icon blocks', () => {
+ expect( getIconTemplateAttributes( 'blockparty/icon' ) ).toEqual( {
+ width: 24,
+ maxIcons: 1,
+ } );
+ } );
+} );
+
+describe( 'getRegisteredIconBlocks', () => {
+ beforeEach( () => {
+ getBlockType.mockReset();
+ delete window.blockpartyTabsSettings;
+ } );
+
+ it( 'defaults to registered candidates from the shared default list', () => {
+ getBlockType.mockImplementation( ( name ) =>
+ name === 'blockparty/icon' ? { name } : undefined
+ );
+
+ expect( BLOCKPARTY_TABS_DEFAULT_ICON_BLOCKS ).toEqual( [
+ 'core/icon',
+ 'blockparty/icon',
+ 'beapi/icon-block',
+ ] );
+ expect( getRegisteredIconBlocks() ).toEqual( [ 'blockparty/icon' ] );
+ } );
+
+ it( 'prefers core/icon when it is registered among defaults', () => {
+ getBlockType.mockImplementation( ( name ) =>
+ [ 'core/icon', 'blockparty/icon' ].includes( name )
+ ? { name }
+ : undefined
+ );
+
+ expect( getRegisteredIconBlocks() ).toEqual( [
+ 'core/icon',
+ 'blockparty/icon',
+ ] );
+ } );
+
+ it( 'keeps only registered blocks from PHP settings', () => {
+ window.blockpartyTabsSettings = {
+ allowedIconBlocks: [
+ 'core/icon',
+ 'blockparty/icon',
+ 'beapi/icon-block',
+ ],
+ };
+ getBlockType.mockImplementation( ( name ) =>
+ [ 'core/icon', 'beapi/icon-block' ].includes( name )
+ ? { name }
+ : undefined
+ );
+
+ expect( getRegisteredIconBlocks() ).toEqual( [
+ 'core/icon',
+ 'beapi/icon-block',
+ ] );
+ } );
+
+ it( 'returns an empty list when no candidate is registered', () => {
+ window.blockpartyTabsSettings = {
+ allowedIconBlocks: [ 'blockparty/icon' ],
+ };
+ getBlockType.mockReturnValue( undefined );
+
+ expect( getRegisteredIconBlocks() ).toEqual( [] );
+ } );
+
+ it( 'returns an empty list on WordPress without core/icon or legacy plugins', () => {
+ getBlockType.mockReturnValue( undefined );
+
+ expect( getRegisteredIconBlocks() ).toEqual( [] );
+ } );
+} );
diff --git a/tests/js/tabs-automatic.test.js b/tests/js/tabs-automatic.test.js
new file mode 100644
index 0000000..3ce69d7
--- /dev/null
+++ b/tests/js/tabs-automatic.test.js
@@ -0,0 +1,153 @@
+/**
+ * @jest-environment jsdom
+ */
+
+/**
+ * Internal dependencies
+ */
+import { TabsAutomatic } from '../../src/blockparty-tabs/tabs-automatic';
+
+/**
+ * Builds a minimal accessible tabs markup tree for unit tests.
+ *
+ * @param {number} count Number of tabs/panels.
+ * @return {HTMLElement} Root tabs element.
+ */
+const createTabsMarkup = ( count = 3 ) => {
+ const root = document.createElement( 'div' );
+ root.className = 'wp-block-blockparty-tabs';
+
+ const nav = document.createElement( 'ul' );
+ nav.className = 'wp-block-blockparty-tabs-nav';
+ nav.setAttribute( 'role', 'tablist' );
+
+ const panels = document.createElement( 'section' );
+ panels.className = 'wp-block-blockparty-tabs-panels';
+
+ for ( let i = 0; i < count; i += 1 ) {
+ const item = document.createElement( 'li' );
+ item.className = 'wp-block-blockparty-tabs-nav-item';
+ if ( 0 === i ) {
+ item.classList.add( 'is-active' );
+ }
+
+ const tab = document.createElement( 'a' );
+ tab.id = `block-tab-0-${ i }`;
+ tab.href = `#block-tab-0-${ i }`;
+ tab.setAttribute( 'role', 'tab' );
+ tab.setAttribute( 'aria-controls', `block-panel-0-${ i }` );
+ tab.setAttribute( 'aria-selected', 0 === i ? 'true' : 'false' );
+ tab.tabIndex = 0 === i ? 0 : -1;
+ tab.textContent = `Tab ${ i + 1 }`;
+ item.appendChild( tab );
+ nav.appendChild( item );
+
+ const panel = document.createElement( 'div' );
+ panel.id = `block-panel-0-${ i }`;
+ panel.setAttribute( 'role', 'tabpanel' );
+ panel.setAttribute( 'aria-labelledby', `block-tab-0-${ i }` );
+ panel.className = `wp-block-blockparty-tabs-panel-item ${
+ 0 === i ? 'is-active' : 'is-hidden'
+ }`;
+ panel.textContent = `Panel ${ i + 1 }`;
+ panels.appendChild( panel );
+ }
+
+ root.appendChild( nav );
+ root.appendChild( panels );
+ document.body.appendChild( root );
+
+ return root;
+};
+
+describe( 'TabsAutomatic', () => {
+ afterEach( () => {
+ document.body.innerHTML = '';
+ } );
+
+ it( 'selects the first tab on init without forcing focus', () => {
+ const root = createTabsMarkup( 3 );
+ const widget = new TabsAutomatic( root );
+ const tabs = root.querySelectorAll( 'a[role=tab]' );
+ const panels = root.querySelectorAll( '[role=tabpanel]' );
+
+ expect( tabs[ 0 ].getAttribute( 'aria-selected' ) ).toBe( 'true' );
+ expect( tabs[ 0 ].hasAttribute( 'tabindex' ) ).toBe( false );
+ expect( panels[ 0 ].classList.contains( 'is-hidden' ) ).toBe( false );
+ expect( panels[ 1 ].classList.contains( 'is-hidden' ) ).toBe( true );
+ expect( panels[ 2 ].classList.contains( 'is-hidden' ) ).toBe( true );
+ expect( widget.firstTab ).toBe( tabs[ 0 ] );
+ } );
+
+ it( 'activates the clicked tab and hides other panels', () => {
+ const root = createTabsMarkup( 3 );
+ new TabsAutomatic( root );
+ const tabs = root.querySelectorAll( 'a[role=tab]' );
+ const panels = root.querySelectorAll( '[role=tabpanel]' );
+
+ tabs[ 1 ].dispatchEvent(
+ new MouseEvent( 'click', { bubbles: true, cancelable: true } )
+ );
+
+ expect( tabs[ 1 ].getAttribute( 'aria-selected' ) ).toBe( 'true' );
+ expect( tabs[ 0 ].getAttribute( 'aria-selected' ) ).toBe( 'false' );
+ expect( panels[ 1 ].classList.contains( 'is-active' ) ).toBe( true );
+ expect( panels[ 1 ].classList.contains( 'is-hidden' ) ).toBe( false );
+ expect( panels[ 0 ].classList.contains( 'is-hidden' ) ).toBe( true );
+ } );
+
+ it( 'moves selection with ArrowRight and wraps from last to first', () => {
+ const root = createTabsMarkup( 3 );
+ new TabsAutomatic( root );
+ const tabs = [ ...root.querySelectorAll( 'a[role=tab]' ) ];
+
+ tabs[ 0 ].dispatchEvent(
+ new KeyboardEvent( 'keydown', {
+ key: 'ArrowRight',
+ bubbles: true,
+ cancelable: true,
+ } )
+ );
+ expect( tabs[ 1 ].getAttribute( 'aria-selected' ) ).toBe( 'true' );
+
+ tabs[ 2 ].focus();
+ tabs[ 2 ].dispatchEvent(
+ new MouseEvent( 'click', { bubbles: true, cancelable: true } )
+ );
+ tabs[ 2 ].dispatchEvent(
+ new KeyboardEvent( 'keydown', {
+ key: 'ArrowRight',
+ bubbles: true,
+ cancelable: true,
+ } )
+ );
+ expect( tabs[ 0 ].getAttribute( 'aria-selected' ) ).toBe( 'true' );
+ } );
+
+ it( 'supports Home and End keys', () => {
+ const root = createTabsMarkup( 3 );
+ new TabsAutomatic( root );
+ const tabs = root.querySelectorAll( 'a[role=tab]' );
+
+ tabs[ 1 ].dispatchEvent(
+ new MouseEvent( 'click', { bubbles: true, cancelable: true } )
+ );
+ tabs[ 1 ].dispatchEvent(
+ new KeyboardEvent( 'keydown', {
+ key: 'End',
+ bubbles: true,
+ cancelable: true,
+ } )
+ );
+ expect( tabs[ 2 ].getAttribute( 'aria-selected' ) ).toBe( 'true' );
+
+ tabs[ 2 ].dispatchEvent(
+ new KeyboardEvent( 'keydown', {
+ key: 'Home',
+ bubbles: true,
+ cancelable: true,
+ } )
+ );
+ expect( tabs[ 0 ].getAttribute( 'aria-selected' ) ).toBe( 'true' );
+ } );
+} );
diff --git a/tests/php/AllowAttributesTest.php b/tests/php/AllowAttributesTest.php
new file mode 100644
index 0000000..2eb6b36
--- /dev/null
+++ b/tests/php/AllowAttributesTest.php
@@ -0,0 +1,62 @@
+ [],
+ 'button' => [],
+ 'div' => [],
+ ];
+
+ $result = allow_attributes( $tags, 'post' );
+
+ $this->assertTrue( $result['a']['aria-controls'] );
+ $this->assertTrue( $result['a']['aria-selected'] );
+ $this->assertTrue( $result['a']['tabindex'] );
+ $this->assertTrue( $result['div']['tabindex'] );
+ $this->assertTrue( $result['button']['aria-expanded'] );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_does_not_modify_non_post_context(): void {
+ $tags = [
+ 'a' => [],
+ ];
+
+ $result = allow_attributes( $tags, 'strip' );
+
+ $this->assertSame( $tags, $result );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_kses_preserves_tab_aria_attributes_on_anchor(): void {
+ $html = 'Tab ';
+
+ $filtered = wp_kses_post( $html );
+
+ $this->assertStringContainsString( 'aria-selected="true"', $filtered );
+ $this->assertStringContainsString( 'tabindex="-1"', $filtered );
+ $this->assertStringContainsString( 'aria-controls="block-panel-0-0"', $filtered );
+ }
+}
diff --git a/tests/php/AllowedIconBlocksTest.php b/tests/php/AllowedIconBlocksTest.php
new file mode 100644
index 0000000..16a6814
--- /dev/null
+++ b/tests/php/AllowedIconBlocksTest.php
@@ -0,0 +1,146 @@
+assertSame(
+ BLOCKPARTY_TABS_DEFAULT_ICON_BLOCKS,
+ get_allowed_icon_blocks()
+ );
+ $this->assertSame(
+ [
+ 'core/icon',
+ 'blockparty/icon',
+ 'beapi/icon-block',
+ ],
+ get_allowed_icon_blocks()
+ );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_filter_can_replace_icon_blocks(): void {
+ add_filter(
+ 'blockparty_tabs_allowed_icon_blocks',
+ static function (): array {
+ return [ 'core/icon' ];
+ }
+ );
+
+ $this->assertSame(
+ [ 'core/icon' ],
+ get_allowed_icon_blocks()
+ );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_filter_can_append_icon_blocks(): void {
+ add_filter(
+ 'blockparty_tabs_allowed_icon_blocks',
+ static function ( array $blocks ): array {
+ $blocks[] = 'my-plugin/custom-icon';
+ return $blocks;
+ }
+ );
+
+ $this->assertSame(
+ [
+ 'core/icon',
+ 'blockparty/icon',
+ 'beapi/icon-block',
+ 'my-plugin/custom-icon',
+ ],
+ get_allowed_icon_blocks()
+ );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_invalid_block_names_are_stripped(): void {
+ add_filter(
+ 'blockparty_tabs_allowed_icon_blocks',
+ static function (): array {
+ return [
+ 'core/icon',
+ 'Not A Block',
+ 123,
+ 'uppercase/Block',
+ 'beapi/icon-block',
+ 'core/icon',
+ ];
+ }
+ );
+
+ $this->assertSame(
+ [
+ 'core/icon',
+ 'uppercase/block',
+ 'beapi/icon-block',
+ ],
+ get_allowed_icon_blocks()
+ );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_non_array_filter_result_falls_back_to_default(): void {
+ add_filter(
+ 'blockparty_tabs_allowed_icon_blocks',
+ static function () {
+ return 'core/icon';
+ }
+ );
+
+ $this->assertSame(
+ BLOCKPARTY_TABS_DEFAULT_ICON_BLOCKS,
+ get_allowed_icon_blocks()
+ );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_empty_sanitized_list_falls_back_to_default(): void {
+ add_filter(
+ 'blockparty_tabs_allowed_icon_blocks',
+ static function (): array {
+ return [ 'invalid', 42 ];
+ }
+ );
+
+ $this->assertSame(
+ BLOCKPARTY_TABS_DEFAULT_ICON_BLOCKS,
+ get_allowed_icon_blocks()
+ );
+ }
+}
diff --git a/tests/php/BlockRegistrationTest.php b/tests/php/BlockRegistrationTest.php
new file mode 100644
index 0000000..273b3cc
--- /dev/null
+++ b/tests/php/BlockRegistrationTest.php
@@ -0,0 +1,119 @@
+assertTrue( defined( 'BLOCKPARTY_TABS_VERSION' ) );
+ $this->assertTrue( defined( 'BLOCKPARTY_TABS_DIR' ) );
+ $this->assertTrue( defined( 'BLOCKPARTY_TABS_URL' ) );
+ $this->assertTrue( defined( 'BLOCKPARTY_TABS_PLUGIN_BASENAME' ) );
+ $this->assertNotEmpty( BLOCKPARTY_TABS_VERSION );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_all_tabs_blocks_are_registered(): void {
+ $expected = [
+ 'blockparty/tabs',
+ 'blockparty/tabs-nav',
+ 'blockparty/tabs-nav-item',
+ 'blockparty/tabs-panels',
+ 'blockparty/tabs-panel-item',
+ ];
+
+ foreach ( $expected as $block_name ) {
+ $this->assertTrue(
+ \WP_Block_Type_Registry::get_instance()->is_registered( $block_name ),
+ sprintf( 'Expected block "%s" to be registered.', $block_name )
+ );
+ }
+ }
+
+ /**
+ * @return void
+ */
+ public function test_parent_tabs_block_exposes_view_script(): void {
+ $block = \WP_Block_Type_Registry::get_instance()->get_registered( 'blockparty/tabs' );
+
+ $this->assertNotNull( $block );
+ $this->assertNotEmpty( $block->view_script_handles );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_init_callback_is_registered_on_init(): void {
+ $this->assertNotFalse(
+ has_action( 'init', 'Blockparty\\Tabs\\init' )
+ );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_blockparty_tabs_init_action_ran_during_bootstrap(): void {
+ $this->assertGreaterThan( 0, did_action( 'blockparty_tabs_init' ) );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_react_jsx_runtime_script_is_registered(): void {
+ $this->assertTrue(
+ wp_script_is( 'react-jsx-runtime', 'registered' ),
+ 'Expected react-jsx-runtime to be registered (core or plugin polyfill).'
+ );
+ }
+
+ /**
+ * Editor scripts depend on the `react-jsx-runtime` handle; it must be emitted by webpack.
+ *
+ * @return void
+ */
+ public function test_react_jsx_runtime_polyfill_asset_is_built(): void {
+ $asset = BLOCKPARTY_TABS_DIR . 'build/react-jsx-runtime.js';
+
+ $this->assertFileIsReadable(
+ $asset,
+ 'Run `npm run build` so webpack emits build/react-jsx-runtime.js (see webpack.config.js).'
+ );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_react_jsx_runtime_polyfill_registers_plugin_script(): void {
+ $asset = BLOCKPARTY_TABS_DIR . 'build/react-jsx-runtime.js';
+ if ( ! is_readable( $asset ) ) {
+ $this->markTestSkipped( 'Built polyfill asset missing; run npm run build.' );
+ }
+
+ global $wp_scripts;
+
+ unset( $wp_scripts->registered['react-jsx-runtime'] );
+
+ \Blockparty\Tabs\register_react_jsx_runtime( $wp_scripts );
+
+ $this->assertTrue( wp_script_is( 'react-jsx-runtime', 'registered' ) );
+ $this->assertStringContainsString(
+ 'build/react-jsx-runtime.js',
+ $wp_scripts->registered['react-jsx-runtime']->src
+ );
+ }
+}
diff --git a/tests/php/EnqueueEditorSettingsTest.php b/tests/php/EnqueueEditorSettingsTest.php
new file mode 100644
index 0000000..dc3dbce
--- /dev/null
+++ b/tests/php/EnqueueEditorSettingsTest.php
@@ -0,0 +1,64 @@
+registered = [];
+ wp_scripts()->queue = [];
+ wp_scripts()->done = [];
+ // phpcs:enable WordPress.WP.GlobalVariablesOverride.Prohibited
+ }
+
+ /**
+ * @return void
+ */
+ public function test_adds_inline_settings_when_nav_item_script_is_registered(): void {
+ $handle = generate_block_asset_handle( 'blockparty/tabs-nav-item', 'editorScript' );
+
+ wp_register_script( $handle, false, [], '1.0.0', true );
+
+ enqueue_editor_settings();
+
+ $after = wp_scripts()->get_data( $handle, 'after' );
+ $before = wp_scripts()->get_data( $handle, 'before' );
+ $data = is_array( $before ) ? implode( "\n", $before ) : (string) $before;
+
+ if ( '' === $data && is_array( $after ) ) {
+ $data = implode( "\n", $after );
+ }
+
+ $this->assertStringContainsString( 'window.blockpartyTabsSettings', $data );
+ $this->assertStringContainsString( 'allowedIconBlocks', $data );
+ $this->assertMatchesRegularExpression( '/core\\\\?\/icon/', $data );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_is_noop_when_nav_item_script_is_not_registered(): void {
+ enqueue_editor_settings();
+
+ $handle = generate_block_asset_handle( 'blockparty/tabs-nav-item', 'editorScript' );
+ $before = wp_scripts()->get_data( $handle, 'before' );
+
+ $this->assertFalse( $before );
+ }
+}
diff --git a/tests/playwright/playwright.config.js b/tests/playwright/playwright.config.js
new file mode 100644
index 0000000..33da69f
--- /dev/null
+++ b/tests/playwright/playwright.config.js
@@ -0,0 +1,34 @@
+/**
+ * External dependencies
+ */
+import { join } from 'node:path';
+import { defineConfig } from '@playwright/test';
+/**
+ * WordPress dependencies
+ */
+import baseConfig from '@wordpress/scripts/config/playwright.config.js';
+
+process.env.WP_ARTIFACTS_PATH ??= join( process.cwd(), 'artifacts' );
+process.env.STORAGE_STATE_PATH ??= join(
+ process.env.WP_ARTIFACTS_PATH,
+ 'storage-states/admin.json'
+);
+process.env.WP_BASE_URL ??= 'http://localhost:8889';
+
+export default defineConfig( {
+ ...baseConfig,
+ testDir: './specs',
+ forbidOnly: Boolean( process.env.CI ),
+ workers: 1,
+ retries: 0,
+ use: {
+ ...( baseConfig.use || {} ),
+ baseURL: process.env.WP_BASE_URL,
+ },
+ webServer: {
+ command: 'npm run env:start-ci',
+ port: 8889,
+ timeout: 120_000,
+ reuseExistingServer: true,
+ },
+} );
diff --git a/tests/playwright/specs/editor-tabs.spec.js b/tests/playwright/specs/editor-tabs.spec.js
new file mode 100644
index 0000000..5643807
--- /dev/null
+++ b/tests/playwright/specs/editor-tabs.spec.js
@@ -0,0 +1,56 @@
+/**
+ * WordPress dependencies
+ */
+import { test, expect } from '../utils/fixtures';
+
+/**
+ * Resolve the block editor content root.
+ *
+ * WordPress 6.4+ uses an iframed canvas (`editor-canvas`). Detect the canvas
+ * mode explicitly: `FrameLocator.or()` is unreliable when the iframe is absent.
+ *
+ * @param {import('@playwright/test').Page} page
+ */
+const getEditorCanvas = async ( page ) => {
+ const iframe = page.locator( 'iframe[name="editor-canvas"]' );
+ const hasIframe = await iframe
+ .waitFor( { state: 'attached', timeout: 3_000 } )
+ .then( () => true )
+ .catch( () => false );
+
+ if ( hasIframe ) {
+ return page.frameLocator( 'iframe[name="editor-canvas"]' );
+ }
+
+ return page.locator( 'body' );
+};
+
+test.describe( 'Blockparty Tabs editor', () => {
+ test.beforeEach( async ( { admin } ) => {
+ await admin.createNewPost();
+ } );
+
+ test( 'inserts the Tabs block from the inserter', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( { name: 'blockparty/tabs' } );
+
+ const canvas = await getEditorCanvas( page );
+ const tabsRoot = canvas.locator( '.wp-block-blockparty-tabs' );
+ await expect( tabsRoot ).toBeVisible( { timeout: 10_000 } );
+
+ // Default template ships with three nav items and three panels.
+ await expect(
+ canvas.locator( '.wp-block-blockparty-tabs-nav-item' )
+ ).toHaveCount( 3 );
+ await expect(
+ canvas.locator( '.wp-block-blockparty-tabs-panel-item' )
+ ).toHaveCount( 3 );
+
+ await editor.saveDraft();
+ await expect(
+ page.getByRole( 'button', { name: 'Saved' } )
+ ).toBeVisible( { timeout: 15_000 } );
+ } );
+} );
diff --git a/tests/playwright/specs/frontend-tabs.spec.js b/tests/playwright/specs/frontend-tabs.spec.js
new file mode 100644
index 0000000..eefd4e5
--- /dev/null
+++ b/tests/playwright/specs/frontend-tabs.spec.js
@@ -0,0 +1,101 @@
+/**
+ * WordPress dependencies
+ */
+import { test, expect } from '../utils/fixtures';
+
+const TABS_MARKUP = `
+
+`;
+
+test.describe( 'Blockparty Tabs frontend', () => {
+ let post;
+
+ test.beforeEach( async ( { requestUtils } ) => {
+ post = await requestUtils.createPost( {
+ title: 'Blockparty Tabs E2E',
+ content: TABS_MARKUP,
+ status: 'publish',
+ } );
+ } );
+
+ test.afterEach( async ( { requestUtils } ) => {
+ if ( post?.id ) {
+ await requestUtils.rest( {
+ method: 'DELETE',
+ path: `/wp/v2/posts/${ post.id }`,
+ params: { force: true },
+ } );
+ }
+ } );
+
+ test( 'renders ARIA tablist and switches panels on click and keyboard', async ( {
+ page,
+ } ) => {
+ await page.goto( post.link );
+
+ const tabsRoot = page.locator( '.wp-block-blockparty-tabs' );
+ await expect( tabsRoot ).toBeVisible();
+
+ const tabs = page.getByRole( 'tab' );
+ await expect( tabs ).toHaveCount( 3 );
+ await expect( tabs.nth( 0 ) ).toHaveAttribute(
+ 'aria-selected',
+ 'true'
+ );
+ await expect( page.getByText( 'First panel content' ) ).toBeVisible();
+
+ await tabs.nth( 1 ).click();
+ await expect( tabs.nth( 1 ) ).toHaveAttribute(
+ 'aria-selected',
+ 'true'
+ );
+ await expect( page.getByText( 'Second panel content' ) ).toBeVisible();
+ await expect( page.getByText( 'First panel content' ) ).toBeHidden();
+
+ await tabs.nth( 1 ).press( 'ArrowRight' );
+ await expect( tabs.nth( 2 ) ).toHaveAttribute(
+ 'aria-selected',
+ 'true'
+ );
+ await expect( page.getByText( 'Third panel content' ) ).toBeVisible();
+
+ await tabs.nth( 2 ).press( 'Home' );
+ await expect( tabs.nth( 0 ) ).toHaveAttribute(
+ 'aria-selected',
+ 'true'
+ );
+ await expect( page.getByText( 'First panel content' ) ).toBeVisible();
+ } );
+} );
diff --git a/tests/playwright/utils/fixtures.js b/tests/playwright/utils/fixtures.js
new file mode 100644
index 0000000..c4dd20a
--- /dev/null
+++ b/tests/playwright/utils/fixtures.js
@@ -0,0 +1,11 @@
+/**
+ * External dependencies
+ */
+import { test as base, expect } from '@wordpress/e2e-test-utils-playwright';
+
+/**
+ * Shared fixtures for Blockparty Tabs E2E specs.
+ */
+export const test = base.extend( {} );
+
+export { expect };
diff --git a/tests/wp-tests-config.php b/tests/wp-tests-config.php
new file mode 100644
index 0000000..fcdee86
--- /dev/null
+++ b/tests/wp-tests-config.php
@@ -0,0 +1,34 @@
+