diff --git a/lib/blocks.php b/lib/blocks.php index cc3f4c8662254e..a3ed5a393dea3f 100644 --- a/lib/blocks.php +++ b/lib/blocks.php @@ -59,6 +59,7 @@ function gutenberg_reregister_core_block_types() { 'loginout.php' => 'core/loginout', 'navigation.php' => 'core/navigation', 'navigation-link.php' => 'core/navigation-link', + 'navigation-heading.php' => 'core/navigation-heading', 'home-link.php' => 'core/home-link', 'rss.php' => 'core/rss', 'search.php' => 'core/search', diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index da7fcacc8cadff..14e6f471b1101a 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -33,6 +33,7 @@ import * as html from './html'; import * as mediaText from './media-text'; import * as navigation from './navigation'; import * as navigationLink from './navigation-link'; +import * as navigationHeading from './navigation-heading'; import * as homeLink from './home-link'; import * as latestComments from './latest-comments'; import * as latestPosts from './latest-posts'; @@ -230,6 +231,7 @@ export const __experimentalRegisterExperimentalCoreBlocks = [ navigation, navigationLink, + navigationHeading, homeLink, // Register Full Site Editing Blocks. diff --git a/packages/block-library/src/navigation-heading/block.json b/packages/block-library/src/navigation-heading/block.json new file mode 100644 index 00000000000000..2503d3f196f690 --- /dev/null +++ b/packages/block-library/src/navigation-heading/block.json @@ -0,0 +1,32 @@ +{ + "apiVersion": 2, + "name": "core/navigation-heading", + "title": "Navigation Heading", + "category": "design", + "parent": [ + "core/navigation" + ], + "description": "Add a heading to your navigation.", + "textdomain": "default", + "attributes": { + "label": { + "type": "string" + } + }, + "usesContext": [ + "textColor", + "customTextColor", + "backgroundColor", + "customBackgroundColor", + "fontSize", + "customFontSize", + "showSubmenuIcon", + "style" + ], + "supports": { + "reusable": false, + "html": false + }, + "editorStyle": "wp-block-navigation-link-editor", + "style": "wp-block-navigation-link" +} diff --git a/packages/block-library/src/navigation-heading/edit.js b/packages/block-library/src/navigation-heading/edit.js new file mode 100644 index 00000000000000..2677bcc5942551 --- /dev/null +++ b/packages/block-library/src/navigation-heading/edit.js @@ -0,0 +1,236 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { ToolbarButton, ToolbarGroup } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { + BlockControls, + InnerBlocks, + __experimentalUseInnerBlocksProps as useInnerBlocksProps, + RichText, + useBlockProps, + store as blockEditorStore, +} from '@wordpress/block-editor'; +import { Fragment, useState, useEffect, useRef } from '@wordpress/element'; + +import { addSubmenu } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { ItemSubmenuIcon } from '../navigation-link/icons'; + +const ALLOWED_BLOCKS = [ 'core/navigation-link' ]; + +/** + * A React hook to determine if it's dragging within the target element. + * + * @typedef {import('@wordpress/element').RefObject} RefObject + * + * @param {RefObject} elementRef The target elementRef object. + * + * @return {boolean} Is dragging within the target element. + */ +const useIsDraggingWithin = ( elementRef ) => { + const [ isDraggingWithin, setIsDraggingWithin ] = useState( false ); + + useEffect( () => { + const { ownerDocument } = elementRef.current; + + function handleDragStart( event ) { + // Check the first time when the dragging starts. + handleDragEnter( event ); + } + + // Set to false whenever the user cancel the drag event by either releasing the mouse or press Escape. + function handleDragEnd() { + setIsDraggingWithin( false ); + } + + function handleDragEnter( event ) { + // Check if the current target is inside the item element. + if ( elementRef.current.contains( event.target ) ) { + setIsDraggingWithin( true ); + } else { + setIsDraggingWithin( false ); + } + } + + // Bind these events to the document to catch all drag events. + // Ideally, we can also use `event.relatedTarget`, but sadly that + // doesn't work in Safari. + ownerDocument.addEventListener( 'dragstart', handleDragStart ); + ownerDocument.addEventListener( 'dragend', handleDragEnd ); + ownerDocument.addEventListener( 'dragenter', handleDragEnter ); + + return () => { + ownerDocument.removeEventListener( 'dragstart', handleDragStart ); + ownerDocument.removeEventListener( 'dragend', handleDragEnd ); + ownerDocument.removeEventListener( 'dragenter', handleDragEnter ); + }; + }, [] ); + + return isDraggingWithin; +}; + +export default function NavigationHeadingEdit( { + attributes, + isSelected, + setAttributes, + insertBlocksAfter, + mergeBlocks, + onReplace, + context, + clientId, +} ) { + const { label } = attributes; + + const { textColor, backgroundColor, style, showSubmenuIcon } = context; + const { insertBlock } = useDispatch( blockEditorStore ); + const listItemRef = useRef( null ); + const isDraggingWithin = useIsDraggingWithin( listItemRef ); + const itemLabelPlaceholder = __( 'Add linkā€¦' ); + const ref = useRef(); + + const { + isParentOfSelectedBlock, + isImmediateParentOfSelectedBlock, + hasDescendants, + selectedBlockHasDescendants, + numberOfDescendants, + } = useSelect( + ( select ) => { + const { + getClientIdsOfDescendants, + hasSelectedInnerBlock, + getSelectedBlockClientId, + } = select( blockEditorStore ); + + const selectedBlockId = getSelectedBlockClientId(); + + const descendants = getClientIdsOfDescendants( [ clientId ] ) + .length; + + return { + isImmediateParentOfSelectedBlock: hasSelectedInnerBlock( + clientId, + false + ), + hasDescendants: !! descendants, + selectedBlockHasDescendants: !! getClientIdsOfDescendants( [ + selectedBlockId, + ] )?.length, + numberOfDescendants: descendants, + }; + }, + [ clientId ] + ); + + /** + * Insert a link block when submenu is added. + */ + function insertLinkBlock() { + const insertionPoint = numberOfDescendants; + const blockToInsert = createBlock( 'core/navigation-link' ); + insertBlock( blockToInsert, insertionPoint, clientId ); + } + + const blockProps = useBlockProps( { + ref: listItemRef, + className: classnames( { + 'is-editing': isSelected || isParentOfSelectedBlock, + 'is-dragging-within': isDraggingWithin, + 'has-child': hasDescendants, + 'has-text-color': !! textColor || !! style?.color?.text, + [ `has-${ textColor }-color` ]: !! textColor, + 'has-background': !! backgroundColor || !! style?.color?.background, + [ `has-${ backgroundColor }-background-color` ]: !! backgroundColor, + } ), + style: { + color: style?.color?.text, + backgroundColor: style?.color?.background, + }, + } ); + + const innerBlocksProps = useInnerBlocksProps( + { + className: classnames( 'wp-block-navigation-link__container', { + 'is-parent-of-selected-block': isParentOfSelectedBlock, + } ), + }, + { + allowedBlocks: ALLOWED_BLOCKS, + renderAppender: + ( isSelected && hasDescendants ) || + ( isImmediateParentOfSelectedBlock && + ! selectedBlockHasDescendants ) || + // Show the appender while dragging to allow inserting element between item and the appender. + hasDescendants + ? InnerBlocks.DefaultAppender + : false, + } + ); + + return ( + + + + + + +
+ { /* eslint-disable jsx-a11y/anchor-is-valid */ } + + { /* eslint-enable */ } + + setAttributes( { label: labelValue } ) + } + onMerge={ mergeBlocks } + onReplace={ onReplace } + __unstableOnSplitAtEnd={ () => + insertBlocksAfter( + createBlock( 'core/navigation-link' ) + ) + } + aria-label={ __( 'Navigation link text' ) } + placeholder={ itemLabelPlaceholder } + withoutInteractiveFormatting + allowedFormats={ [ + 'core/bold', + 'core/italic', + 'core/image', + 'core/strikethrough', + ] } + /> + + { hasDescendants && showSubmenuIcon && ( + + + + ) } + +
+
+ + ); +} diff --git a/packages/block-library/src/navigation-heading/index.js b/packages/block-library/src/navigation-heading/index.js new file mode 100644 index 00000000000000..2e132bfcff95c9 --- /dev/null +++ b/packages/block-library/src/navigation-heading/index.js @@ -0,0 +1,25 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; +import edit from './edit'; +import save from './save'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + edit, + save, + example: { + attributes: { + label: __( 'Projects' ), + }, + }, +}; diff --git a/packages/block-library/src/navigation-heading/index.php b/packages/block-library/src/navigation-heading/index.php new file mode 100644 index 00000000000000..89f4ab5cdc2964 --- /dev/null +++ b/packages/block-library/src/navigation-heading/index.php @@ -0,0 +1,205 @@ + array(), + 'inline_styles' => '', + ); + + // Text color. + $has_named_text_color = array_key_exists( 'textColor', $context ); + $has_custom_text_color = isset( $context['style']['color']['text'] ); + + // If has text color. + if ( $has_custom_text_color || $has_named_text_color ) { + // Add has-text-color class. + $colors['css_classes'][] = 'has-text-color'; + } + + if ( $has_named_text_color ) { + // Add the color class. + $colors['css_classes'][] = sprintf( 'has-%s-color', $context['textColor'] ); + } elseif ( $has_custom_text_color ) { + // Add the custom color inline style. + $colors['inline_styles'] .= sprintf( 'color: %s;', $context['style']['color']['text'] ); + } + + // Background color. + $has_named_background_color = array_key_exists( 'backgroundColor', $context ); + $has_custom_background_color = isset( $context['style']['color']['background'] ); + + // If has background color. + if ( $has_custom_background_color || $has_named_background_color ) { + // Add has-background class. + $colors['css_classes'][] = 'has-background'; + } + + if ( $has_named_background_color ) { + // Add the background-color class. + $colors['css_classes'][] = sprintf( 'has-%s-background-color', $context['backgroundColor'] ); + } elseif ( $has_custom_background_color ) { + // Add the custom background-color inline style. + $colors['inline_styles'] .= sprintf( 'background-color: %s;', $context['style']['color']['background'] ); + } + + return $colors; +} + +/** + * Build an array with CSS classes and inline styles defining the font sizes + * which will be applied to the navigation markup in the front-end. + * + * @param array $context Navigation block context. + * @return array Font size CSS classes and inline styles. + */ +function block_core_navigation_heading_build_css_font_sizes( $context ) { + // CSS classes. + $font_sizes = array( + 'css_classes' => array(), + 'inline_styles' => '', + ); + + $has_named_font_size = array_key_exists( 'fontSize', $context ); + $has_custom_font_size = isset( $context['style']['typography']['fontSize'] ); + + if ( $has_named_font_size ) { + // Add the font size class. + $font_sizes['css_classes'][] = sprintf( 'has-%s-font-size', $context['fontSize'] ); + } elseif ( $has_custom_font_size ) { + // Add the custom font size inline style. + $font_sizes['inline_styles'] = sprintf( 'font-size: %spx;', $context['style']['typography']['fontSize'] ); + } + + return $font_sizes; +} + +/** + * Returns the top-level submenu SVG chevron icon. + * + * @return string + */ +function block_core_navigation_heading_render_submenu_icon() { + return ''; +} + +/** + * Renders the `core/navigation-heading` block. + * + * @param array $attributes The block attributes. + * @param array $content The saved content. + * @param array $block The parsed block. + * + * @return string Returns the post content with the legacy widget added. + */ +function render_block_core_navigation_heading( $attributes, $content, $block ) { + // Don't render the block's subtree if it has no label. + if ( empty( $attributes['label'] ) ) { + return ''; + } + + $colors = block_core_navigation_heading_build_css_colors( $block->context ); + $font_sizes = block_core_navigation_heading_build_css_font_sizes( $block->context ); + $classes = array_merge( + $colors['css_classes'], + $font_sizes['css_classes'] + ); + $style_attribute = ( $colors['inline_styles'] . $font_sizes['inline_styles'] ); + + $css_classes = trim( implode( ' ', $classes ) ); + $has_submenu = count( $block->inner_blocks ) > 0; + + $class_name = ! empty( $attributes['className'] ) ? implode( ' ', (array) $attributes['className'] ) : false; + + if ( false !== $class_name ) { + $css_classes .= ' ' . $class_name; + } + + $wrapper_attributes = get_block_wrapper_attributes( + array( + 'class' => $css_classes . ( $has_submenu ? ' has-child' : '' ), + 'style' => $style_attribute, + ) + ); + $html = '
  • ' . + ''; + + // Start heading tag content. + // Wrap title with span to isolate it from submenu icon. + $html .= ''; + + if ( isset( $attributes['label'] ) ) { + $html .= wp_kses( + $attributes['label'], + array( + 'code' => array(), + 'em' => array(), + 'img' => array( + 'scale' => array(), + 'class' => array(), + 'style' => array(), + 'src' => array(), + 'alt' => array(), + ), + 's' => array(), + 'span' => array( + 'style' => array(), + ), + 'strong' => array(), + ) + ); + } + + $html .= ''; + + if ( isset( $block->context['showSubmenuIcon'] ) && $block->context['showSubmenuIcon'] && $has_submenu ) { + // The submenu icon can be hidden by a CSS rule on the Navigation Block. + $html .= '' . block_core_navigation_heading_render_submenu_icon() . ''; + } + + $html .= ''; + // End heading tag content. + + if ( $has_submenu ) { + $inner_blocks_html = ''; + foreach ( $block->inner_blocks as $inner_block ) { + $inner_blocks_html .= $inner_block->render(); + } + + $html .= sprintf( + '', + $inner_blocks_html + ); + } + + $html .= '
  • '; + + return $html; +} + +/** + * Register the navigation heading block. + * + * @uses render_block_core_navigation() + * @throws WP_Error An WP_Error exception parsing the block definition. + */ +function register_block_core_navigation_heading() { + register_block_type_from_metadata( + __DIR__ . '/navigation-heading', + array( + 'render_callback' => 'render_block_core_navigation_heading', + ) + ); +} +add_action( 'init', 'register_block_core_navigation_heading' ); diff --git a/packages/block-library/src/navigation-heading/save.js b/packages/block-library/src/navigation-heading/save.js new file mode 100644 index 00000000000000..17571d8f30d2de --- /dev/null +++ b/packages/block-library/src/navigation-heading/save.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { InnerBlocks } from '@wordpress/block-editor'; + +export default function save() { + return ; +} diff --git a/packages/block-library/src/navigation/edit.js b/packages/block-library/src/navigation/edit.js index 0e0e0ab021ce5b..a792c1511c99a1 100644 --- a/packages/block-library/src/navigation/edit.js +++ b/packages/block-library/src/navigation/edit.js @@ -31,6 +31,7 @@ import ResponsiveWrapper from './responsive-wrapper'; const ALLOWED_BLOCKS = [ 'core/navigation-link', + 'core/navigation-heading', 'core/search', 'core/social-links', 'core/page-list', diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index efc18558ca3f7c..2a643103c9cd0e 100644 --- a/packages/block-library/src/navigation/index.php +++ b/packages/block-library/src/navigation/index.php @@ -256,11 +256,11 @@ function render_block_core_navigation( $attributes, $content, $block ) { $inner_blocks_html = ''; $is_list_open = false; foreach ( $inner_blocks as $inner_block ) { - if ( ( 'core/navigation-link' === $inner_block->name || 'core/home-link' === $inner_block->name ) && false === $is_list_open ) { + if ( ( 'core/navigation-link' === $inner_block->name || 'core/home-link' === $inner_block->name || 'core/navigation-heading' === $inner_block->name ) && false === $is_list_open ) { $is_list_open = true; $inner_blocks_html .= '
      '; } - if ( 'core/navigation-link' !== $inner_block->name && 'core/home-link' !== $inner_block->name && true === $is_list_open ) { + if ( 'core/navigation-link' !== $inner_block->name && 'core/home-link' !== $inner_block->name && 'core/navigation-heading' !== $inner_block->name && true === $is_list_open ) { $is_list_open = false; $inner_blocks_html .= '
    '; } diff --git a/packages/block-library/src/navigation/style.scss b/packages/block-library/src/navigation/style.scss index 60b939799bb825..799553e0b5bc09 100644 --- a/packages/block-library/src/navigation/style.scss +++ b/packages/block-library/src/navigation/style.scss @@ -28,7 +28,8 @@ // Menu item container. .wp-block-pages-list__item, - .wp-block-navigation-link { + .wp-block-navigation-link, + .wp-block-navigation-heading { display: flex; align-items: center; position: relative; diff --git a/packages/e2e-tests/fixtures/blocks/core__navigation-heading.html b/packages/e2e-tests/fixtures/blocks/core__navigation-heading.html new file mode 100644 index 00000000000000..c618d32b322b19 --- /dev/null +++ b/packages/e2e-tests/fixtures/blocks/core__navigation-heading.html @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/packages/e2e-tests/fixtures/blocks/core__navigation-heading.json b/packages/e2e-tests/fixtures/blocks/core__navigation-heading.json new file mode 100644 index 00000000000000..4737111f892559 --- /dev/null +++ b/packages/e2e-tests/fixtures/blocks/core__navigation-heading.json @@ -0,0 +1,12 @@ +[ + { + "clientId": "_clientId_0", + "name": "core/navigation-heading", + "isValid": true, + "attributes": { + "label": "WordPress" + }, + "innerBlocks": [], + "originalContent": "" + } +] diff --git a/packages/e2e-tests/fixtures/blocks/core__navigation-heading.parsed.json b/packages/e2e-tests/fixtures/blocks/core__navigation-heading.parsed.json new file mode 100644 index 00000000000000..1eb490b6a6b385 --- /dev/null +++ b/packages/e2e-tests/fixtures/blocks/core__navigation-heading.parsed.json @@ -0,0 +1,13 @@ +[ + { + "blockName": "core/navigation-heading", + "attrs": { + "label": "WordPress" + }, + "innerBlocks": [], + "innerHTML": "\n", + "innerContent": [ + "\n" + ] + } +] diff --git a/packages/e2e-tests/fixtures/blocks/core__navigation-heading.serialized.html b/packages/e2e-tests/fixtures/blocks/core__navigation-heading.serialized.html new file mode 100644 index 00000000000000..1339a46239e3ef --- /dev/null +++ b/packages/e2e-tests/fixtures/blocks/core__navigation-heading.serialized.html @@ -0,0 +1 @@ +