diff --git a/packages/block-library/src/tab-list/edit.js b/packages/block-library/src/tab-list/edit.js
index 76783df05046de..1efb40e4a7ba61 100644
--- a/packages/block-library/src/tab-list/edit.js
+++ b/packages/block-library/src/tab-list/edit.js
@@ -21,7 +21,8 @@ import { useEffect, useRef } from '@wordpress/element';
/**
* Internal dependencies
*/
-import TabToolbarControls from '../tab-panel/tab-toolbar-controls';
+import TabToolbarControls from '../tabs/tab-toolbar-controls';
+import useTabActions from '../tabs/use-tab-actions';
const EMPTY_ARRAY = [];
@@ -55,12 +56,12 @@ function Edit( {
},
[ clientId ]
);
+ const { updateBlockAttributes, __unstableMarkNextChangeAsNotPersistent } =
+ useDispatch( blockEditorStore );
+ const { insertTab, removeTab } = useTabActions( tabsClientId );
const effectiveActiveIndex = editorActiveTabIndex ?? activeTabIndex;
- const { __unstableMarkNextChangeAsNotPersistent, updateBlockAttributes } =
- useDispatch( blockEditorStore );
-
function selectTabPanel( tabIndex ) {
if ( tabsClientId && tabIndex !== effectiveActiveIndex ) {
__unstableMarkNextChangeAsNotPersistent();
@@ -154,6 +155,10 @@ function Edit( {
onChange={ ( newLabel ) =>
handleLabelChange( index, newLabel )
}
+ __unstableOnSplitAtEnd={ () =>
+ insertTab( index + 1 )
+ }
+ onRemove={ () => removeTab( index ) }
/>
);
diff --git a/packages/block-library/src/tab-panel/controls.js b/packages/block-library/src/tab-panel/controls.js
index 6d851a3879faf6..f5b69665ed5631 100644
--- a/packages/block-library/src/tab-panel/controls.js
+++ b/packages/block-library/src/tab-panel/controls.js
@@ -16,7 +16,7 @@ import { useDispatch } from '@wordpress/data';
/**
* Internal dependencies
*/
-import TabToolbarControls from './tab-toolbar-controls';
+import TabToolbarControls from '../tabs/tab-toolbar-controls';
import { useToolsPanelDropdownMenuProps } from '../utils/hooks';
export default function Controls( { tabsClientId, blockIndex, isDefaultTab } ) {
diff --git a/packages/block-library/src/tab-panel/tab-toolbar-controls.js b/packages/block-library/src/tab-panel/tab-toolbar-controls.js
deleted file mode 100644
index 9de12efe2a5063..00000000000000
--- a/packages/block-library/src/tab-panel/tab-toolbar-controls.js
+++ /dev/null
@@ -1,129 +0,0 @@
-/**
- * WordPress dependencies
- */
-import { __ } from '@wordpress/i18n';
-import { createBlock } from '@wordpress/blocks';
-import {
- BlockControls,
- store as blockEditorStore,
-} from '@wordpress/block-editor';
-import { ToolbarGroup, ToolbarButton } from '@wordpress/components';
-import { useDispatch, useSelect } from '@wordpress/data';
-
-/**
- * "Add tab" and "Remove tab" buttons in the block toolbar for the tabs block.
- * Inserts or removes a core/tab-panel. The tab-list items attribute is kept
- * in sync automatically via useTabListItemsSync.
- *
- * @param {Object} props
- * @param {string} props.tabsClientId The client ID of the parent tabs block.
- * @return {React.JSX.Element} The toolbar control element.
- */
-export default function TabToolbarControls( { tabsClientId } ) {
- const {
- insertBlock,
- removeBlock,
- updateBlockAttributes,
- __unstableMarkNextChangeAsNotPersistent,
- } = useDispatch( blockEditorStore );
-
- const {
- tabPanelsClientId,
- activeTabPanelClientId,
- tabCount,
- editorActiveTabIndex,
- } = useSelect(
- ( select ) => {
- if ( ! tabsClientId ) {
- return {
- tabPanelsClientId: null,
- activeTabPanelClientId: null,
- tabCount: 0,
- editorActiveTabIndex: 0,
- };
- }
- const { getBlocks, getBlockAttributes } =
- select( blockEditorStore );
- const tabsAttributes = getBlockAttributes( tabsClientId );
- const activeIndex =
- tabsAttributes?.editorActiveTabIndex ??
- tabsAttributes?.activeTabIndex ??
- 0;
- const innerBlocks = getBlocks( tabsClientId );
- const tabPanels = innerBlocks.find(
- ( block ) => block.name === 'core/tab-panels'
- );
- const tabPanelBlocks = tabPanels?.innerBlocks || [];
- const activeTabPanel = tabPanelBlocks[ activeIndex ];
-
- return {
- tabPanelsClientId: tabPanels?.clientId || null,
- activeTabPanelClientId: activeTabPanel?.clientId || null,
- tabCount: tabPanelBlocks.length,
- editorActiveTabIndex: activeIndex,
- };
- },
- [ tabsClientId ]
- );
-
- const addTab = () => {
- if ( ! tabPanelsClientId ) {
- return;
- }
-
- insertBlock(
- createBlock( 'core/tab-panel', {
- label: __( 'Tab' ),
- } ),
- undefined,
- tabPanelsClientId,
- false
- );
-
- // Switch editor active tab to the new tab.
- const newIndex = tabCount;
- __unstableMarkNextChangeAsNotPersistent();
- updateBlockAttributes( tabsClientId, {
- editorActiveTabIndex: newIndex,
- } );
- };
-
- const removeTab = () => {
- if ( ! activeTabPanelClientId || tabCount <= 1 ) {
- return;
- }
-
- // Calculate new active index after removal.
- const newActiveIndex =
- editorActiveTabIndex >= tabCount - 1
- ? tabCount - 2
- : editorActiveTabIndex;
-
- // Switch editor to next active tab and remove the current one.
- __unstableMarkNextChangeAsNotPersistent();
- updateBlockAttributes( tabsClientId, {
- editorActiveTabIndex: newActiveIndex,
- } );
- removeBlock( activeTabPanelClientId, false );
- };
-
- const isRemoveDisabled = tabCount <= 1 || ! activeTabPanelClientId;
-
- return (
-
-
-
-
-
-
- );
-}
diff --git a/packages/block-library/src/tab-panels/edit.js b/packages/block-library/src/tab-panels/edit.js
index f36488d83adcf7..13d3acd511446d 100644
--- a/packages/block-library/src/tab-panels/edit.js
+++ b/packages/block-library/src/tab-panels/edit.js
@@ -12,7 +12,7 @@ import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
-import TabToolbarControls from '../tab-panel/tab-toolbar-controls';
+import TabToolbarControls from '../tabs/tab-toolbar-controls';
/**
* Initial template applied only when the block is first inserted (i.e. when
diff --git a/packages/block-library/src/tabs/edit.js b/packages/block-library/src/tabs/edit.js
index 67ca422e309254..7d24ae4009f530 100644
--- a/packages/block-library/src/tabs/edit.js
+++ b/packages/block-library/src/tabs/edit.js
@@ -13,7 +13,7 @@ import { useMemo } from '@wordpress/element';
/**
* Internal dependencies
*/
-import TabToolbarControls from '../tab-panel/tab-toolbar-controls';
+import TabToolbarControls from './tab-toolbar-controls';
import useTabListItemsSync from './use-tab-list-items-sync';
const EMPTY_ARRAY = [];
diff --git a/packages/block-library/src/tabs/tab-toolbar-controls.js b/packages/block-library/src/tabs/tab-toolbar-controls.js
new file mode 100644
index 00000000000000..ed685fd56c65ed
--- /dev/null
+++ b/packages/block-library/src/tabs/tab-toolbar-controls.js
@@ -0,0 +1,58 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import {
+ BlockControls,
+ store as blockEditorStore,
+} from '@wordpress/block-editor';
+import { ToolbarGroup, ToolbarButton } from '@wordpress/components';
+import { useSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import useTabActions from './use-tab-actions';
+
+/**
+ * "Add tab" and "Remove tab" buttons in the block toolbar for the tabs block.
+ * Inserts or removes a core/tab-panel. The tab-list items attribute is kept
+ * in sync automatically via useTabListItemsSync.
+ *
+ * @param {Object} props
+ * @param {string} props.tabsClientId The client ID of the parent tabs block.
+ * @return {React.JSX.Element} The toolbar control element.
+ */
+export default function TabToolbarControls( { tabsClientId } ) {
+ const { insertTab, removeTab } = useTabActions( tabsClientId );
+ const isRemoveDisabled = useSelect(
+ ( select ) => {
+ if ( ! tabsClientId ) {
+ return true;
+ }
+ const tabPanels = select( blockEditorStore )
+ .getBlocks( tabsClientId )
+ .find( ( block ) => block.name === 'core/tab-panels' );
+ return ( tabPanels?.innerBlocks.length ?? 0 ) <= 1;
+ },
+ [ tabsClientId ]
+ );
+
+ return (
+
+
+ insertTab() }
+ text={ __( 'Add tab' ) }
+ />
+ removeTab() }
+ text={ __( 'Remove tab' ) }
+ disabled={ isRemoveDisabled }
+ />
+
+
+ );
+}
diff --git a/packages/block-library/src/tabs/use-tab-actions.js b/packages/block-library/src/tabs/use-tab-actions.js
new file mode 100644
index 00000000000000..e64a76f3165248
--- /dev/null
+++ b/packages/block-library/src/tabs/use-tab-actions.js
@@ -0,0 +1,102 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { createBlock } from '@wordpress/blocks';
+import { store as blockEditorStore } from '@wordpress/block-editor';
+import { useDispatch, useRegistry } from '@wordpress/data';
+
+/**
+ * Provides callbacks to insert and remove tabs for a tabs block.
+ *
+ * The hook intentionally avoids subscribing to the store: all data is derived
+ * lazily inside the callbacks via `registry.select`, so consumers don't
+ * re-render when the tab structure changes.
+ *
+ * @param {string|null} tabsClientId The client ID of the parent tabs block.
+ * @return {{ insertTab: Function, removeTab: Function }} Tab action callbacks.
+ */
+export default function useTabActions( tabsClientId ) {
+ const registry = useRegistry();
+ const {
+ insertBlock,
+ removeBlock,
+ updateBlockAttributes,
+ __unstableMarkNextChangeAsNotPersistent,
+ } = useDispatch( blockEditorStore );
+
+ // Derive the current tabs state at call time without subscribing.
+ const getTabsState = () => {
+ const { getBlocks, getBlockAttributes } =
+ registry.select( blockEditorStore );
+
+ const tabsAttributes = tabsClientId
+ ? getBlockAttributes( tabsClientId )
+ : undefined;
+ const tabPanels = tabsClientId
+ ? getBlocks( tabsClientId ).find(
+ ( block ) => block.name === 'core/tab-panels'
+ )
+ : undefined;
+
+ return {
+ tabPanelsClientId: tabPanels?.clientId ?? null,
+ tabPanelBlocks: tabPanels?.innerBlocks ?? [],
+ activeIndex:
+ tabsAttributes?.editorActiveTabIndex ??
+ tabsAttributes?.activeTabIndex ??
+ 0,
+ };
+ };
+
+ // Insert a new tab and make it active. Defaults to appending at the end.
+ const insertTab = ( atIndex ) => {
+ const { tabPanelsClientId, tabPanelBlocks } = getTabsState();
+ if ( ! tabPanelsClientId ) {
+ return;
+ }
+
+ const newIndex = atIndex ?? tabPanelBlocks.length;
+ insertBlock(
+ createBlock( 'core/tab-panel', { label: __( 'Tab' ) } ),
+ newIndex,
+ tabPanelsClientId,
+ false
+ );
+
+ // Switch editor active tab to the new tab.
+ __unstableMarkNextChangeAsNotPersistent();
+ updateBlockAttributes( tabsClientId, {
+ editorActiveTabIndex: newIndex,
+ } );
+ };
+
+ // Remove a tab and move the active tab to an adjacent one. Defaults to the
+ // currently active tab.
+ const removeTab = ( atIndex ) => {
+ const { tabPanelBlocks, activeIndex } = getTabsState();
+ const tabCount = tabPanelBlocks.length;
+ if ( tabCount <= 1 ) {
+ return;
+ }
+
+ const removeIndex = atIndex ?? activeIndex;
+ const target = tabPanelBlocks[ removeIndex ];
+ if ( ! target ) {
+ return;
+ }
+
+ // Calculate new active index after removal.
+ const newActiveIndex =
+ removeIndex >= tabCount - 1 ? tabCount - 2 : removeIndex;
+
+ // Switch editor to the adjacent tab and remove the current one.
+ __unstableMarkNextChangeAsNotPersistent();
+ updateBlockAttributes( tabsClientId, {
+ editorActiveTabIndex: newActiveIndex,
+ } );
+ removeBlock( target.clientId, false );
+ };
+
+ return { insertTab, removeTab };
+}