diff --git a/lib/blocks.php b/lib/blocks.php index 77dcaae989b55..4e8910b536e9a 100644 --- a/lib/blocks.php +++ b/lib/blocks.php @@ -33,6 +33,7 @@ function gutenberg_reregister_core_block_types() { 'missing', 'more', 'navigation-link', + 'navigation-submenu', 'nextpage', 'paragraph', 'preformatted', @@ -59,6 +60,7 @@ function gutenberg_reregister_core_block_types() { 'loginout.php' => 'core/loginout', 'navigation.php' => 'core/navigation', 'navigation-link.php' => 'core/navigation-link', + 'navigation-submenu.php' => 'core/navigation-submenu', 'home-link.php' => 'core/home-link', 'rss.php' => 'core/rss', 'search.php' => 'core/search', diff --git a/packages/block-library/src/editor.scss b/packages/block-library/src/editor.scss index b1919397804a2..806f1bfce0b95 100644 --- a/packages/block-library/src/editor.scss +++ b/packages/block-library/src/editor.scss @@ -18,6 +18,7 @@ @import "./more/editor.scss"; @import "./navigation/editor.scss"; @import "./navigation-link/editor.scss"; +@import "./navigation-submenu/editor.scss"; @import "./nextpage/editor.scss"; @import "./page-list/editor.scss"; @import "./paragraph/editor.scss"; diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index da7fcacc8cadf..600fe010b7f13 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 navigationSubmenu from './navigation-submenu'; import * as homeLink from './home-link'; import * as latestComments from './latest-comments'; import * as latestPosts from './latest-posts'; @@ -231,6 +232,7 @@ export const __experimentalRegisterExperimentalCoreBlocks = navigation, navigationLink, homeLink, + navigationSubmenu, // Register Full Site Editing Blocks. ...( enableFSEBlocks diff --git a/packages/block-library/src/navigation-link/edit.js b/packages/block-library/src/navigation-link/edit.js index fce0a339b2e25..d022817295a51 100644 --- a/packages/block-library/src/navigation-link/edit.js +++ b/packages/block-library/src/navigation-link/edit.js @@ -292,7 +292,7 @@ export default function NavigationLinkEdit( { const { showSubmenuIcon } = context; const { saveEntityRecord } = useDispatch( coreStore ); const { - insertBlock, + replaceBlock, __unstableMarkNextChangeAsNotPersistent, } = useDispatch( blockEditorStore ); const [ isLinkOpen, setIsLinkOpen ] = useState( false ); @@ -302,18 +302,21 @@ export default function NavigationLinkEdit( { const ref = useRef(); const { + innerBlocks, isAtMaxNesting, isTopLevelLink, isParentOfSelectedBlock, isImmediateParentOfSelectedBlock, hasDescendants, selectedBlockHasDescendants, - numberOfDescendants, userCanCreatePages, userCanCreatePosts, } = useSelect( ( select ) => { const { + getBlocks, + getBlockName, + getBlockRootClientId, getClientIdsOfDescendants, hasSelectedInnerBlock, getSelectedBlockClientId, @@ -326,11 +329,15 @@ export default function NavigationLinkEdit( { .length; return { + innerBlocks: getBlocks( clientId ), isAtMaxNesting: - getBlockParentsByBlockName( clientId, name ).length >= - MAX_NESTING, + getBlockParentsByBlockName( clientId, [ + name, + 'core/navigation-submenu', + ] ).length >= MAX_NESTING, isTopLevelLink: - getBlockParentsByBlockName( clientId, name ).length === 0, + getBlockName( getBlockRootClientId( clientId ) ) === + 'core/navigation', isParentOfSelectedBlock: hasSelectedInnerBlock( clientId, true @@ -343,7 +350,6 @@ export default function NavigationLinkEdit( { selectedBlockHasDescendants: !! getClientIdsOfDescendants( [ selectedBlockId, ] )?.length, - numberOfDescendants: descendants, userCanCreatePages: select( coreStore ).canUser( 'create', 'pages' @@ -367,12 +373,15 @@ export default function NavigationLinkEdit( { }, [ isTopLevelLink ] ); /** - * Insert a link block when submenu is added. + * Transform to submenu block. */ - function insertLinkBlock() { - const insertionPoint = numberOfDescendants; - const blockToInsert = createBlock( 'core/navigation-link' ); - insertBlock( blockToInsert, insertionPoint, clientId ); + function transformToSubmenu() { + const newSubmenu = createBlock( + 'core/navigation-submenu', + attributes, + innerBlocks + ); + replaceBlock( clientId, newSubmenu ); } // Show the LinkControl on mount if the URL is empty @@ -596,7 +605,7 @@ export default function NavigationLinkEdit( { name="submenu" icon={ addSubmenu } title={ __( 'Add submenu' ) } - onClick={ insertLinkBlock } + onClick={ transformToSubmenu } /> ) } diff --git a/packages/block-library/src/navigation-link/index.js b/packages/block-library/src/navigation-link/index.js index a62a27bfa8b29..4da4b2885e05f 100644 --- a/packages/block-library/src/navigation-link/index.js +++ b/packages/block-library/src/navigation-link/index.js @@ -5,6 +5,7 @@ import { _x } from '@wordpress/i18n'; import { customLink as linkIcon } from '@wordpress/icons'; import { InnerBlocks } from '@wordpress/block-editor'; import { addFilter } from '@wordpress/hooks'; +import { createBlock } from '@wordpress/blocks'; /** * Internal dependencies @@ -84,6 +85,20 @@ export const settings = { }, }, ], + transforms: { + to: [ + { + type: 'block', + blocks: [ 'core/navigation-submenu' ], + transform: ( attributes, innerBlocks ) => + createBlock( + 'core/navigation-submenu', + attributes, + innerBlocks + ), + }, + ], + }, }; // importing this file includes side effects. This is whitelisted in block-library/package.json under sideEffects diff --git a/packages/block-library/src/navigation-submenu/block.json b/packages/block-library/src/navigation-submenu/block.json new file mode 100644 index 0000000000000..943e3c948d969 --- /dev/null +++ b/packages/block-library/src/navigation-submenu/block.json @@ -0,0 +1,66 @@ +{ + "apiVersion": 2, + "name": "core/navigation-submenu", + "title": "Submenu", + "category": "design", + "parent": [ + "core/navigation" + ], + "description": "Add a submenu to your navigation.", + "textdomain": "default", + "attributes": { + "label": { + "type": "string" + }, + "type": { + "type": "string" + }, + "description": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "id": { + "type": "number" + }, + "opensInNewTab": { + "type": "boolean", + "default": false + }, + "url": { + "type": "string" + }, + "title": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "isTopLevelItem": { + "type": "boolean" + } + }, + "usesContext": [ + "textColor", + "customTextColor", + "backgroundColor", + "customBackgroundColor", + "overlayTextColor", + "customOverlayTextColor", + "overlayBackgroundColor", + "customOverlayBackgroundColor", + "fontSize", + "customFontSize", + "showSubmenuIcon", + "openSubmenusOnClick", + "style" + ], + "supports": { + "reusable": false, + "html": false + }, + "viewScript": "file:./view.min.js", + "editorStyle": "wp-block-navigation-submenu-editor", + "style": "wp-block-navigation-submenu" +} diff --git a/packages/block-library/src/navigation-submenu/edit.js b/packages/block-library/src/navigation-submenu/edit.js new file mode 100644 index 0000000000000..e852db3f6c3cc --- /dev/null +++ b/packages/block-library/src/navigation-submenu/edit.js @@ -0,0 +1,655 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { escape, pull } from 'lodash'; + +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { + KeyboardShortcuts, + PanelBody, + Popover, + TextControl, + TextareaControl, + ToolbarButton, + ToolbarGroup, +} from '@wordpress/components'; +import { rawShortcut, displayShortcut } from '@wordpress/keycodes'; +import { __, sprintf } from '@wordpress/i18n'; +import { + BlockControls, + InnerBlocks, + __experimentalUseInnerBlocksProps as useInnerBlocksProps, + InspectorControls, + RichText, + __experimentalLinkControl as LinkControl, + useBlockProps, + store as blockEditorStore, + getColorClassName, +} from '@wordpress/block-editor'; +import { isURL, prependHTTP, safeDecodeURI } from '@wordpress/url'; +import { + Fragment, + useState, + useEffect, + useRef, + createInterpolateElement, +} from '@wordpress/element'; +import { placeCaretAtHorizontalEdge } from '@wordpress/dom'; +import { link as linkIcon } from '@wordpress/icons'; +import { store as coreStore } from '@wordpress/core-data'; +import { speak } from '@wordpress/a11y'; + +/** + * Internal dependencies + */ +import { ItemSubmenuIcon } from './icons'; +import { name } from './block.json'; + +const ALLOWED_BLOCKS = [ 'core/navigation-link', 'core/navigation-submenu' ]; + +const MAX_NESTING = 5; + +/** + * 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; +}; + +/** + * Given the Link block's type attribute, return the query params to give to + * /wp/v2/search. + * + * @param {string} type Link block's type attribute. + * @param {string} kind Link block's entity of kind (post-type|taxonomy) + * @return {{ type?: string, subtype?: string }} Search query params. + */ +function getSuggestionsQuery( type, kind ) { + switch ( type ) { + case 'post': + case 'page': + return { type: 'post', subtype: type }; + case 'category': + return { type: 'term', subtype: 'category' }; + case 'tag': + return { type: 'term', subtype: 'post_tag' }; + case 'post_format': + return { type: 'post-format' }; + default: + if ( kind === 'taxonomy' ) { + return { type: 'term', subtype: type }; + } + if ( kind === 'post-type' ) { + return { type: 'post', subtype: type }; + } + return {}; + } +} + +/** + * Determine the colors for a menu. + * + * Order of priority is: + * 1: Overlay custom colors (if submenu) + * 2: Overlay theme colors (if submenu) + * 3: Custom colors + * 4: Theme colors + * 5: Global styles + * + * @param {Object} context + * @param {boolean} isSubMenu + */ +function getColors( context, isSubMenu ) { + const { + textColor, + customTextColor, + backgroundColor, + customBackgroundColor, + overlayTextColor, + customOverlayTextColor, + overlayBackgroundColor, + customOverlayBackgroundColor, + style, + } = context; + + const colors = {}; + + if ( isSubMenu && !! customOverlayTextColor ) { + colors.customTextColor = customOverlayTextColor; + } else if ( isSubMenu && !! overlayTextColor ) { + colors.textColor = overlayTextColor; + } else if ( !! customTextColor ) { + colors.customTextColor = customTextColor; + } else if ( !! textColor ) { + colors.textColor = textColor; + } else if ( !! style?.color?.text ) { + colors.customTextColor = style.color.text; + } + + if ( isSubMenu && !! customOverlayBackgroundColor ) { + colors.customBackgroundColor = customOverlayBackgroundColor; + } else if ( isSubMenu && !! overlayBackgroundColor ) { + colors.backgroundColor = overlayBackgroundColor; + } else if ( !! customBackgroundColor ) { + colors.customBackgroundColor = customBackgroundColor; + } else if ( !! backgroundColor ) { + colors.backgroundColor = backgroundColor; + } else if ( !! style?.color?.background ) { + colors.customTextColor = style.color.background; + } + + return colors; +} + +/** + * @typedef {'post-type'|'custom'|'taxonomy'|'post-type-archive'} WPNavigationLinkKind + */ + +/** + * Navigation Link Block Attributes + * + * @typedef {Object} WPNavigationLinkBlockAttributes + * + * @property {string} [label] Link text. + * @property {WPNavigationLinkKind} [kind] Kind is used to differentiate between term and post ids to check post draft status. + * @property {string} [type] The type such as post, page, tag, category and other custom types. + * @property {string} [rel] The relationship of the linked URL. + * @property {number} [id] A post or term id. + * @property {boolean} [opensInNewTab] Sets link target to _blank when true. + * @property {string} [url] Link href. + * @property {string} [title] Link title attribute. + */ + +/** + * Link Control onChange handler that updates block attributes when a setting is changed. + * + * @param {Object} updatedValue New block attributes to update. + * @param {Function} setAttributes Block attribute update function. + * @param {WPNavigationLinkBlockAttributes} blockAttributes Current block attributes. + * + */ +export const updateNavigationLinkBlockAttributes = ( + updatedValue = {}, + setAttributes, + blockAttributes = {} +) => { + const { + label: originalLabel = '', + kind: originalKind = '', + type: originalType = '', + } = blockAttributes; + const { + title = '', + url = '', + opensInNewTab, + id, + kind: newKind = originalKind, + type: newType = originalType, + } = updatedValue; + + const normalizedTitle = title.replace( /http(s?):\/\//gi, '' ); + const normalizedURL = url.replace( /http(s?):\/\//gi, '' ); + const escapeTitle = + title !== '' && + normalizedTitle !== normalizedURL && + originalLabel !== title; + const label = escapeTitle + ? escape( title ) + : originalLabel || escape( normalizedURL ); + + // In https://github.com/WordPress/gutenberg/pull/24670 we decided to use "tag" in favor of "post_tag" + const type = newType === 'post_tag' ? 'tag' : newType.replace( '-', '_' ); + + const isBuiltInType = + [ 'post', 'page', 'tag', 'category' ].indexOf( type ) > -1; + + const isCustomLink = + ( ! newKind && ! isBuiltInType ) || newKind === 'custom'; + const kind = isCustomLink ? 'custom' : newKind; + + setAttributes( { + // Passed `url` may already be encoded. To prevent double encoding, decodeURI is executed to revert to the original string. + ...( url && { url: encodeURI( safeDecodeURI( url ) ) } ), + ...( label && { label } ), + ...( undefined !== opensInNewTab && { opensInNewTab } ), + ...( id && Number.isInteger( id ) && { id } ), + ...( kind && { kind } ), + ...( type && type !== 'URL' && { type } ), + } ); +}; + +export default function NavigationSubmenuEdit( { + attributes, + isSelected, + setAttributes, + mergeBlocks, + onReplace, + context, + clientId, +} ) { + const { + label, + type, + opensInNewTab, + url, + description, + rel, + title, + kind, + } = attributes; + const link = { + url, + opensInNewTab, + }; + const { showSubmenuIcon, openSubmenusOnClick } = context; + const { saveEntityRecord } = useDispatch( coreStore ); + const { __unstableMarkNextChangeAsNotPersistent } = useDispatch( + blockEditorStore + ); + const [ isLinkOpen, setIsLinkOpen ] = useState( false ); + const listItemRef = useRef( null ); + const isDraggingWithin = useIsDraggingWithin( listItemRef ); + const itemLabelPlaceholder = __( 'Add text…' ); + const ref = useRef(); + + const { + isAtMaxNesting, + isTopLevelItem, + isParentOfSelectedBlock, + isImmediateParentOfSelectedBlock, + hasDescendants, + selectedBlockHasDescendants, + userCanCreatePages, + userCanCreatePosts, + } = useSelect( + ( select ) => { + const { + getClientIdsOfDescendants, + hasSelectedInnerBlock, + getSelectedBlockClientId, + getBlockParentsByBlockName, + } = select( blockEditorStore ); + + const selectedBlockId = getSelectedBlockClientId(); + + const descendants = getClientIdsOfDescendants( [ clientId ] ) + .length; + + return { + isAtMaxNesting: + getBlockParentsByBlockName( clientId, name ).length >= + MAX_NESTING, + isTopLevelItem: + getBlockParentsByBlockName( clientId, name ).length === 0, + isParentOfSelectedBlock: hasSelectedInnerBlock( + clientId, + true + ), + isImmediateParentOfSelectedBlock: hasSelectedInnerBlock( + clientId, + false + ), + hasDescendants: !! descendants, + selectedBlockHasDescendants: !! getClientIdsOfDescendants( [ + selectedBlockId, + ] )?.length, + userCanCreatePages: select( coreStore ).canUser( + 'create', + 'pages' + ), + userCanCreatePosts: select( coreStore ).canUser( + 'create', + 'posts' + ), + }; + }, + [ clientId ] + ); + + // Store the colors from context as attributes for rendering + useEffect( () => { + // This side-effect should not create an undo level as those should + // only be created via user interactions. Mark this change as + // not persistent to avoid undo level creation. + // See https://github.com/WordPress/gutenberg/issues/34564. + __unstableMarkNextChangeAsNotPersistent(); + setAttributes( { isTopLevelItem } ); + }, [ isTopLevelItem ] ); + + /** + * The hook shouldn't be necessary but due to a focus loss happening + * when selecting a suggestion in the link popover, we force close on block unselection. + */ + useEffect( () => { + if ( ! isSelected ) { + setIsLinkOpen( false ); + } + }, [ isSelected ] ); + + // If the LinkControl popover is open and the URL has changed, close the LinkControl and focus the label text. + useEffect( () => { + if ( isLinkOpen && url ) { + // Does this look like a URL and have something TLD-ish? + if ( + isURL( prependHTTP( label ) ) && + /^.+\.[a-z]+/.test( label ) + ) { + // Focus and select the label text. + selectLabelText(); + } else { + // Focus it (but do not select). + placeCaretAtHorizontalEdge( ref.current, true ); + } + } + }, [ url ] ); + + /** + * Focus the Link label text and select it. + */ + function selectLabelText() { + ref.current.focus(); + const { ownerDocument } = ref.current; + const { defaultView } = ownerDocument; + const selection = defaultView.getSelection(); + const range = ownerDocument.createRange(); + // Get the range of the current ref contents so we can add this range to the selection. + range.selectNodeContents( ref.current ); + selection.removeAllRanges(); + selection.addRange( range ); + } + + let userCanCreate = false; + if ( ! type || type === 'page' ) { + userCanCreate = userCanCreatePages; + } else if ( type === 'post' ) { + userCanCreate = userCanCreatePosts; + } + + async function handleCreate( pageTitle ) { + const postType = type || 'page'; + + const page = await saveEntityRecord( 'postType', postType, { + title: pageTitle, + status: 'draft', + } ); + + return { + id: page.id, + type: postType, + title: page.title.rendered, + url: page.link, + kind: 'post-type', + }; + } + + const { + textColor, + customTextColor, + backgroundColor, + customBackgroundColor, + } = getColors( context, ! isTopLevelItem ); + + const blockProps = useBlockProps( { + ref: listItemRef, + className: classnames( 'wp-block-navigation-item', { + 'is-editing': isSelected || isParentOfSelectedBlock, + 'is-dragging-within': isDraggingWithin, + 'has-link': !! url, + 'has-child': hasDescendants, + 'has-text-color': !! textColor || !! customTextColor, + [ getColorClassName( 'color', textColor ) ]: !! textColor, + 'has-background': !! backgroundColor || customBackgroundColor, + [ getColorClassName( + 'background-color', + backgroundColor + ) ]: !! backgroundColor, + 'open-on-click': openSubmenusOnClick, + } ), + style: { + color: ! textColor && customTextColor, + backgroundColor: ! backgroundColor && customBackgroundColor, + }, + } ); + + // Always use overlay colors for submenus + const innerBlocksColors = getColors( context, true ); + + if ( isAtMaxNesting ) { + pull( ALLOWED_BLOCKS, 'core/navigation-submenu' ); + } + + const innerBlocksProps = useInnerBlocksProps( + { + className: classnames( 'wp-block-navigation__submenu-container', { + 'is-parent-of-selected-block': isParentOfSelectedBlock, + 'has-text-color': !! ( + innerBlocksColors.textColor || + innerBlocksColors.customTextColor + ), + [ `has-${ innerBlocksColors.textColor }-color` ]: !! innerBlocksColors.textColor, + 'has-background': !! ( + innerBlocksColors.backgroundColor || + innerBlocksColors.customBackgroundColor + ), + [ `has-${ innerBlocksColors.backgroundColor }-background-color` ]: !! innerBlocksColors.backgroundColor, + } ), + style: { + color: innerBlocksColors.customTextColor, + backgroundColor: innerBlocksColors.customBackgroundColor, + }, + }, + { + allowedBlocks: ALLOWED_BLOCKS, + renderAppender: + isSelected || + ( isImmediateParentOfSelectedBlock && + ! selectedBlockHasDescendants ) || + // Show the appender while dragging to allow inserting element between item and the appender. + hasDescendants + ? InnerBlocks.DefaultAppender + : false, + } + ); + + const ParentElement = openSubmenusOnClick ? 'button' : 'a'; + + return ( + + + + + setIsLinkOpen( true ), + } } + /> + { ! openSubmenusOnClick && ( + setIsLinkOpen( true ) } + /> + ) } + + + + + { + setAttributes( { + description: descriptionValue, + } ); + } } + label={ __( 'Description' ) } + help={ __( + 'The description will be displayed in the menu if the current theme supports it.' + ) } + /> + { + setAttributes( { title: titleValue } ); + } } + label={ __( 'Link title' ) } + autoComplete="off" + /> + { + setAttributes( { rel: relValue } ); + } } + label={ __( 'Link rel' ) } + autoComplete="off" + /> + + +
+ { /* eslint-disable jsx-a11y/anchor-is-valid */ } + + { /* eslint-enable */ } + { + + setAttributes( { label: labelValue } ) + } + onMerge={ mergeBlocks } + onReplace={ onReplace } + aria-label={ __( 'Navigation link text' ) } + placeholder={ itemLabelPlaceholder } + withoutInteractiveFormatting + allowedFormats={ [ + 'core/bold', + 'core/italic', + 'core/image', + 'core/strikethrough', + ] } + onClick={ () => { + if ( ! openSubmenusOnClick && ! url ) { + setIsLinkOpen( true ); + } + } } + /> + } + { ! openSubmenusOnClick && isLinkOpen && ( + setIsLinkOpen( false ) } + anchorRef={ listItemRef.current } + > + setIsLinkOpen( false ), + } } + /> + { + let format; + if ( type === 'post' ) { + /* translators: %s: search term. */ + format = __( + 'Create draft post: %s' + ); + } else { + /* translators: %s: search term. */ + format = __( + 'Create draft page: %s' + ); + } + return createInterpolateElement( + sprintf( format, searchTerm ), + { mark: } + ); + } } + noDirectEntry={ !! type } + noURLSuggestion={ !! type } + suggestionsQuery={ getSuggestionsQuery( + type, + kind + ) } + onChange={ ( updatedValue ) => + updateNavigationLinkBlockAttributes( + updatedValue, + setAttributes, + attributes + ) + } + onRemove={ () => { + setAttributes( { url: '' } ); + speak( __( 'Link removed.' ), 'assertive' ); + } } + /> + + ) } + { ( showSubmenuIcon || openSubmenusOnClick ) && ( + + + + ) } + +
+
+ + ); +} diff --git a/packages/block-library/src/navigation-submenu/editor.scss b/packages/block-library/src/navigation-submenu/editor.scss new file mode 100644 index 0000000000000..e53e11e4fdbf0 --- /dev/null +++ b/packages/block-library/src/navigation-submenu/editor.scss @@ -0,0 +1,42 @@ +.wp-block-navigation-submenu { + display: block; + + .wp-block-navigation__submenu-container { + z-index: 28; + } + + // Show on editor selected, even if on frontend it only stays open on focus-within. + &.is-selected, + &.has-child-selected { + > .wp-block-navigation__submenu-container { + // We use important here because if the parent block is selected and submenus are present, they should always be visible. + visibility: visible !important; + opacity: 1 !important; + min-width: 200px !important; + height: auto !important; + width: auto !important; + // These styles are needed to display the dropdown properly when it is empty. + position: absolute; + left: -1em; + top: 100%; + + @include break-medium { + .wp-block-navigation__submenu-container { + left: 100%; + top: -1px; // Border width. + + // Prevent the menu from disappearing when the mouse is over the gap + &::before { + content: ""; + position: absolute; + right: 100%; + height: 100%; + display: block; + width: 0.5em; + background: transparent; + } + } + } + } + } +} diff --git a/packages/block-library/src/navigation-submenu/icons.js b/packages/block-library/src/navigation-submenu/icons.js new file mode 100644 index 0000000000000..3a44a250b084e --- /dev/null +++ b/packages/block-library/src/navigation-submenu/icons.js @@ -0,0 +1,16 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export const ItemSubmenuIcon = () => ( + + + +); diff --git a/packages/block-library/src/navigation-submenu/index.js b/packages/block-library/src/navigation-submenu/index.js new file mode 100644 index 0000000000000..a018cd6b31a95 --- /dev/null +++ b/packages/block-library/src/navigation-submenu/index.js @@ -0,0 +1,26 @@ +/** + * WordPress dependencies + */ +import { addSubmenu } from '@wordpress/icons'; + +/** + * Internal dependencies + */ + +import metadata from './block.json'; +import edit from './edit'; +import save from './save'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + icon: addSubmenu, + + __experimentalLabel: ( { label } ) => label, + + edit, + + save, +}; diff --git a/packages/block-library/src/navigation-submenu/index.php b/packages/block-library/src/navigation-submenu/index.php new file mode 100644 index 0000000000000..0a12cb0e93e67 --- /dev/null +++ b/packages/block-library/src/navigation-submenu/index.php @@ -0,0 +1,302 @@ + array(), + 'inline_styles' => '', + ); + + $is_sub_menu = isset( $attributes['isTopLevelLink'] ) ? ( ! $attributes['isTopLevelLink'] ) : false; + + // Text color. + $named_text_color = null; + $custom_text_color = null; + + if ( $is_sub_menu && array_key_exists( 'customOverlayTextColor', $context ) ) { + $custom_text_color = $context['customOverlayTextColor']; + } elseif ( $is_sub_menu && array_key_exists( 'overlayTextColor', $context ) ) { + $named_text_color = $context['overlayTextColor']; + } elseif ( array_key_exists( 'customTextColor', $context ) ) { + $custom_text_color = $context['customTextColor']; + } elseif ( array_key_exists( 'textColor', $context ) ) { + $named_text_color = $context['textColor']; + } elseif ( isset( $context['style']['color']['text'] ) ) { + $custom_text_color = $context['style']['color']['text']; + } + + // If has text color. + if ( ! is_null( $named_text_color ) ) { + // Add the color class. + array_push( $colors['css_classes'], 'has-text-color', sprintf( 'has-%s-color', $named_text_color ) ); + } elseif ( ! is_null( $custom_text_color ) ) { + // Add the custom color inline style. + $colors['css_classes'][] = 'has-text-color'; + $colors['inline_styles'] .= sprintf( 'color: %s;', $custom_text_color ); + } + + // Background color. + $named_background_color = null; + $custom_background_color = null; + + if ( $is_sub_menu && array_key_exists( 'customOverlayBackgroundColor', $context ) ) { + $custom_background_color = $context['customOverlayBackgroundColor']; + } elseif ( $is_sub_menu && array_key_exists( 'overlayBackgroundColor', $context ) ) { + $named_background_color = $context['overlayBackgroundColor']; + } elseif ( array_key_exists( 'customBackgroundColor', $context ) ) { + $custom_background_color = $context['customBackgroundColor']; + } elseif ( array_key_exists( 'backgroundColor', $context ) ) { + $named_background_color = $context['backgroundColor']; + } elseif ( isset( $context['style']['color']['background'] ) ) { + $custom_background_color = $context['style']['color']['background']; + } + + // If has background color. + if ( ! is_null( $named_background_color ) ) { + // Add the background-color class. + array_push( $colors['css_classes'], 'has-background', sprintf( 'has-%s-background-color', $named_background_color ) ); + } elseif ( ! is_null( $custom_background_color ) ) { + // Add the custom background-color inline style. + $colors['css_classes'][] = 'has-background'; + $colors['inline_styles'] .= sprintf( 'background-color: %s;', $custom_background_color ); + } + + 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_submenu_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_submenu_render_submenu_icon() { + return ''; +} + +/** + * Renders the `core/navigation-submenu` 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_submenu( $attributes, $content, $block ) { + if ( ! wp_script_is( 'wp-block-navigation-submenu-view' ) ) { + wp_enqueue_script( 'wp-block-navigation-submenu-view' ); + } + + $navigation_link_has_id = isset( $attributes['id'] ) && is_numeric( $attributes['id'] ); + $is_post_type = isset( $attributes['kind'] ) && 'post-type' === $attributes['kind']; + $is_post_type = $is_post_type || isset( $attributes['type'] ) && ( 'post' === $attributes['type'] || 'page' === $attributes['type'] ); + + // Don't render the block's subtree if it is a draft. + if ( $is_post_type && $navigation_link_has_id ) { + $post = get_post( $attributes['id'] ); + if ( 'publish' !== $post->post_status ) { + return ''; + } + } + + // Don't render the block's subtree if it has no label. + if ( empty( $attributes['label'] ) ) { + return ''; + } + + $colors = block_core_navigation_submenu_build_css_colors( $block->context, $attributes ); + $font_sizes = block_core_navigation_submenu_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; + $is_active = ! empty( $attributes['id'] ) && ( get_the_ID() === $attributes['id'] ); + + $class_name = ! empty( $attributes['className'] ) ? implode( ' ', (array) $attributes['className'] ) : false; + + if ( false !== $class_name ) { + $css_classes .= ' ' . $class_name; + } + + $show_submenu_indicators = isset( $block->context['showSubmenuIcon'] ) && $block->context['showSubmenuIcon']; + $open_on_click = isset( $block->context['openSubmenusOnClick'] ) && $block->context['openSubmenusOnClick']; + $open_on_hover_and_click = isset( $block->context['openSubmenusOnClick'] ) && ! $block->context['openSubmenusOnClick'] && + $show_submenu_indicators; + + $wrapper_attributes = get_block_wrapper_attributes( + array( + 'class' => $css_classes . ' wp-block-navigation-item' . ( $has_submenu ? ' has-child' : '' ) . + ( $open_on_click ? ' open-on-click' : '' ) . ( $open_on_hover_and_click ? ' open-on-hover-click' : '' ) . + ( $is_active ? ' current-menu-item' : '' ), + 'style' => $style_attribute, + ) + ); + $html = '
  • '; + + // If Submenus open on hover, we render an anchor tag with attributes. + // If submenu icons are set to show, we also render a submenu button, so the submenu can be opened on click. + if ( ! $open_on_click ) { + $item_url = isset( $attributes['url'] ) ? esc_url( $attributes['url'] ) : ''; + // Start appending HTML attributes to anchor tag. + $html .= ' array(), + 'em' => array(), + 'img' => array( + 'scale' => array(), + 'class' => array(), + 'style' => array(), + 'src' => array(), + 'alt' => array(), + ), + 's' => array(), + 'span' => array( + 'style' => array(), + ), + 'strong' => array(), + ) + ); + } + + $html .= ''; + // End anchor tag content. + + if ( $show_submenu_indicators ) { + // The submenu icon is rendered in a button here + // so that there's a clickable elment to open the submenu. + $html .= ''; + } + } else { + // If menus open on click, we render the parent as a button. + $html .= ''; + + } + + if ( $has_submenu ) { + $inner_blocks_html = ''; + foreach ( $block->inner_blocks as $inner_block ) { + $inner_blocks_html .= $inner_block->render(); + } + + $html .= sprintf( + '
      %s
    ', + $inner_blocks_html + ); + } + + $html .= '
  • '; + + return $html; +} + +/** + * Register the navigation submenu block. + * + * @uses render_block_core_navigation_submenu() + * @throws WP_Error An WP_Error exception parsing the block definition. + */ +function register_block_core_navigation_submenu() { + register_block_type_from_metadata( + __DIR__ . '/navigation-submenu', + array( + 'render_callback' => 'render_block_core_navigation_submenu', + ) + ); +} +add_action( 'init', 'register_block_core_navigation_submenu' ); diff --git a/packages/block-library/src/navigation-submenu/save.js b/packages/block-library/src/navigation-submenu/save.js new file mode 100644 index 0000000000000..17571d8f30d2d --- /dev/null +++ b/packages/block-library/src/navigation-submenu/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-submenu/style.scss b/packages/block-library/src/navigation-submenu/style.scss new file mode 100644 index 0000000000000..46d68e2aeb8e5 --- /dev/null +++ b/packages/block-library/src/navigation-submenu/style.scss @@ -0,0 +1,36 @@ +.wp-block-navigation-submenu { + position: relative; + display: flex; + + button.wp-block-navigation__submenu-icon.wp-block-navigation__submenu-icon.wp-block-navigation__submenu-icon.wp-block-navigation__submenu-icon { + padding: $grid-unit $grid-unit-20; + background-color: inherit; + color: currentColor; + border: none; + } + + .wp-block-navigation__submenu-icon svg { + stroke: currentColor; + } + + // Show submenus on click. + .wp-block-navigation-submenu__toggle[aria-expanded="true"] + .wp-block-navigation__submenu-container { + visibility: visible; + opacity: 1; + width: auto; + height: auto; + min-width: 200px; + } +} + +button.wp-block-navigation-item__content { + background-color: transparent; + border: none; + color: currentColor; + font-size: inherit; + font-family: inherit; +} + +.wp-block-navigation-submenu__toggle { + cursor: pointer; +} diff --git a/packages/block-library/src/navigation-submenu/view.js b/packages/block-library/src/navigation-submenu/view.js new file mode 100644 index 0000000000000..f990729f48af0 --- /dev/null +++ b/packages/block-library/src/navigation-submenu/view.js @@ -0,0 +1,62 @@ +const closeSubmenus = ( element ) => { + element + .querySelectorAll( '[aria-expanded="true"]' ) + .forEach( ( toggle ) => { + toggle.setAttribute( 'aria-expanded', 'false' ); + } ); +}; + +const toggleSubmenuOnClick = ( event ) => { + const buttonToggle = event.target.closest( '[aria-expanded]' ); + const isSubmenuOpen = buttonToggle.getAttribute( 'aria-expanded' ); + + if ( isSubmenuOpen === 'true' ) { + closeSubmenus( buttonToggle.closest( '.wp-block-navigation-submenu' ) ); + } else { + // Close all sibling submenus. + const parentElement = buttonToggle.closest( + '.wp-block-navigation-submenu' + ); + const parentList = + buttonToggle.closest( '.wp-block-navigation__submenu-container' ) || + buttonToggle.closest( '.wp-block-navigation__container' ); + Array.from( parentList.children ).forEach( ( child ) => { + if ( child !== parentElement ) { + closeSubmenus( child ); + } + } ); + // Open submenu. + buttonToggle.setAttribute( 'aria-expanded', 'true' ); + } +}; + +const submenuButtons = document.querySelectorAll( + '.wp-block-navigation-submenu__toggle' +); + +submenuButtons.forEach( ( button ) => { + button.addEventListener( 'click', toggleSubmenuOnClick ); +} ); + +// Close on click outside. +document.addEventListener( 'click', function ( event ) { + const navigationBlocks = document.querySelectorAll( + '.wp-block-navigation' + ); + navigationBlocks.forEach( ( block ) => { + if ( ! block.contains( event.target ) ) { + closeSubmenus( block ); + } + } ); +} ); +// Close on focus outside. +document.addEventListener( 'keyup', function ( event ) { + const submenuBlocks = document.querySelectorAll( + '.wp-block-navigation-submenu' + ); + submenuBlocks.forEach( ( block ) => { + if ( ! block.contains( event.target ) ) { + closeSubmenus( block ); + } + } ); +} ); diff --git a/packages/block-library/src/navigation/block.json b/packages/block-library/src/navigation/block.json index 98663a8075b23..273ac75dd65f3 100644 --- a/packages/block-library/src/navigation/block.json +++ b/packages/block-library/src/navigation/block.json @@ -36,6 +36,10 @@ "type": "boolean", "default": true }, + "openSubmenusOnClick":{ + "type": "boolean", + "default": false + }, "isResponsive": { "type": "boolean", "default": false @@ -68,6 +72,7 @@ "fontSize": "fontSize", "customFontSize": "customFontSize", "showSubmenuIcon": "showSubmenuIcon", + "openSubmenusOnClick": "openSubmenusOnClick", "style": "style", "orientation": "orientation" }, diff --git a/packages/block-library/src/navigation/edit.js b/packages/block-library/src/navigation/edit.js index bdab02b4b7c80..769b9dee1c3f5 100644 --- a/packages/block-library/src/navigation/edit.js +++ b/packages/block-library/src/navigation/edit.js @@ -48,6 +48,7 @@ const ALLOWED_BLOCKS = [ 'core/home-link', 'core/site-title', 'core/site-logo', + 'core/navigation-submenu', ]; const LAYOUT = { @@ -252,23 +253,34 @@ function Navigation( { { hasSubmenuIndicatorSetting && ( { setAttributes( { - showSubmenuIcon: value, + isResponsive: value, } ); } } - label={ __( 'Show submenu indicator icons' ) } + label={ __( 'Enable responsive menu' ) } /> { setAttributes( { - isResponsive: value, + openSubmenusOnClick: value, } ); } } - label={ __( 'Enable responsive menu' ) } + label={ __( 'Open submenus on click' ) } /> + { ! attributes.openSubmenusOnClick && ( + { + setAttributes( { + showSubmenuIcon: value, + } ); + } } + label={ __( 'Show submenu indicator icons' ) } + /> + ) } ) } { hasColorSettings && ( diff --git a/packages/block-library/src/navigation/editor.scss b/packages/block-library/src/navigation/editor.scss index ba4212cf67777..abbef77d9c330 100644 --- a/packages/block-library/src/navigation/editor.scss +++ b/packages/block-library/src/navigation/editor.scss @@ -25,7 +25,6 @@ display: inline-block; } - /** * Submenus. */ @@ -43,7 +42,8 @@ } // Only show the flyout on hover if the parent menu item is selected. -.wp-block-navigation:not(.is-selected):not(.has-child-selected) .has-child:hover { +.wp-block-navigation:not(.is-selected):not(.has-child-selected) +.has-child:hover { > .wp-block-navigation__submenu-container { opacity: 0; visibility: hidden; @@ -79,7 +79,8 @@ flex-direction: column; } -.is-dragging-components-draggable .wp-block-navigation-link > .wp-block-navigation__container { +.is-dragging-components-draggable +.wp-block-navigation-link > .wp-block-navigation__container { // Set opacity to 1 to still be able to show the draggable chip. opacity: 1; visibility: hidden; @@ -173,7 +174,6 @@ $color-control-label-height: 20px; justify-content: flex-start; } - /** * Setup state */ @@ -243,7 +243,6 @@ $color-control-label-height: 20px; } } - .wp-block-navigation-item.wp-block-navigation-item, .wp-block-navigation-placeholder__preview-search-icon { opacity: 0.3; @@ -355,7 +354,9 @@ $color-control-label-height: 20px; .is-vertical .wp-block-navigation-placeholder, .is-vertical .wp-block-navigation-placeholder__preview, .is-vertical .wp-block-navigation-placeholder__controls { - min-height: $icon-size + ($grid-unit-20 + $grid-unit-05 + $grid-unit-15 + $grid-unit-15) * 3; + min-height: + $icon-size + + ( $grid-unit-20 + $grid-unit-05 + $grid-unit-15 + $grid-unit-15 ) * 3; } .is-vertical .wp-block-navigation-placeholder__preview, @@ -370,7 +371,9 @@ $color-control-label-height: 20px; font-family: $default-font; .components-button.components-dropdown-menu__toggle.has-icon { - padding: ($grid-unit-15 * 0.5) $grid-unit-05 ($grid-unit-15 * 0.5) $grid-unit-15; + padding: + ( $grid-unit-15 * 0.5 ) $grid-unit-05 ( $grid-unit-15 * 0.5 ) + $grid-unit-15; display: flex; flex-direction: row-reverse; // This puts the chevron, which is hidden from screen readers, on the right. } @@ -382,7 +385,6 @@ $color-control-label-height: 20px; } } - /** * Mobile menu. */ @@ -407,7 +409,9 @@ $color-control-label-height: 20px; // When not fullscreen. .wp-block-navigation__responsive-container.is-menu-open { position: fixed; - top: $admin-bar-height-big + $header-height + $block-toolbar-height + $border-width; + top: + $admin-bar-height-big + $header-height + $block-toolbar-height + + $border-width; @include break-medium() { top: $admin-bar-height + $header-height + $border-width; @@ -424,13 +428,17 @@ $color-control-label-height: 20px; .has-fixed-toolbar .wp-block-navigation__responsive-container.is-menu-open { @include break-medium() { - top: $admin-bar-height + $header-height + $block-toolbar-height + $border-width; + top: + $admin-bar-height + $header-height + $block-toolbar-height + + $border-width; } } .is-mobile-preview .wp-block-navigation__responsive-container.is-menu-open, .is-tablet-preview .wp-block-navigation__responsive-container.is-menu-open { - top: $admin-bar-height + $header-height + $block-toolbar-height + $border-width; + top: + $admin-bar-height + $header-height + $block-toolbar-height + + $border-width; } .is-sidebar-opened .wp-block-navigation__responsive-container.is-menu-open { @@ -441,7 +449,9 @@ $color-control-label-height: 20px; .is-fullscreen-mode { .wp-block-navigation__responsive-container.is-menu-open { left: 0; // Unset the value from non fullscreen mode. - top: $admin-bar-height-big + $header-height + $block-toolbar-height + $border-width; + top: + $admin-bar-height-big + $header-height + $block-toolbar-height + + $border-width; @include break-medium() { top: $header-height + $border-width; @@ -461,7 +471,8 @@ $color-control-label-height: 20px; } // The iframe makes these rules a lot simpler. -body.editor-styles-wrapper .wp-block-navigation__responsive-container.is-menu-open { +body.editor-styles-wrapper +.wp-block-navigation__responsive-container.is-menu-open { top: 0; right: 0; bottom: 0; diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index 68246111ffe0e..f2bd64737870a 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 || 'core/site-title' === $inner_block->name || 'core/site-logo' === $inner_block->name ) && ! $is_list_open ) { + if ( ( 'core/navigation-link' === $inner_block->name || 'core/home-link' === $inner_block->name || 'core/site-title' === $inner_block->name || 'core/site-logo' === $inner_block->name || 'core/navigation-submenu' === $inner_block->name ) && ! $is_list_open ) { $is_list_open = true; $inner_blocks_html .= '
      '; } - if ( 'core/navigation-link' !== $inner_block->name && 'core/home-link' !== $inner_block->name && 'core/site-title' !== $inner_block->name && 'core/site-logo' !== $inner_block->name && $is_list_open ) { + if ( 'core/navigation-link' !== $inner_block->name && 'core/home-link' !== $inner_block->name && 'core/site-title' !== $inner_block->name && 'core/site-logo' !== $inner_block->name && 'core/navigation-submenu' !== $inner_block->name && $is_list_open ) { $is_list_open = false; $inner_blocks_html .= '
    '; } diff --git a/packages/block-library/src/navigation/menu-items-to-blocks.js b/packages/block-library/src/navigation/menu-items-to-blocks.js index 024c267d67cef..d079463e36eca 100644 --- a/packages/block-library/src/navigation/menu-items-to-blocks.js +++ b/packages/block-library/src/navigation/menu-items-to-blocks.js @@ -65,12 +65,12 @@ function mapMenuItemsToBlocks( menuItems ) { ...nestedMapping, }; + const blockType = menuItem.children?.length + ? 'core/navigation-submenu' + : 'core/navigation-link'; + // Create block with nested "innerBlocks". - const block = createBlock( - 'core/navigation-link', - attributes, - nestedBlocks - ); + const block = createBlock( blockType, attributes, nestedBlocks ); // Create mapping for menuItem -> block mapping[ menuItem.id ] = block.clientId; diff --git a/packages/block-library/src/navigation/style.scss b/packages/block-library/src/navigation/style.scss index 379ac1ee403e7..4491442bcb212 100644 --- a/packages/block-library/src/navigation/style.scss +++ b/packages/block-library/src/navigation/style.scss @@ -92,7 +92,6 @@ } } - // Styles for submenu flyout. // These are separated out with reduced specificity to allow better inheritance from Global Styles. .wp-block-navigation .has-child { @@ -167,13 +166,12 @@ } } - // Separating out hover and focus-within so hover works again on IE: https://davidwalsh.name/css-focus-within#comment-513401 // We will need to replace focus-within with a JS solution for IE keyboard support. // Custom menu items. - // Show submenus on hover. - &:hover > .wp-block-navigation__submenu-container { + // Show submenus on hover unless they open on click. + &:where(:not(.open-on-click)):hover > .wp-block-navigation__submenu-container { visibility: visible; opacity: 1; width: auto; @@ -182,7 +180,7 @@ } // Keep submenus open when focus is within. - &:focus-within > .wp-block-navigation__submenu-container { + &:where(:not(.open-on-click):not(.open-on-hover-click)):focus-within > .wp-block-navigation__submenu-container { visibility: visible; opacity: 1; width: auto; @@ -192,7 +190,9 @@ } // Submenu indentation when there's a background. -.wp-block-navigation.has-background .has-child .wp-block-navigation__submenu-container { +.wp-block-navigation.has-background +.has-child +.wp-block-navigation__submenu-container { left: 0; top: 100%; @@ -205,7 +205,6 @@ } } - /** * Margins */ @@ -227,7 +226,6 @@ } } - /** * Paddings */ @@ -252,7 +250,6 @@ padding: 0.5em 1em; } - /** * Justifications. */ @@ -260,11 +257,12 @@ // When justified space-between, open submenus leftward for last menu item. // When justified right, open all submenus leftwards. // This needs high specificity. -.wp-block-navigation.items-justified-space-between .wp-block-page-list > .has-child:last-child, +.wp-block-navigation.items-justified-space-between +.wp-block-page-list > .has-child:last-child, .wp-block-navigation.items-justified-space-between > .wp-block-navigation__container > .has-child:last-child, .wp-block-navigation.items-justified-right .wp-block-page-list > .has-child, -.wp-block-navigation.items-justified-right > .wp-block-navigation__container .has-child { - +.wp-block-navigation.items-justified-right > .wp-block-navigation__container +.has-child { // First submenu. .wp-block-navigation__submenu-container { left: auto; @@ -348,7 +346,6 @@ } } - /** * Mobile menu. */ @@ -429,7 +426,8 @@ } // Default menu background and font color. -.wp-block-navigation:not(.has-background) .wp-block-navigation__responsive-container.is-menu-open { +.wp-block-navigation:not(.has-background) +.wp-block-navigation__responsive-container.is-menu-open { background-color: #fff; color: #000; } diff --git a/packages/block-library/src/navigation/test/menu-items-to-blocks.js b/packages/block-library/src/navigation/test/menu-items-to-blocks.js index 599b30e679ff5..ce89829d0188d 100644 --- a/packages/block-library/src/navigation/test/menu-items-to-blocks.js +++ b/packages/block-library/src/navigation/test/menu-items-to-blocks.js @@ -84,7 +84,7 @@ describe( 'converting menu items to blocks', () => { attr_title: '', description: '', type: 'custom', - type_label: 'Custom Link', + type_label: 'Submenu', object: 'custom', parent: 0, menu_order: 1, @@ -120,7 +120,7 @@ describe( 'converting menu items to blocks', () => { attr_title: '', description: '', type: 'custom', - type_label: 'Custom Link', + type_label: 'Submenu', object: 'custom', parent: 1, menu_order: 2, @@ -138,7 +138,7 @@ describe( 'converting menu items to blocks', () => { attr_title: '', description: '', type: 'custom', - type_label: 'Custom Link', + type_label: 'Submenu', object: 'custom', parent: 3, menu_order: 1, @@ -186,7 +186,7 @@ describe( 'converting menu items to blocks', () => { expect( actual ).toEqual( [ expect.objectContaining( { - name: 'core/navigation-link', + name: 'core/navigation-submenu', attributes: expect.objectContaining( { label: 'Top Level', } ), @@ -199,13 +199,13 @@ describe( 'converting menu items to blocks', () => { innerBlocks: [], } ), expect.objectContaining( { - name: 'core/navigation-link', + name: 'core/navigation-submenu', attributes: expect.objectContaining( { label: 'Child 2', } ), innerBlocks: [ expect.objectContaining( { - name: 'core/navigation-link', + name: 'core/navigation-submenu', attributes: expect.objectContaining( { label: 'Sub Child', } ), diff --git a/packages/block-library/src/page-list/convert-to-links-modal.js b/packages/block-library/src/page-list/convert-to-links-modal.js index e900a9ab94d38..80c735bff9685 100644 --- a/packages/block-library/src/page-list/convert-to-links-modal.js +++ b/packages/block-library/src/page-list/convert-to-links-modal.js @@ -50,6 +50,26 @@ export const convertSelectedBlockToNavigationLinks = ( { } } ); + // Transform all links with innerBlocks into Submenus. This can't be done + // sooner because page objects have no information on their children. + + const transformSubmenus = ( listOfLinks ) => { + listOfLinks.forEach( ( block, index, listOfLinksArray ) => { + const { attributes, innerBlocks } = block; + if ( innerBlocks.length !== 0 ) { + transformSubmenus( innerBlocks ); + const transformedBlock = createBlock( + 'core/navigation-submenu', + attributes, + innerBlocks + ); + listOfLinksArray[ index ] = transformedBlock; + } + } ); + }; + + transformSubmenus( navigationLinks ); + replaceBlock( clientId, navigationLinks ); }; diff --git a/packages/block-library/src/page-list/test/convert-to-links-modal.js b/packages/block-library/src/page-list/test/convert-to-links-modal.js index 930582e85aae4..073766d70143e 100644 --- a/packages/block-library/src/page-list/test/convert-to-links-modal.js +++ b/packages/block-library/src/page-list/test/convert-to-links-modal.js @@ -149,7 +149,7 @@ describe( 'page list convert to links', () => { name: 'core/navigation-link', }, ], - name: 'core/navigation-link', + name: 'core/navigation-submenu', }, { attributes: { @@ -193,10 +193,10 @@ describe( 'page list convert to links', () => { name: 'core/navigation-link', }, ], - name: 'core/navigation-link', + name: 'core/navigation-submenu', }, ], - name: 'core/navigation-link', + name: 'core/navigation-submenu', }, ] ); } ); @@ -344,7 +344,7 @@ describe( 'page list convert to links', () => { name: 'core/navigation-link', }, ], - name: 'core/navigation-link', + name: 'core/navigation-submenu', }, { attributes: { @@ -388,10 +388,10 @@ describe( 'page list convert to links', () => { name: 'core/navigation-link', }, ], - name: 'core/navigation-link', + name: 'core/navigation-submenu', }, ], - name: 'core/navigation-link', + name: 'core/navigation-submenu', }, ] ); } ); diff --git a/packages/block-library/src/style.scss b/packages/block-library/src/style.scss index 8c3b5dc131749..2f64d28818e5a 100644 --- a/packages/block-library/src/style.scss +++ b/packages/block-library/src/style.scss @@ -19,6 +19,7 @@ @import "./media-text/style.scss"; @import "./navigation/style.scss"; @import "./navigation-link/style.scss"; +@import "./navigation-submenu/style.scss"; @import "./home-link/style.scss"; @import "./page-list/style.scss"; @import "./paragraph/style.scss"; diff --git a/packages/e2e-tests/specs/experiments/blocks/__snapshots__/navigation.test.js.snap b/packages/e2e-tests/specs/experiments/blocks/__snapshots__/navigation.test.js.snap index aa967c86f181d..862e65929b6c2 100644 --- a/packages/e2e-tests/specs/experiments/blocks/__snapshots__/navigation.test.js.snap +++ b/packages/e2e-tests/specs/experiments/blocks/__snapshots__/navigation.test.js.snap @@ -4,35 +4,35 @@ exports[`Navigation Creating from existing Menus allows a navigation block to be " - + - + - - - - + + + + - - - - + + + + - - - + + + - - - + + + - + - + " `; diff --git a/packages/e2e-tests/specs/experiments/blocks/navigation.test.js b/packages/e2e-tests/specs/experiments/blocks/navigation.test.js index c4a3ba9193c3b..40449589463c6 100644 --- a/packages/e2e-tests/specs/experiments/blocks/navigation.test.js +++ b/packages/e2e-tests/specs/experiments/blocks/navigation.test.js @@ -346,7 +346,7 @@ describe( 'Navigation', () => { // Scope element selector to the Editor's "Content" region as otherwise it picks up on // block previews. const navLinkSelector = - '[aria-label="Editor content"][role="region"] div[aria-label="Block: Custom Link"]'; + '[aria-label="Editor content"][role="region"] .wp-block-navigation-item'; await page.waitForSelector( navLinkSelector ); @@ -595,6 +595,53 @@ describe( 'Navigation', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + it( 'allows navigation submenus to open on click instead of hover', async () => { + await mockAllMenusResponses(); + + // Add the navigation block. + await insertBlock( 'Navigation' ); + + await selectDropDownOption( 'Test Menu 2' ); + + // const blocks = await getAllBlocks(); + // await selectBlockByClientId( blocks[ 0 ].clientId ); + + await toggleSidebar(); + + const [ openOnClickButton ] = await page.$x( + '//label[contains(text(),"Open submenus on click")]' + ); + + await openOnClickButton.click(); + + await saveDraft(); + + // Scope element selector to the Editor's "Content" region as otherwise it picks up on + // block previews. + const navSubmenuSelector = + '[aria-label="Editor content"][role="region"] [aria-label="Block: Submenu"]'; + + await page.waitForSelector( navSubmenuSelector ); + + const navSubmenusLength = await page.$$eval( + navSubmenuSelector, + ( els ) => els.length + ); + + const navButtonTogglesSelector = + '[aria-label="Editor content"][role="region"] [aria-label="Block: Submenu"] button.wp-block-navigation-item__content'; + + await page.waitForSelector( navButtonTogglesSelector ); + + const navButtonTogglesLength = await page.$$eval( + navButtonTogglesSelector, + ( els ) => els.length + ); + + // Assert the correct number of button toggles are present. + expect( navSubmenusLength ).toEqual( navButtonTogglesLength ); + } ); + // The following tests are unstable, roughly around when https://github.com/WordPress/wordpress-develop/pull/1412 // landed. The block manually tests well, so let's skip to unblock other PRs and immediately follow up. cc @vcanales it.skip( 'loads frontend code only if the block is present', async () => { diff --git a/test/integration/fixtures/blocks/core__navigation-submenu.html b/test/integration/fixtures/blocks/core__navigation-submenu.html new file mode 100644 index 0000000000000..ae652c313e822 --- /dev/null +++ b/test/integration/fixtures/blocks/core__navigation-submenu.html @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/test/integration/fixtures/blocks/core__navigation-submenu.json b/test/integration/fixtures/blocks/core__navigation-submenu.json new file mode 100644 index 0000000000000..a2889a39797ac --- /dev/null +++ b/test/integration/fixtures/blocks/core__navigation-submenu.json @@ -0,0 +1,27 @@ +[ + { + "clientId": "_clientId_0", + "name": "core/navigation-submenu", + "isValid": true, + "attributes": { + "label": "A fine submenu", + "opensInNewTab": false, + "isTopLevelItem": true + }, + "innerBlocks": [ + { + "clientId": "_clientId_0", + "name": "core/navigation-link", + "isValid": true, + "attributes": { + "label": "WordPress", + "opensInNewTab": false, + "url": "https://wordpress.org/" + }, + "innerBlocks": [], + "originalContent": "" + } + ], + "originalContent": "" + } +] diff --git a/test/integration/fixtures/blocks/core__navigation-submenu.parsed.json b/test/integration/fixtures/blocks/core__navigation-submenu.parsed.json new file mode 100644 index 0000000000000..dbc4ee68d54e9 --- /dev/null +++ b/test/integration/fixtures/blocks/core__navigation-submenu.parsed.json @@ -0,0 +1,23 @@ +[ + { + "blockName": "core/navigation-submenu", + "attrs": { + "label": "A fine submenu", + "isTopLevelItem": true + }, + "innerBlocks": [ + { + "blockName": "core/navigation-link", + "attrs": { + "label": "WordPress", + "url": "https://wordpress.org/" + }, + "innerBlocks": [], + "innerHTML": "\n", + "innerContent": [ "\n" ] + } + ], + "innerHTML": "\n\n", + "innerContent": [ "\n", null, "\n" ] + } +] diff --git a/test/integration/fixtures/blocks/core__navigation-submenu.serialized.html b/test/integration/fixtures/blocks/core__navigation-submenu.serialized.html new file mode 100644 index 0000000000000..bda4d67a75b24 --- /dev/null +++ b/test/integration/fixtures/blocks/core__navigation-submenu.serialized.html @@ -0,0 +1,3 @@ + + + diff --git a/test/integration/fixtures/blocks/core__navigation.json b/test/integration/fixtures/blocks/core__navigation.json index f0820d51af100..4378f2605bc61 100644 --- a/test/integration/fixtures/blocks/core__navigation.json +++ b/test/integration/fixtures/blocks/core__navigation.json @@ -6,6 +6,7 @@ "attributes": { "orientation": "horizontal", "showSubmenuIcon": true, + "openSubmenusOnClick": false, "isResponsive": false }, "innerBlocks": [],