From 94395393917523cc17649a5e52607d6bfa3e7915 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Fri, 26 Jun 2026 14:33:09 +0400 Subject: [PATCH 1/3] Tabs: Create a new tab when pressing Enter at the end of a tab label --- packages/block-library/src/tab-list/edit.js | 47 ++++++++++++++++++--- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/block-library/src/tab-list/edit.js b/packages/block-library/src/tab-list/edit.js index 76783df05046de..de4e44cef0b156 100644 --- a/packages/block-library/src/tab-list/edit.js +++ b/packages/block-library/src/tab-list/edit.js @@ -7,6 +7,7 @@ import clsx from 'clsx'; * WordPress dependencies */ import { __ } from '@wordpress/i18n'; +import { createBlock } from '@wordpress/blocks'; import { useBlockProps, store as blockEditorStore, @@ -37,30 +38,43 @@ function Edit( { const borderProps = useBorderProps( attributes ); const spacingProps = getSpacingClassesAndStyles( attributes ); - const { tabsClientId, editorActiveTabIndex, activeTabIndex } = useSelect( + const { + tabsClientId, + tabPanelsClientId, + editorActiveTabIndex, + activeTabIndex, + } = useSelect( ( select ) => { - const { getBlockRootClientId, getBlockAttributes } = + const { getBlockRootClientId, getBlockAttributes, getBlocks } = select( blockEditorStore ); const _tabsClientId = getBlockRootClientId( clientId ); const tabsAttributes = _tabsClientId ? getBlockAttributes( _tabsClientId ) : {}; + const tabPanels = _tabsClientId + ? getBlocks( _tabsClientId ).find( + ( block ) => block.name === 'core/tab-panels' + ) + : undefined; return { tabsClientId: _tabsClientId, + tabPanelsClientId: tabPanels?.clientId ?? null, editorActiveTabIndex: tabsAttributes?.editorActiveTabIndex, activeTabIndex: tabsAttributes?.activeTabIndex ?? 0, }; }, [ clientId ] ); + const { + insertBlock, + updateBlockAttributes, + __unstableMarkNextChangeAsNotPersistent, + } = useDispatch( blockEditorStore ); const effectiveActiveIndex = editorActiveTabIndex ?? activeTabIndex; - const { __unstableMarkNextChangeAsNotPersistent, updateBlockAttributes } = - useDispatch( blockEditorStore ); - function selectTabPanel( tabIndex ) { if ( tabsClientId && tabIndex !== effectiveActiveIndex ) { __unstableMarkNextChangeAsNotPersistent(); @@ -77,6 +91,26 @@ function Edit( { } } + function insertTabAfter( tabIndex ) { + if ( ! tabPanelsClientId ) { + return; + } + + const newIndex = tabIndex + 1; + insertBlock( + createBlock( 'core/tab-panel', { label: __( 'Tab' ) } ), + newIndex, + tabPanelsClientId, + false + ); + + // Switch editor active tab to the new tab. + __unstableMarkNextChangeAsNotPersistent(); + updateBlockAttributes( tabsClientId, { + editorActiveTabIndex: newIndex, + } ); + } + const menuRef = useRef(); const prevTabCountRef = useRef( tabsList.length ); @@ -154,6 +188,9 @@ function Edit( { onChange={ ( newLabel ) => handleLabelChange( index, newLabel ) } + __unstableOnSplitAtEnd={ () => + insertTabAfter( index ) + } /> ); From 257dfd2523f4319698181e7d54c66dca40771cbb Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Sat, 27 Jun 2026 11:46:03 +0400 Subject: [PATCH 2/3] Add RichText onRemove handler --- packages/block-library/src/tab-list/edit.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/block-library/src/tab-list/edit.js b/packages/block-library/src/tab-list/edit.js index de4e44cef0b156..21837a331510a6 100644 --- a/packages/block-library/src/tab-list/edit.js +++ b/packages/block-library/src/tab-list/edit.js @@ -69,6 +69,7 @@ function Edit( { ); const { insertBlock, + removeBlock, updateBlockAttributes, __unstableMarkNextChangeAsNotPersistent, } = useDispatch( blockEditorStore ); @@ -111,6 +112,24 @@ function Edit( { } ); } + function removeTab( tabIndex ) { + const tab = tabsList[ tabIndex ]; + if ( ! tab?.clientId || tabsList.length <= 1 ) { + return; + } + + // Calculate new active index after removal. + const newActiveIndex = + tabIndex >= tabsList.length - 1 ? tabsList.length - 2 : tabIndex; + + // Switch editor to the adjacent tab and remove the current one. + __unstableMarkNextChangeAsNotPersistent(); + updateBlockAttributes( tabsClientId, { + editorActiveTabIndex: newActiveIndex, + } ); + removeBlock( tab.clientId, false ); + } + const menuRef = useRef(); const prevTabCountRef = useRef( tabsList.length ); @@ -191,6 +210,7 @@ function Edit( { __unstableOnSplitAtEnd={ () => insertTabAfter( index ) } + onRemove={ () => removeTab( index ) } /> ); From 29a8a452a323edadfbe3666c4080d710d619c18b Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Sat, 27 Jun 2026 12:02:17 +0400 Subject: [PATCH 3/3] Refactor a bit --- packages/block-library/src/tab-list/edit.js | 68 ++------- .../block-library/src/tab-panel/controls.js | 2 +- .../src/tab-panel/tab-toolbar-controls.js | 129 ------------------ packages/block-library/src/tab-panels/edit.js | 2 +- packages/block-library/src/tabs/edit.js | 2 +- .../src/tabs/tab-toolbar-controls.js | 58 ++++++++ .../block-library/src/tabs/use-tab-actions.js | 102 ++++++++++++++ 7 files changed, 171 insertions(+), 192 deletions(-) delete mode 100644 packages/block-library/src/tab-panel/tab-toolbar-controls.js create mode 100644 packages/block-library/src/tabs/tab-toolbar-controls.js create mode 100644 packages/block-library/src/tabs/use-tab-actions.js diff --git a/packages/block-library/src/tab-list/edit.js b/packages/block-library/src/tab-list/edit.js index 21837a331510a6..1efb40e4a7ba61 100644 --- a/packages/block-library/src/tab-list/edit.js +++ b/packages/block-library/src/tab-list/edit.js @@ -7,7 +7,6 @@ import clsx from 'clsx'; * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { createBlock } from '@wordpress/blocks'; import { useBlockProps, store as blockEditorStore, @@ -22,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 = []; @@ -38,41 +38,27 @@ function Edit( { const borderProps = useBorderProps( attributes ); const spacingProps = getSpacingClassesAndStyles( attributes ); - const { - tabsClientId, - tabPanelsClientId, - editorActiveTabIndex, - activeTabIndex, - } = useSelect( + const { tabsClientId, editorActiveTabIndex, activeTabIndex } = useSelect( ( select ) => { - const { getBlockRootClientId, getBlockAttributes, getBlocks } = + const { getBlockRootClientId, getBlockAttributes } = select( blockEditorStore ); const _tabsClientId = getBlockRootClientId( clientId ); const tabsAttributes = _tabsClientId ? getBlockAttributes( _tabsClientId ) : {}; - const tabPanels = _tabsClientId - ? getBlocks( _tabsClientId ).find( - ( block ) => block.name === 'core/tab-panels' - ) - : undefined; return { tabsClientId: _tabsClientId, - tabPanelsClientId: tabPanels?.clientId ?? null, editorActiveTabIndex: tabsAttributes?.editorActiveTabIndex, activeTabIndex: tabsAttributes?.activeTabIndex ?? 0, }; }, [ clientId ] ); - const { - insertBlock, - removeBlock, - updateBlockAttributes, - __unstableMarkNextChangeAsNotPersistent, - } = useDispatch( blockEditorStore ); + const { updateBlockAttributes, __unstableMarkNextChangeAsNotPersistent } = + useDispatch( blockEditorStore ); + const { insertTab, removeTab } = useTabActions( tabsClientId ); const effectiveActiveIndex = editorActiveTabIndex ?? activeTabIndex; @@ -92,44 +78,6 @@ function Edit( { } } - function insertTabAfter( tabIndex ) { - if ( ! tabPanelsClientId ) { - return; - } - - const newIndex = tabIndex + 1; - insertBlock( - createBlock( 'core/tab-panel', { label: __( 'Tab' ) } ), - newIndex, - tabPanelsClientId, - false - ); - - // Switch editor active tab to the new tab. - __unstableMarkNextChangeAsNotPersistent(); - updateBlockAttributes( tabsClientId, { - editorActiveTabIndex: newIndex, - } ); - } - - function removeTab( tabIndex ) { - const tab = tabsList[ tabIndex ]; - if ( ! tab?.clientId || tabsList.length <= 1 ) { - return; - } - - // Calculate new active index after removal. - const newActiveIndex = - tabIndex >= tabsList.length - 1 ? tabsList.length - 2 : tabIndex; - - // Switch editor to the adjacent tab and remove the current one. - __unstableMarkNextChangeAsNotPersistent(); - updateBlockAttributes( tabsClientId, { - editorActiveTabIndex: newActiveIndex, - } ); - removeBlock( tab.clientId, false ); - } - const menuRef = useRef(); const prevTabCountRef = useRef( tabsList.length ); @@ -208,7 +156,7 @@ function Edit( { handleLabelChange( index, newLabel ) } __unstableOnSplitAtEnd={ () => - insertTabAfter( index ) + 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 }; +}