diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index 72281a53c3dd18..511d65f4149be2 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -887,6 +887,17 @@ Add white space between blocks and customize its height. ([Source](https://githu - **Supports:** anchor, interactivity (clientNavigation), spacing (margin) - **Attributes:** height, width +## Tab + +Single tab within a tabs block. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/tab)) + +- **Name:** core/tab +- **Experimental:** true +- **Category:** design +- **Parent:** core/tabs +- **Supports:** anchor, ~~html~~, ~~reusable~~ +- **Attributes:** isActive, label, slug, tabIndex + ## Table Create structured content in rows and columns to display information. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/table)) @@ -906,6 +917,17 @@ Summarize your post with a list of headings. Add HTML anchors to Heading blocks - **Supports:** color (background, gradients, link, text), interactivity (clientNavigation), spacing (margin, padding), typography (fontSize, lineHeight), ~~html~~ - **Attributes:** headings, onlyIncludeCurrentPage +## Tabs + +Organize content into tabs. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/tabs)) + +- **Name:** core/tabs +- **Experimental:** true +- **Category:** design +- **Allowed Blocks:** core/tab +- **Supports:** align (full, wide), color (text, ~~background~~), interactivity, spacing (margin, padding), ~~html~~ +- **Attributes:** innerTabs + ## Tag Cloud A cloud of popular keywords, each sized by how often it appears. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/tag-cloud)) diff --git a/lib/blocks.php b/lib/blocks.php index c3fdb26700c58c..3f53eb8c4372fe 100644 --- a/lib/blocks.php +++ b/lib/blocks.php @@ -116,6 +116,8 @@ function gutenberg_reregister_core_block_types() { 'site-logo.php' => 'core/site-logo', 'site-tagline.php' => 'core/site-tagline', 'site-title.php' => 'core/site-title', + 'tab.php' => 'core/tab', + 'tabs.php' => 'core/tabs', 'tag-cloud.php' => 'core/tag-cloud', 'template-part.php' => 'core/template-part', 'term-description.php' => 'core/term-description', diff --git a/packages/block-library/src/editor.scss b/packages/block-library/src/editor.scss index 52f3aa64287fae..896f7fe4ab6720 100644 --- a/packages/block-library/src/editor.scss +++ b/packages/block-library/src/editor.scss @@ -44,6 +44,7 @@ @import "./social-links/editor.scss"; @import "./spacer/editor.scss"; @import "./table/editor.scss"; +@import "./tabs/editor.scss"; @import "./tag-cloud/editor.scss"; @import "./template-part/editor.scss"; @import "./text-columns/editor.scss"; diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index 56365c87a268fd..2ebd1b0cb0ecc3 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -114,6 +114,8 @@ import * as socialLinks from './social-links'; import * as spacer from './spacer'; import * as table from './table'; import * as tableOfContents from './table-of-contents'; +import * as tab from './tab'; +import * as tabs from './tabs'; import * as tagCloud from './tag-cloud'; import * as templatePart from './template-part'; import * as termDescription from './term-description'; @@ -232,6 +234,12 @@ const getAllBlocks = () => { queryTitle, postAuthorBiography, ]; + + if ( window.__experimentalEnableBlockExperiments ) { + blocks.push( tab ); + blocks.push( tabs ); + } + if ( window?.__experimentalEnableFormBlocks ) { blocks.push( form ); blocks.push( formInput ); diff --git a/packages/block-library/src/style.scss b/packages/block-library/src/style.scss index 8f17cd7a50f55c..e4f029c084a003 100644 --- a/packages/block-library/src/style.scss +++ b/packages/block-library/src/style.scss @@ -53,9 +53,11 @@ @import "./site-title/style.scss"; @import "./social-links/style.scss"; @import "./spacer/style.scss"; +@import "./tab/style.scss"; @import "./tag-cloud/style.scss"; @import "./table/style.scss"; @import "./table-of-contents/style.scss"; +@import "./tabs/style.scss"; @import "./term-description/style.scss"; @import "./text-columns/style.scss"; @import "./verse/style.scss"; diff --git a/packages/block-library/src/tab/block.json b/packages/block-library/src/tab/block.json new file mode 100644 index 00000000000000..9234361b44c86d --- /dev/null +++ b/packages/block-library/src/tab/block.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "core/tab", + "title": "Tab", + "category": "design", + "description": "Single tab within a tabs block.", + "textdomain": "default", + "__experimental": true, + "attributes": { + "isActive": { + "type": "boolean", + "default": false + }, + "label": { + "type": "string", + "default": "" + }, + "slug": { + "type": "string", + "default": "" + }, + "tabIndex": { + "type": "number" + } + }, + "parent": [ "core/tabs" ], + "supports": { + "anchor": true, + "html": false, + "reusable": false + }, + "style": "wp-block-tab" +} diff --git a/packages/block-library/src/tab/edit.js b/packages/block-library/src/tab/edit.js new file mode 100644 index 00000000000000..0d86e5e9ae8015 --- /dev/null +++ b/packages/block-library/src/tab/edit.js @@ -0,0 +1,68 @@ +/** + * WordPress dependencies + */ +import { + InnerBlocks, + useBlockProps, + useInnerBlocksProps, + store as blockEditorStore, +} from '@wordpress/block-editor'; +import { useSelect } from '@wordpress/data'; +import { useEffect } from '@wordpress/element'; +import { cleanForSlug } from '@wordpress/url'; + +/** + * Generates a slug from a tab's text label. + * + * @param {string} label Tab label RichText value. + * @param {number} tabIndex Tab index value. + * + * @return {string} The generated slug with HTML stripped out. + */ +function slugFromLabel( label, tabIndex ) { + // Get just the text content, filtering out any HTML tags from the RichText value. + const htmlDocument = new window.DOMParser().parseFromString( + label, + 'text/html' + ); + if ( htmlDocument.body?.textContent ) { + return cleanForSlug( htmlDocument.body.textContent ); + } + + // Fall back to using the tab index if the label is empty. + return 'tab-panel-' + tabIndex; +} + +export default function Edit( { attributes, clientId, setAttributes } ) { + const { anchor, isActive, label, slug, tabIndex } = attributes; + // Use a custom anchor, if set. Otherwise fall back to the slug generated from the label text. + const tabPanelId = anchor || slug; + const tabLabelId = tabPanelId + '--tab'; + const hasChildBlocks = useSelect( + ( select ) => + select( blockEditorStore ).getBlockOrder( clientId ).length > 0, + [ clientId ] + ); + + useEffect( () => { + if ( label ) { + setAttributes( { slug: slugFromLabel( label, tabIndex ) } ); + } + }, [ label, setAttributes, tabIndex ] ); + + const blockProps = useBlockProps(); + const innerBlocksProps = useInnerBlocksProps( blockProps, { + renderAppender: hasChildBlocks + ? undefined + : InnerBlocks.ButtonBlockAppender, + } ); + return ( +
+ ); +} diff --git a/packages/block-library/src/tab/icon.js b/packages/block-library/src/tab/icon.js new file mode 100644 index 00000000000000..8ccdf6d3a4473e --- /dev/null +++ b/packages/block-library/src/tab/icon.js @@ -0,0 +1,19 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/components'; + +export default ( + +); diff --git a/packages/block-library/src/tab/index.js b/packages/block-library/src/tab/index.js new file mode 100644 index 00000000000000..c42afb84470d67 --- /dev/null +++ b/packages/block-library/src/tab/index.js @@ -0,0 +1,20 @@ +/** + * Internal dependencies + */ +import initBlock from '../utils/init-block'; +import metadata from './block.json'; +import edit from './edit'; +import save from './save'; +import icon from './icon'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + icon, + edit, + save, +}; + +export const init = () => initBlock( { name, metadata, settings } ); diff --git a/packages/block-library/src/tab/index.php b/packages/block-library/src/tab/index.php new file mode 100644 index 00000000000000..004216ae125ed3 --- /dev/null +++ b/packages/block-library/src/tab/index.php @@ -0,0 +1,53 @@ +next_tag( array( 'class_name' => 'wp-block-tab' ) ) ) { + // Add role="tabpanel" to each tab panel. + $p->set_attribute( 'data-wp-bind--role', 'state.roleAttribute' ); + + // Hide all tab panels that are not currently selected. + $p->set_attribute( 'data-wp-bind--hidden', '!state.isActiveTab' ); + + // Add tabindex="0" to the selected tab panel, so it can be focused. + $p->set_attribute( 'data-wp-bind--tabindex', 'state.tabindexPanelAttribute' ); + + // Store the index of each tab panel for tracking the selected tab. + $p->set_attribute( 'data-tab-index', $attributes['tabIndex'] ); + } + + return $p->get_updated_html(); +} + +/** + * Registers the `core/tab` block on server. + */ +function register_block_core_tab() { + register_block_type_from_metadata( + __DIR__ . '/tab', + array( + 'render_callback' => 'render_block_core_tab', + ) + ); +} +add_action( 'init', 'register_block_core_tab' ); diff --git a/packages/block-library/src/tab/init.js b/packages/block-library/src/tab/init.js new file mode 100644 index 00000000000000..79f0492c2cb2f8 --- /dev/null +++ b/packages/block-library/src/tab/init.js @@ -0,0 +1,6 @@ +/** + * Internal dependencies + */ +import { init } from './'; + +export default init(); diff --git a/packages/block-library/src/tab/save.js b/packages/block-library/src/tab/save.js new file mode 100644 index 00000000000000..d631a48eeb5966 --- /dev/null +++ b/packages/block-library/src/tab/save.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ +import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { anchor, slug } = attributes; + const tabPanelId = anchor || slug; + + const blockProps = useBlockProps.save(); + const innerBlocksProps = useInnerBlocksProps.save( blockProps ); + + return ; +} diff --git a/packages/block-library/src/tab/style.scss b/packages/block-library/src/tab/style.scss new file mode 100644 index 00000000000000..1d92981230b173 --- /dev/null +++ b/packages/block-library/src/tab/style.scss @@ -0,0 +1,7 @@ +.wp-block-tab { + padding: 1em 0; + + > *:first-child { + margin-top: 0; + } +} diff --git a/packages/block-library/src/tabs/block.json b/packages/block-library/src/tabs/block.json new file mode 100644 index 00000000000000..c46e93afda3d92 --- /dev/null +++ b/packages/block-library/src/tabs/block.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "core/tabs", + "title": "Tabs", + "category": "design", + "description": "Organize content into tabs.", + "textdomain": "default", + "__experimental": true, + "allowedBlocks": [ "core/tab" ], + "attributes": { + "innerTabs": { + "type": "array", + "default": [], + "source": "query", + "selector": ".wp-block-tabs__tab-label", + "query": { + "href": { + "type": "string", + "source": "attribute", + "attribute": "href" + }, + "label": { + "type": "string", + "source": "html" + } + } + } + }, + "supports": { + "align": [ "wide", "full" ], + "color": { + "background": false, + "text": true + }, + "html": false, + "interactivity": true, + "spacing": { + "margin": true, + "padding": true + } + }, + "editorStyle": "wp-block-tabs-editor", + "style": "wp-block-tabs" +} diff --git a/packages/block-library/src/tabs/edit.js b/packages/block-library/src/tabs/edit.js new file mode 100644 index 00000000000000..e30e7d6844eb85 --- /dev/null +++ b/packages/block-library/src/tabs/edit.js @@ -0,0 +1,183 @@ +/** + * WordPress dependencies + */ +import { + useBlockProps, + useInnerBlocksProps, + store as blockEditorStore, + RichText, +} from '@wordpress/block-editor'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { useCallback, useEffect } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +const TABS_TEMPLATE = [ + [ 'core/tab', { label: 'Tab 1' } ], + [ 'core/tab', { label: 'Tab 2' } ], +]; + +export default function Edit( { clientId, setAttributes } ) { + const { innerTabBlocks, selectedTabClientId } = useSelect( + ( select ) => { + const { + getBlocks, + getSelectedBlockClientId, + hasSelectedInnerBlock, + } = select( blockEditorStore ); + const innerBlocks = getBlocks( clientId ); + const selectedBlockClientId = getSelectedBlockClientId(); + let selectedTabId = null; + + // Find the first tab that is selected or has selected inner blocks so we can set it as active. + for ( const block of innerBlocks ) { + if ( + block.clientId === selectedBlockClientId || + hasSelectedInnerBlock( block.clientId, true ) + ) { + selectedTabId = block.clientId; + break; + } + } + + return { + innerTabBlocks: innerBlocks, + selectedTabClientId: selectedTabId, + }; + }, + [ clientId ] + ); + + const { __unstableMarkNextChangeAsNotPersistent, updateBlockAttributes } = + useDispatch( blockEditorStore ); + + const setActiveTab = useCallback( + ( activeTabClientId ) => { + // Set each inner tab's `isActive` attribute. + innerTabBlocks.forEach( ( block ) => { + __unstableMarkNextChangeAsNotPersistent(); + updateBlockAttributes( block.clientId, { + isActive: block.clientId === activeTabClientId, + } ); + } ); + }, + [ + innerTabBlocks, + updateBlockAttributes, + __unstableMarkNextChangeAsNotPersistent, + ] + ); + + // Set the first tab as active when the editor is loaded. + useEffect( () => { + if ( innerTabBlocks?.length ) { + setActiveTab( innerTabBlocks[ 0 ].clientId ); + } + }, [] ); // eslint-disable-line react-hooks/exhaustive-deps -- set first tab as active when the editor is loaded. + + // Update active tab when selection or inner blocks change. + useEffect( () => { + const hasActiveTab = + innerTabBlocks && + innerTabBlocks.some( ( block ) => block.attributes.isActive ); + + if ( selectedTabClientId ) { + // If an inner tab block is selected, or its inner blocks are selected, it becomes the active tab. + setActiveTab( selectedTabClientId ); + } else if ( ! hasActiveTab && innerTabBlocks?.length ) { + // Otherwise, if there's no active tab, default to the first inner tab. + setActiveTab( innerTabBlocks[ 0 ].clientId ); + } + }, [ innerTabBlocks, selectedTabClientId, setActiveTab ] ); + + /** + * Cache data needed for save functions: + * - labels from inner tabs to generate tab list + * - href values pointing to ids of inner tab blocks + */ + useEffect( () => { + __unstableMarkNextChangeAsNotPersistent(); + setAttributes( { + innerTabs: innerTabBlocks.map( ( block ) => ( { + label: block.attributes.label, + href: + '#' + ( block.attributes.anchor || block.attributes.slug ), + } ) ), + } ); + + innerTabBlocks.forEach( ( block, index ) => { + __unstableMarkNextChangeAsNotPersistent(); + updateBlockAttributes( block.clientId, { + tabIndex: index, + } ); + } ); + }, [ + __unstableMarkNextChangeAsNotPersistent, + innerTabBlocks, + setAttributes, + updateBlockAttributes, + ] ); + + const blockProps = useBlockProps( { className: 'interactive' } ); + const innerBlockProps = useInnerBlocksProps( + { + className: 'wp-block-tabs__content', + }, + { + __experimentalCaptureToolbars: true, + clientId, + orientation: 'horizontal', + template: TABS_TEMPLATE, + } + ); + + return ( +