diff --git a/CHANGELOG.md b/CHANGELOG.md index 34a96bc90..73f8b1a98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ item (Added, Changed, Depreciated, Removed, Fixed, Security). ## [2026.03] +- Updated: Refactor Horizontal Tabs block into a dynamic block. Add reordering functionality. - Updated: Refactor Vertical Tabs block to dynamic block; allow sorting. ## [2026.02] diff --git a/wp-content/plugins/core/src/Components/Blocks/Horizontal_Tabs_Block_Controller.php b/wp-content/plugins/core/src/Components/Blocks/Horizontal_Tabs_Block_Controller.php new file mode 100644 index 000000000..c28d22a7d --- /dev/null +++ b/wp-content/plugins/core/src/Components/Blocks/Horizontal_Tabs_Block_Controller.php @@ -0,0 +1,71 @@ + + */ + protected array $tabs = []; + + protected \WP_Block $block; + + public function __construct( array $args = [] ) { + parent::__construct( $args ); + + $this->block = $args['block'] ?? new \WP_Block( [ 'blockName' => 'tribe/horizontal-tabs' ] ); + $this->tabs = $this->build_tabs_from_inner_blocks(); + } + + /** + * @return array + */ + public function get_tabs(): array { + return $this->tabs; + } + + /** + * Whether the given tab (by index) should be selected. First tab is always active on the front-end. + */ + public function is_tab_selected( int $index ): bool { + return $index === 0; + } + + /** + * Build tab list from parsed inner blocks (preserves saved order). + * + * @return array + */ + protected function build_tabs_from_inner_blocks(): array { + $block = $this->block->parsed_block; + $inner_blocks = $block['innerBlocks'] ?? []; + $tabs = []; + + foreach ( $inner_blocks as $inner ) { + if ( ( $inner['blockName'] ?? '' ) !== 'tribe/horizontal-tab' ) { + continue; + } + + $attrs = $inner['attrs'] ?? []; + $id = $attrs['blockId'] ?? ''; + $label = $attrs['tabLabel'] ?? ''; + $tabs[] = [ + 'id' => $id, + 'buttonId' => 'button-' . $id, + 'label' => $label !== '' ? $label : __( 'Tab Label', 'tribe' ), + ]; + } + + return $tabs; + } + +} diff --git a/wp-content/themes/core/blocks/tribe/horizontal-tabs/block.json b/wp-content/themes/core/blocks/tribe/horizontal-tabs/block.json index dfd413e8d..1ba66e98a 100644 --- a/wp-content/themes/core/blocks/tribe/horizontal-tabs/block.json +++ b/wp-content/themes/core/blocks/tribe/horizontal-tabs/block.json @@ -45,5 +45,6 @@ "editorScript": "file:./index.js", "editorStyle": "file:./index.css", "style": "file:./style-index.css", - "viewScript": "file:./view.js" + "viewScript": "file:./view.js", + "render": "file:./render.php" } diff --git a/wp-content/themes/core/blocks/tribe/horizontal-tabs/edit.js b/wp-content/themes/core/blocks/tribe/horizontal-tabs/edit.js index 139f95b26..1626f9078 100644 --- a/wp-content/themes/core/blocks/tribe/horizontal-tabs/edit.js +++ b/wp-content/themes/core/blocks/tribe/horizontal-tabs/edit.js @@ -1,27 +1,161 @@ -/** - * WordPress Dependencies - */ import { createBlock } from '@wordpress/blocks'; import { useBlockProps, RichText, useInnerBlocksProps, } from '@wordpress/block-editor'; -import { Button, Flex, FlexItem } from '@wordpress/components'; +import { Button, Flex, FlexItem, Tooltip } from '@wordpress/components'; import { useSelect, useDispatch } from '@wordpress/data'; -import { useEffect } from '@wordpress/element'; +import { useCallback, useEffect, useMemo, useRef } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { SVG, Path } from '@wordpress/primitives'; +import { dragHandle, trash } from '@wordpress/icons'; + +import { + closestCenter, + DndContext, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + useSortable, + horizontalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import './editor.pcss'; +/** + * Sortable tab wrapper for drag-and-drop reordering. + * @param {Object} root0 + * @param {Object} root0.tab + * @param {number} root0.index + * @param {string} root0.currentActiveTabInstanceId + * @param {Function} root0.onSelectTab + * @param {Function} root0.onUpdateLabel + * @param {Function} root0.onDeleteTab + */ +function SortableTab( { + tab, + index, + currentActiveTabInstanceId, + onSelectTab, + onUpdateLabel, + onDeleteTab, +} ) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable( { id: tab.clientId } ); + + const style = { + transform: CSS.Transform.toString( transform ), + transition, + opacity: isDragging ? 0.5 : 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + gap: 'var(--spacer-10)', + }; + + const handleTabKeyDown = ( e ) => { + // Don't capture keys when user is editing the tab label (so Space/Enter can be typed). + if ( e.target.closest( '[contenteditable="true"]' ) ) { + return; + } + + if ( e.key === 'Enter' || e.key === ' ' ) { + e.preventDefault(); + onSelectTab( tab.id ); + } + }; + + return ( +
onSelectTab( tab.id ) } + onKeyDown={ handleTabKeyDown } + aria-pressed={ currentActiveTabInstanceId === tab.id } + > +
+ ); +} + export default function Edit( { clientId, attributes, setAttributes } ) { const blockProps = useBlockProps(); const dispatch = useDispatch( 'core/block-editor' ); - const { removeBlocks } = useDispatch( 'core/block-editor' ); - const select = useSelect( 'core/block-editor' ); - const innerBlocks = select.getBlocks( clientId ); - const { currentActiveTabInstanceId, tabs } = attributes; + const { removeBlocks, moveBlockToPosition } = + useDispatch( 'core/block-editor' ); + // Subscribe to inner blocks so the component re-renders when order changes (e.g. after moveBlockToPosition). + const innerBlocks = useSelect( + ( select ) => select( 'core/block-editor' ).getBlocks( clientId ), + [ clientId ] + ); + const blockEditorSelect = useSelect( + ( select ) => select( 'core/block-editor' ), + [] + ); + const { currentActiveTabInstanceId } = attributes; + + // Derive tab list directly from innerBlocks so the tab bar reflects order immediately (no useEffect delay). + const tabs = useMemo( + () => + innerBlocks.map( ( block ) => ( { + clientId: block.clientId, + id: block.attributes.blockId, + buttonId: 'button-' + block.attributes.blockId, + label: block.attributes.tabLabel, + isActive: + currentActiveTabInstanceId === block.attributes.blockId, + } ) ), + [ innerBlocks, currentActiveTabInstanceId ] + ); + + const sensors = useSensors( useSensor( PointerSensor ) ); /** * setup inner block props and add classname to wrapper @@ -38,37 +172,19 @@ export default function Edit( { clientId, attributes, setAttributes } ) { ); /** - * Update the current active tab to the client Id of the first tab - * This will only run once when the block is first added to the editor - */ - useEffect( () => { - if ( innerBlocks.length === 0 || currentActiveTabInstanceId !== '' ) { - return; - } - - setAttributes( { - currentActiveTabInstanceId: innerBlocks[ 0 ].attributes.blockId, - } ); - }, [ innerBlocks, setAttributes, currentActiveTabInstanceId ] ); - - /** - * set new tab state when innerBlocks or currentActiveTabInstanceId changes + * Default to the first tab when the block loads in the editor (once per mount). + * Does not persist which tab was last active; always open with first tab. */ + const hasSetInitialTab = useRef( false ); useEffect( () => { - const data = innerBlocks.map( ( tab ) => { - return { - clientId: tab.clientId, - id: tab.attributes.blockId, - buttonId: 'button-' + tab.attributes.blockId, - label: tab.attributes.tabLabel, - isActive: currentActiveTabInstanceId === tab.attributes.blockId, - }; - } ); + if ( innerBlocks.length > 0 && ! hasSetInitialTab.current ) { + setAttributes( { + currentActiveTabInstanceId: innerBlocks[ 0 ].attributes.blockId, + } ); - setAttributes( { - tabs: data, - } ); - }, [ innerBlocks, currentActiveTabInstanceId, setAttributes ] ); + hasSetInitialTab.current = true; + } + }, [ innerBlocks, setAttributes ] ); /** * @function updateTabLabel @@ -99,8 +215,8 @@ export default function Edit( { clientId, attributes, setAttributes } ) { .then( () => { // dispatch will return us a promise which we can use to set our new active tab instanceId const newInstanceId = - select.getBlocks( clientId )[ positionToAdd ].attributes - .blockId; + blockEditorSelect.getBlocks( clientId )[ positionToAdd ] + .attributes.blockId; // set new tab as active setAttributes( { @@ -123,7 +239,7 @@ export default function Edit( { clientId, attributes, setAttributes } ) { removeBlocks( tabClientId ); // Fetch new inner blocks - const newInnerBlocks = select.getBlocks( clientId ); + const newInnerBlocks = blockEditorSelect.getBlocks( clientId ); // Add a new tab if we've deleted the last one if ( newInnerBlocks.length === 0 ) { @@ -148,78 +264,95 @@ export default function Edit( { clientId, attributes, setAttributes } ) { } ); }; - return ( -
- { + if ( + fromIndex === toIndex || + toIndex < 0 || + toIndex >= innerBlocks.length + ) { + return; + } + + // Use moveBlockToPosition so the block tree order is updated for save. + // Signature: (clientId, fromRootClientId, toRootClientId, index). + const blockClientId = innerBlocks[ fromIndex ].clientId; + + moveBlockToPosition( blockClientId, clientId, clientId, toIndex ); + }, + [ innerBlocks, clientId, moveBlockToPosition ] + ); + + const handleDragEnd = useCallback( + ( event ) => { + const { active, over } = event; + + if ( ! over || active.id === over.id ) { + return; + } + + const oldIndex = tabs.findIndex( + ( t ) => t.clientId === active.id + ); + const newIndex = tabs.findIndex( ( t ) => t.clientId === over.id ); + + if ( oldIndex === -1 || newIndex === -1 ) { + return; + } + + moveTab( oldIndex, newIndex ); + }, + [ tabs, moveTab ] + ); + + const tabList = ( + + { tabs.map( ( tab, index ) => ( + + setAttributes( { currentActiveTabInstanceId: id } ) + } + onUpdateLabel={ updateTabLabel } + onDeleteTab={ deleteTab } + /> + ) ) } + - { tabs.map( ( tab, index ) => ( - { - setAttributes( { - currentActiveTabInstanceId: tab.id, - } ); - } } - > - - updateTabLabel( value, tab.clientId ) - } - allowedFormats={ [] } - placeholder={ __( 'Tab Label', 'tribe' ) } - /> - - - ) ) } - addNewTab() }> + { __( 'Add New Tab', 'tribe' ) } + + + + ); + + return ( +
+ { tabs.length > 0 ? ( + - - - + t.clientId ) } + strategy={ horizontalListSortingStrategy } + > + { tabList } + + + ) : ( + tabList + ) }
); diff --git a/wp-content/themes/core/blocks/tribe/horizontal-tabs/editor.pcss b/wp-content/themes/core/blocks/tribe/horizontal-tabs/editor.pcss index 6fcfeead3..d3c43a7db 100644 --- a/wp-content/themes/core/blocks/tribe/horizontal-tabs/editor.pcss +++ b/wp-content/themes/core/blocks/tribe/horizontal-tabs/editor.pcss @@ -31,6 +31,29 @@ } } +.wp-block-tribe-horizontal-tabs__tab-drag-handle { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + margin: 0; + border: 0; + background: transparent; + color: var(--horizontal-tabs-editor-delete-color) !important; + cursor: grab; + opacity: 0.7; + + &:hover, + &:focus { + color: var(--horizontal-tabs-editor-delete-hover-color) !important; + opacity: 1; + } + + &:active { + cursor: grabbing; + } +} + .wp-block-tribe-horizontal-tabs__tab-label { cursor: text; } diff --git a/wp-content/themes/core/blocks/tribe/horizontal-tabs/index.js b/wp-content/themes/core/blocks/tribe/horizontal-tabs/index.js index 2dea36232..04a3adbff 100644 --- a/wp-content/themes/core/blocks/tribe/horizontal-tabs/index.js +++ b/wp-content/themes/core/blocks/tribe/horizontal-tabs/index.js @@ -1,9 +1,9 @@ import { registerBlockType } from '@wordpress/blocks'; +import { InnerBlocks } from '@wordpress/block-editor'; import './style.pcss'; import Edit from './edit'; -import save from './save'; import metadata from './block.json'; registerBlockType( metadata.name, { @@ -30,7 +30,8 @@ registerBlockType( metadata.name, { edit: Edit, /** - * @see ./save.js + * Saves only inner blocks so order is persisted. Front-end markup is from render.php. + * @param {Object} props */ - save, + save: ( props ) => , } ); diff --git a/wp-content/themes/core/blocks/tribe/horizontal-tabs/render.php b/wp-content/themes/core/blocks/tribe/horizontal-tabs/render.php new file mode 100644 index 000000000..b16d8188d --- /dev/null +++ b/wp-content/themes/core/blocks/tribe/horizontal-tabs/render.php @@ -0,0 +1,49 @@ + $attributes, + 'block' => $block, + 'block_classes' => 'wp-block-tribe-horizontal-tabs', +] ); + +$wrapper_attrs = get_block_wrapper_attributes( + [ + 'class' => $c->get_block_classes(), + 'style' => $c->get_block_styles(), + 'data-js' => 'tabs-block', + ] +); +?> +
> +
+
+ get_tabs() as $index => $tab ) : ?> + is_tab_selected( $index ); ?> + + +
+
+
+ +
+
diff --git a/wp-content/themes/core/blocks/tribe/horizontal-tabs/save.js b/wp-content/themes/core/blocks/tribe/horizontal-tabs/save.js deleted file mode 100644 index d981de2bd..000000000 --- a/wp-content/themes/core/blocks/tribe/horizontal-tabs/save.js +++ /dev/null @@ -1,47 +0,0 @@ -import { InnerBlocks, useBlockProps } from '@wordpress/block-editor'; -import { __ } from '@wordpress/i18n'; - -export default function save( props ) { - const blockProps = { - ...useBlockProps.save(), - 'data-js': 'tabs-block', - }; - const { - attributes: { tabs }, - } = props; - - // TODO: add control for accessible label - - return ( -
-
-
- { tabs.map( ( tab, index ) => { - return ( - - ); - } ) } -
-
-
- -
-
- ); -}