diff --git a/package-lock.json b/package-lock.json index cd0bad87a00b99..6ac3058a7a696a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13277,6 +13277,7 @@ "fast-average-color": "4.3.0", "lodash": "^4.17.19", "memize": "^1.1.0", + "micromodal": "^0.4.6", "moment": "^2.22.1", "react-easy-crop": "^3.0.0", "tinycolor2": "^1.4.2" @@ -43272,6 +43273,11 @@ "to-regex": "^3.0.2" } }, + "micromodal": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/micromodal/-/micromodal-0.4.6.tgz", + "integrity": "sha512-2VDso2a22jWPpqwuWT/4RomVpoU3Bl9qF9D01xzwlNp5UVsImeA0gY4nSpF44vqcQtQOtkiMUV9EZkAJSRxBsg==" + }, "miller-rabin": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", diff --git a/packages/block-library/package.json b/packages/block-library/package.json index 03fb96d81fd908..a077547ccfd227 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -63,6 +63,7 @@ "fast-average-color": "4.3.0", "lodash": "^4.17.19", "memize": "^1.1.0", + "micromodal": "^0.4.6", "moment": "^2.22.1", "react-easy-crop": "^3.0.0", "tinycolor2": "^1.4.2" diff --git a/packages/block-library/src/navigation-link/block.json b/packages/block-library/src/navigation-link/block.json index 7a1cc81bb20579..efcc6810705506 100644 --- a/packages/block-library/src/navigation-link/block.json +++ b/packages/block-library/src/navigation-link/block.json @@ -3,7 +3,9 @@ "name": "core/navigation-link", "title": "Custom Link", "category": "design", - "parent": [ "core/navigation" ], + "parent": [ + "core/navigation" + ], "description": "Add a page, link, or another item to your navigation.", "textdomain": "default", "attributes": { diff --git a/packages/block-library/src/navigation/block.json b/packages/block-library/src/navigation/block.json index 957980b70376c7..d27fdc579ccd94 100644 --- a/packages/block-library/src/navigation/block.json +++ b/packages/block-library/src/navigation/block.json @@ -4,7 +4,11 @@ "title": "Navigation", "category": "design", "description": "A collection of blocks that allow visitors to get around your site.", - "keywords": [ "menu", "navigation", "links" ], + "keywords": [ + "menu", + "navigation", + "links" + ], "textdomain": "default", "attributes": { "orientation": { @@ -34,6 +38,10 @@ "showSubmenuIcon": { "type": "boolean", "default": true + }, + "isResponsive": { + "type": "boolean", + "default": false } }, "providesContext": { @@ -48,7 +56,10 @@ "orientation": "orientation" }, "supports": { - "align": [ "wide", "full" ], + "align": [ + "wide", + "full" + ], "anchor": true, "html": false, "inserter": true, diff --git a/packages/block-library/src/navigation/edit.js b/packages/block-library/src/navigation/edit.js index 18d9da732015c9..68a1fd72a9ae66 100644 --- a/packages/block-library/src/navigation/edit.js +++ b/packages/block-library/src/navigation/edit.js @@ -28,6 +28,7 @@ import useBlockNavigator from './use-block-navigator'; import NavigationPlaceholder from './placeholder'; import PlaceholderPreview from './placeholder-preview'; +import ResponsiveWrapper from './responsive-wrapper'; const ALLOWED_BLOCKS = [ 'core/navigation-link', @@ -58,6 +59,9 @@ function Navigation( { const [ isPlaceholderShown, setIsPlaceholderShown ] = useState( ! hasExistingNavItems ); + const [ isResponsiveMenuOpen, setResponsiveMenuVisibility ] = useState( + false + ); const { selectBlock } = useDispatch( blockEditorStore ); @@ -65,6 +69,7 @@ function Navigation( { className: classnames( className, { [ `items-justified-${ attributes.itemsJustification }` ]: attributes.itemsJustification, 'is-vertical': attributes.orientation === 'vertical', + 'is-responsive': attributes.isResponsive, } ), } ); @@ -148,11 +153,27 @@ function Navigation( { } } label={ __( 'Show submenu indicator icons' ) } /> + { + setAttributes( { + isResponsive: value, + } ); + } } + label={ __( 'Enable responsive menu' ) } + /> ) } ); diff --git a/packages/block-library/src/navigation/editor.scss b/packages/block-library/src/navigation/editor.scss index 34d671be6e5fe9..74e217cca1f05b 100644 --- a/packages/block-library/src/navigation/editor.scss +++ b/packages/block-library/src/navigation/editor.scss @@ -316,3 +316,76 @@ $color-control-label-height: 20px; margin-right: $grid-unit-15; } } + + +/** + * Mobile menu. + */ + +// These needs extra specificity in the editor. +.wp-block-navigation__responsive-container:not(.is-menu-open) { + .components-button.wp-block-navigation__responsive-container-close { + @include break-small { + display: none; + } + } +} +.components-button.wp-block-navigation__responsive-container-open { + @include break-small { + display: none; + } +} + +// Emulate the fullscreen editing inside the editor. +.wp-block-navigation__responsive-container.is-menu-open { + position: fixed; + + // Handle top position. + // For now, the editing of menu items in the mobile view only happens <600px. + // That means we only have to consider the big adminbar height (<783px). + // And in that view we also know that the toolbar is stacked. + body.admin-bar & { + top: $admin-bar-height-big + $header-height + $block-toolbar-height; + + @include break-medium() { + top: $header-height + $border-width; + } + } +} + +// Without this, the block cannot be selected, nor does the right container get focus. +// @todo: this is disruptive. Ideally we can retire a few of the containers, +// so focus is applied naturally on the block container. +// It's important the right container has focus, otherwise you can't press +// "Delete" to remove the block. +.wp-block-navigation__responsive-close { + @include break-small() { + pointer-events: none; + + .wp-block-navigation__responsive-container-close, + .block-editor-block-list__layout * { + pointer-events: all; + } + } + + // Page List items should remain inert. + .wp-block-pages-list__item__link { + pointer-events: none; + } +} + +// The menu and close buttons need higher specificity in the editor. +.components-button.wp-block-navigation__responsive-container-open.wp-block-navigation__responsive-container-open, +.components-button.wp-block-navigation__responsive-container-close.wp-block-navigation__responsive-container-close { + padding: 0; + height: auto; + color: inherit; +} + +// Customize the mobile editing. +// This can be revisited in the future, but for now, inherit design from the parent. +.is-menu-open .wp-block-navigation__responsive-container-content * { + .block-list-appender { + margin-top: $grid-unit-20; + } +} diff --git a/packages/block-library/src/navigation/frontend.js b/packages/block-library/src/navigation/frontend.js new file mode 100644 index 00000000000000..592106763e4825 --- /dev/null +++ b/packages/block-library/src/navigation/frontend.js @@ -0,0 +1,23 @@ +/** + * External dependencies + */ +import MicroModal from 'micromodal'; + +function navigationToggleModal( modal ) { + const triggerButton = document.querySelector( + `button[data-micromodal-trigger="${ modal.id }"]` + ); + const closeButton = modal.querySelector( 'button[data-micromodal-close]' ); + // Use aria-hidden to determine the status of the modal, as this attribute is + // managed by micromodal. + const isHidden = 'true' === modal.getAttribute( 'aria-hidden' ); + triggerButton.setAttribute( 'aria-expanded', ! isHidden ); + closeButton.setAttribute( 'aria-expanded', ! isHidden ); + modal.classList.toggle( 'has-modal-open', ! isHidden ); +} + +MicroModal.init( { + onShow: navigationToggleModal, + onClose: navigationToggleModal, + openClass: 'is-menu-open', +} ); diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index b1fe553df3b3fb..0dff15eeaa1978 100644 --- a/packages/block-library/src/navigation/index.php +++ b/packages/block-library/src/navigation/index.php @@ -120,6 +120,17 @@ function render_block_core_navigation( $attributes, $content, $block ) { } unset( $attributes['rgbTextColor'], $attributes['rgbBackgroundColor'] ); + $should_load_frontend_script = $attributes['isResponsive'] && ! wp_script_is( 'core_block_navigation_load_frontend_scripts' ); + + if ( $should_load_frontend_script ) { + wp_enqueue_script( + 'core_block_navigation_load_frontend_scripts', + plugins_url( 'frontend.js', __DIR__ . '/navigation/frontend.js' ), + array(), + false, + true + ); + } if ( empty( $block->inner_blocks ) ) { return ''; @@ -131,7 +142,8 @@ function render_block_core_navigation( $attributes, $content, $block ) { $colors['css_classes'], $font_sizes['css_classes'], ( isset( $attributes['orientation'] ) && 'vertical' === $attributes['orientation'] ) ? array( 'is-vertical' ) : array(), - isset( $attributes['itemsJustification'] ) ? array( 'items-justified-' . $attributes['itemsJustification'] ) : array() + isset( $attributes['itemsJustification'] ) ? array( 'items-justified-' . $attributes['itemsJustification'] ) : array(), + isset( $attributes['isResponsive'] ) && true === $attributes['isResponsive'] ? array( 'is-responsive' ) : array() ); $inner_blocks_html = ''; @@ -148,10 +160,40 @@ function render_block_core_navigation( $attributes, $content, $block ) { ) ); + $modal_unique_id = uniqid(); + + // Determine whether or not navigation elements should be wrapped in the markup required to make it responsive, + // return early if they don't. + if ( ! isset( $attributes['isResponsive'] ) || false === $attributes['isResponsive'] ) { + return sprintf( + '', + $wrapper_attributes, + $inner_blocks_html + ); + } + + $responsive_container_markup = sprintf( + ' + ', + $modal_unique_id, + $inner_blocks_html, + __( 'Open menu' ), // Open button label. + __( 'Close menu' ) // Close button label. + ); + return sprintf( - '', + '', $wrapper_attributes, - $inner_blocks_html + $responsive_container_markup ); } @@ -201,4 +243,5 @@ function block_core_navigation_typographic_presets_backcompatibility( $parsed_bl } return $parsed_block; } + add_filter( 'render_block_data', 'block_core_navigation_typographic_presets_backcompatibility' ); diff --git a/packages/block-library/src/navigation/responsive-wrapper.js b/packages/block-library/src/navigation/responsive-wrapper.js new file mode 100644 index 00000000000000..b29cdbdef7c902 --- /dev/null +++ b/packages/block-library/src/navigation/responsive-wrapper.js @@ -0,0 +1,91 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { close, Icon } from '@wordpress/icons'; +import { Button } from '@wordpress/components'; +import { SVG, Rect } from '@wordpress/primitives'; +import { __ } from '@wordpress/i18n'; + +export default function ResponsiveWrapper( { + children, + id, + isOpen, + isResponsive, + onToggle, +} ) { + if ( ! isResponsive ) { + return children; + } + const responsiveContainerClasses = classnames( + 'wp-block-navigation__responsive-container', + { + 'is-menu-open': isOpen, + } + ); + + const modalId = `${ id }-modal`; + + return ( + <> + { ! isOpen && ( + + ) } + +
+
+
+ +
+ { children } +
+
+
+
+ + ); +} diff --git a/packages/block-library/src/navigation/style.scss b/packages/block-library/src/navigation/style.scss index 141907bd08becf..322407bf28b44c 100644 --- a/packages/block-library/src/navigation/style.scss +++ b/packages/block-library/src/navigation/style.scss @@ -4,6 +4,7 @@ // The CSS lives here so that it is output even if you only use a // Page List block inside your navigation block. .wp-block-navigation { + position: relative; // Normalize list styles. ul, ul li { @@ -301,28 +302,36 @@ margin: 0; padding-left: 0; - // Horizontal layout - display: flex; - flex-wrap: wrap; + // Only hide the menu by default if responsiveness is active. + .is-responsive { + display: none; + } - // Vertical layout - .is-vertical & { - display: block; - flex-direction: column; - align-items: flex-start; + @include break-small() { + // Horizontal layout + display: flex; + flex-wrap: wrap; + + // Vertical layout + .is-vertical & { + display: block; + flex-direction: column; + align-items: flex-start; + } } } // Justification. -.items-justified-center > ul { +// These target the named container class to work even with the additional mobile menu containers. +.items-justified-center .wp-block-navigation__container { justify-content: center; } -.items-justified-right > ul { +.items-justified-right .wp-block-navigation__container { justify-content: flex-end; } -.items-justified-space-between > ul { +.items-justified-space-between .wp-block-navigation__container { justify-content: space-between; } @@ -340,3 +349,159 @@ justify-content: flex-end; } } + +/** + * Mobile menu. + */ + +.wp-block-navigation__responsive-container { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1; + align-items: flex-start; + justify-content: flex-start; + + // Overlay menu. + // Provide an opinionated default style for menu items inside. + // Inherit as much as we can regarding colors, fonts, sizes, + // but otherwise provide a baseline. + // In a future version, we can explore more customizability. + &.is-menu-open { + display: flex; + flex-direction: column; + + // Allow modal to scroll. + overflow: auto; + + // Give it a z-index just higher than the adminbar. + z-index: 100000; + + padding: 24px; + background-color: inherit; + + .wp-block-navigation__container { + display: flex; + flex-direction: column; + margin-left: auto; + margin-right: auto; + align-items: flex-start; + line-height: 48px; + padding: 0; + + .wp-block-page-list { + flex-direction: column; + } + } + + // Remove background colors for items inside the overlay menu. + // Has to be !important to override global styles. + .wp-block-pages-list__item .submenu-container, + .wp-block-navigation-link .wp-block-navigation-link__container, + .wp-block-pages-list__item, + .wp-block-navigation-link { + background: transparent !important; + } + } + + @include break-small() { + &:not(.is-menu-open) { + display: flex; + flex-direction: row; + position: relative; + background-color: inherit; + + .wp-block-navigation__responsive-container-close { + display: none; + } + } + + &.is-menu-open { + // Override breakpoint-inherited submenu rules. + .submenu-container.submenu-container.submenu-container.submenu-container, + .wp-block-navigation-link__container.wp-block-navigation-link__container.wp-block-navigation-link__container.wp-block-navigation-link__container { + left: 0; + } + } + } +} + +// Default menu background and font color. +.wp-block-navigation:not(.has-background) .wp-block-navigation__responsive-container.is-menu-open { + background-color: #fff; + color: #000; +} + +// Menu and close buttons. +.wp-block-navigation__responsive-container-open, +.wp-block-navigation__responsive-container-close { + vertical-align: middle; + cursor: pointer; + color: currentColor; + background: transparent; + border: none; + margin: 0; + padding: 0; + + svg { + fill: currentColor; + pointer-events: none; + display: block; + width: 24px; + height: 24px; + } +} + +// Button to open the menu. +.wp-block-navigation__responsive-container-open { + @include break-small { + display: none; + } +} + +// Button to close the menus. +.wp-block-navigation__responsive-container-close { + position: absolute; + top: 24px; + right: 24px; + z-index: 2; // Needs to be above the modal z index itself. +} + +// The menu adds a wrapping container. +.is-menu-open .wp-block-navigation__responsive-close, +.is-menu-open .wp-block-navigation__responsive-dialog, +.is-menu-open .wp-block-navigation__responsive-container-content { + width: 100%; + height: 100%; +} + +// Always show submenus fully expanded inside the modal menu. +.wp-block-navigation .wp-block-navigation__responsive-container.is-menu-open { + .wp-block-page-list__submenu-icon, + .wp-block-navigation-link__submenu-icon { + display: none; + } + + .has-child .submenu-container, + .has-child .wp-block-navigation-link__container { + position: relative; + opacity: 1; + visibility: visible; + + padding: 0 0 0 32px; + border: none; + } + + .wp-block-navigation-link, + .wp-block-pages-list__item { + flex-direction: column; + align-items: flex-start; + } +} + +html.has-modal-open { + overflow: hidden; +} diff --git a/packages/block-library/src/page-list/style.scss b/packages/block-library/src/page-list/style.scss index 264ebe23387021..189b794886a163 100644 --- a/packages/block-library/src/page-list/style.scss +++ b/packages/block-library/src/page-list/style.scss @@ -25,8 +25,16 @@ } } -.is-vertical .wp-block-navigation__container { +.is-vertical .wp-block-navigation__container, +.is-open .wp-block-navigation__container { .wp-block-page-list { display: block; } } +.is-open .wp-block-navigation__container { + .wp-block-page-list { + @include break-mobile() { + display: flex; + } + } +} diff --git a/packages/e2e-tests/fixtures/blocks/core__navigation.json b/packages/e2e-tests/fixtures/blocks/core__navigation.json index 20acaa79f5a437..0e1e52afebd32b 100644 --- a/packages/e2e-tests/fixtures/blocks/core__navigation.json +++ b/packages/e2e-tests/fixtures/blocks/core__navigation.json @@ -4,7 +4,8 @@ "name": "core/navigation", "isValid": true, "attributes": { - "showSubmenuIcon": true + "showSubmenuIcon": true, + "isResponsive": false }, "innerBlocks": [], "originalContent": "" diff --git a/packages/e2e-tests/specs/experiments/blocks/navigation.test.js b/packages/e2e-tests/specs/experiments/blocks/navigation.test.js index 66ec2fa9960f0d..c29e1f5dadb786 100644 --- a/packages/e2e-tests/specs/experiments/blocks/navigation.test.js +++ b/packages/e2e-tests/specs/experiments/blocks/navigation.test.js @@ -8,7 +8,11 @@ import { insertBlock, setUpResponseMocking, pressKeyWithModifier, + saveDraft, showBlockToolbar, + openPreviewPage, + selectBlockByClientId, + getAllBlocks, } from '@wordpress/e2e-test-utils'; /** @@ -253,6 +257,27 @@ async function addLinkBlock() { await linkButton.click(); } +async function toggleSidebar() { + await page.click( + '.edit-post-header__settings button[aria-label="Settings"]' + ); +} + +async function turnResponsivenessOn() { + const blocks = await getAllBlocks(); + + await selectBlockByClientId( blocks[ 0 ].clientId ); + await toggleSidebar(); + + const [ responsivenessToggleButton ] = await page.$x( + '//label[text()[contains(.,"Enable responsive menu")]]' + ); + + await responsivenessToggleButton.click(); + + await saveDraft(); +} + beforeEach( async () => { await createNewPost(); } ); @@ -506,4 +531,111 @@ describe( 'Navigation', () => { // Expect a Navigation Block with a link for "A really long page name that will not exist". expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + + it( 'loads frontend code only if the block is present', async () => { + // Mock the response from the Pages endpoint. This is done so that the pages returned are always + // consistent and to test the feature more rigorously than the single default sample page. + await mockPagesResponse( [ + { + title: 'Home', + slug: 'home', + }, + { + title: 'About', + slug: 'about', + }, + { + title: 'Contact Us', + slug: 'contact', + }, + ] ); + + // Create first block at the start in order to enable preview. + await insertBlock( 'Navigation' ); + await saveDraft(); + + const previewPage = await openPreviewPage(); + const isScriptLoaded = await previewPage.evaluate( + () => + null !== + document.querySelector( + 'script[src*="navigation/frontend.js"]' + ) + ); + + expect( isScriptLoaded ).toBe( false ); + + await createNavBlockWithAllPages(); + await insertBlock( 'Navigation' ); + await createNavBlockWithAllPages(); + await turnResponsivenessOn(); + + await previewPage.reload( { + waitFor: [ 'networkidle0', 'domcontentloaded' ], + } ); + + /* + Count instances of the tag to make sure that it's been loaded only once, + regardless of the number of navigation blocks present. + */ + const tagCount = await previewPage.evaluate( + () => + Array.from( + document.querySelectorAll( + 'script[src*="navigation/frontend.js"]' + ) + ).length + ); + + expect( tagCount ).toBe( 1 ); + } ); + + it( 'loads frontend code only if responsiveness is turned on', async () => { + await mockPagesResponse( [ + { + title: 'Home', + slug: 'home', + }, + { + title: 'About', + slug: 'about', + }, + { + title: 'Contact Us', + slug: 'contact', + }, + ] ); + + await insertBlock( 'Navigation' ); + await saveDraft(); + + const previewPage = await openPreviewPage(); + let isScriptLoaded = await previewPage.evaluate( + () => + null !== + document.querySelector( + 'script[src*="navigation/frontend.js"]' + ) + ); + + expect( isScriptLoaded ).toBe( false ); + + await createNavBlockWithAllPages(); + + await turnResponsivenessOn(); + + await previewPage.reload( { + waitFor: [ 'networkidle0', 'domcontentloaded' ], + } ); + + isScriptLoaded = await previewPage.evaluate( + () => + null !== + document.querySelector( + 'script[src*="navigation/frontend.js"]' + ) + ); + + expect( isScriptLoaded ).toBe( true ); + } ); } ); diff --git a/packages/icons/src/library/menu.js b/packages/icons/src/library/menu.js index 5004e0532fbc99..23eedb2923d992 100644 --- a/packages/icons/src/library/menu.js +++ b/packages/icons/src/library/menu.js @@ -5,7 +5,7 @@ import { SVG, Path } from '@wordpress/primitives'; const menu = ( - + );